-
Notifications
You must be signed in to change notification settings - Fork 0
Add template substitution syntax for workflow configurations #108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
47cc3ab
Initial plan
Copilot 4365a54
Core: Add template substitution resolver functions and tests
Copilot 193b5ba
Docs: Add template substitution section to workflows.md
Copilot c125a6c
Code review: Simplify null checks per review feedback
Copilot 3b0948e
Docs: Fix example and add request value explanation
Copilot ce5bcf6
Tests: Move workflow definitions to fixture files
Copilot 80c8ab3
Apply suggestion from @Copilot
blindzero 42a53d3
Fix template test fixtures and resolver issues
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| function Resolve-IdleTemplateString { | ||
| <# | ||
| .SYNOPSIS | ||
| Resolves template placeholders in a string using request context. | ||
|
|
||
| .DESCRIPTION | ||
| Scans a string for {{...}} placeholders and resolves them against the request object. | ||
| Only allowlisted request roots are permitted for security. | ||
|
|
||
| Template syntax: | ||
| - Placeholder format: {{<Path>}} | ||
| - Path is a dot-separated property path | ||
| - Multiple placeholders are supported in one string | ||
|
|
||
| Allowed roots (security boundary): | ||
| - Request.Input.* (aliased to Request.DesiredState.* if Input does not exist) | ||
| - Request.DesiredState.* | ||
| - Request.IdentityKeys.* | ||
| - Request.Changes.* | ||
| - Request.LifecycleEvent | ||
| - Request.CorrelationId | ||
| - Request.Actor | ||
|
|
||
| Escaping: | ||
| - \{{ → literal {{ (escape removed after resolution) | ||
|
|
||
| .PARAMETER Value | ||
| The string value to resolve. If not a string, returns the value unchanged. | ||
|
|
||
| .PARAMETER Request | ||
| The request object providing context for template resolution. | ||
|
|
||
| .PARAMETER StepName | ||
| The name of the step being processed (for error messages). | ||
|
|
||
| .OUTPUTS | ||
| Resolved string with placeholders replaced by request values. | ||
| #> | ||
| [CmdletBinding()] | ||
| param( | ||
| [Parameter()] | ||
| [AllowNull()] | ||
| [object] $Value, | ||
|
|
||
| [Parameter(Mandatory)] | ||
| [ValidateNotNull()] | ||
| [object] $Request, | ||
|
|
||
| [Parameter(Mandatory)] | ||
| [ValidateNotNullOrEmpty()] | ||
| [string] $StepName | ||
| ) | ||
|
|
||
| if ($null -eq $Value) { | ||
| return $null | ||
| } | ||
|
|
||
| if ($Value -isnot [string]) { | ||
| return $Value | ||
| } | ||
|
|
||
| $stringValue = [string]$Value | ||
|
|
||
| # Quick exit: no template markers present | ||
| if ($stringValue -notlike '*{{*' -and $stringValue -notlike '*}}*') { | ||
| # Handle escaped braces with no actual templates | ||
| if ($stringValue -like '*\{{*') { | ||
| return $stringValue -replace '\\{{', '{{' | ||
| } | ||
| return $stringValue | ||
| } | ||
|
|
||
| # Check for unbalanced braces (typo safety) | ||
| # Count non-escaped opening braces | ||
| $openCount = ([regex]::Matches($stringValue, '(?<!\\)\{\{')).Count | ||
| # For closing braces, only count those that belong to templates (have a corresponding non-escaped opening) | ||
| # We can do this by counting matches of the full template pattern | ||
| $templatePattern = '(?<!\\)\{\{([^}]+)\}\}' | ||
| $templateCount = ([regex]::Matches($stringValue, $templatePattern)).Count | ||
| # Any }} that's part of a template is matched. Any other }} is unbalanced. | ||
| $allCloseCount = ([regex]::Matches($stringValue, '\}\}')).Count | ||
|
|
||
| # The expected close count should equal template count (each template has one closing) | ||
| if ($openCount -ne $templateCount -or $allCloseCount -ne $templateCount) { | ||
| throw [System.ArgumentException]::new( | ||
| ("Template syntax error in step '{0}': Unbalanced braces in value '{1}'. Found {2} opening '{{{{' and {3} closing '}}}}'. Check for typos or missing braces." -f $StepName, $stringValue, $openCount, $allCloseCount), | ||
| 'Workflow' | ||
| ) | ||
| } | ||
|
|
||
| # Parse and resolve placeholders | ||
| $result = $stringValue | ||
| $pattern = '(?<!\\)\{\{([^}]+)\}\}' | ||
| $matches = [regex]::Matches($stringValue, $pattern) | ||
|
|
||
| foreach ($match in $matches) { | ||
| $placeholder = $match.Groups[0].Value | ||
| $path = $match.Groups[1].Value.Trim() | ||
|
|
||
| # Validate path pattern (strict: alphanumeric + dots only) | ||
| if ($path -notmatch '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$') { | ||
| throw [System.ArgumentException]::new( | ||
| ("Template path error in step '{0}': Invalid path pattern '{1}'. Paths must use dot-separated identifiers (letters, numbers, underscores) with no spaces or special characters." -f $StepName, $path), | ||
| 'Workflow' | ||
| ) | ||
| } | ||
|
|
||
| # Security: validate allowed roots | ||
| $allowedRoots = @('Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') | ||
| $isAllowed = $false | ||
| foreach ($root in $allowedRoots) { | ||
| if ($path -eq $root -or $path.StartsWith("$root.")) { | ||
| $isAllowed = $true | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if (-not $isAllowed) { | ||
| throw [System.ArgumentException]::new( | ||
| ("Template security error in step '{0}': Path '{1}' is not allowed. Only these roots are permitted: {2}" -f $StepName, $path, ([string]::Join(', ', $allowedRoots))), | ||
| 'Workflow' | ||
| ) | ||
| } | ||
|
|
||
| # Handle Request.Input.* alias to Request.DesiredState.* | ||
| $resolvePath = $path | ||
| $hasInputProperty = $false | ||
| if ($Request.PSObject.Properties['Input']) { | ||
| $hasInputProperty = $true | ||
| } | ||
|
|
||
| if ($path.StartsWith('Request.Input.')) { | ||
| if (-not $hasInputProperty) { | ||
| # Alias to DesiredState | ||
| $resolvePath = $path -replace '^Request\.Input\.', 'Request.DesiredState.' | ||
| } | ||
| } | ||
| elseif ($path -eq 'Request.Input') { | ||
| if (-not $hasInputProperty) { | ||
| $resolvePath = 'Request.DesiredState' | ||
| } | ||
| } | ||
|
blindzero marked this conversation as resolved.
|
||
|
|
||
| # Resolve the value (using custom logic that handles hashtables) | ||
| $contextWrapper = [pscustomobject]@{ Request = $Request } | ||
| $current = $contextWrapper | ||
| foreach ($segment in ($resolvePath -split '\.')) { | ||
| if ($null -eq $current) { | ||
| $resolvedValue = $null | ||
| break | ||
| } | ||
|
|
||
| # Handle hashtables/dictionaries | ||
| if ($current -is [System.Collections.IDictionary]) { | ||
| if ($current.ContainsKey($segment)) { | ||
| $current = $current[$segment] | ||
| } | ||
| else { | ||
| $current = $null | ||
| } | ||
| } | ||
| # Handle PSCustomObjects and class instances | ||
| else { | ||
| $prop = $current.PSObject.Properties[$segment] | ||
| if ($null -eq $prop) { | ||
| $current = $null | ||
| } | ||
| else { | ||
| $current = $prop.Value | ||
| } | ||
| } | ||
| } | ||
| $resolvedValue = $current | ||
|
|
||
| # Fail fast on null/missing values | ||
| if ($null -eq $resolvedValue) { | ||
| throw [System.ArgumentException]::new( | ||
| ("Template resolution error in step '{0}': Path '{1}' resolved to null or does not exist. Ensure the request contains all required values." -f $StepName, $path), | ||
| 'Workflow' | ||
| ) | ||
| } | ||
|
|
||
| # Type validation: only scalar-ish types allowed | ||
| if ($resolvedValue -is [hashtable] -or | ||
| $resolvedValue -is [System.Collections.IDictionary] -or | ||
| $resolvedValue -is [array] -or | ||
| ($resolvedValue -is [System.Collections.IEnumerable] -and $resolvedValue -isnot [string])) { | ||
| throw [System.ArgumentException]::new( | ||
| ("Template type error in step '{0}': Path '{1}' resolved to a non-scalar value (hashtable/array/object). Templates only support scalar values (string, number, bool, datetime, guid). Use an explicit mapping step or host-side pre-flattening." -f $StepName, $path), | ||
| 'Workflow' | ||
| ) | ||
| } | ||
|
|
||
| # Convert to string | ||
| $stringReplacement = [string]$resolvedValue | ||
|
|
||
| # Replace placeholder | ||
| $result = $result.Replace($placeholder, $stringReplacement) | ||
| } | ||
|
|
||
| # Process escape sequences (unescape \{{ to {{) | ||
| $result = $result -replace '\\{{', '{{' | ||
|
|
||
| return $result | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.