Modules/IdLE.Core/Public/New-IdlePlanObject.ps1
|
function New-IdlePlanObject { <# .SYNOPSIS Builds a deterministic plan from a request and a workflow definition. .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-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). .PARAMETER Request Lifecycle request object (must contain LifecycleEvent and CorrelationId). .PARAMETER Providers Provider map passed through to the plan for later execution. .OUTPUTS PSCustomObject (PSTypeName: IdLE.Plan) #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkflowPath, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Request, [Parameter()] [AllowNull()] [object] $Providers ) function ConvertTo-NullIfEmptyString { [CmdletBinding()] param( [Parameter()] [AllowNull()] [string] $Value ) if ($null -eq $Value) { return $null } if ([string]::IsNullOrWhiteSpace($Value)) { return $null } return $Value } 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 } # 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 $Value -is [double] -or $Value -is [decimal] -or $Value -is [bool] -or $Value -is [datetime] -or $Value -is [guid]) { return $Value } if ($Value -is [System.Collections.IDictionary]) { $copy = @{} foreach ($k in $Value.Keys) { $copy[$k] = Copy-IdleDataObject -Value $Value[$k] } return $copy } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { $arr = @() foreach ($item in $Value) { $arr += Copy-IdleDataObject -Value $item } return $arr } $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) if ($null -ne $props -and @($props).Count -gt 0) { $o = [ordered]@{} foreach ($p in $props) { $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 $null } $m = $Object | Get-Member -Name $Name -MemberType NoteProperty,Property -ErrorAction SilentlyContinue if ($null -eq $m) { return $null } return $Object.$Name } function ConvertTo-IdleRequiredCapabilities { <# .SYNOPSIS Normalizes the optional RequiresCapabilities key from a workflow step. .DESCRIPTION Supported shapes: - missing / $null -> empty list - string -> single capability - array/enumerable of strings -> list of capabilities The output is a stable, sorted, unique string array. #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Value, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $StepName ) if ($null -eq $Value) { return @() } $items = @() if ($Value -is [string]) { $items = @($Value) } elseif ($Value -is [System.Collections.IEnumerable]) { foreach ($v in $Value) { $items += $v } } else { throw [System.ArgumentException]::new( ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), 'Workflow' ) } $normalized = @() foreach ($c in $items) { if ($null -eq $c) { continue } $s = ([string]$c).Trim() if ([string]::IsNullOrWhiteSpace($s)) { continue } # Keep convention aligned with Get-IdleProviderCapabilities: # - dot-separated segments # - no whitespace # - starts with a letter if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { throw [System.ArgumentException]::new( ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'Identity.Read'." -f $StepName, $s), 'Workflow' ) } $normalized += $s } return @($normalized | Sort-Object -Unique) } function Get-IdleProvidersFromMap { <# .SYNOPSIS Extracts provider instances from the -Providers argument. .DESCRIPTION Supports both: - hashtable map: @{ Name = <providerObject>; ... } - array/list: @( <providerObject>, ... ) Returns an array of provider objects. #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Providers ) if ($null -eq $Providers) { return @() } if ($Providers -is [System.Collections.IDictionary]) { $items = @() foreach ($k in $Providers.Keys) { $items += $Providers[$k] } return @($items) } if ($Providers -is [System.Collections.IEnumerable] -and $Providers -isnot [string]) { $items = @() foreach ($p in $Providers) { $items += $p } return @($items) } 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 ($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 @() } function Get-IdleAvailableCapabilities { <# .SYNOPSIS Aggregates capabilities from all providers. #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Providers ) $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) $caps = @() foreach ($p in $providerInstances) { $caps += @(Get-IdleProviderCapabilities -Provider $p) } return @($caps | Sort-Object -Unique) } function Assert-IdlePlanCapabilitiesSatisfied { <# .SYNOPSIS Validates that all required step capabilities are available. .DESCRIPTION Fail-fast validation executed during planning. If one or more capabilities are missing, an ArgumentException is thrown with a deterministic error message listing missing capabilities and affected steps. #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object[]] $Steps, [Parameter()] [AllowNull()] [object] $Providers ) if ($null -eq $Steps -or @($Steps).Count -eq 0) { return } $required = @() $requiredByStep = [ordered]@{} foreach ($s in @($Steps)) { if ($null -eq $s) { continue } $stepName = Get-IdleOptionalPropertyValue -Object $s -Name 'Name' if ($null -eq $stepName -or [string]::IsNullOrWhiteSpace([string]$stepName)) { $stepName = '<UnnamedStep>' } $capsRaw = Get-IdleOptionalPropertyValue -Object $s -Name 'RequiresCapabilities' $caps = if ($null -eq $capsRaw) { @() } else { @($capsRaw) } if (@($caps).Count -gt 0) { $required += $caps $requiredByStep[$stepName] = @($caps) } } $required = @($required | Sort-Object -Unique) if (@($required).Count -eq 0) { return } $available = @(Get-IdleAvailableCapabilities -Providers $Providers) $missing = @() foreach ($c in $required) { if ($available -notcontains $c) { $missing += $c } } $missing = @($missing | Sort-Object -Unique) if (@($missing).Count -eq 0) { return } $affectedSteps = @() foreach ($k in $requiredByStep.Keys) { $capsForStep = @($requiredByStep[$k]) foreach ($m in $missing) { if ($capsForStep -contains $m) { $affectedSteps += $k break } } } $affectedSteps = @($affectedSteps | Sort-Object -Unique) $msg = @() $msg += "Plan cannot be built because required provider capabilities are missing." $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', @($missing)))) $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', @($affectedSteps)))) $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', @($available)))) throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') } function Test-IdleWorkflowStepKey { <# .SYNOPSIS Checks whether a workflow step contains a given key. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Key ) if ($Step -is [System.Collections.IDictionary]) { return $Step.ContainsKey($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. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Step, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Key ) if ($Step -is [System.Collections.IDictionary]) { return $Step[$Key] } return $Step.$Key } function ConvertTo-IdleWorkflowSteps { <# .SYNOPSIS Normalizes workflow steps into IdLE.PlanStep objects. .DESCRIPTION Evaluates Condition during planning and sets Status = Planned / NotApplicable. 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, [Parameter(Mandatory)] [ValidateNotNull()] [object] $PlanningContext ) if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { return @() } $normalizedSteps = @() 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( ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), 'Workflow' ) } $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { Get-IdleWorkflowStepValue -Step $s -Key 'Condition' } else { $null } $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' ) } $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext if (-not $isApplicable) { $status = 'NotApplicable' } } $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 { '' } $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 } } # 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) Assert-IdlePlanCapabilitiesSatisfied -Steps $allStepsForCapabilities -Providers $Providers return $plan } |