diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4b48482a..1def3025 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -59,9 +59,14 @@ IdLE uses multiple configuration layers with clear boundaries: - Defines lifecycle intent - Declares steps, conditions, and parameters +- Declares optional OnFailureSteps for cleanup/rollback - Is environment-agnostic - Stored as version-controlled files (e.g. PSD1) +**OnFailureSteps** are an optional workflow section that defines cleanup or rollback steps +executed when primary steps fail. They run in best-effort mode: each OnFailure step is attempted +regardless of previous OnFailure step failures. + #### Execution request - Describes *why* a workflow is executed diff --git a/docs/usage/steps.md b/docs/usage/steps.md index e81acf24..afb90dbe 100644 --- a/docs/usage/steps.md +++ b/docs/usage/steps.md @@ -66,10 +66,40 @@ Security and portability: ## Error behavior -IdLE uses a fail-fast execution model in V1: +### Primary steps (fail-fast) -- a failing step stops plan execution -- results and events capture what happened +IdLE uses a **fail-fast execution model** for primary workflow steps: + +- A failing step stops plan execution immediately +- Subsequent primary steps are not executed +- Results and events capture what happened up to the failure + +### OnFailureSteps (best-effort) + +When primary steps fail, workflows can define **OnFailureSteps** for cleanup or rollback. + +OnFailureSteps are executed in **best-effort mode**: + +- Each OnFailure step is attempted regardless of previous OnFailure step failures +- OnFailure step failures do not stop execution of remaining OnFailure steps +- The overall execution status remains 'Failed' even if all OnFailure steps succeed + +**Execution result structure:** + +```powershell +$result.Status # 'Failed' when primary steps fail +$result.Steps # Array of primary step results (only executed steps) +$result.OnFailure.Status # 'NotRun', 'Completed', or 'PartiallyFailed' +$result.OnFailure.Steps # Array of OnFailure step results +``` + +**OnFailure status values:** + +- `NotRun`: No primary steps failed, OnFailure steps were not executed +- `Completed`: All OnFailure steps succeeded +- `PartiallyFailed`: At least one OnFailure step failed, but execution continued + +For details on declaring OnFailureSteps, see [Workflows](workflows.md). ## Built-in steps (starter pack) diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 992a8682..df02f4a6 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -25,6 +25,52 @@ Example: } ``` +### OnFailureSteps (optional) + +Workflows can define cleanup or rollback steps that run when primary steps fail. +OnFailureSteps are executed in **best-effort mode**: + +- They run only if at least one primary step fails +- Each OnFailure step is attempted regardless of previous OnFailure step failures +- OnFailure step failures do not stop execution of remaining OnFailure steps +- The overall execution status remains 'Failed' even if all OnFailure steps succeed + +Example: + +```powershell +@{ + Name = 'Joiner - With Cleanup' + LifecycleEvent = 'Joiner' + + Steps = @( + @{ Name = 'CreateAccount'; Type = 'IdLE.Step.CreateAccount' } + @{ Name = 'AssignLicense'; Type = 'IdLE.Step.AssignLicense' } + ) + + OnFailureSteps = @( + @{ Name = 'NotifyAdmin'; Type = 'IdLE.Step.SendEmail'; With = @{ Recipient = 'admin@example.com' } } + @{ Name = 'RollbackAccount'; Type = 'IdLE.Step.DeleteAccount' } + @{ Name = 'LogFailure'; Type = 'IdLE.Step.LogToDatabase' } + ) +} +``` + +**Best practices:** + +- Use OnFailureSteps for notifications, logging, or rollback operations +- Keep OnFailure steps simple and resilient +- Avoid dependencies between OnFailure steps +- Don't assume OnFailure steps will always succeed + +**Execution result:** + +The execution result includes a separate `OnFailure` section: + +```powershell +$result.OnFailure.Status # 'NotRun', 'Completed', or 'PartiallyFailed' +$result.OnFailure.Steps # Array of OnFailure step results +``` + ## Planning and validation Workflows are validated during planning. diff --git a/examples/Invoke-IdleDemo.ps1 b/examples/Invoke-IdleDemo.ps1 index 9d092a02..c24d2f3a 100644 --- a/examples/Invoke-IdleDemo.ps1 +++ b/examples/Invoke-IdleDemo.ps1 @@ -56,7 +56,7 @@ function Write-DemoHeader { } function Format-EventRow { - param([Parameter(Mandatory)][object]$Event) + param([Parameter(Mandatory)][object]$EventRecord) $icons = @{ RunStarted = '🚀' @@ -69,23 +69,23 @@ function Format-EventRow { Debug = '🔎' } - $icon = if ($icons.ContainsKey($Event.Type)) { $icons[$Event.Type] } else { '•' } + $icon = if ($icons.ContainsKey($EventRecord.Type)) { $icons[$EventRecord.Type] } else { '•' } - $time = ([DateTime]$Event.TimestampUtc).ToLocalTime().ToString('HH:mm:ss.fff') - $step = if ([string]::IsNullOrWhiteSpace($Event.StepName)) { '-' } else { [string]$Event.StepName } + $time = ([DateTime]$EventRecord.TimestampUtc).ToLocalTime().ToString('HH:mm:ss.fff') + $step = if ([string]::IsNullOrWhiteSpace($EventRecord.StepName)) { '-' } else { [string]$EventRecord.StepName } - $msg = [string]$Event.Message + $msg = [string]$EventRecord.Message # IMPORTANT: Show error details if the engine attached them. - if ($Event.PSObject.Properties.Name -contains 'Data' -and $Event.Data -is [hashtable]) { - if ($Event.Data.ContainsKey('Error') -and -not [string]::IsNullOrWhiteSpace([string]$Event.Data.Error)) { - $msg = "$msg | ERROR: $([string]$Event.Data.Error)" + if ($EventRecord.PSObject.Properties.Name -contains 'Data' -and $EventRecord.Data -is [hashtable]) { + if ($EventRecord.Data.ContainsKey('Error') -and -not [string]::IsNullOrWhiteSpace([string]$EventRecord.Data.Error)) { + $msg = "$msg | ERROR: $([string]$EventRecord.Data.Error)" } } [pscustomobject]@{ Time = $time - Type = "$icon $($Event.Type)" + Type = "$icon $($EventRecord.Type)" Step = $step Message = $msg } @@ -238,7 +238,7 @@ foreach ($wf in $selected) { Write-DemoHeader "Plan" $lifecycleEvent = Get-IdleLifecycleEventFromWorkflowName -Name $wf.Name $request = New-IdleLifecycleRequest -LifecycleEvent $lifecycleEvent -Actor 'example-user' - $plan = New-IdlePlan -WorkflowPath $wf.Path -Request $request + $plan = New-IdlePlan -WorkflowPath $wf.Path -Request $request -Providers $providers Write-Host ("Plan created: LifecycleEvent={0} | Steps={1}" -f $lifecycleEvent, ($plan.Steps | Measure-Object).Count) Write-Host "" diff --git a/examples/README.md b/examples/README.md index ff72efca..7557a0ff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,10 @@ Workflow samples are located in: Highlighted samples: +- `joiner-minimal.psd1` — minimal workflow with a single EmitEvent step +- `joiner-with-condition.psd1` — demonstrates conditional step execution - `joiner-ensureentitlement.psd1` — ensures a demo group assignment via the built-in EnsureEntitlement step +- `joiner-with-onfailure.psd1` — demonstrates OnFailureSteps for cleanup and notifications Workflows are **data-only** PSD1 files. A minimal workflow looks like: diff --git a/examples/workflows/joiner-with-onfailure.psd1 b/examples/workflows/joiner-with-onfailure.psd1 new file mode 100644 index 00000000..f8e0b326 --- /dev/null +++ b/examples/workflows/joiner-with-onfailure.psd1 @@ -0,0 +1,54 @@ +@{ + Name = 'Joiner - With OnFailure Cleanup' + LifecycleEvent = 'Joiner' + Description = 'Demonstrates OnFailureSteps for cleanup and notifications when primary steps fail' + + Steps = @( + @{ + Name = 'Emit start' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Starting Joiner workflow with OnFailure handling' } + } + @{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'user1' + Name = 'Department' + Value = 'IT' + Provider = 'Identity' + } + RequiresCapabilities = 'Identity.Attribute.Ensure' + } + @{ + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ + Kind = 'Group' + Id = 'demo-group' + DisplayName = 'Demo Group' + } + State = 'Present' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + } + ) + + OnFailureSteps = @( + @{ + Name = 'Log failure' + Type = 'IdLE.Step.EmitEvent' + Description = 'Emits a custom event to log the failure' + With = @{ Message = 'Workflow execution failed - cleanup initiated' } + } + @{ + Name = 'Notify administrator' + Type = 'IdLE.Step.EmitEvent' + Description = 'Simulates sending a notification to administrators' + With = @{ Message = 'ALERT: Joiner workflow failed for user1 - manual intervention required' } + } + ) +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 1b434f2d..a245bf93 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -8,7 +8,7 @@ function Test-IdleWorkflowSchema { # Strict validation: collect all schema violations and return them as a list. $errors = [System.Collections.Generic.List[string]]::new() - $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'Description') + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") @@ -77,5 +77,59 @@ function Test-IdleWorkflowSchema { } } + # OnFailureSteps are optional. If present, validate them like regular Steps. + if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) { + if ($Workflow.OnFailureSteps -isnot [System.Collections.IEnumerable] -or $Workflow.OnFailureSteps -is [string]) { + $errors.Add("'OnFailureSteps' must be an array/list of step hashtables.") + } + else { + $failureStepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + $i = 0 + foreach ($step in $Workflow.OnFailureSteps) { + $stepPath = "OnFailureSteps[$i]" + + if ($null -eq $step -or $step -isnot [hashtable]) { + $errors.Add("$stepPath must be a hashtable.") + $i++ + continue + } + + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') + foreach ($k in $step.Keys) { + if ($allowedStepKeys -notcontains $k) { + $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") + } + } + + if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { + $errors.Add("Missing or empty required key '$stepPath.Name'.") + } + else { + if (-not $failureStepNames.Add([string]$step.Name)) { + $errors.Add("Duplicate step name '$($step.Name)' detected in 'OnFailureSteps'. Step names must be unique within this collection.") + } + } + + if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { + $errors.Add("Missing or empty required key '$stepPath.Type'.") + } + + # Conditions must be declarative data, never a ScriptBlock/expression. + # We only enforce the shape here; semantic validation comes later. + if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { + $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") + } + + # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. + if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { + $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") + } + + $i++ + } + } + } + return $errors } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 5b38cc64..5bb0feac 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -14,10 +14,10 @@ function Invoke-IdlePlanObject { Plan object created by New-IdlePlanObject. .PARAMETER Providers - Provider registry/collection (may be $null). + Provider registry/collection passed through to execution. .PARAMETER EventSink - Optional external event sink provided by the host. Must be an object with WriteEvent(event) method. + Optional external event sink object. Must provide a WriteEvent(event) method. .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) @@ -30,16 +30,13 @@ function Invoke-IdlePlanObject { [Parameter()] [AllowNull()] - [hashtable] $Providers, + [object] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' - Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' - function Get-IdleCommandParameterNames { [CmdletBinding()] param( @@ -59,31 +56,21 @@ function Invoke-IdlePlanObject { } foreach ($p in $paramBlock.Parameters) { - # Parameter name is stored as '$name' in the AST; we normalize to 'name' - $name = $p.Name.VariablePath.UserPath - if (-not [string]::IsNullOrWhiteSpace($name)) { - [void]$set.Add($name) - } + # Parameter name is stored without the leading '$' + $null = $set.Add([string]$p.Name.VariablePath.UserPath) } return $set } - # Command name or CommandInfo - $command = $null - if ($Handler -is [System.Management.Automation.CommandInfo]) { - $command = $Handler - } - else { - # Most commonly, registry returns a function name (string) - $command = Get-Command -Name $Handler -ErrorAction Stop - } - - foreach ($kv in $command.Parameters.GetEnumerator()) { - [void]$set.Add($kv.Key) + foreach ($n in $Handler.Parameters.Keys) { + $null = $set.Add([string]$n) + } + return $set } + # Unknown handler shape: return an empty set. return $set } @@ -99,42 +86,35 @@ function Invoke-IdlePlanObject { [object] $StepRegistry ) - # Get-IdleStepRegistry currently returns a hashtable that maps Step.Type -> handler function name (string). - # We keep this resolver isolated so that internal representation changes don't leak into the engine loop. + $handlerName = $null - if ($StepRegistry -is [hashtable]) { - if (-not $StepRegistry.ContainsKey($StepType)) { - throw [System.ArgumentException]::new( - "No step handler registered for step type '$StepType'.", - 'Providers' - ) + if ($StepRegistry -is [System.Collections.IDictionary]) { + if ($StepRegistry.Contains($StepType)) { + $handlerName = $StepRegistry[$StepType] } - - $handler = $StepRegistry[$StepType] - if ($handler -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$handler)) { - throw [System.ArgumentException]::new( - "Step handler for step type '$StepType' must be a non-empty string (function name).", - 'Providers' - ) + } + else { + if ($StepRegistry.PSObject.Properties.Name -contains $StepType) { + $handlerName = $StepRegistry.$StepType } + } - return ([string]$handler).Trim() + if ($null -eq $handlerName -or [string]::IsNullOrWhiteSpace([string]$handlerName)) { + throw [System.ArgumentException]::new("No step handler registered for step type '$StepType'.", 'Providers') } - # Backward-compatible shape: registry object with GetStep(string) method. - if ($StepRegistry.PSObject.Methods.Name -contains 'GetStep') { - return $StepRegistry.GetStep($StepType) + # Reject ScriptBlock handlers (secure default). + if ($handlerName -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Step registry handler for '$StepType' must be a function name (string), not a ScriptBlock.", + 'Providers' + ) } - throw [System.ArgumentException]::new( - 'Step registry must be a hashtable mapping Step.Type to a handler function name (string).', - 'Providers' - ) + $cmd = Get-Command -Name ([string]$handlerName) -CommandType Function -ErrorAction Stop + return $cmd } - $events = [System.Collections.Generic.List[object]]::new() - - # Resolve request/correlation/actor early because New-IdleEventSink requires CorrelationId. $planPropNames = @($Plan.PSObject.Properties.Name) $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null } @@ -154,9 +134,15 @@ function Invoke-IdlePlanObject { if ($planPropNames -contains 'Actor') { $Plan.Actor } else { $null } } + $events = [System.Collections.Generic.List[object]]::new() + # Host may pass an external sink. If none is provided, we still buffer events internally. $engineEventSink = New-IdleEventSink -CorrelationId $corr -Actor $actor -ExternalEventSink $EventSink -EventBuffer $events + # Enforce data-only boundary: reject ScriptBlocks in untrusted inputs. + Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' + Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist. $stepRegistry = Get-IdleStepRegistry -Providers $Providers @@ -167,7 +153,7 @@ function Invoke-IdlePlanObject { EventSink = $engineEventSink } - $context.EventSink.WriteEvent('RunStarted', "Plan execution started (correlationId: $corr).", $null, @{ + $context.EventSink.WriteEvent('RunStarted', 'Plan execution started.', $null, @{ CorrelationId = $corr Actor = $actor StepCount = @($Plan.Steps).Count @@ -185,7 +171,7 @@ function Invoke-IdlePlanObject { $stepPropNames = @($step.PSObject.Properties.Name) - $stepName = if ($stepPropNames -contains 'Name') { $step.Name } else { $null } + $stepName = if ($stepPropNames -contains 'Name') { [string]$step.Name } else { '' } $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } @@ -236,7 +222,7 @@ function Invoke-IdlePlanObject { # - Only retries if the thrown exception is explicitly marked transient. # - Emits 'StepRetrying' events and uses deterministic jitter/backoff. $retrySeed = "$corr|$stepType|$stepName|$i" - $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName ([string]$stepName) -OperationName 'StepExecution' -DeterministicSeed $retrySeed + $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $stepName -OperationName 'StepExecution' -DeterministicSeed $retrySeed $result = $retry.Value $attempts = [int]$retry.Attempts @@ -303,6 +289,162 @@ function Invoke-IdlePlanObject { $runStatus = if ($failed) { 'Failed' } else { 'Completed' } + # Public result contract: the OnFailure section is always present. + $onFailure = [pscustomobject]@{ + PSTypeName = 'IdLE.OnFailureExecutionResult' + Status = 'NotRun' + Steps = [object[]]@() + } + + $planOnFailureSteps = @() + if ($planPropNames -contains 'OnFailureSteps') { + # Treat nulls as empty deterministically. + $planOnFailureSteps = @($Plan.OnFailureSteps) | Where-Object { $null -ne $_ } + } + + if ($failed -and @($planOnFailureSteps).Count -gt 0) { + $context.EventSink.WriteEvent('OnFailureStarted', 'Executing OnFailureSteps (best effort).', $null, @{ + OnFailureStepCount = @($planOnFailureSteps).Count + }) + + $onFailureHadFailures = $false + $onFailureStepResults = @() + + $j = 0 + foreach ($ofStep in @($planOnFailureSteps)) { + + if ($null -eq $ofStep) { + $j++ + continue + } + + $ofPropNames = @($ofStep.PSObject.Properties.Name) + $ofName = if ($ofPropNames -contains 'Name') { [string]$ofStep.Name } else { '' } + $ofType = if ($ofPropNames -contains 'Type') { $ofStep.Type } else { $null } + $ofWith = if ($ofPropNames -contains 'With') { $ofStep.With } else { $null } + $ofStatus = if ($ofPropNames -contains 'Status') { [string]$ofStep.Status } else { '' } + + # Conditions for OnFailure steps are evaluated during planning as well. + if ($ofStatus -eq 'NotApplicable') { + + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = 'NotApplicable' + Attempts = 1 + } + + $context.EventSink.WriteEvent('OnFailureStepNotApplicable', "OnFailure step '$ofName' not applicable (condition not met).", $ofName, @{ + StepType = $ofType + Index = $j + }) + + $j++ + continue + } + + $context.EventSink.WriteEvent('OnFailureStepStarted', "OnFailure step '$ofName' started.", $ofName, @{ + StepType = $ofType + Index = $j + }) + + try { + $impl = Resolve-IdleStepHandler -StepType ([string]$ofType) -StepRegistry $stepRegistry + + $supportedParams = Get-IdleCommandParameterNames -Handler $impl + + $invokeParams = @{ + Context = $context + } + + if ($null -ne $ofWith -and $supportedParams.Contains('With')) { + $invokeParams.With = $ofWith + } + + if ($supportedParams.Contains('Step')) { + $invokeParams.Step = $ofStep + } + + # Reuse safe-by-default transient retries for OnFailure steps. + $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j" + $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $ofName -OperationName 'OnFailureStepExecution' -DeterministicSeed $retrySeed + + $result = $retry.Value + $attempts = [int]$retry.Attempts + + if ($null -eq $result) { + $result = [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = 'Completed' + Attempts = $attempts + } + } + else { + # Normalize result to include Attempts for observability (non-breaking). + if ($result.PSObject.Properties.Name -notcontains 'Attempts') { + $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force + } + } + + $onFailureStepResults += $result + + if ($result.Status -eq 'Failed') { + $onFailureHadFailures = $true + + $context.EventSink.WriteEvent('OnFailureStepFailed', "OnFailure step '$ofName' failed.", $ofName, @{ + StepType = $ofType + Index = $j + Error = $result.Error + }) + } + else { + $context.EventSink.WriteEvent('OnFailureStepCompleted', "OnFailure step '$ofName' completed.", $ofName, @{ + StepType = $ofType + Index = $j + }) + } + } + catch { + $onFailureHadFailures = $true + $err = $_ + + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = 'Failed' + Error = $err.Exception.Message + Attempts = 1 + } + + $context.EventSink.WriteEvent('OnFailureStepFailed', "OnFailure step '$ofName' failed.", $ofName, @{ + StepType = $ofType + Index = $j + Error = $err.Exception.Message + }) + } + + $j++ + } + + $onFailureStatus = if ($onFailureHadFailures) { 'PartiallyFailed' } else { 'Completed' } + + $onFailure = [pscustomobject]@{ + PSTypeName = 'IdLE.OnFailureExecutionResult' + Status = $onFailureStatus + Steps = @($onFailureStepResults) + } + + $context.EventSink.WriteEvent('OnFailureCompleted', "OnFailureSteps finished (status: $onFailureStatus).", $null, @{ + Status = $onFailureStatus + StepCount = @($planOnFailureSteps).Count + }) + } + + # RunCompleted should always be the last event for deterministic event order. $context.EventSink.WriteEvent('RunCompleted', "Plan execution finished (status: $runStatus).", $null, @{ Status = $runStatus StepCount = @($Plan.Steps).Count @@ -322,7 +464,8 @@ function Invoke-IdlePlanObject { Status = $runStatus CorrelationId = $corr Actor = $actor - Steps = $stepResults + Steps = @($stepResults) + OnFailure = $onFailure Events = $events Providers = $redactedProviders } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 0d7d274f..05aa98cd 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -5,7 +5,13 @@ function New-IdlePlanObject { .DESCRIPTION Loads and validates the workflow definition (PSD1) and creates a normalized plan object. - This is a planning-only artifact. Execution is handled by Invoke-IdlePlan later. + This is a planning-only artifact. Execution is handled by Invoke-IdlePlanObject later. + + Planning responsibilities: + - Create a data-only request snapshot for deterministic exports and auditing. + - Normalize workflow steps to IdLE.PlanStep objects. + - Evaluate step conditions during planning and mark steps as NotApplicable. + - Validate required provider capabilities fail-fast (includes OnFailureSteps). .PARAMETER WorkflowPath Path to the workflow definition (PSD1). @@ -38,14 +44,15 @@ function New-IdlePlanObject { [CmdletBinding()] param( [Parameter()] - [object] $Value + [AllowNull()] + [string] $Value ) if ($null -eq $Value) { return $null } - if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } @@ -53,17 +60,34 @@ function New-IdlePlanObject { } function Copy-IdleDataObject { + <# + .SYNOPSIS + Creates a deep-ish, data-only copy of an object. + + .DESCRIPTION + This helper is used to snapshot the request input so that the plan can be exported + deterministically, without retaining references to the original live object. + + NOTE: + This is intentionally conservative and only supports data-like objects: + - Hashtable / OrderedDictionary + - PSCustomObject / NoteProperties + - Arrays/lists + - Primitive types + + ScriptBlocks and other executable objects are rejected by upstream validation. + #> [CmdletBinding()] param( [Parameter()] + [AllowNull()] [object] $Value ) - if ($null -eq $Value) { - return $null - } + if ($null -eq $Value) { return $null } - # Primitive / immutable-ish types can be returned as-is. + # Primitive / immutable types should be returned as-is before property inspection. + # This prevents strings from being converted to PSCustomObject with Length property. if ($Value -is [string] -or $Value -is [int] -or $Value -is [long] -or @@ -75,7 +99,6 @@ function New-IdlePlanObject { return $Value } - # Hashtable / IDictionary -> clone recursively. if ($Value -is [System.Collections.IDictionary]) { $copy = @{} foreach ($k in $Value.Keys) { @@ -84,36 +107,75 @@ function New-IdlePlanObject { return $copy } - # Arrays / enumerables -> clone recursively. - if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { - $items = @() + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $arr = @() foreach ($item in $Value) { - $items += Copy-IdleDataObject -Value $item + $arr += Copy-IdleDataObject -Value $item } - return $items + return $arr } - # PSCustomObject and other objects -> shallow map of public properties (data-only). - $props = $Value.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' -or $_.MemberType -eq 'Property' } + $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) if ($null -ne $props -and @($props).Count -gt 0) { - $copy = @{} + $o = [ordered]@{} foreach ($p in $props) { - $copy[$p.Name] = Copy-IdleDataObject -Value $p.Value + $o[$p.Name] = Copy-IdleDataObject -Value $p.Value + } + return [pscustomobject]$o + } + + return $Value + } + + function Get-IdleOptionalPropertyValue { + <# + .SYNOPSIS + Safely reads an optional property from an object. + + .DESCRIPTION + Works with: + - IDictionary (hashtables / ordered dictionaries) + - PSCustomObject / objects with note properties + + Returns $null when the property does not exist. + Uses Get-Member to avoid PropertyNotFoundException in strict mode. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + if ($null -eq $Object) { + return $null + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.ContainsKey($Name)) { + return $Object[$Name] } - return [pscustomobject] $copy + return $null + } + + $m = $Object | Get-Member -Name $Name -MemberType NoteProperty,Property -ErrorAction SilentlyContinue + if ($null -eq $m) { + return $null } - # Fallback: return string representation (keeps export stable without leaking runtime handles). - return [string] $Value + return $Object.$Name } - function Normalize-IdleRequiredCapabilities { + function ConvertTo-IdleRequiredCapabilities { <# .SYNOPSIS Normalizes the optional RequiresCapabilities key from a workflow step. .DESCRIPTION - A workflow step may declare required capabilities via RequiresCapabilities. Supported shapes: - missing / $null -> empty list - string -> single capability @@ -124,6 +186,7 @@ function New-IdlePlanObject { [CmdletBinding()] param( [Parameter()] + [AllowNull()] [object] $Value, [Parameter(Mandatory)] @@ -186,16 +249,11 @@ function New-IdlePlanObject { Extracts provider instances from the -Providers argument. .DESCRIPTION - The engine currently treats -Providers as a host-controlled bag of objects. - This function extracts candidate provider objects for capability discovery. - - Supported shapes: - - $null -> no providers - - hashtable -> iterate values, ignoring known non-provider keys like 'StepRegistry' - - PSCustomObject -> read public properties as provider entries + Supports both: + - hashtable map: @{ Name = ; ... } + - array/list: @( , ... ) - This is intentionally conservative: only values that look like provider instances - (non-null objects) are returned. + Returns an array of provider objects. #> [CmdletBinding()] param( @@ -208,54 +266,60 @@ function New-IdlePlanObject { return @() } - $result = @() - - if ($Providers -is [hashtable]) { + if ($Providers -is [System.Collections.IDictionary]) { + $items = @() foreach ($k in $Providers.Keys) { - # 'StepRegistry' is explicitly not a provider; it is a host extension point. - if ([string]$k -eq 'StepRegistry') { - continue - } - - $v = $Providers[$k] - if ($null -ne $v) { - $result += $v - } + $items += $Providers[$k] } - - return $result + return @($items) } - # Allow an object bag (Providers.IdentityProvider, Providers.Directory, ...). - $props = @($Providers.PSObject.Properties) - foreach ($p in $props) { - if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { - continue + if ($Providers -is [System.Collections.IEnumerable] -and $Providers -isnot [string]) { + $items = @() + foreach ($p in $Providers) { + $items += $p } + return @($items) + } - if ([string]$p.Name -eq 'StepRegistry') { - continue - } + return @($Providers) + } + + function Get-IdleProviderCapabilities { + <# + .SYNOPSIS + Gets the capability list advertised by a provider. + + .DESCRIPTION + Providers are expected to expose a GetCapabilities() method. + If not present, the provider is treated as advertising no capabilities. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Provider + ) + + if ($null -eq $Provider) { + return @() + } - if ($null -ne $p.Value) { - $result += $p.Value + if ($Provider.PSObject.Methods.Name -contains 'GetCapabilities') { + $caps = $Provider.GetCapabilities() + if ($null -eq $caps) { + return @() } + return @($caps | Where-Object { $null -ne $_ } | ForEach-Object { ([string]$_).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) } - return $result + return @() } function Get-IdleAvailableCapabilities { <# .SYNOPSIS - Builds a stable set of capabilities available from the provided providers. - - .DESCRIPTION - Capabilities are discovered from each provider via Get-IdleProviderCapabilities. - During the migration phase we allow minimal inference for legacy providers - to avoid breaking existing demos/tests. - - The returned list is stable (sorted, unique). + Aggregates capabilities from all providers. #> [CmdletBinding()] param( @@ -264,15 +328,14 @@ function New-IdlePlanObject { [object] $Providers ) - $all = @() + $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) - foreach ($p in @(Get-IdleProvidersFromMap -Providers $Providers)) { - # AllowInference is a migration aid. Once the ecosystem advertises capabilities - # explicitly, we can tighten this to explicit-only for maximum determinism. - $all += @(Get-IdleProviderCapabilities -Provider $p -AllowInference) + $caps = @() + foreach ($p in $providerInstances) { + $caps += @(Get-IdleProviderCapabilities -Provider $p) } - return @($all | Sort-Object -Unique) + return @($caps | Sort-Object -Unique) } function Assert-IdlePlanCapabilitiesSatisfied { @@ -281,11 +344,9 @@ function New-IdlePlanObject { Validates that all required step capabilities are available. .DESCRIPTION - This is a fail-fast validation executed during planning. + Fail-fast validation executed during planning. If one or more capabilities are missing, an ArgumentException is thrown with a - deterministic error message that lists missing capabilities and affected steps. - - No-op when the plan contains no steps. + deterministic error message listing missing capabilities and affected steps. #> [CmdletBinding()] param( @@ -303,16 +364,21 @@ function New-IdlePlanObject { } $required = @() - $requiredByStep = @{} + $requiredByStep = [ordered]@{} foreach ($s in @($Steps)) { - $stepName = if ($s.PSObject.Properties.Name -contains 'Name') { [string]$s.Name } else { '' } - $caps = @() + if ($null -eq $s) { + continue + } - if ($s.PSObject.Properties.Name -contains 'RequiresCapabilities') { - $caps = @($s.RequiresCapabilities) + $stepName = Get-IdleOptionalPropertyValue -Object $s -Name 'Name' + if ($null -eq $stepName -or [string]::IsNullOrWhiteSpace([string]$stepName)) { + $stepName = '' } + $capsRaw = Get-IdleOptionalPropertyValue -Object $s -Name 'RequiresCapabilities' + $caps = if ($null -eq $capsRaw) { @() } else { @($capsRaw) } + if (@($caps).Count -gt 0) { $required += $caps $requiredByStep[$stepName] = @($caps) @@ -320,8 +386,6 @@ function New-IdlePlanObject { } $required = @($required | Sort-Object -Unique) - - # Nothing required -> nothing to validate. if (@($required).Count -eq 0) { return } @@ -336,17 +400,15 @@ function New-IdlePlanObject { } $missing = @($missing | Sort-Object -Unique) - if (@($missing).Count -eq 0) { return } - # Determine which steps are affected for better UX. $affectedSteps = @() foreach ($k in $requiredByStep.Keys) { - $caps = @($requiredByStep[$k]) + $capsForStep = @($requiredByStep[$k]) foreach ($m in $missing) { - if ($caps -contains $m) { + if ($capsForStep -contains $m) { $affectedSteps += $k break } @@ -364,64 +426,10 @@ function New-IdlePlanObject { throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') } - # Ensure required request properties exist without hard-typing the request class. - $reqProps = $Request.PSObject.Properties.Name - if ($reqProps -notcontains 'LifecycleEvent') { - throw [System.ArgumentException]::new("Request object must contain property 'LifecycleEvent'.", 'Request') - } - if ($reqProps -notcontains 'CorrelationId') { - throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') - } - - # Create a data-only snapshot of the incoming request. - # This is required for auditing/approvals and for deterministic plan export artifacts. - # We intentionally store a snapshot (not a reference) to avoid accidental mutations later. - $requestSnapshot = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequestSnapshot' - LifecycleEvent = ConvertTo-NullIfEmptyString -Value ([string] $Request.LifecycleEvent) - CorrelationId = ConvertTo-NullIfEmptyString -Value ([string] $Request.CorrelationId) - Actor = if ($reqProps -contains 'Actor') { ConvertTo-NullIfEmptyString -Value ([string] $Request.Actor) } else { $null } - IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } - DesiredState = if ($reqProps -contains 'DesiredState') { Copy-IdleDataObject -Value $Request.DesiredState } else { $null } - Changes = if ($reqProps -contains 'Changes') { Copy-IdleDataObject -Value $Request.Changes } else { $null } - } - - # Validate workflow and ensure it matches the request's LifecycleEvent. - $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request - - # Create the plan object (planning artifact). - # Steps will be populated after we have a stable plan context for condition evaluation. - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - WorkflowName = [string]$workflow.Name - LifecycleEvent = [string]$workflow.LifecycleEvent - CorrelationId = [string]$requestSnapshot.CorrelationId - Request = $requestSnapshot - Actor = $requestSnapshot.Actor - CreatedUtc = [DateTime]::UtcNow - Steps = @() - Actions = @() - Warnings = @() - Providers = $Providers - } - - # Build a planning context for condition evaluation. - # This allows conditions to reference "Plan.*" paths (e.g. Plan.LifecycleEvent). - $planningContext = [pscustomobject]@{ - Plan = $plan - Request = $Request - Workflow = $workflow - } - function Test-IdleWorkflowStepKey { <# .SYNOPSIS Checks whether a workflow step contains a given key. - - .DESCRIPTION - Workflow steps can be represented as hashtables (IDictionary) or as objects - (PSCustomObject) depending on how they were imported. This helper provides a - stable way to check for keys across both representations. #> [CmdletBinding()] param( @@ -438,21 +446,14 @@ function New-IdlePlanObject { return $Step.ContainsKey($Key) } - return ($Step.PSObject.Properties.Name -contains $Key) + $m = $Step | Get-Member -Name $Key -MemberType NoteProperty,Property -ErrorAction SilentlyContinue + return ($null -ne $m) } function Get-IdleWorkflowStepValue { <# .SYNOPSIS Gets a value from a workflow step by key. - - .DESCRIPTION - Workflow steps can be represented as hashtables (IDictionary) or as objects - (PSCustomObject) depending on how they were imported. This helper provides a - stable way to read values across both representations. - - IMPORTANT: - Call Test-IdleWorkflowStepKey before calling this function when the key may be optional. #> [CmdletBinding()] param( @@ -469,102 +470,190 @@ function New-IdlePlanObject { return $Step[$Key] } - return $Step.PSObject.Properties[$Key].Value + return $Step.$Key } - # Normalize steps into a stable internal representation. - # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. - # Step conditions are evaluated during planning and may mark steps as NotApplicable. - $normalizedSteps = @() - foreach ($s in @($workflow.Steps)) { - $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') - } - else { - '' - } + function ConvertTo-IdleWorkflowSteps { + <# + .SYNOPSIS + Normalizes workflow steps into IdLE.PlanStep objects. - if ([string]::IsNullOrWhiteSpace($stepName)) { - throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') - } + .DESCRIPTION + Evaluates Condition during planning and sets Status = Planned / NotApplicable. - $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') - } - else { - '' - } + IMPORTANT: + WorkflowSteps is optional and may be null or empty. A workflow is allowed to omit + OnFailureSteps entirely. Therefore we must not mark this parameter as Mandatory. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object[]] $WorkflowSteps, - if ([string]::IsNullOrWhiteSpace($stepType)) { - throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') - } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $PlanningContext + ) - if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), - 'Workflow' - ) + if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { + return @() } - $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { - Get-IdleWorkflowStepValue -Step $s -Key 'Condition' - } - else { - $null - } + $normalizedSteps = @() - $status = 'Planned' - if ($null -ne $condition) { - $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName - if (@($schemaErrors).Count -gt 0) { + foreach ($s in @($WorkflowSteps)) { + $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepName)) { + throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') + } + + $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') + } + + if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { throw [System.ArgumentException]::new( - ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), + ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), 'Workflow' ) } - $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext - if (-not $isApplicable) { - $status = 'NotApplicable' + $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { + Get-IdleWorkflowStepValue -Step $s -Key 'Condition' + } + else { + $null } - } - $requiresCaps = @() - if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { - $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName - } + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName + if (@($schemaErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) + } - $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') - } - else { - '' - } + $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext + if (-not $isApplicable) { + $status = 'NotApplicable' + } + } - $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { - Get-IdleWorkflowStepValue -Step $s -Key 'With' - } - else { - @{} - } + $requiresCaps = @() + if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { + $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + } + + $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') + } + else { + '' + } - $normalizedSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = $stepName - Type = $stepType - Description = $description - Condition = $condition - With = $with - RequiresCapabilities = $requiresCaps - Status = $status + $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { + Copy-IdleDataObject -Value (Get-IdleWorkflowStepValue -Step $s -Key 'With') + } + else { + @{} + } + + $normalizedSteps += [pscustomobject]@{ + PSTypeName = 'IdLE.PlanStep' + Name = $stepName + Type = $stepType + Description = $description + Condition = Copy-IdleDataObject -Value $condition + With = $with + RequiresCapabilities = $requiresCaps + Status = $status + } } + + # IMPORTANT: + # Returning an empty array variable can produce no pipeline output, resulting in $null on assignment. + # Force a stable array output shape. + return @($normalizedSteps) + } + + # Ensure required request properties exist without hard-typing the request class. + $reqProps = $Request.PSObject.Properties.Name + if ($reqProps -notcontains 'LifecycleEvent') { + throw [System.ArgumentException]::new("Request object must contain property 'LifecycleEvent'.", 'Request') + } + if ($reqProps -notcontains 'CorrelationId') { + throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') + } + + # Create a data-only snapshot of the incoming request for deterministic exports. + $requestSnapshot = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequestSnapshot' + LifecycleEvent = ConvertTo-NullIfEmptyString -Value ([string]$Request.LifecycleEvent) + CorrelationId = ConvertTo-NullIfEmptyString -Value ([string]$Request.CorrelationId) + Actor = if ($reqProps -contains 'Actor') { ConvertTo-NullIfEmptyString -Value ([string]$Request.Actor) } else { $null } + IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } + DesiredState = if ($reqProps -contains 'DesiredState') { Copy-IdleDataObject -Value $Request.DesiredState } else { $null } + Changes = if ($reqProps -contains 'Changes') { Copy-IdleDataObject -Value $Request.Changes } else { $null } } - # Attach steps to the plan after normalization. - $plan.Steps = $normalizedSteps + # Validate workflow and ensure it matches the request's LifecycleEvent. + $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + + # Create the plan object (planning artifact). + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + WorkflowName = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + CorrelationId = [string]$requestSnapshot.CorrelationId + Request = $requestSnapshot + Actor = $requestSnapshot.Actor + CreatedUtc = [DateTime]::UtcNow + + Steps = @() + OnFailureSteps = @() + + Actions = @() + Warnings = @() + Providers = $Providers + } + + # Build a planning context for condition evaluation. + $planningContext = [pscustomobject]@{ + Plan = $plan + Request = $Request + Workflow = $workflow + } + + $workflowOnFailureSteps = Get-IdleOptionalPropertyValue -Object $workflow -Name 'OnFailureSteps' + + # Normalize primary and OnFailure steps. + # IMPORTANT: + # ConvertTo-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. + $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext) + $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext) + + # Fail-fast capability validation (includes OnFailureSteps). + $allStepsForCapabilities = @() + $allStepsForCapabilities += @($plan.Steps) + $allStepsForCapabilities += @($plan.OnFailureSteps) - # Fail-fast capability validation (only if at least one step declares requirements). - Assert-IdlePlanCapabilitiesSatisfied -Steps $plan.Steps -Providers $Providers + Assert-IdlePlanCapabilitiesSatisfied -Steps $allStepsForCapabilities -Providers $Providers return $plan } diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index 7449cfd7..8f61bfce 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -68,6 +68,21 @@ function Test-IdleWorkflowDefinitionObject { $idx++ } + # 4c) Validate OnFailureSteps definitions (Name/Type/Condition/With + data-only). + # These are executed only when a run fails, but they must be valid workflow steps. + if ($workflow.ContainsKey('OnFailureSteps') -and $null -ne $workflow.OnFailureSteps) { + $idx = 0 + foreach ($s in @($workflow.OnFailureSteps)) { + $stepErrors = Test-IdleStepDefinition -Step $s -Index $idx + foreach ($e in @($stepErrors)) { + # Re-label errors so operators can clearly distinguish the step collection. + $normalizedError = ([string]$e) -replace '^Step\[(\d+)\]', 'OnFailureSteps[$1]' + $null = $errors.Add($normalizedError) + } + $idx++ + } + } + if ($errors.Count -gt 0) { # Fail early with a single terminating exception, including all violations. $message = "Workflow validation failed:`n- " + ($errors -join "`n- ") @@ -82,5 +97,6 @@ function Test-IdleWorkflowDefinitionObject { LifecycleEvent = [string]$workflow.LifecycleEvent Description = if ($workflow.ContainsKey('Description')) { [string]$workflow.Description } else { $null } Steps = @($workflow.Steps) + OnFailureSteps = if ($workflow.ContainsKey('OnFailureSteps') -and $null -ne $workflow.OnFailureSteps) { @($workflow.OnFailureSteps) } else { @() } } } diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index 1b387b6b..26570ea1 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -40,16 +40,33 @@ function Invoke-IdlePlan { process { if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { # For -WhatIf: return a minimal preview object. + # Keep the public output contract stable by always including OnFailure. $correlationId = $null if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { $correlationId = [string]$Plan.CorrelationId } + $actor = $null + if ($Plan.PSObject.Properties.Name -contains 'Actor') { + $actor = [string]$Plan.Actor + } + elseif ($Plan.PSObject.Properties.Name -contains 'Request' -and $null -ne $Plan.Request) { + if ($Plan.Request.PSObject.Properties.Name -contains 'Actor') { + $actor = [string]$Plan.Request.Actor + } + } + return [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionResult' Status = 'WhatIf' CorrelationId = $correlationId + Actor = $actor Steps = @($Plan.Steps) + OnFailure = [pscustomobject]@{ + PSTypeName = 'IdLE.OnFailureExecutionResult' + Status = 'NotRun' + Steps = @() + } Events = @() } } diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 09b4ae1b..485b6160 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -47,12 +47,34 @@ BeforeAll { Error = $null } } + + function global:Invoke-IdleTestFailStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Failed' + Error = 'Boom' + } + } } AfterAll { # Cleanup global test functions to avoid polluting the session. Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue Remove-Item -Path 'Function:\Invoke-IdleTestEmitStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleTestFailStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan' { @@ -219,6 +241,129 @@ Describe 'Invoke-IdlePlan' { ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 } + It 'executes OnFailureSteps when a step fails (best effort)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo - OnFailure' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'FailPrimary'; Type = 'IdLE.Step.FailPrimary' } + @{ Name = 'NeverRuns'; Type = 'IdLE.Step.NeverRuns' } + ) + OnFailureSteps = @( + @{ Name = 'OnFailure1'; Type = 'IdLE.Step.OnFailure1' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.NeverRuns' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + @($result.Steps).Count | Should -Be 1 + $result.Steps[0].Name | Should -Be 'FailPrimary' + + $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' + $result.OnFailure.Status | Should -Be 'Completed' + @($result.OnFailure.Steps).Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'Completed' + + $types = @($result.Events | ForEach-Object { $_.Type }) + $types | Should -Contain 'StepFailed' + $types | Should -Contain 'OnFailureStarted' + $types | Should -Contain 'OnFailureCompleted' + + [array]::IndexOf($types, 'StepFailed') | Should -BeLessThan ([array]::IndexOf($types, 'OnFailureStarted')) + + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } + + It 'continues OnFailureSteps when an OnFailure step fails (best effort)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-partial.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo - OnFailure Partial' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'FailPrimary'; Type = 'IdLE.Step.FailPrimary' } + ) + OnFailureSteps = @( + @{ Name = 'OnFailureFail'; Type = 'IdLE.Step.OnFailureFail' } + @{ Name = 'OnFailureOk'; Type = 'IdLE.Step.OnFailureOk' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.OnFailureFail' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.OnFailureOk' = 'Invoke-IdleTestEmitStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'PartiallyFailed' + @($result.OnFailure.Steps).Count | Should -Be 2 + $result.OnFailure.Steps[0].Status | Should -Be 'Failed' + $result.OnFailure.Steps[1].Status | Should -Be 'Completed' + + ($result.Events | Where-Object Type -eq 'OnFailureStepStarted').Count | Should -Be 2 + ($result.Events | Where-Object Type -eq 'OnFailureStepFailed').Count | Should -Be 1 + ($result.Events | Where-Object Type -eq 'OnFailureStepCompleted').Count | Should -Be 1 + } + + It 'does not execute OnFailureSteps when run completes successfully' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-notrun.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo - OnFailure NotRun' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Ok'; Type = 'IdLE.Step.Ok' } + ) + OnFailureSteps = @( + @{ Name = 'OnFailure1'; Type = 'IdLE.Step.OnFailure1' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Ok' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + + @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + } + It 'fails planning when a step is missing Type' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @@ -255,4 +400,37 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw } -} + + It 'rejects ScriptBlock in Plan object' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'TestStep' + Type = 'Test' + With = @{ + Payload = { Write-Host 'Should not execute' } + } + } + ) + } + + { Invoke-IdlePlan -Plan $plan } | Should -Throw '*ScriptBlocks are not allowed*' + } + + It 'rejects ScriptBlock in Providers object' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @() + } + + $providers = @{ + Config = @{ + Secret = { Get-Secret } + } + } + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*ScriptBlocks are not allowed*' + }} \ No newline at end of file diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 5283b250..8c05b5be 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -7,6 +7,34 @@ BeforeAll { . (Join-Path $PSScriptRoot '_testHelpers.ps1') Import-IdleTestModule + + # The engine invokes step handlers by function name (string). + # This handler is used to validate the public output contract of Invoke-IdlePlan + # without relying on built-in step implementations. + function global:Invoke-IdleSurfaceTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleSurfaceTestNoopStep' -ErrorAction SilentlyContinue } Describe 'Module manifests and public surface' { @@ -35,6 +63,50 @@ Describe 'Module manifests and public surface' { $actual | Should -Be $expected } + It 'Invoke-IdlePlan returns a public execution result that includes an OnFailure section' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $idlePsd1 -Force -ErrorAction Stop + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'surface-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Surface - OnFailure Contract' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.Primary' } + ) + OnFailureSteps = @( + @{ Name = 'Containment'; Type = 'IdLE.Step.Containment' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Primary' = 'Invoke-IdleSurfaceTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleSurfaceTestNoopStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result | Should -Not -BeNullOrEmpty + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + + # Public result contract: the OnFailure section is always present. + $result.PSObject.Properties.Name | Should -Contain 'OnFailure' + $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + + # Successful runs must not emit OnFailure events. + @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + } + It 'Importing IdLE makes built-in steps available to the engine without exporting them globally' { Remove-Module IdLE, IdLE.Core, IdLE.Steps.Common -Force -ErrorAction SilentlyContinue Import-Module $idlePsd1 -Force -ErrorAction Stop diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index 4f377075..85b962f9 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -15,9 +15,8 @@ Describe 'New-IdlePlan - required provider capabilities' { Steps = @( @{ Name = 'Disable identity' - Type = 'IdLE.Step.EmitEvent' - With = @{ Message = 'Disable identity (planning only test)' } - RequiresCapabilities = 'Identity.Disable' + Type = 'IdLE.Step.DisableIdentity' + RequiresCapabilities = @('Identity.Disable') } ) } @@ -26,29 +25,27 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $null | Out-Null + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'required provider capabilities are missing' - $_.Exception.Message | Should -Match 'MissingCapabilities:\s+Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps:\s+Disable identity' + $_.Exception.Message | Should -Match 'MissingCapabilities: Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' } } - It 'builds the plan when required capabilities are available' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities.psd1' + It 'allows planning when a provider advertises the required capabilities' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities-ok.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Capability Validation' + Name = 'Joiner - Capability Validation OK' LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'Disable identity' - Type = 'IdLE.Step.EmitEvent' - With = @{ Message = 'Disable identity (planning only test)' } - RequiresCapabilities = 'Identity.Disable' + Type = 'IdLE.Step.DisableIdentity' + RequiresCapabilities = @('Identity.Disable') } ) } @@ -56,10 +53,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - # Minimal provider that advertises the required capability. - $provider = [pscustomobject]@{ - Name = 'TestProvider' - } + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('Identity.Disable') } -Force @@ -75,6 +69,82 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan.Steps[0].RequiresCapabilities | Should -Be @('Identity.Disable') } + It 'fails fast when an OnFailure step requires capabilities that no provider advertises' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailure Capability Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Primary step' + Type = 'IdLE.Step.Primary' + } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.Containment' + RequiresCapabilities = @('Identity.Disable') + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities: Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Containment' + } + } + + It 'includes OnFailureSteps capability requirements in successful planning' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities-ok.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailure Capability Validation OK' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Primary step' + Type = 'IdLE.Step.Primary' + } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.Containment' + RequiresCapabilities = @('Identity.Disable') + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.OnFailureSteps.Count | Should -Be 1 + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Be @('Identity.Disable') + } + It 'validates entitlement capabilities for EnsureEntitlement steps' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-entitlements.psd1' @@ -96,7 +166,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $null | Out-Null + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null throw 'Expected an exception but none was thrown.' } catch { diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 index c833b980..ddcea388 100644 --- a/tests/New-IdlePlan.Tests.ps1 +++ b/tests/New-IdlePlan.Tests.ps1 @@ -36,6 +36,55 @@ Describe 'New-IdlePlan' { @($plan.Warnings).Count | Should -Be 0 $plan.Providers.Dummy | Should -BeTrue + + # OnFailureSteps are optional in workflows, but the plan should always expose the property + # to keep downstream execution deterministic and avoid "property exists?" checks. + $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' + @($plan.OnFailureSteps).Count | Should -Be 0 + } + + It 'normalizes OnFailureSteps and evaluates their conditions during planning' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailureSteps' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.Containment' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + With = @{ Mode = 'Quarantine' } + } + @{ + Name = 'NeverApplicable' + Type = 'IdLE.Step.NeverApplicable' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Mover' } } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + + $plan | Should -Not -BeNullOrEmpty + $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' + @($plan.OnFailureSteps).Count | Should -Be 2 + + $plan.OnFailureSteps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.OnFailureSteps[0].Name | Should -Be 'Containment' + $plan.OnFailureSteps[0].Type | Should -Be 'IdLE.Step.Containment' + $plan.OnFailureSteps[0].Status | Should -Be 'Planned' + $plan.OnFailureSteps[0].With.Mode | Should -Be 'Quarantine' + + $plan.OnFailureSteps[1].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.OnFailureSteps[1].Name | Should -Be 'NeverApplicable' + $plan.OnFailureSteps[1].Type | Should -Be 'IdLE.Step.NeverApplicable' + $plan.OnFailureSteps[1].Status | Should -Be 'NotApplicable' } It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { diff --git a/tests/Test-IdleWorkflow.Tests.ps1 b/tests/Test-IdleWorkflow.Tests.ps1 index 6ddc7585..6bb6733e 100644 --- a/tests/Test-IdleWorkflow.Tests.ps1 +++ b/tests/Test-IdleWorkflow.Tests.ps1 @@ -17,66 +17,52 @@ Describe 'Test-IdleWorkflow' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $result = Test-IdleWorkflow -WorkflowPath $wfPath -Request $req + $result = Test-IdleWorkflow -WorkflowPath $wfPath + $result | Should -Not -BeNullOrEmpty $result.IsValid | Should -BeTrue $result.WorkflowName | Should -Be 'Joiner - Standard' $result.LifecycleEvent | Should -Be 'Joiner' $result.StepCount | Should -Be 1 } - It 'throws for unknown root keys (strict validation)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-root.psd1' + It 'accepts OnFailureSteps as an optional top-level section' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Standard' + Name = 'Joiner - OnFailure' LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } ) - Bogus = 'nope' + OnFailureSteps = @( + @{ Name = 'Containment'; Type = 'IdLE.Step.DisableIdentity' } + ) } '@ - try { - Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'Unknown root key' - } - } + { Test-IdleWorkflow -WorkflowPath $wfPath } | Should -Not -Throw - It 'throws when a step is missing required keys' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-step.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity' } - ) -} -'@ + $result = Test-IdleWorkflow -WorkflowPath $wfPath + $result.IsValid | Should -BeTrue + $result.WorkflowName | Should -Be 'Joiner - OnFailure' + $result.LifecycleEvent | Should -Be 'Joiner' - try { - Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'Steps\[0\]\.Type' - } + # Test-IdleWorkflow returns a small report; StepCount reflects primary Steps only. + $result.StepCount | Should -Be 1 } - It 'throws when the workflow contains ScriptBlocks (data-only rule)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-sb.psd1' + It 'rejects unknown root keys such as CleanupSteps' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-cleanupsteps.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Standard' + Name = 'Joiner - Invalid' LifecycleEvent = 'Joiner' Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity'; With = @{ X = { "NOPE" } } } + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) + CleanupSteps = @( + @{ Name = 'Nope'; Type = 'IdLE.Step.DisableIdentity' } ) } '@ @@ -86,12 +72,13 @@ Describe 'Test-IdleWorkflow' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'Unknown root key' + $_.Exception.Message | Should -Match 'CleanupSteps' } } - It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + It 'fails when workflow LifecycleEvent does not match request LifecycleEvent' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-mismatch.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Joiner - Standard'