fix: create missing autopilot profile on restore

This commit is contained in:
Ahmed Darrazi 2025-12-28 00:36:39 +01:00
parent 783d8581b9
commit ae16a394d8
7 changed files with 280 additions and 9 deletions

View File

@ -69,8 +69,7 @@ public function sanitizeUpdatePayload(string $policyType, array $snapshot): arra
$whitelist = $contract['update_whitelist'] ?? null;
$stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []);
$mapping = $contract['update_map'] ?? [];
$stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']);
$stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys']));
$result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping);

View File

@ -330,6 +330,26 @@ public function execute(
$payload,
$graphOptions
);
if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
$createOutcome = $this->createAutopilotDeploymentProfileIfMissing(
originalPayload: $originalPayload,
graphOptions: $graphOptions,
context: $context,
policyId: $item->policy_identifier,
);
if ($createOutcome['attempted']) {
$response = $createOutcome['response'] ?? $response;
if ($createOutcome['success']) {
$createdPolicyId = $createOutcome['policy_id'];
$createdPolicyMode = 'created';
$itemStatus = 'applied';
$resultReason = 'Policy missing; created new Autopilot profile.';
}
}
}
}
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
@ -1324,6 +1344,91 @@ private function createSettingsCatalogPolicy(
];
}
/**
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
*/
private function createAutopilotDeploymentProfileIfMissing(
array $originalPayload,
array $graphOptions,
array $context,
string $policyId,
): array {
if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) {
return [
'attempted' => false,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile')
?? 'deviceManagement/windowsAutopilotDeploymentProfiles';
$payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload);
if ($payload === []) {
return [
'attempted' => true,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$this->graphLogger->logRequest('create_autopilot_profile', $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$response = $this->graphClient->request(
'POST',
$resource,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
);
$this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$policyId = $this->extractCreatedPolicyId($response);
return [
'attempted' => true,
'success' => $response->successful(),
'policy_id' => $policyId,
'response' => $response,
];
}
private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool
{
$response = $this->graphClient->getPolicy(
'windowsAutopilotDeploymentProfile',
$policyId,
$graphOptions
);
if ($response->successful()) {
return false;
}
if ($response->status === 404) {
return true;
}
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
if (str_contains($code, 'notfound') || str_contains($code, 'resource')) {
return true;
}
return str_contains($message, 'not found')
|| str_contains($message, 'resource not found')
|| str_contains($message, 'does not exist');
}
private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool
{
$code = strtolower((string) ($response->meta['error_code'] ?? ''));

View File

@ -258,11 +258,26 @@
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsAutopilotDeploymentProfile',
'#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
'#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'strip_odata' => false,
'update_strip_keys' => [
'assignments',
'managementServiceAppId',
'outOfBoxExperienceSetting',
'hardwareHashExtractionEnabled',
'locale',
],
'assignments_list_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments',
'assignments_create_method' => 'POST',
'assignments_delete_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'windowsEnrollmentStatusPage' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',

View File

@ -237,9 +237,11 @@
@if (! empty($item['created_policy_id']))
@php
$createdMode = $item['created_policy_mode'] ?? null;
$createdMessage = $createdMode === 'metadata_only'
? 'New policy created (metadata only). Apply settings manually.'
: 'New policy created (manual cleanup required).';
$createdMessage = match ($createdMode) {
'metadata_only' => 'New policy created (metadata only). Apply settings manually.',
'created' => 'New policy created.',
default => 'New policy created (manual cleanup required).',
};
@endphp
<div class="mt-2 text-xs text-amber-800">
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}

View File

@ -15,10 +15,10 @@ ## Phase 1: Policy Types, Contracts, Permissions
**Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage.
- [ ] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk).
- [ ] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`.
- [ ] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`.
- [ ] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
- [x] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk).
- [x] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`.
- [x] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`.
- [x] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
**Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry.

View File

@ -282,3 +282,109 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'resource_id' => (string) $run->id,
]);
});
test('restore execution creates an autopilot profile when missing', function () {
$graphClient = new class implements GraphClientInterface
{
public int $applyCalls = 0;
public int $getCalls = 0;
public int $createCalls = 0;
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->getCalls++;
return new GraphResponse(false, [], 404, [], [], [
'error_code' => 'ResourceNotFound',
'error_message' => 'Resource not found.',
]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyCalls++;
return new GraphResponse(false, [], 500, [], [], [
'error_code' => 'InternalServerError',
'error_message' => 'An internal server error has occurred.',
]);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) {
$this->createCalls++;
return new GraphResponse(true, ['id' => 'autopilot-created']);
}
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
};
app()->instance(GraphClientInterface::class, $graphClient);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => 'autopilot-1',
'policy_type' => 'windowsAutopilotDeploymentProfile',
'platform' => 'windows',
'payload' => [
'@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
'displayName' => 'Autopilot Profile',
'language' => 'en-US',
],
])
->create();
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
);
expect($graphClient->applyCalls)->toBe(1);
expect($graphClient->getCalls)->toBe(1);
expect($graphClient->createCalls)->toBe(1);
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
});

View File

@ -49,3 +49,47 @@
// Null values should be preserved (Graph might need them)
expect(array_key_exists('settingValueTemplateReference', $sanitized[0]['settingInstance']['choiceSettingValue']))->toBeTrue();
});
it('exposes autopilot assignments paths', function () {
$contract = $this->registry->get('windowsAutopilotDeploymentProfile');
expect($contract)->not->toBeEmpty();
expect($contract['assignments_list_path'] ?? null)
->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments');
expect($contract['assignments_create_path'] ?? null)
->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments');
expect($contract['assignments_delete_path'] ?? null)
->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}');
expect($this->registry->matchesTypeFamily(
'windowsAutopilotDeploymentProfile',
'#microsoft.graph.azureADWindowsAutopilotDeploymentProfile'
))->toBeTrue();
expect($this->registry->matchesTypeFamily(
'windowsAutopilotDeploymentProfile',
'#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile'
))->toBeTrue();
});
it('sanitizes autopilot update payload by stripping odata and assignments', function () {
$payload = [
'@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
'id' => 'profile-1',
'displayName' => 'Autopilot Profile',
'assignments' => [['id' => 'assignment-1']],
'managementServiceAppId' => 'service-app',
'outOfBoxExperienceSetting' => ['deviceUsageType' => 'shared'],
'hardwareHashExtractionEnabled' => true,
'locale' => 'de-DE',
];
$sanitized = $this->registry->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $payload);
expect($sanitized)->toHaveKey('displayName');
expect($sanitized)->toHaveKey('@odata.type');
expect($sanitized)->not->toHaveKey('id');
expect($sanitized)->not->toHaveKey('assignments');
expect($sanitized)->not->toHaveKey('managementServiceAppId');
expect($sanitized)->not->toHaveKey('outOfBoxExperienceSetting');
expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled');
expect($sanitized)->not->toHaveKey('locale');
});