diff --git a/docs/extend/steps.md b/docs/extend/steps.md index fd63ff72..07c7c7e3 100644 --- a/docs/extend/steps.md +++ b/docs/extend/steps.md @@ -231,6 +231,20 @@ $result.OnFailure.Steps # Array of OnFailure step results For details on declaring OnFailureSteps, see [Workflows](../use/workflows.md). +### Blocked outcome (runtime preconditions) + +A step may produce a `Blocked` outcome when a **runtime precondition** fails at execution time. + +`Blocked` is a first-class outcome distinct from `Failed`: + +- `Blocked` represents a **policy/safety gate**, not a technical error. +- Execution stops immediately (subsequent steps are not run). +- `OnFailureSteps` do **not** run for a `Blocked` outcome. +- `result.Status` is `'Blocked'`; `result.OnFailure.Status` is `'NotRun'`. + +For details on configuring runtime preconditions, see +[Runtime Preconditions](../use/preconditions.md). + --- ## Common pitfalls diff --git a/docs/use/preconditions.md b/docs/use/preconditions.md new file mode 100644 index 00000000..a862c5f9 --- /dev/null +++ b/docs/use/preconditions.md @@ -0,0 +1,225 @@ +--- +title: Runtime Preconditions +sidebar_label: Runtime Preconditions +--- + +Runtime Preconditions are **read-only execution guards** evaluated immediately before a step runs. +They protect against stale plans: when time passes between plan creation and execution, external +state may have changed. Preconditions check live (or request-context) data at execution time and +stop the run before an unsafe action is taken. + +:::info Planning-time vs. runtime +`Condition` is evaluated at **planning time** and controls whether a step is included in the plan +(`Status = Planned | NotApplicable`). Preconditions are evaluated at **execution time**, after the +plan is built, immediately before each step runs. This keeps planning deterministic while enabling +safety guards. +::: + +--- + +## When to use preconditions + +Use preconditions when: + +- The validity of a step depends on **current state** that may change after plan creation. +- A policy or compliance rule must be checked **live** before an action is allowed to proceed. +- You want to surface a structured, human-readable message to an operator when a gate fails. + +**Example — BYOD policy:** + +Before disabling an identity, the system should verify that company data has been wiped from any +BYOD (Bring Your Own Device) device. If the wipe confirmation is missing, execution must stop with +a `Blocked` outcome and a message instructing the operator to perform the wipe manually. + +--- + +## Schema + +Add these optional properties to a workflow step definition: + +| Property | Type | Required | Description | +|---|---|---|---| +| `Preconditions` | `Array[Condition]` | No | One or more condition nodes (same DSL as `Condition`). All must pass for the step to execute. | +| `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default), `Fail`, or `Continue`. | +| `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. | + +### PreconditionEvent schema + +| Key | Type | Required | Description | +|---|---|---|---| +| `Type` | `String` | **Yes** | Event type string (for example: `ManualActionRequired`). | +| `Message` | `String` | **Yes** | Human-readable description of the required action. | +| `Data` | `Hashtable` | No | Optional key-value payload. Must not contain secrets. | + +--- + +## Example + +```powershell +@{ + Name = 'Leaver' + LifecycleEvent = 'Leaver' + + Steps = @( + @{ + Name = 'DisableIdentity' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + Provider = 'Identity' + IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' + } + + # Runtime guard: only execute if BYOD wipe is confirmed. + # Note: the condition DSL compares values as strings. + # Request.Context.Byod.WipeConfirmed must be the string 'true' (e.g. set by a ContextResolver). + Preconditions = @( + @{ + Equals = @{ + Path = 'Request.Context.Byod.WipeConfirmed' + Value = 'true' + } + } + ) + OnPreconditionFalse = 'Blocked' + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Perform Intune retire / wipe company data for BYOD device before disabling the identity.' + Data = @{ + Reason = 'BYOD wipe not confirmed' + } + } + } + ) +} +``` + +--- + +## Condition DSL + +Each entry in `Preconditions` uses the same **declarative condition DSL** as the `Condition` +property. Supported operators: + +| Operator | Shape | Description | +|---|---|---| +| `Equals` | `@{ Path = '...'; Value = '...' }` | True when the resolved path equals the value (string comparison). | +| `NotEquals` | `@{ Path = '...'; Value = '...' }` | True when the resolved path does not equal the value. | +| `Exists` | `'path'` or `@{ Path = '...' }` | True when the resolved path is non-null. | +| `In` | `@{ Path = '...'; Values = @(...) }` | True when the resolved path value is in the list. | +| `All` | `@{ All = @( ... ) }` | True when all child conditions are true (AND). | +| `Any` | `@{ Any = @( ... ) }` | True when at least one child condition is true (OR). | +| `None` | `@{ None = @( ... ) }` | True when no child conditions are true (NOR). | + +### Path resolution + +Paths are resolved against the **execution-time context**, which includes: + +| Root | Description | +|---|---| +| `Plan.*` | The plan object (e.g. `Plan.LifecycleEvent`). | +| `Request.*` | The lifecycle request, including `Request.Intent.*`, `Request.Context.*`, `Request.IdentityKeys.*`. | + +A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department` +resolves identically to `Request.Intent.Department`). + +--- + +## Blocked vs. Failed vs. Continue outcomes + +| Outcome | `OnPreconditionFalse` | Meaning | Stops execution? | OnFailureSteps triggered? | +|---|---|---|---|---| +| `Blocked` | `Blocked` (default) | A policy or precondition gate stopped execution. Not a technical failure. | **Yes** | **No** | +| `Failed` | `Fail` | Treated as a genuine failure (same semantics as a step error). | **Yes** | **Yes** | +| `PreconditionSkipped` | `Continue` | Emits observability events and skips the step; subsequent steps run normally. | **No** | **No** | + +### Execution result — Blocked + +When a step is `Blocked`: + +- `result.Status` is `'Blocked'`. +- `result.Steps[n].Status` is `'Blocked'` for the blocking step. +- `result.OnFailure.Status` is `'NotRun'` (OnFailureSteps do not execute). +- A `StepPreconditionFailed` engine event is always emitted. +- A `StepBlocked` engine event is emitted for the blocked step. +- If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. + +### Execution result — Fail + +When `OnPreconditionFalse = 'Fail'`: + +- `result.Status` is `'Failed'`. +- `result.Steps[n].Status` is `'Failed'` with `Error = 'Precondition check failed.'`. +- `OnFailureSteps` run (same behavior as any other step failure). +- A `StepPreconditionFailed` engine event is always emitted. +- A `StepFailed` engine event is emitted (matching the format of regular step failure events). +- If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. + +### Execution result — Continue + +When `OnPreconditionFalse = 'Continue'`: + +- `result.Status` is `'Completed'` (unless a subsequent step fails for another reason). +- `result.Steps[n].Status` is `'PreconditionSkipped'` for the skipped step. +- Subsequent steps execute as normal. +- A `StepPreconditionFailed` engine event is always emitted for observability. +- If `PreconditionEvent` is configured, an additional event of the declared `Type` is also emitted. + +Use `Continue` when a precondition failure is advisory rather than blocking — for example, to emit +an audit event noting that an optional step was skipped due to a policy condition, while allowing +the rest of the workflow to complete. + +--- + +## Events emitted on precondition failure + +| Event type | `OnPreconditionFalse` modes | Description | +|---|---|---| +| `StepPreconditionFailed` | All (`Blocked`, `Fail`, `Continue`) | Always emitted. Contains `StepType`, `Index`, `OnPreconditionFalse`. | +| `StepBlocked` | `Blocked` | Emitted when the step outcome is `Blocked`. Contains `StepType`, `Index`. | +| `StepFailed` | `Fail` | Emitted when the step outcome is `Failed`. Contains `StepType`, `Index`, `Error`. | +| Configured `PreconditionEvent.Type` | All (if `PreconditionEvent` configured) | Caller-defined event. | + +### StepPreconditionFailed event + +| Field | Value | +|---|---| +| `Type` | `StepPreconditionFailed` | +| `StepName` | The name of the affected step. | +| `Data.StepType` | The step type identifier. | +| `Data.Index` | The step index in the plan. | +| `Data.OnPreconditionFalse` | `Blocked`, `Fail`, or `Continue`. | + +### PreconditionEvent (caller-configured) + +If `PreconditionEvent` is configured, an additional event is emitted with: + +| Field | Value | +|---|---| +| `Type` | The configured `PreconditionEvent.Type`. | +| `Message` | The configured `PreconditionEvent.Message`. | +| `StepName` | The name of the affected step. | +| `Data` | The configured `PreconditionEvent.Data` (if provided). | + +:::warning Log safety +`PreconditionEvent.Data` is surfaced as a structured event and may be forwarded to audit sinks. +Do **not** include secrets, credentials, or personal data in `Data`. +::: + +:::note String comparison +The condition DSL always compares values as **strings** (for example, boolean `$true` becomes `'True'`). +Ensure context values are stored as strings when using `Equals` or `In` operators. +::: + +--- + +## Backward compatibility + +Steps without `Preconditions` behave exactly as before. Adding preconditions to a step does not +affect any other steps. + +--- + +## Reference + +- [Steps reference](../reference/steps.md) +- [Concepts: Plan → Execute separation](../about/concepts.md) diff --git a/docs/use/workflows.md b/docs/use/workflows.md index cd14e79b..1570141e 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -24,6 +24,16 @@ At a high level, a workflow contains: The Big Picture is described in [Concepts](../about/concepts.md). +### Step execution controls + +Each step supports several optional execution control properties: + +| Property | Evaluated at | Purpose | +|---|---|---| +| `Condition` | Plan time | Include or skip the step based on request/intent data. | +| `Preconditions` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](preconditions.md). | +| `OnFailureSteps` | After failure (workflow-level) | Cleanup/rollback steps run after a primary step fails. | + --- ## Minimal workflow example @@ -165,5 +175,6 @@ For full definitions and reference, see: ## Next steps +- Add runtime safety guards: [Runtime Preconditions](preconditions.md) - Map external systems: [Providers](providers.md) - Review and export plans: [Plan Export](plan-export.md) (e.g. for CI systems) diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index a4448264..22da8a13 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -126,12 +126,99 @@ function ConvertTo-IdleWorkflowSteps { $null } + # Runtime Preconditions: evaluated at execution time (not planning time). + # Each precondition uses the same declarative condition DSL as Condition. + $preconditions = $null + if (Test-IdleWorkflowStepKey -Step $s -Key 'Preconditions') { + $rawPreconditions = Get-IdlePropertyValue -Object $s -Name 'Preconditions' + if ($null -ne $rawPreconditions) { + $pcList = @($rawPreconditions) + for ($pcIdx = 0; $pcIdx -lt $pcList.Count; $pcIdx++) { + $pc = $pcList[$pcIdx] + if ($pc -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': Preconditions[{1}] must be a hashtable (condition node)." -f $stepName, $pcIdx), + 'Workflow' + ) + } + $pcErrors = Test-IdleConditionSchema -Condition $pc -StepName $stepName + if (@($pcErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Preconditions[{0}] on step '{1}': {2}" -f $pcIdx, $stepName, ([string]::Join(' ', @($pcErrors)))), + 'Workflow' + ) + } + } + $preconditions = @() + foreach ($pc in $pcList) { + $preconditions += Copy-IdleDataObject -Value $pc + } + } + } + + $onPreconditionFalse = $null + if (Test-IdleWorkflowStepKey -Step $s -Key 'OnPreconditionFalse') { + $rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $s -Name 'OnPreconditionFalse' + if ($null -ne $rawOnPreconditionFalseValue) { + $rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue + if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) { + if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $stepName, $rawOnPreconditionFalse), + 'Workflow' + ) + } + $onPreconditionFalse = $rawOnPreconditionFalse + } + } + } + + $preconditionEvent = $null + if (Test-IdleWorkflowStepKey -Step $s -Key 'PreconditionEvent') { + $rawPreconditionEvent = Get-IdlePropertyValue -Object $s -Name 'PreconditionEvent' + if ($null -ne $rawPreconditionEvent) { + if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $stepName), + 'Workflow' + ) + } + $pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtType)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $stepName), + 'Workflow' + ) + } + $pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null } + if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $stepName), + 'Workflow' + ) + } + # PreconditionEvent.Data is optional but must be a hashtable if present. + if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) { + if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $stepName), + 'Workflow' + ) + } + } + $preconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent + } + } + $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = $stepName Type = $stepType Description = $description Condition = Copy-IdleDataObject -Value $condition + Preconditions = $preconditions + OnPreconditionFalse = $onPreconditionFalse + PreconditionEvent = $preconditionEvent With = $with RequiresCapabilities = $requiresCaps Status = $status diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 12a3566d..767fb7e3 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -23,7 +23,7 @@ function Test-IdleWorkflowSchema { [System.Collections.Generic.List[string]] $ErrorList ) - $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile') + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RetryProfile', 'Preconditions', 'OnPreconditionFalse', 'PreconditionEvent') foreach ($k in $Step.Keys) { if ($allowedStepKeys -notcontains $k) { $ErrorList.Add("Unknown key '$k' in $StepPath. Allowed keys: $($allowedStepKeys -join ', ').") @@ -57,6 +57,64 @@ function Test-IdleWorkflowSchema { } } + # Helper: Validate Preconditions, OnPreconditionFalse, and PreconditionEvent fields on a step. + function Test-IdleWorkflowStepPreconditions { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable] $Step, + + [Parameter(Mandatory)] + [string] $StepPath, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.List[string]] $ErrorList + ) + + if ($Step.ContainsKey('Preconditions') -and $null -ne $Step.Preconditions) { + if ($Step.Preconditions -is [string] -or + $Step.Preconditions -is [System.Collections.IDictionary] -or + -not ($Step.Preconditions -is [System.Collections.IEnumerable])) { + $ErrorList.Add("'$StepPath.Preconditions' must be an array/list of condition hashtables (not a single hashtable).") + } + else { + $pcIdx = 0 + foreach ($pc in @($Step.Preconditions)) { + if ($pc -isnot [System.Collections.IDictionary]) { + $ErrorList.Add("'$StepPath.Preconditions[$pcIdx]' must be a hashtable (condition node).") + } + $pcIdx++ + } + } + } + + if ($Step.ContainsKey('OnPreconditionFalse') -and $null -ne $Step.OnPreconditionFalse) { + $opf = [string]$Step.OnPreconditionFalse + if ($opf -notin @('Blocked', 'Fail', 'Continue')) { + $ErrorList.Add("'$StepPath.OnPreconditionFalse' must be 'Blocked', 'Fail', or 'Continue'. Got: '$opf'.") + } + } + + if ($Step.ContainsKey('PreconditionEvent') -and $null -ne $Step.PreconditionEvent) { + if ($Step.PreconditionEvent -isnot [hashtable]) { + $ErrorList.Add("'$StepPath.PreconditionEvent' must be a hashtable.") + } + else { + $pcEvt = $Step.PreconditionEvent + if (-not $pcEvt.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$pcEvt.Type)) { + $ErrorList.Add("'$StepPath.PreconditionEvent.Type' is required and must be a non-empty string.") + } + if (-not $pcEvt.ContainsKey('Message') -or [string]::IsNullOrWhiteSpace([string]$pcEvt.Message)) { + $ErrorList.Add("'$StepPath.PreconditionEvent.Message' is required and must be a non-empty string.") + } + if ($pcEvt.ContainsKey('Data') -and $null -ne $pcEvt.Data -and $pcEvt.Data -isnot [hashtable]) { + $ErrorList.Add("'$StepPath.PreconditionEvent.Data' must be a hashtable when provided.") + } + } + } + } + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { @@ -120,6 +178,9 @@ function Test-IdleWorkflowSchema { # Validate RetryProfile Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors + # Validate Preconditions, OnPreconditionFalse, PreconditionEvent + Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + $i++ } } @@ -171,6 +232,9 @@ function Test-IdleWorkflowSchema { # Validate RetryProfile Test-IdleWorkflowStepRetryProfile -Step $step -StepPath $stepPath -ErrorList $errors + # Validate Preconditions, OnPreconditionFalse, PreconditionEvent + Test-IdleWorkflowStepPreconditions -Step $step -StepPath $stepPath -ErrorList $errors + $i++ } } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index bd0b1af2..f060f765 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -269,8 +269,15 @@ function Invoke-IdlePlanObject { ) $failed = $false + $blocked = $false $stepResults = @() + # Precondition evaluation context: includes Plan and Request for condition DSL path resolution. + $preconditionContext = @{ + Plan = $Plan + Request = $request + } + $i = 0 foreach ($step in $Plan.Steps) { @@ -311,6 +318,115 @@ function Invoke-IdlePlanObject { continue } + # Runtime Preconditions: evaluated immediately before step execution (online, not planning-time). + # Blocked = policy/precondition gate (does not trigger OnFailureSteps). Stops execution. + # Fail = treated as a technical failure (triggers OnFailureSteps). Stops execution. + # Continue = emits events but skips the step and continues to the next step. + # Non-IDictionary precondition nodes are treated as precondition failures (fail closed). + $stepPreconditions = Get-IdlePropertyValue -Object $step -Name 'Preconditions' + if ($null -ne $stepPreconditions -and @($stepPreconditions).Count -gt 0) { + $preconditionPassed = $true + foreach ($pc in @($stepPreconditions)) { + if ($pc -isnot [System.Collections.IDictionary]) { + # Fail closed: a malformed or unexpected node type is treated as a failed precondition. + $preconditionPassed = $false + break + } + if (-not (Test-IdleCondition -Condition ([hashtable]$pc) -Context $preconditionContext)) { + $preconditionPassed = $false + break + } + } + + if (-not $preconditionPassed) { + $onPreconditionFalse = [string](Get-IdlePropertyValue -Object $step -Name 'OnPreconditionFalse') + if ([string]::IsNullOrWhiteSpace($onPreconditionFalse)) { $onPreconditionFalse = 'Blocked' } + + # Always emit StepPreconditionFailed for engine observability. + $context.EventSink.WriteEvent( + 'StepPreconditionFailed', + "Step '$stepName' precondition check failed.", + $stepName, + @{ + StepType = $stepType + Index = $i + OnPreconditionFalse = $onPreconditionFalse + } + ) + + # Emit the caller-configured PreconditionEvent if present. + $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent' + if ($null -ne $pcEvt) { + $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type') + $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message') + $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data' + # PreconditionEvent.Data is validated as a hashtable at planning time and + # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here. + $pcEvtDataHt = if ($pcEvtData -is [System.Collections.IDictionary]) { [hashtable]$pcEvtData } else { $null } + $context.EventSink.WriteEvent($pcEvtType, $pcEvtMsg, $stepName, $pcEvtDataHt) + } + + if ($onPreconditionFalse -eq 'Fail') { + $failed = $true + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Failed' + Error = 'Precondition check failed.' + Attempts = 0 + } + $context.EventSink.WriteEvent( + 'StepFailed', + "Step '$stepName' failed (precondition check failed).", + $stepName, + @{ + StepType = $stepType + Index = $i + Error = 'Precondition check failed.' + } + ) + } + elseif ($onPreconditionFalse -eq 'Continue') { + # Emit events and skip the step; continue to subsequent steps. + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'PreconditionSkipped' + Attempts = 0 + } + $i++ + continue + } + else { + # Default: Blocked. Does not trigger OnFailureSteps. + $blocked = $true + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Blocked' + Attempts = 0 + } + $context.EventSink.WriteEvent( + 'StepBlocked', + "Step '$stepName' blocked (precondition check failed).", + $stepName, + @{ + StepType = $stepType + Index = $i + } + ) + } + + break + } + } + + # Stop processing if a precondition failure was handled above. + if ($failed -or $blocked) { break } + $context.EventSink.WriteEvent( 'StepStarted', "Step '$stepName' started.", @@ -437,7 +553,7 @@ function Invoke-IdlePlanObject { $i++ } - $runStatus = if ($failed) { 'Failed' } else { 'Completed' } + $runStatus = if ($blocked) { 'Blocked' } elseif ($failed) { 'Failed' } else { 'Completed' } # Public result contract: the OnFailure section is always present. $onFailure = [pscustomobject]@{ @@ -452,7 +568,8 @@ function Invoke-IdlePlanObject { $planOnFailureSteps = @($Plan.OnFailureSteps) | Where-Object { $null -ne $_ } } - if ($failed -and @($planOnFailureSteps).Count -gt 0) { + # OnFailureSteps run only for genuine failures, NOT for Blocked outcomes (policy gates). + if ($failed -and -not $blocked -and @($planOnFailureSteps).Count -gt 0) { $context.EventSink.WriteEvent( 'OnFailureStarted', 'Executing OnFailureSteps (best effort).', @@ -504,6 +621,98 @@ function Invoke-IdlePlanObject { continue } + # Runtime Preconditions for OnFailure steps: evaluated immediately before execution. + # OnFailure runs best-effort, so precondition failures skip the step but do not halt + # remaining OnFailure steps. Non-IDictionary nodes are treated as failures (fail closed). + $ofPreconditions = Get-IdlePropertyValue -Object $ofStep -Name 'Preconditions' + if ($null -ne $ofPreconditions -and @($ofPreconditions).Count -gt 0) { + $ofPreconditionPassed = $true + foreach ($opc in @($ofPreconditions)) { + if ($opc -isnot [System.Collections.IDictionary]) { + $ofPreconditionPassed = $false + break + } + if (-not (Test-IdleCondition -Condition ([hashtable]$opc) -Context $preconditionContext)) { + $ofPreconditionPassed = $false + break + } + } + + if (-not $ofPreconditionPassed) { + $ofOnPreconditionFalse = [string](Get-IdlePropertyValue -Object $ofStep -Name 'OnPreconditionFalse') + if ([string]::IsNullOrWhiteSpace($ofOnPreconditionFalse)) { $ofOnPreconditionFalse = 'Blocked' } + + # Always emit StepPreconditionFailed for engine observability. + $context.EventSink.WriteEvent( + 'StepPreconditionFailed', + "OnFailure step '$ofName' precondition check failed.", + $ofName, + @{ + StepType = $ofType + Index = $j + OnPreconditionFalse = $ofOnPreconditionFalse + } + ) + + # Emit the caller-configured PreconditionEvent if present. + $ofPcEvt = Get-IdlePropertyValue -Object $ofStep -Name 'PreconditionEvent' + if ($null -ne $ofPcEvt) { + $ofPcEvtType = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Type') + $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message') + $ofPcEvtData = Get-IdlePropertyValue -Object $ofPcEvt -Name 'Data' + $ofPcEvtDataHt = if ($ofPcEvtData -is [System.Collections.IDictionary]) { [hashtable]$ofPcEvtData } else { $null } + $context.EventSink.WriteEvent($ofPcEvtType, $ofPcEvtMsg, $ofName, $ofPcEvtDataHt) + } + + if ($ofOnPreconditionFalse -eq 'Fail') { + $onFailureHadFailures = $true + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = 'Failed' + Error = 'Precondition check failed.' + Attempts = 0 + } + $context.EventSink.WriteEvent( + 'StepFailed', + "OnFailure step '$ofName' failed (precondition check failed).", + $ofName, + @{ + StepType = $ofType + Index = $j + Error = 'Precondition check failed.' + } + ) + } + else { + # Blocked or Continue: skip this OnFailure step and proceed to the next. + $ofStatus = if ($ofOnPreconditionFalse -eq 'Continue') { 'PreconditionSkipped' } else { 'Blocked' } + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $ofName + Type = $ofType + Status = $ofStatus + Attempts = 0 + } + if ($ofOnPreconditionFalse -ne 'Continue') { + $context.EventSink.WriteEvent( + 'StepBlocked', + "OnFailure step '$ofName' blocked (precondition check failed).", + $ofName, + @{ + StepType = $ofType + Index = $j + } + ) + } + } + + $j++ + continue + } + } + $context.EventSink.WriteEvent( 'OnFailureStepStarted', "OnFailure step '$ofName' started.", diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 new file mode 100644 index 00000000..31b6e19b --- /dev/null +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -0,0 +1,378 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows/preconditions' + + function global:Invoke-IdlePreconditionTestNoopStep { + [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 + } + } + + function global:Invoke-IdlePreconditionTestSecondStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('SecondStepRan', 'Second step executed', $Step.Name, @{ StepType = $Step.Type }) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + + function global:Invoke-IdlePreconditionTestOnFailureStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $Context.EventSink.WriteEvent('OnFailureRan', 'OnFailure step executed', $Step.Name, @{ StepType = $Step.Type }) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdlePreconditionTestNoopStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdlePreconditionTestSecondStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdlePreconditionTestOnFailureStep' -ErrorAction SilentlyContinue +} + +Describe 'Invoke-IdlePlan - Runtime Preconditions' { + + Context 'Step without preconditions' { + It 'behaves exactly as before (no preconditions = no change)' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'no-preconditions.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NoPrecondition') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.NoPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.NoPrecondition') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 0 + } + } + + Context 'Passing preconditions' { + It 'executes the step when all preconditions pass' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'passing.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PassingPrecondition') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.PassingPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PassingPrecondition') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'Completed' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 0 + } + } + + Context 'Failing precondition - Blocked (default)' { + It 'produces Blocked step result and stops execution when precondition fails' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'blocked.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedPrecondition', 'IdLE.Step.SecondStep') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.BlockedPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.SecondStep' = 'Invoke-IdlePreconditionTestSecondStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedPrecondition', 'IdLE.Step.SecondStep') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + $result.Steps.Count | Should -Be 1 + $result.Steps[0].Name | Should -Be 'Step1' + $result.Steps[0].Status | Should -Be 'Blocked' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'StepBlocked').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 0 + } + + It 'uses Blocked as the default when OnPreconditionFalse is omitted' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'blocked-default.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedDefault') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.BlockedDefault' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedDefault') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + $result.Steps[0].Status | Should -Be 'Blocked' + } + } + + Context 'Failing precondition - Fail' { + It 'produces Failed step result and stops execution when OnPreconditionFalse=Fail' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'fail.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrecondition', 'IdLE.Step.SecondStep') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrecondition' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.SecondStep' = 'Invoke-IdlePreconditionTestSecondStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailPrecondition', 'IdLE.Step.SecondStep') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + $result.Steps.Count | Should -Be 1 + $result.Steps[0].Status | Should -Be 'Failed' + $result.Steps[0].Error | Should -Not -BeNullOrEmpty + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'StepFailed').Count | Should -Be 1 + @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 0 + } + } + + Context 'Failing precondition - Continue' { + It 'emits events, marks step as PreconditionSkipped, and continues execution of subsequent steps' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'continue.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePrecondition', 'IdLE.Step.SecondStep') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.ContinuePrecondition' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.SecondStep' = 'Invoke-IdlePreconditionTestSecondStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePrecondition', 'IdLE.Step.SecondStep') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + # Overall run completes successfully + $result.Status | Should -Be 'Completed' + # Step1 is skipped, Step2 runs + $result.Steps.Count | Should -Be 2 + $result.Steps[0].Name | Should -Be 'Step1' + $result.Steps[0].Status | Should -Be 'PreconditionSkipped' + $result.Steps[1].Name | Should -Be 'Step2' + $result.Steps[1].Status | Should -Be 'Completed' + # Engine event is emitted for observability + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 1 + # Subsequent step ran + @($result.Events | Where-Object Type -eq 'SecondStepRan').Count | Should -Be 1 + } + + It 'emits PreconditionEvent when Continue mode is used' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'continue-event.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePreconditionEvent') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.ContinuePreconditionEvent' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ContinuePreconditionEvent') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.Steps[0].Status | Should -Be 'PreconditionSkipped' + ($result.Events | Where-Object Type -eq 'PolicyAdvisory').Message | Should -Be 'Step skipped due to policy advisory' + } + } + + Context 'Blocked does not trigger OnFailureSteps' { + It 'does not run OnFailureSteps when a step is Blocked' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'blocked-no-onfailure.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedNoOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.BlockedNoOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.BlockedNoOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 + } + + It 'does run OnFailureSteps when OnPreconditionFalse=Fail' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'fail-runs-onfailure.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailRunsOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'Completed' + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 1 + } + } + + Context 'PreconditionEvent emission' { + It 'emits configured PreconditionEvent when precondition fails' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'event.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PreconditionEvent') } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.PreconditionEvent' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.PreconditionEvent') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + + # StepPreconditionFailed must be emitted + $pcFailedEvent = $result.Events | Where-Object Type -eq 'StepPreconditionFailed' + $pcFailedEvent | Should -Not -BeNullOrEmpty + + # Configured PreconditionEvent should also be emitted with the declared Type/Message + $customEvent = $result.Events | Where-Object Type -eq 'ManualActionRequired' + $customEvent | Should -Not -BeNullOrEmpty + $customEvent.Message | Should -Be 'Perform Intune wipe before proceeding' + $customEvent.Data['Reason'] | Should -Be 'BYOD wipe not confirmed' + } + } + + Context 'Invalid precondition schema at planning time' { + It 'throws when a precondition node has an invalid schema' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-schema.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPreconditionSchema') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + + It 'throws when OnPreconditionFalse has an invalid value' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-onpreconditionfalse.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidOPF') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + + It 'throws when PreconditionEvent is missing required Type' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-event-type.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCEvt') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + + It 'throws when Preconditions is a single hashtable instead of an array' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'invalid-single-hashtable.psd1' + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $providers = @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.InvalidPCSingleHt') } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw + } + } + + Context 'OnFailureSteps with preconditions' { + It 'evaluates preconditions on OnFailureSteps: Blocked skips the step but continues remaining OnFailure steps' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'onfailure-blocked-precondition.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailRunsOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + # OnFailure cleanup step was blocked by its precondition (Leaver != Joiner) + $result.OnFailure.Steps.Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'Blocked' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 2 + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 + } + + It 'evaluates preconditions on OnFailureSteps: Continue skips the step but continues remaining OnFailure steps' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'onfailure-continue-precondition.psd1' + $req = New-IdleRequest -LifecycleEvent 'Leaver' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailRunsOnFailure' = 'Invoke-IdlePreconditionTestNoopStep' + 'IdLE.Step.OnFailureCleanup' = 'Invoke-IdlePreconditionTestOnFailureStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.FailRunsOnFailure', 'IdLE.Step.OnFailureCleanup') + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + # OnFailure cleanup step was skipped via Continue + $result.OnFailure.Steps.Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'PreconditionSkipped' + @($result.Events | Where-Object Type -eq 'StepPreconditionFailed').Count | Should -Be 2 + @($result.Events | Where-Object Type -eq 'OnFailureRan').Count | Should -Be 0 + } + } +} + diff --git a/tests/fixtures/workflows/preconditions/blocked-default.psd1 b/tests/fixtures/workflows/preconditions/blocked-default.psd1 new file mode 100644 index 00000000..a216facd --- /dev/null +++ b/tests/fixtures/workflows/preconditions/blocked-default.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Blocked Default' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedDefault' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 new file mode 100644 index 00000000..ebfccaf4 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/blocked-no-onfailure.psd1 @@ -0,0 +1,24 @@ +@{ + Name = 'Blocked No OnFailure' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedNoOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/blocked.psd1 b/tests/fixtures/workflows/preconditions/blocked.psd1 new file mode 100644 index 00000000..8d51859a --- /dev/null +++ b/tests/fixtures/workflows/preconditions/blocked.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Blocked Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.BlockedPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Blocked' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/continue-event.psd1 b/tests/fixtures/workflows/preconditions/continue-event.psd1 new file mode 100644 index 00000000..a3f5ea0d --- /dev/null +++ b/tests/fixtures/workflows/preconditions/continue-event.psd1 @@ -0,0 +1,24 @@ +@{ + Name = 'Continue With Event' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.ContinuePreconditionEvent' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + PreconditionEvent = @{ + Type = 'PolicyAdvisory' + Message = 'Step skipped due to policy advisory' + Data = @{ Hint = 'BYOD check not met' } + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/continue.psd1 b/tests/fixtures/workflows/preconditions/continue.psd1 new file mode 100644 index 00000000..0d56086c --- /dev/null +++ b/tests/fixtures/workflows/preconditions/continue.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Continue Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.ContinuePrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/event.psd1 b/tests/fixtures/workflows/preconditions/event.psd1 new file mode 100644 index 00000000..3e6cacf1 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/event.psd1 @@ -0,0 +1,25 @@ +@{ + Name = 'Precondition Event' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.PreconditionEvent' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + PreconditionEvent = @{ + Type = 'ManualActionRequired' + Message = 'Perform Intune wipe before proceeding' + Data = @{ + Reason = 'BYOD wipe not confirmed' + } + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 new file mode 100644 index 00000000..ada7f0be --- /dev/null +++ b/tests/fixtures/workflows/preconditions/fail-runs-onfailure.psd1 @@ -0,0 +1,25 @@ +@{ + Name = 'Fail Runs OnFailure' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/fail.psd1 b/tests/fixtures/workflows/preconditions/fail.psd1 new file mode 100644 index 00000000..1a572cf7 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/fail.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Fail Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + @{ + Name = 'Step2' + Type = 'IdLE.Step.SecondStep' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 new file mode 100644 index 00000000..8de5d90a --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-event-type.psd1 @@ -0,0 +1,21 @@ +@{ + Name = 'Invalid PreconditionEvent Type' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPCEvt' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + PreconditionEvent = @{ + Message = 'Some message' + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 new file mode 100644 index 00000000..166161e8 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-onpreconditionfalse.psd1 @@ -0,0 +1,19 @@ +@{ + Name = 'Invalid OnPreconditionFalse' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidOPF' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Skip' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-schema.psd1 b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 new file mode 100644 index 00000000..3caa0e71 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-schema.psd1 @@ -0,0 +1,15 @@ +@{ + Name = 'Invalid Precondition Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPreconditionSchema' + Preconditions = @( + @{ + UnknownKey = 'bad' + } + ) + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 new file mode 100644 index 00000000..316f51b7 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/invalid-single-hashtable.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Invalid Preconditions Single Hashtable' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.InvalidPCSingleHt' + Preconditions = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/no-preconditions.psd1 b/tests/fixtures/workflows/preconditions/no-preconditions.psd1 new file mode 100644 index 00000000..a39e9d2a --- /dev/null +++ b/tests/fixtures/workflows/preconditions/no-preconditions.psd1 @@ -0,0 +1,10 @@ +@{ + Name = 'No Preconditions' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.NoPrecondition' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 new file mode 100644 index 00000000..5fcc341c --- /dev/null +++ b/tests/fixtures/workflows/preconditions/onfailure-blocked-precondition.psd1 @@ -0,0 +1,33 @@ +@{ + Name = 'Blocked OnFailure Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 new file mode 100644 index 00000000..9ac0b888 --- /dev/null +++ b/tests/fixtures/workflows/preconditions/onfailure-continue-precondition.psd1 @@ -0,0 +1,34 @@ +@{ + Name = 'Continue OnFailure Precondition' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.FailRunsOnFailure' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Fail' + } + ) + OnFailureSteps = @( + @{ + Name = 'Cleanup' + Type = 'IdLE.Step.OnFailureCleanup' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Joiner' + } + } + ) + OnPreconditionFalse = 'Continue' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/passing.psd1 b/tests/fixtures/workflows/preconditions/passing.psd1 new file mode 100644 index 00000000..4374340b --- /dev/null +++ b/tests/fixtures/workflows/preconditions/passing.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Passing Preconditions' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'Step1' + Type = 'IdLE.Step.PassingPrecondition' + Preconditions = @( + @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + ) + } + ) +} diff --git a/website/sidebars.js b/website/sidebars.js index 2ac97e77..f861a6fa 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -47,6 +47,7 @@ const sidebars = { ], }, 'use/workflows', + 'use/preconditions', 'use/providers', 'use/plan-export', ],