Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
92e3ade
core: allow OnFailureSteps in workflow schema
blindzero Jan 5, 2026
a15c128
core: validate and normalize OnFailureSteps in workflow definition
blindzero Jan 5, 2026
d0e925c
core: allow OnFailureSteps in workflow schema
blindzero Jan 5, 2026
0fe8cde
core: include OnFailureSteps in plan object and capability validation
blindzero Jan 5, 2026
185deff
core: execute OnFailureSteps on failure (best effort)
blindzero Jan 5, 2026
726c531
tests: cover OnFailureSteps best-effort execution
blindzero Jan 5, 2026
9ebcd1c
tests: validate provider capabilities for OnFailureSteps
blindzero Jan 5, 2026
9ea5e8f
tests: assert OnFailureSteps are normalized and default to empty
blindzero Jan 5, 2026
252a0ab
tests: validate OnFailureSteps and reject CleanupSteps in workflow va…
blindzero Jan 5, 2026
fad9d4f
tests: pin public execution result contract for OnFailure
blindzero Jan 5, 2026
9e7da26
core: keep WhatIf execution result contract stable (OnFailure section)
blindzero Jan 5, 2026
e9cec2e
core: restore New-IdlePlanObject and plan normalization (incl. OnFail…
blindzero Jan 5, 2026
96ec97f
fix(core): ensure Normalize-IdleWorkflowSteps always returns arrays
blindzero Jan 6, 2026
c31c941
fix(core): prevent strings from being serialized as objects in Copy-I…
ntt-matthias-fleschuetz Jan 9, 2026
19115a0
chore(core): use approved verbs for conversion helpers
ntt-matthias-fleschuetz Jan 9, 2026
8bda1f4
fix(tests): wrap Where-Object results in @() to handle empty collections
ntt-matthias-fleschuetz Jan 9, 2026
3866c01
fix(core): ensure Steps arrays in execution results are always typed …
ntt-matthias-fleschuetz Jan 9, 2026
b2b4ed4
Fix demo script execution failures
ntt-matthias-fleschuetz Jan 9, 2026
f5f4922
changing Event variable to EventRecord as Event is PS internal
ntt-matthias-fleschuetz Jan 9, 2026
5799241
docs: document OnFailureSteps feature (Issue #12)
ntt-matthias-fleschuetz Jan 9, 2026
cbbc46c
security: Reinstate ScriptBlock validation in Invoke-IdlePlanObject
ntt-matthias-fleschuetz Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions docs/usage/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions docs/usage/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions examples/Invoke-IdleDemo.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function Write-DemoHeader {
}

function Format-EventRow {
param([Parameter(Mandatory)][object]$Event)
param([Parameter(Mandatory)][object]$EventRecord)

$icons = @{
RunStarted = '🚀'
Expand All @@ -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
}
Expand Down Expand Up @@ -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 ""
Expand Down
3 changes: 3 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
54 changes: 54 additions & 0 deletions examples/workflows/joiner-with-onfailure.psd1
Original file line number Diff line number Diff line change
@@ -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' }
}
)
}
56 changes: 55 additions & 1 deletion src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ', ').")
Expand Down Expand Up @@ -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
}
Loading