Private/ConvertTo-IdleWorkflowSteps.ps1
|
Set-StrictMode -Version Latest function ConvertTo-IdleWorkflowSteps { <# .SYNOPSIS Normalizes workflow steps into IdLE.PlanStep objects. .DESCRIPTION Evaluates Condition during planning and sets Status = Planned / NotApplicable. When a step's Condition evaluates to false, the step is marked NotApplicable and all subsequent plan-time processing that only makes sense for executable steps (With template resolution and WithSchema validation) is skipped for that step. This prevents false-positive planning failures caused by missing data referenced in With blocks that are intentionally guarded by a Condition. Condition path validation uses -ExcludeExistsOperatorPaths so that steps using the Exists operator to guard optional context attributes are not rejected at plan time merely because the attribute is absent — that is the intended use of the Exists operator. 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, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $StepMetadataCatalog ) 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-IdlePropertyValue -Object $s -Name '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-IdlePropertyValue -Object $s -Name '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-IdlePropertyValue -Object $s -Name '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' ) } Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' -ExcludeExistsOperatorPaths $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext if (-not $isApplicable) { $status = 'NotApplicable' } } # Derive RequiresCapabilities from StepMetadataCatalog instead of workflow. $requiresCaps = @() if ($StepMetadataCatalog.ContainsKey($stepType)) { $metadata = $StepMetadataCatalog[$stepType] if ($null -ne $metadata -and $metadata -is [hashtable] -and $metadata.ContainsKey('RequiredCapabilities')) { $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value $metadata['RequiredCapabilities'] -StepName $stepName } } else { # Workflow references a Step.Type for which no StepMetadata entry is available - fail fast. $errorMessage = "MissingStepTypeMetadata: Workflow step '$stepName' references step type '$stepType' which has no metadata entry. " + ` "To resolve this: (1) Import/load the step pack module (IdLE.Steps.*) that provides metadata for '$stepType' via Get-IdleStepMetadataCatalog, OR " + ` "(2) For host-defined/custom step types only, provide Providers.StepMetadata['$stepType'] = @{ RequiredCapabilities = @(...) }." throw [System.InvalidOperationException]::new($errorMessage) } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { [string](Get-IdlePropertyValue -Object $s -Name 'Description') } else { '' } $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { Copy-IdleDataObject -Value (Get-IdlePropertyValue -Object $s -Name 'With') } else { @{} } # Skip With template resolution and WithSchema validation for NotApplicable steps. # A step whose Condition evaluated to false will never be executed, so further plan-time # validation that assumes the step is eligible would produce false-positive failures. if ($status -ne 'NotApplicable') { # Resolve template placeholders in With (planning-time resolution) $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName # Validate WithSchema declared by step metadata (fail-fast plan-time schema check). # Every step type must declare WithSchema. Required keys must be present; unknown keys are rejected. # If OptionalKeys contains '*', any additional key is accepted (permissive schema for test/internal use). if ($StepMetadataCatalog.ContainsKey($stepType)) { $md = $StepMetadataCatalog[$stepType] if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('WithSchema')) { $schema = $md['WithSchema'] if ($null -ne $schema -and $schema -is [hashtable]) { $requiredKeys = @() if ($schema.ContainsKey('RequiredKeys') -and $null -ne $schema['RequiredKeys']) { $requiredKeys = @($schema['RequiredKeys']) } $optionalKeys = @() if ($schema.ContainsKey('OptionalKeys') -and $null -ne $schema['OptionalKeys']) { $optionalKeys = @($schema['OptionalKeys']) } # Build allowed set from all keys (required and optional combined) $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() foreach ($keyList in @($requiredKeys, $optionalKeys)) { foreach ($k in $keyList) { if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { $null = $allAllowedKeysList.Add([string]$k) } } } $allowedSet = [System.Collections.Generic.HashSet[string]]::new( $allAllowedKeysList, [System.StringComparer]::OrdinalIgnoreCase ) $permissive = $allowedSet.Contains('*') # Validate required keys are present foreach ($rk in $requiredKeys) { if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } if (-not $with.ContainsKey($rk)) { $requiredList = [string]::Join(', ', ($requiredKeys | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object)) throw [System.ArgumentException]::new( ("Step '{0}' (type '{1}') is missing required With.{2}. Required With keys: {3}." -f $stepName, $stepType, $rk, $requiredList), 'Workflow' ) } } # Validate no unknown keys (skip if permissive wildcard) if (-not $permissive) { foreach ($wk in @($with.Keys)) { if (-not $allowedSet.Contains([string]$wk)) { $supportedList = [string]::Join(', ', ($allAllowedKeysList | Sort-Object)) throw [System.ArgumentException]::new( ("Step '{0}' (type '{1}') does not support With.{2}. Supported With keys: {3}." -f $stepName, $stepType, [string]$wk, $supportedList), 'Workflow' ) } } } } } } } $retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') { [string](Get-IdlePropertyValue -Object $s -Name 'RetryProfile') } else { $null } $planWarnings = $null $planObj = $PlanningContext.Plan if ($null -ne $planObj) { if ($planObj -is [System.Collections.IDictionary]) { if ($planObj.Contains('Warnings')) { $planWarnings = $planObj['Warnings'] } } else { $wProp = $planObj.PSObject.Properties['Warnings'] if ($null -ne $wProp) { $planWarnings = $wProp.Value } } } $planWarningsCanTrackCount = $planWarnings -is [System.Collections.IList] $warningCountBefore = if ($planWarningsCanTrackCount) { [int]$planWarnings.Count } else { 0 } $preconditionSettings = ConvertTo-IdleWorkflowStepPreconditionSettings -Step $s -StepName $stepName -PlanningContext $PlanningContext $precondition = $preconditionSettings.Precondition $onPreconditionFalse = $preconditionSettings.OnPreconditionFalse $preconditionEvent = $preconditionSettings.PreconditionEvent $preconditionWarnings = @() if ($planWarningsCanTrackCount) { $warningCountAfter = [int]$planWarnings.Count if ($warningCountAfter -gt $warningCountBefore) { for ($warningIndex = $warningCountBefore; $warningIndex -lt $warningCountAfter; $warningIndex++) { $warning = $planWarnings[$warningIndex] $warningSource = Get-IdlePropertyValue -Object $warning -Name 'Source' $warningStep = Get-IdlePropertyValue -Object $warning -Name 'Step' if ($warningSource -eq 'Precondition' -and $warningStep -eq $stepName) { $preconditionWarnings += $warning } } } } $normalizedSteps += [pscustomobject]@{ PSTypeName = 'IdLE.PlanStep' Name = $stepName Type = $stepType Description = $description Condition = Copy-IdleDataObject -Value $condition Precondition = $precondition OnPreconditionFalse = $onPreconditionFalse PreconditionEvent = $preconditionEvent Warnings = $preconditionWarnings With = $with RequiresCapabilities = $requiresCaps Status = $status RetryProfile = $retryProfile } } # IMPORTANT: # Returning an empty array variable can produce no pipeline output, resulting in $null on assignment. # Force a stable array output shape. return @($normalizedSteps) } |