LibreDevOpsHelpers.LogicApps/LibreDevOpsHelpers.LogicApps.psm1
|
Set-StrictMode -Version Latest # Consumption Logic App workflow tooling: the offline half checks a definition against the # contract the platform enforces, and the online half asks Azure to validate it without deploying. # # The contract these commands encode is not taste, it is what the resource provider rejects: # # * A definition arrives in one of three shapes. The designer's code view gives # {"definition": {...}, "parameters": {...}}; an ARM resource GET gives # {"properties": {"definition": {...}}, ...}; a template carries the bare definition. A # workflow definition has no top-level "definition" or "properties" key of its own, so the # unwrap is unambiguous. # * A wrapper's "parameters" block holds VALUES. A bare definition's top-level "parameters" is # its DECLARATIONS. Reading one as the other is the classic round-trip trap, and it is why # Get-LdoLogicAppParameterStatus reports the shape it matched alongside its verdict. # * Every parameter the definition DECLARES needs a value by deploy time, or the engine returns # InvalidTemplate, "The value for the workflow parameter 'x' ... is not provided". $connections # is the exception a deployment tool generates; SecureString and SecureObject should arrive as # inputs so the secret never lands in the definition. # * A native Workflow dispatch action's TARGET is validated at PUT time (NestedWorkflowNotFound), # so a workflow invoking a sibling has to deploy after it. Get-LdoLogicAppDeployOrder derives # that ordering from the definitions themselves. # ------------------------------------------------------------------------------------------------- # Private helpers # ------------------------------------------------------------------------------------------------- function Test-LdoLogicAppMember { <# .SYNOPSIS Returns true when an object carries the named property. .DESCRIPTION Set-StrictMode -Version Latest turns a missing-property read into a terminating error, so every optional property access in this module goes through this check first. .PARAMETER InputObject The object to inspect. A null input always returns false. .PARAMETER Name The property name to look for. .EXAMPLE if (Test-LdoLogicAppMember -InputObject $doc -Name 'definition') { $doc.definition } .OUTPUTS System.Boolean #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Name ) if ($null -eq $InputObject) { return $false } if ($InputObject -is [hashtable]) { return $InputObject.ContainsKey($Name) } # Enumerate the property objects rather than reading .Name off the collection: on a primitive # the collection is empty, and StrictMode turns that member access into a terminating error. return (@($InputObject.PSObject.Properties | ForEach-Object { $_.Name }) -contains $Name) } function ConvertTo-LdoLogicAppTokenSafeJson { <# .SYNOPSIS Blanks Terraform template tokens so a .json.tftpl can be parsed as JSON. .DESCRIPTION A templatefile source is not valid JSON while it still carries ${token} placeholders. Replacing each with a safe literal lets the SHAPE be checked (parameter names, declarations, trigger names) without rendering. Logic App workflow expressions use the @{...} form, so ${...} is unambiguously a Terraform token here. WHERE the token sits decides the substitution, which is why this scans rather than running a regex. A token inside a JSON string ("inputs": "${x}") becomes bare text, but one standing alone in value position ("interval": ${x}, the shape a number or a jsonencode result takes) has to gain quotes or the result is not parseable. The scanner also tracks escapes and brace nesting, so a token containing braces is still matched as one unit. A token in value position is read back as a string regardless of what it would really render to. That is fine for the shape checks this module performs, but it means this is not a substitute for rendering when a VALUE matters. .PARAMETER Json The raw file or string content. .EXAMPLE ConvertTo-LdoLogicAppTokenSafeJson -Json (Get-Content -Raw ./workflow.json.tftpl) .OUTPUTS System.String #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Json ) $builder = [System.Text.StringBuilder]::new($Json.Length) $inString = $false $index = 0 while ($index -lt $Json.Length) { $character = $Json[$index] if ($inString) { # A backslash escape carries its next character verbatim, so a \" never ends the string. if ($character -eq '\' -and $index + 1 -lt $Json.Length) { [void]$builder.Append($character).Append($Json[$index + 1]) $index += 2 continue } if ($character -eq '"') { $inString = $false [void]$builder.Append($character) $index++ continue } } elseif ($character -eq '"') { $inString = $true [void]$builder.Append($character) $index++ continue } # $${ is an escaped literal in templatefile: it renders as ${ and is not a token. if ($character -eq '$' -and $index + 2 -lt $Json.Length -and $Json[$index + 1] -eq '$' -and $Json[$index + 2] -eq '{') { [void]$builder.Append('${') $index += 3 continue } if ($character -eq '$' -and $index + 1 -lt $Json.Length -and $Json[$index + 1] -eq '{') { $depth = 0 $scan = $index + 1 while ($scan -lt $Json.Length) { if ($Json[$scan] -eq '{') { $depth++ } elseif ($Json[$scan] -eq '}') { $depth-- if ($depth -eq 0) { break } } $scan++ } if ($scan -lt $Json.Length) { # Inside a string the marker is bare; in value position it needs its own quotes. [void]$builder.Append($(if ($inString) { 'LDO_TOKEN' } else { '"LDO_TOKEN"' })) $index = $scan + 1 continue } } [void]$builder.Append($character) $index++ } return $builder.ToString() } function Resolve-LdoLogicAppDocument { <# .SYNOPSIS Loads a Logic App document from a path or a JSON string. .DESCRIPTION Accepts either a file path or raw JSON, tolerates unrendered Terraform tokens, and returns the parsed object together with the original text (the text matters: sibling dispatch detection reads it directly). .PARAMETER Path Path to a .json or .json.tftpl file. .PARAMETER Json Raw JSON content. .EXAMPLE Resolve-LdoLogicAppDocument -Path ./raw/workflow.json .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json ) if ($PSCmdlet.ParameterSetName -eq 'Path') { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Logic App definition file not found: '$Path'." } $source = (Resolve-Path -LiteralPath $Path).Path $text = Get-Content -Raw -LiteralPath $source } else { $source = '<string>' $text = $Json } if ([string]::IsNullOrWhiteSpace($text)) { throw "Logic App definition source '$source' is empty." } $probe = ConvertTo-LdoLogicAppTokenSafeJson -Json $text try { $parsed = $probe | ConvertFrom-Json -Depth 100 } catch { throw "Logic App definition source '$source' is not parseable as JSON: $($_.Exception.Message)" } return [pscustomobject]@{ Source = $source Text = $text Parsed = $parsed } } # ------------------------------------------------------------------------------------------------- # Shape detection and unwrapping # ------------------------------------------------------------------------------------------------- function ConvertFrom-LdoLogicAppExport { <# .SYNOPSIS Unwraps a Logic App export into its definition, wrapper parameter values and connections. .DESCRIPTION Detects which of the three shapes Azure hands you the document is in and returns the parts separated, so callers never have to guess whether a "parameters" block holds values or declarations: CodeView {"definition": {...}, "parameters": {...}} designer code view ArmResource {"properties": {"definition": {...}}, ...} az rest / Export template BareDefinition {"$schema": ..., "triggers": ..., "actions": ...} a template file ParameterValues is populated only for the two wrapped shapes. A bare definition carries no values at all, and treating its declarations as values is the trap this command exists to remove. .PARAMETER Path Path to a .json or .json.tftpl file. Unrendered ${tokens} are tolerated. .PARAMETER Json Raw JSON content instead of a file. .EXAMPLE ConvertFrom-LdoLogicAppExport -Path ./raw/logic-soc-router.json | Select-Object Shape .EXAMPLE (ConvertFrom-LdoLogicAppExport -Path ./export.json).ParameterValues.PSObject.Properties.Name .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json ) process { $doc = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-LdoLogicAppDocument -Path $Path } else { Resolve-LdoLogicAppDocument -Json $Json } $parsed = $doc.Parsed if ((Test-LdoLogicAppMember -InputObject $parsed -Name 'properties') -and (Test-LdoLogicAppMember -InputObject $parsed.properties -Name 'definition')) { $shape = 'ArmResource' $definition = $parsed.properties.definition $values = if (Test-LdoLogicAppMember -InputObject $parsed.properties -Name 'parameters') { $parsed.properties.parameters } else { $null } } elseif (Test-LdoLogicAppMember -InputObject $parsed -Name 'definition') { $shape = 'CodeView' $definition = $parsed.definition $values = if (Test-LdoLogicAppMember -InputObject $parsed -Name 'parameters') { $parsed.parameters } else { $null } } else { $shape = 'BareDefinition' $definition = $parsed $values = $null } $name = $null if (Test-LdoLogicAppMember -InputObject $parsed -Name 'name') { $name = $parsed.name } elseif ($doc.Source -ne '<string>') { $name = [System.IO.Path]::GetFileNameWithoutExtension($doc.Source) -replace '\.json$', '' } Write-LdoLog -Level DEBUG -Message "Resolved '$($doc.Source)' as shape '$shape'." return [pscustomobject]@{ Source = $doc.Source Name = $name Shape = $shape Definition = $definition ParameterValues = $values Declarations = if (Test-LdoLogicAppMember -InputObject $definition -Name 'parameters') { $definition.parameters } else { $null } Text = $doc.Text } } } # ------------------------------------------------------------------------------------------------- # Offline validation # ------------------------------------------------------------------------------------------------- function Get-LdoLogicAppParameterStatus { <# .SYNOPSIS Reports whether each parameter a definition declares will have a value at deploy time. .DESCRIPTION For every declared parameter, works out which of the four sources satisfies it and returns one record per parameter. A parameter is satisfied by any of: * being $connections, which a deployment tool generates from its connections input, * a defaultValue on the declaration itself, * a value carried by the pasted wrapper (the two wrapped shapes only, never a bare definition, whose top-level parameters block is declarations), * a name passed to -SuppliedParameter, standing in for a deployment tool's own parameters input. SecureString and SecureObject are deliberately never satisfied by the wrapper: a secret should arrive as an input so it stays out of the definition, the plan and the state. Anything left unsatisfied is rejected by the engine at deploy time with InvalidTemplate, "The value for the workflow parameter ... is not provided". .PARAMETER Path Path to a .json or .json.tftpl file. Unrendered ${tokens} are tolerated. .PARAMETER Json Raw JSON content instead of a file. .PARAMETER SuppliedParameter Names supplied out of band, for example through a Terraform module's parameters input. .PARAMETER UnsatisfiedOnly Return only the parameters that will fail, which is what a gate wants. .EXAMPLE Get-LdoLogicAppParameterStatus -Path ./templates/handler.json.tftpl -UnsatisfiedOnly .EXAMPLE Get-LdoLogicAppParameterStatus -Path ./export.json -SuppliedParameter review, tenant_id .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json, [string[]]$SuppliedParameter = @(), [switch]$UnsatisfiedOnly ) process { $export = if ($PSCmdlet.ParameterSetName -eq 'Path') { ConvertFrom-LdoLogicAppExport -Path $Path } else { ConvertFrom-LdoLogicAppExport -Json $Json } if ($null -eq $export.Declarations) { Write-LdoLog -Level DEBUG -Message "'$($export.Source)' declares no parameters." return } $wrapperNames = @() if ($null -ne $export.ParameterValues) { $wrapperNames = @($export.ParameterValues.PSObject.Properties.Name) } foreach ($declaration in $export.Declarations.PSObject.Properties) { $name = $declaration.Name $type = if (Test-LdoLogicAppMember -InputObject $declaration.Value -Name 'type') { $declaration.Value.type } else { $null } $isSecure = $type -in @('SecureString', 'SecureObject') $hasDefault = Test-LdoLogicAppMember -InputObject $declaration.Value -Name 'defaultValue' $inWrapper = $wrapperNames -contains $name $inSupplied = $SuppliedParameter -contains $name if ($name -eq '$connections') { $satisfied = $true $satisfiedBy = 'Generated' $reason = 'generated by the deployment tool from its connections input' } elseif ($inSupplied) { $satisfied = $true $satisfiedBy = 'SuppliedParameter' $reason = 'supplied out of band as a deployment tool input' } elseif ($hasDefault) { $satisfied = $true $satisfiedBy = 'DefaultValue' $reason = 'the declaration carries a defaultValue' } elseif ($inWrapper -and -not $isSecure) { $satisfied = $true $satisfiedBy = 'Wrapper' $reason = "the pasted $($export.Shape) wrapper carries a value" } elseif ($inWrapper -and $isSecure) { $satisfied = $false $satisfiedBy = 'None' $reason = 'secure type: the wrapper value is ignored on purpose, supply it as an input so the secret stays out of the definition' } elseif ($export.Shape -eq 'BareDefinition') { $satisfied = $false $satisfiedBy = 'None' $reason = 'no defaultValue, and a bare definition has no wrapper to take a value from' } else { $satisfied = $false $satisfiedBy = 'None' $reason = "no defaultValue, and the $($export.Shape) wrapper carries no value for it" } $record = [pscustomobject]@{ Source = $export.Source Workflow = $export.Name Shape = $export.Shape Name = $name Type = $type IsSecure = $isSecure HasDefault = $hasDefault InWrapper = $inWrapper InSupplied = $inSupplied Satisfied = $satisfied SatisfiedBy = $satisfiedBy Reason = $reason } if ($UnsatisfiedOnly -and $satisfied) { continue } $record } } } function Test-LdoLogicAppDefinition { <# .SYNOPSIS Checks a Logic App definition offline against the contract the platform enforces. .DESCRIPTION Runs every offline check that corresponds to something Azure actually rejects or that deploys cleanly and then bites at run time, with no network access and nothing deployed. Errors (the deploy is rejected): * a declared parameter with no value from any source, * a supplied parameter the definition does not declare, * connections wired with no $connections declaration in the definition, * a named callback trigger that does not exist in the definition. Warnings (it deploys, then surprises you): * no triggers block, so nothing can start the workflow, * no actions block, * a $connections declaration with nothing wired to it. Returns a boolean by default so it drops straight into an if. Use -Detailed to get the finding objects for a report. .PARAMETER Path Path to a .json or .json.tftpl file. Unrendered ${tokens} are tolerated. .PARAMETER Json Raw JSON content instead of a file. .PARAMETER SuppliedParameter Parameter names supplied out of band, for example through a Terraform module input. .PARAMETER ConnectionName Connection keys the deployment wires up, used to check the $connections declaration. .PARAMETER CallbackTriggerName A trigger expected to be callable, checked against the definition's triggers. .PARAMETER Detailed Return finding objects instead of a boolean. .EXAMPLE if (-not (Test-LdoLogicAppDefinition -Path ./export.json)) { throw 'invalid' } .EXAMPLE Get-ChildItem ./templates/*.json.tftpl | Test-LdoLogicAppDefinition -Detailed .OUTPUTS System.Boolean .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([bool], [pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json, [string[]]$SuppliedParameter = @(), [string[]]$ConnectionName = @(), [string]$CallbackTriggerName, [switch]$Detailed ) process { $export = if ($PSCmdlet.ParameterSetName -eq 'Path') { ConvertFrom-LdoLogicAppExport -Path $Path } else { ConvertFrom-LdoLogicAppExport -Json $Json } $findings = [System.Collections.Generic.List[pscustomobject]]::new() $addFinding = { param([string]$Severity, [string]$Rule, [string]$Message) $findings.Add([pscustomobject]@{ Source = $export.Source Workflow = $export.Name Shape = $export.Shape Severity = $Severity Rule = $Rule Message = $Message }) } $statusArgs = @{ SuppliedParameter = $SuppliedParameter } $status = if ($PSCmdlet.ParameterSetName -eq 'Path') { Get-LdoLogicAppParameterStatus -Path $Path @statusArgs } else { Get-LdoLogicAppParameterStatus -Json $Json @statusArgs } foreach ($parameter in @($status | Where-Object { -not $_.Satisfied })) { & $addFinding 'Error' 'ParameterHasNoValue' ` "parameter '$($parameter.Name)' ($($parameter.Type)) has no value: $($parameter.Reason)." } $declaredNames = @() if ($null -ne $export.Declarations) { $declaredNames = @($export.Declarations.PSObject.Properties.Name) } foreach ($supplied in $SuppliedParameter) { if ($declaredNames -notcontains $supplied) { & $addFinding 'Error' 'SuppliedParameterNotDeclared' ` "parameter '$supplied' is supplied but the definition does not declare it: the definition is the contract, values only fill it." } } if ($ConnectionName.Count -gt 0 -and $declaredNames -notcontains '$connections') { & $addFinding 'Error' 'ConnectionsNotDeclared' ` "$($ConnectionName.Count) connection(s) are wired but the definition declares no `$connections parameter." } if ($ConnectionName.Count -eq 0 -and $declaredNames -contains '$connections') { & $addFinding 'Warning' 'ConnectionsDeclaredButUnwired' ` 'the definition declares $connections but no connections were wired to it.' } $triggerNames = @() if ((Test-LdoLogicAppMember -InputObject $export.Definition -Name 'triggers') -and $null -ne $export.Definition.triggers) { $triggerNames = @($export.Definition.triggers.PSObject.Properties.Name) } if ($triggerNames.Count -eq 0) { & $addFinding 'Warning' 'NoTrigger' 'the definition has no triggers, so nothing can start this workflow.' } if ($PSBoundParameters.ContainsKey('CallbackTriggerName') -and $triggerNames -notcontains $CallbackTriggerName) { $known = if ($triggerNames.Count -gt 0) { $triggerNames -join ', ' } else { 'none' } & $addFinding 'Error' 'CallbackTriggerNotFound' ` "callback trigger '$CallbackTriggerName' does not exist in the definition (triggers: $known)." } $hasActions = $false if ((Test-LdoLogicAppMember -InputObject $export.Definition -Name 'actions') -and $null -ne $export.Definition.actions) { $hasActions = @($export.Definition.actions.PSObject.Properties.Name).Count -gt 0 } if (-not $hasActions) { & $addFinding 'Warning' 'NoActions' 'the definition has no actions, so this workflow does nothing when it runs.' } $errorCount = @($findings | Where-Object { $_.Severity -eq 'Error' }).Count if ($errorCount -gt 0) { Write-LdoLog -Level WARN -Message "'$($export.Source)': $errorCount error finding(s)." } else { Write-LdoLog -Level DEBUG -Message "'$($export.Source)': no error findings." } if ($Detailed) { return $findings.ToArray() } return ($errorCount -eq 0) } } function Assert-LdoLogicAppDefinition { <# .SYNOPSIS Throws when a Logic App definition fails the offline contract checks. .DESCRIPTION The gate form of Test-LdoLogicAppDefinition. Runs the same checks and throws a single error listing every error-severity finding, so a build step fails loudly with the reasons rather than a bare false. Warnings are logged, not thrown, unless -TreatWarningsAsErrors is set. .PARAMETER Path Path to a .json or .json.tftpl file. Unrendered ${tokens} are tolerated. .PARAMETER Json Raw JSON content instead of a file. .PARAMETER SuppliedParameter Parameter names supplied out of band, for example through a Terraform module input. .PARAMETER ConnectionName Connection keys the deployment wires up. .PARAMETER CallbackTriggerName A trigger expected to be callable. .PARAMETER TreatWarningsAsErrors Throw on warning-severity findings too. .EXAMPLE Get-ChildItem ./templates/*.json.tftpl | Assert-LdoLogicAppDefinition .OUTPUTS None #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([void])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json, [string[]]$SuppliedParameter = @(), [string[]]$ConnectionName = @(), [string]$CallbackTriggerName, [switch]$TreatWarningsAsErrors ) process { $forward = @{ SuppliedParameter = $SuppliedParameter ConnectionName = $ConnectionName Detailed = $true } if ($PSBoundParameters.ContainsKey('CallbackTriggerName')) { $forward['CallbackTriggerName'] = $CallbackTriggerName } $findings = if ($PSCmdlet.ParameterSetName -eq 'Path') { Test-LdoLogicAppDefinition -Path $Path @forward } else { Test-LdoLogicAppDefinition -Json $Json @forward } $source = if ($PSCmdlet.ParameterSetName -eq 'Path') { $Path } else { '<string>' } foreach ($warning in @($findings | Where-Object { $_.Severity -eq 'Warning' })) { Write-LdoLog -Level WARN -Message "$($warning.Rule): $($warning.Message)" } $fatal = @($findings | Where-Object { $_.Severity -eq 'Error' -or ($TreatWarningsAsErrors -and $_.Severity -eq 'Warning') }) if ($fatal.Count -gt 0) { $detail = ($fatal | ForEach-Object { " [$($_.Severity)] $($_.Rule): $($_.Message)" }) -join [Environment]::NewLine throw "Logic App definition '$source' failed validation with $($fatal.Count) finding(s):$([Environment]::NewLine)$detail" } Write-LdoLog -Level SUCCESS -Message "Logic App definition '$source' passed offline validation." } } # ------------------------------------------------------------------------------------------------- # Azure-side validation, without deploying # ------------------------------------------------------------------------------------------------- function Test-LdoLogicAppDeployment { <# .SYNOPSIS Asks Azure to validate a workflow definition without deploying it. .DESCRIPTION Posts the definition to the Logic Apps validate endpoint (Microsoft.Logic/locations/{location}/workflows/{name}/validate), which type-checks the whole definition and returns the resource provider's own verdict while creating nothing and costing nothing. This is the authoritative oracle. The offline checks in this module encode what the provider rejects, but only the provider decides, so use this to confirm anything the offline pass cannot know: whether a property is accepted on a given api-version, whether an expression type-checks, whether a connector action shape is valid. Note the endpoint validates the DEFINITION, not the estate around it. A native Workflow dispatch action's target is checked at PUT time, so use Get-LdoLogicAppDeployOrder for ordering rather than expecting this to catch NestedWorkflowNotFound. Requires a signed-in Azure CLI. .PARAMETER Path Path to a .json file holding the definition, in any of the three shapes. .PARAMETER Json Raw JSON content instead of a file. .PARAMETER ResourceGroupName Resource group used to scope the validate call. .PARAMETER Location Azure region, for example uksouth. Defaults to the resource group's location. .PARAMETER Name Workflow name used in the validate URL. Defaults to the export's name or the file stem. .PARAMETER SubscriptionId Subscription to validate against. Defaults to the CLI's current subscription. .PARAMETER Detailed Return the provider's response object instead of a boolean. .EXAMPLE Test-LdoLogicAppDeployment -Path ./export.json -ResourceGroupName rg-soc-uks-dev-01 .EXAMPLE Test-LdoLogicAppDeployment -Path ./export.json -ResourceGroupName rg -Detailed .OUTPUTS System.Boolean .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([bool], [pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ResourceGroupName, [string]$Location, [string]$Name, [string]$SubscriptionId, [switch]$Detailed ) process { Assert-LdoCommand -Name 'az' $export = if ($PSCmdlet.ParameterSetName -eq 'Path') { ConvertFrom-LdoLogicAppExport -Path $Path } else { ConvertFrom-LdoLogicAppExport -Json $Json } if (-not $SubscriptionId) { $SubscriptionId = (& az account show --query id -o tsv) Assert-LdoLastExitCode -Operation 'az account show' } if (-not $Location) { $Location = (& az group show --name $ResourceGroupName --subscription $SubscriptionId --query location -o tsv) Assert-LdoLastExitCode -Operation 'az group show' } $workflowName = if ($Name) { $Name } elseif ($export.Name) { $export.Name } else { 'ldo-validate-probe' } # The endpoint takes a workflow resource body: definition, and the parameter VALUES beside # it. A valueless declaration is exactly what it rejects, which is the point of asking. $properties = @{ definition = $export.Definition } if ($null -ne $export.ParameterValues) { $properties['parameters'] = $export.ParameterValues } $body = @{ location = $Location; properties = $properties } | ConvertTo-Json -Depth 100 -Compress $bodyFile = New-TemporaryFile try { Set-Content -LiteralPath $bodyFile.FullName -Value $body -Encoding utf8NoBOM $url = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName" + "/providers/Microsoft.Logic/locations/$Location/workflows/$workflowName/validate?api-version=2019-05-01" Write-LdoLog -Level INFO -Message "Validating '$workflowName' against the Logic Apps validate endpoint in '$Location'." $response = & az rest --method post --url $url --body "@$($bodyFile.FullName)" --headers 'Content-Type=application/json' -o json 2>&1 $succeeded = ($LASTEXITCODE -eq 0) } finally { Remove-Item -LiteralPath $bodyFile.FullName -Force -ErrorAction SilentlyContinue } $responseText = ($response | Out-String).Trim() if ($succeeded) { Write-LdoLog -Level SUCCESS -Message "'$workflowName' passed provider validation." } else { Write-LdoLog -Level ERROR -Message "'$workflowName' failed provider validation: $responseText" } if ($Detailed) { $parsedResponse = $null if ($responseText) { try { $parsedResponse = $responseText | ConvertFrom-Json -Depth 100 } catch { $parsedResponse = $null } } return [pscustomobject]@{ Source = $export.Source Workflow = $workflowName Location = $Location Valid = $succeeded Response = $parsedResponse RawMessage = $responseText } } return $succeeded } } # ------------------------------------------------------------------------------------------------- # Export from Azure # ------------------------------------------------------------------------------------------------- function Export-LdoLogicAppDefinition { <# .SYNOPSIS Exports deployed Consumption Logic App definitions from Azure to disk. .DESCRIPTION Reads the deployed workflows in a resource group and writes each one out as JSON, in either the designer code view shape (definition plus its resolved parameter values, which is what pastes back into a deployment tool) or the raw ARM resource shape. This is a read-only operation against the management plane. .PARAMETER ResourceGroupName Resource group holding the workflows. .PARAMETER Name Optional workflow names to limit the export. All workflows in the group when omitted. .PARAMETER OutputPath Directory to write into. Created when missing. Files are named after the workflow. .PARAMETER Shape CodeView (default) writes {"definition": ..., "parameters": ...}; Arm writes the whole resource as returned by the management API. .PARAMETER SubscriptionId Subscription to read from. Defaults to the CLI's current subscription. .PARAMETER PassThru Emit the exported objects as well as writing the files. .EXAMPLE Export-LdoLogicAppDefinition -ResourceGroupName rg-soc-uks-dev-01 -OutputPath ./raw .EXAMPLE Export-LdoLogicAppDefinition -ResourceGroupName rg -Name logic-router -Shape Arm -OutputPath ./raw .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ResourceGroupName, [string[]]$Name = @(), [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$OutputPath, [ValidateSet('CodeView', 'Arm')] [string]$Shape = 'CodeView', [string]$SubscriptionId, [switch]$PassThru ) Assert-LdoCommand -Name 'az' if (-not $SubscriptionId) { $SubscriptionId = (& az account show --query id -o tsv) Assert-LdoLastExitCode -Operation 'az account show' } Write-LdoLog -Level INFO -Message "Listing Logic App workflows in '$ResourceGroupName'." $listJson = & az resource list --resource-group $ResourceGroupName --resource-type 'Microsoft.Logic/workflows' --subscription $SubscriptionId -o json Assert-LdoLastExitCode -Operation 'az resource list' $workflows = @($listJson | ConvertFrom-Json -Depth 100) if ($Name.Count -gt 0) { $workflows = @($workflows | Where-Object { $Name -contains $_.name }) } if ($workflows.Count -eq 0) { Write-LdoLog -Level WARN -Message "No Logic App workflows matched in '$ResourceGroupName'." return } if (-not (Test-Path -LiteralPath $OutputPath)) { if ($PSCmdlet.ShouldProcess($OutputPath, 'Create output directory')) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } } foreach ($workflow in $workflows) { Write-LdoLog -Level INFO -Message "Exporting '$($workflow.name)'." $showJson = & az rest --method get --url "https://management.azure.com$($workflow.id)?api-version=2019-05-01" -o json Assert-LdoLastExitCode -Operation "az rest get $($workflow.name)" $resource = $showJson | ConvertFrom-Json -Depth 100 $document = if ($Shape -eq 'Arm') { $resource } else { $codeView = [ordered]@{ definition = $resource.properties.definition } if (Test-LdoLogicAppMember -InputObject $resource.properties -Name 'parameters') { $codeView['parameters'] = $resource.properties.parameters } [pscustomobject]$codeView } $file = Join-Path $OutputPath "$($workflow.name).json" if ($PSCmdlet.ShouldProcess($file, 'Write Logic App definition')) { $document | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $file -Encoding utf8NoBOM } if ($PassThru) { [pscustomobject]@{ Name = $workflow.name Id = $workflow.id Shape = $Shape Path = $file } } } } # ------------------------------------------------------------------------------------------------- # Transforms for lifting a definition between estates # ------------------------------------------------------------------------------------------------- function Add-LdoLogicAppParameterDefault { <# .SYNOPSIS Back-fills a defaultValue onto every declaration from the wrapper's values. .DESCRIPTION Makes an export self-sufficient. A deployed definition whose declarations carry no defaults cannot be pasted back in on its own, because the values live in the wrapper and a deployment tool may own those separately. Copying each wrapper value down onto its declaration as a defaultValue means the definition alone is deployable. $connections is skipped, because a deployment tool regenerates it from its own connections input. SecureString and SecureObject are skipped too: writing a secret into the definition as a default is exactly what secure parameters exist to avoid. Note the corollary: the output now carries the SOURCE estate's values, so a copy taken elsewhere deploys against them unless re-pointed. Use Update-LdoLogicAppReference for that. .PARAMETER Path Path to a .json file to read. .PARAMETER Json Raw JSON content instead of a file. .PARAMETER OutputPath Write the result here instead of returning it. .PARAMETER Force Overwrite a defaultValue that is already present. .EXAMPLE Add-LdoLogicAppParameterDefault -Path ./export.json -OutputPath ./template.json .EXAMPLE Get-ChildItem ./raw/*.json | Add-LdoLogicAppParameterDefault -OutputPath ./portable .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json, [string]$OutputPath, [switch]$Force ) process { $export = if ($PSCmdlet.ParameterSetName -eq 'Path') { ConvertFrom-LdoLogicAppExport -Path $Path } else { ConvertFrom-LdoLogicAppExport -Json $Json } if ($export.Shape -eq 'BareDefinition') { throw "Cannot back-fill defaults from '$($export.Source)': it is a bare definition, so it carries declarations but no values." } if ($null -eq $export.Declarations -or $null -eq $export.ParameterValues) { Write-LdoLog -Level WARN -Message "'$($export.Source)' has no declarations or no wrapper values; nothing to back-fill." return $export.Definition } $added = 0 foreach ($declaration in $export.Declarations.PSObject.Properties) { $name = $declaration.Name if ($name -eq '$connections') { continue } $type = if (Test-LdoLogicAppMember -InputObject $declaration.Value -Name 'type') { $declaration.Value.type } else { $null } if ($type -in @('SecureString', 'SecureObject')) { continue } if ((Test-LdoLogicAppMember -InputObject $declaration.Value -Name 'defaultValue') -and -not $Force) { continue } if (-not (Test-LdoLogicAppMember -InputObject $export.ParameterValues -Name $name)) { continue } $wrapperEntry = $export.ParameterValues.$name if (-not (Test-LdoLogicAppMember -InputObject $wrapperEntry -Name 'value')) { continue } $declaration.Value | Add-Member -NotePropertyName 'defaultValue' -NotePropertyValue $wrapperEntry.value -Force $added++ } Write-LdoLog -Level INFO -Message "Back-filled $added defaultValue(s) onto '$($export.Source)'." if ($OutputPath) { if ($PSCmdlet.ShouldProcess($OutputPath, 'Write back-filled definition')) { $export.Definition | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM } return } return $export.Definition } } function Update-LdoLogicAppReference { <# .SYNOPSIS Rewrites estate-specific references in a definition so it can be lifted elsewhere. .DESCRIPTION A deployed definition carries absolute ARM ids in its action bodies and connection blocks: subscription ids, resource group names, connection resource names, instance URLs. Lifting it into another estate means rewriting those, and doing it as literal text over the serialised JSON keeps the definition byte-identical everywhere else. Replacements are applied in the order given, so put the most specific first: a later, more general rule would otherwise rewrite text an earlier one just produced. .PARAMETER Path Path to a .json file to read. .PARAMETER Json Raw JSON content instead of a file. .PARAMETER Replacement Ordered dictionary of literal find/replace pairs. Use [ordered]@{} so the order is kept. .PARAMETER OutputPath Write the result here instead of returning it. .EXAMPLE $map = [ordered]@{ '/subscriptions/aaa' = '/subscriptions/bbb'; 'rg-old' = 'rg-new' } Update-LdoLogicAppReference -Path ./export.json -Replacement $map -OutputPath ./lifted.json .OUTPUTS System.String #> [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)] [OutputType([string])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json, [Parameter(Mandatory)] [ValidateNotNull()] [System.Collections.IDictionary]$Replacement, [string]$OutputPath ) process { $doc = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-LdoLogicAppDocument -Path $Path } else { Resolve-LdoLogicAppDocument -Json $Json } $text = $doc.Text foreach ($key in $Replacement.Keys) { $before = $text $text = $text.Replace([string]$key, [string]$Replacement[$key]) if ($before -ne $text) { Write-LdoLog -Level DEBUG -Message "Rewrote '$key' in '$($doc.Source)'." } } if ($OutputPath) { if ($PSCmdlet.ShouldProcess($OutputPath, 'Write rewritten definition')) { Set-Content -LiteralPath $OutputPath -Value $text -Encoding utf8NoBOM } return } return $text } } # ------------------------------------------------------------------------------------------------- # Estate-level reads # ------------------------------------------------------------------------------------------------- function Get-LdoLogicAppConnection { <# .SYNOPSIS Extracts the managed API connections a definition references. .DESCRIPTION Reads the resolved $connections value out of an export's wrapper and returns one record per connection, including whether it authenticates with a managed identity. That is the connectionProperties.authentication.type field, which must read ManagedServiceIdentity for managed identity auth to be in play, alongside the connection resource's own parameterValueType of Alternative. Only the wrapped shapes carry connection values; a bare definition declares $connections but holds no value for it. .PARAMETER Path Path to a .json file to read. .PARAMETER Json Raw JSON content instead of a file. .EXAMPLE Get-LdoLogicAppConnection -Path ./export.json | Format-Table Api, ManagedIdentityAuth .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding(DefaultParameterSetName = 'Path')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string]$Path, [Parameter(Mandatory, ParameterSetName = 'Json')] [ValidateNotNullOrEmpty()] [string]$Json ) process { $export = if ($PSCmdlet.ParameterSetName -eq 'Path') { ConvertFrom-LdoLogicAppExport -Path $Path } else { ConvertFrom-LdoLogicAppExport -Json $Json } if ($null -eq $export.ParameterValues -or -not (Test-LdoLogicAppMember -InputObject $export.ParameterValues -Name '$connections')) { Write-LdoLog -Level DEBUG -Message "'$($export.Source)' carries no resolved `$connections value." return } $connectionsEntry = $export.ParameterValues.'$connections' if (-not (Test-LdoLogicAppMember -InputObject $connectionsEntry -Name 'value')) { return } foreach ($connection in $connectionsEntry.value.PSObject.Properties) { $detail = $connection.Value $authType = $null if ((Test-LdoLogicAppMember -InputObject $detail -Name 'connectionProperties') -and (Test-LdoLogicAppMember -InputObject $detail.connectionProperties -Name 'authentication') -and (Test-LdoLogicAppMember -InputObject $detail.connectionProperties.authentication -Name 'type')) { $authType = $detail.connectionProperties.authentication.type } [pscustomobject]@{ Source = $export.Source Workflow = $export.Name Api = $connection.Name ConnectionName = if (Test-LdoLogicAppMember -InputObject $detail -Name 'connectionName') { $detail.connectionName } else { $connection.Name } ConnectionId = if (Test-LdoLogicAppMember -InputObject $detail -Name 'connectionId') { $detail.connectionId } else { $null } ManagedApiId = if (Test-LdoLogicAppMember -InputObject $detail -Name 'id') { $detail.id } else { $null } AuthenticationType = $authType ManagedIdentityAuth = ($authType -eq 'ManagedServiceIdentity') } } } } function Get-LdoLogicAppDeployOrder { <# .SYNOPSIS Works out the order a set of workflows has to deploy in. .DESCRIPTION ARM validates a native Workflow dispatch action's TARGET when the workflow is written, so a workflow that invokes a sibling fails with NestedWorkflowNotFound unless that sibling already exists. This derives the ordering from the definitions themselves rather than a hand-maintained list: a workflow referencing another by its /workflows/{name} path must deploy after it. Each workflow is assigned a tier: 0 for leaves that dispatch to nothing in the set, and then one higher than the highest tier it depends on. Deploy tier 0 first, then 1, and so on. A dependency cycle throws, because no ordering can satisfy it. .PARAMETER Path Paths to the definition files forming the set. .EXAMPLE Get-ChildItem ./raw/*.json | Get-LdoLogicAppDeployOrder | Sort-Object Tier .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('FullName')] [string[]]$Path ) begin { $collected = [System.Collections.Generic.List[string]]::new() } process { foreach ($item in $Path) { $collected.Add($item) } } end { $exports = @{} foreach ($file in $collected) { $export = ConvertFrom-LdoLogicAppExport -Path $file $key = if ($export.Name) { $export.Name } else { [System.IO.Path]::GetFileNameWithoutExtension($file) } $exports[$key] = $export } $names = @($exports.Keys) $dependencies = @{} foreach ($name in $names) { $text = $exports[$name].Text $dependencies[$name] = @($names | Where-Object { $_ -ne $name -and $text -match [regex]::Escape("/workflows/$_") }) } $tiers = @{} $remaining = [System.Collections.Generic.List[string]]::new() foreach ($name in $names) { $remaining.Add($name) } $tier = 0 while ($remaining.Count -gt 0) { $ready = @($remaining | Where-Object { $unresolved = @($dependencies[$_] | Where-Object { -not $tiers.ContainsKey($_) }) $unresolved.Count -eq 0 }) if ($ready.Count -eq 0) { throw "Cannot order Logic App deployment: a dispatch cycle exists between $($remaining -join ', ')." } foreach ($name in $ready) { $tiers[$name] = $tier [void]$remaining.Remove($name) } $tier++ } foreach ($name in ($names | Sort-Object { $tiers[$_] }, { $_ })) { [pscustomobject]@{ Workflow = $name Tier = $tiers[$name] DependsOn = $dependencies[$name] Source = $exports[$name].Source } } } } function Compare-LdoLogicAppDefinition { <# .SYNOPSIS Compares two Logic App definitions structurally. .DESCRIPTION Unwraps both sides, walks them recursively and reports the JSON paths that differ. Because both are unwrapped first, a designer code view export compares cleanly against an ARM resource GET of the same workflow, and a rendered template compares against what is actually deployed, which is how drift review becomes a diff rather than archaeology. .PARAMETER ReferencePath The baseline definition, for example the rendered template. .PARAMETER DifferencePath The definition to compare against it, for example the deployed export. .PARAMETER IncludeParameterValues Compare the wrapper parameter values too, not just the definitions. .EXAMPLE Compare-LdoLogicAppDefinition -ReferencePath ./dist/router.json -DifferencePath ./raw/router.json .OUTPUTS System.Management.Automation.PSCustomObject #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ReferencePath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$DifferencePath, [switch]$IncludeParameterValues ) $reference = ConvertFrom-LdoLogicAppExport -Path $ReferencePath $difference = ConvertFrom-LdoLogicAppExport -Path $DifferencePath $differences = [System.Collections.Generic.List[pscustomobject]]::new() $compare = { param($Left, $Right, [string]$Path) $leftNull = ($null -eq $Left) $rightNull = ($null -eq $Right) if ($leftNull -and $rightNull) { return } if ($leftNull -or $rightNull) { $differences.Add([pscustomobject]@{ Path = $Path Change = if ($leftNull) { 'AddedInDifference' } else { 'MissingInDifference' } Reference = $Left Difference = $Right }) return } # Test against the concrete type, never [pscustomobject]: that accelerator resolves to # PSObject, and a string or an int satisfies -is [pscustomobject] too, which would send the # walk recursing into a primitive's (empty) property bag. $leftIsObject = $Left -is [System.Management.Automation.PSCustomObject] $rightIsObject = $Right -is [System.Management.Automation.PSCustomObject] if ($leftIsObject -and $rightIsObject) { $keys = @( @($Left.PSObject.Properties | ForEach-Object { $_.Name }) + @($Right.PSObject.Properties | ForEach-Object { $_.Name }) ) | Select-Object -Unique | Sort-Object foreach ($key in $keys) { $leftValue = if (Test-LdoLogicAppMember -InputObject $Left -Name $key) { $Left.$key } else { $null } $rightValue = if (Test-LdoLogicAppMember -InputObject $Right -Name $key) { $Right.$key } else { $null } & $compare $leftValue $rightValue "$Path.$key" } return } $leftIsList = ($Left -is [System.Collections.IEnumerable]) -and ($Left -isnot [string]) $rightIsList = ($Right -is [System.Collections.IEnumerable]) -and ($Right -isnot [string]) if ($leftIsList -and $rightIsList) { $leftItems = @($Left) $rightItems = @($Right) $max = [Math]::Max($leftItems.Count, $rightItems.Count) for ($i = 0; $i -lt $max; $i++) { $leftValue = if ($i -lt $leftItems.Count) { $leftItems[$i] } else { $null } $rightValue = if ($i -lt $rightItems.Count) { $rightItems[$i] } else { $null } & $compare $leftValue $rightValue "$Path[$i]" } return } if ("$Left" -cne "$Right") { $differences.Add([pscustomobject]@{ Path = $Path Change = 'ValueChanged' Reference = $Left Difference = $Right }) } } & $compare $reference.Definition $difference.Definition 'definition' if ($IncludeParameterValues) { & $compare $reference.ParameterValues $difference.ParameterValues 'parameters' } if ($differences.Count -eq 0) { Write-LdoLog -Level SUCCESS -Message "'$ReferencePath' and '$DifferencePath' are structurally identical." } else { Write-LdoLog -Level WARN -Message "$($differences.Count) difference(s) between '$ReferencePath' and '$DifferencePath'." } return $differences.ToArray() } Export-ModuleMember -Function ` 'ConvertFrom-LdoLogicAppExport', ` 'Get-LdoLogicAppParameterStatus', ` 'Test-LdoLogicAppDefinition', ` 'Assert-LdoLogicAppDefinition', ` 'Test-LdoLogicAppDeployment', ` 'Export-LdoLogicAppDefinition', ` 'Add-LdoLogicAppParameterDefault', ` 'Update-LdoLogicAppReference', ` 'Get-LdoLogicAppConnection', ` 'Get-LdoLogicAppDeployOrder', ` 'Compare-LdoLogicAppDefinition' |