Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
89 changes: 89 additions & 0 deletions src/IdLE.Core/Private/Assert-IdlePlanCapabilitiesSatisfied.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
Set-StrictMode -Version Latest

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')
}
40 changes: 40 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-IdleNormalizedCapability.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Set-StrictMode -Version Latest

function ConvertTo-IdleNormalizedCapability {
<#
.SYNOPSIS
Normalizes capability identifiers and maps deprecated IDs to current ones.

.DESCRIPTION
Handles capability ID migrations and deprecation warnings during planning.
Pre-1.0 deprecated capability IDs are mapped to their replacements and emit a warning.

.PARAMETER Capability
The raw capability identifier to normalize.

.OUTPUTS
Normalized capability identifier (string).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Capability
)

# Deprecated capability ID mappings (pre-1.0)
# Format: @{ 'OldID' = 'NewID' }
$deprecatedMappings = @{
'IdLE.Mailbox.Read' = 'IdLE.Mailbox.Info.Read'
}

$normalized = $Capability.Trim()

if ($deprecatedMappings.ContainsKey($normalized)) {
$newId = $deprecatedMappings[$normalized]
Write-Warning "DEPRECATED: Capability '$normalized' is deprecated in v1.0 and will be removed in v2.0. Use '$newId' instead."
return $newId
}

return $normalized
}
75 changes: 75 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-IdleRequiredCapabilities.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
Set-StrictMode -Version Latest

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 'IdLE.Identity.Read'." -f $StepName, $s),
'Workflow'
)
}

# Normalize deprecated capabilities
$normalized += ConvertTo-IdleNormalizedCapability -Capability $s
}

return @($normalized | Sort-Object -Unique)
}
146 changes: 146 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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.

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-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'
}
}

# 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-IdleWorkflowStepValue -Step $s -Key 'Description')
}
else {
''
}

$with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') {
Copy-IdleDataObject -Value (Get-IdleWorkflowStepValue -Step $s -Key 'With')
}
else {
@{}
}

# Resolve template placeholders in With (planning-time resolution)
$with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName

$retryProfile = if (Test-IdleWorkflowStepKey -Step $s -Key 'RetryProfile') {
[string](Get-IdleWorkflowStepValue -Step $s -Key 'RetryProfile')
}
else {
$null
}

$normalizedSteps += [pscustomobject]@{
PSTypeName = 'IdLE.PlanStep'
Name = $stepName
Type = $stepType
Description = $description
Condition = Copy-IdleDataObject -Value $condition
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)
}
20 changes: 20 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-NullIfEmptyString.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Set-StrictMode -Version Latest

function ConvertTo-NullIfEmptyString {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[string] $Value
)

if ($null -eq $Value) {
return $null
}

if ([string]::IsNullOrWhiteSpace($Value)) {
return $null
}

return $Value
}
Loading
Loading