merge: agent session work
This commit is contained in:
commit
b12c3efee1
@ -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);
|
||||
|
||||
|
||||
@ -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'] ?? ''));
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'] }}
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user