Actions/Engine/DscV3.ps1
|
<#
.SYNOPSIS Engine action: evaluate a resource through dsc.exe (DSC v3 — cross-platform). .DESCRIPTION Drives Microsoft's DSC v3 command-line engine (`dsc resource <verb>`), which runs on Windows, Linux and macOS. This is what makes hosted Linux/macOS agents useful, since Invoke-DscResource is Windows / PowerShell-DSC-v2 first. Behavior: - maps Test/Set/Get to the dsc resource sub-command, - passes the desired-state Property hashtable as JSON on --input, - surfaces a non-zero dsc.exe exit as a thrown error, so the runner records the resource as failed (feeding the exit-code work in a later phase), - parses the JSON result into the normalized engine contract shape. .PARAMETER Context A hashtable with keys: Method ('Test'|'Set'|'Get'), ModuleName, Name, Property. .OUTPUTS An object exposing InDesiredState, RebootRequired, Message and Raw (normalized by the runner into a [DscMethodResult]). #> [CmdletBinding()] param( [hashtable]$Context = @{} ) # DSC v3 resource types are namespaced 'Owner/Resource', matching the runner's task type. $resourceType = '{0}/{1}' -f $Context.ModuleName, $Context.Name # Fail fast on a type that is not even shaped like a DSC v3 identifier. Without this the # only signal is an opaque non-zero exit from dsc.exe ('resource not found'); this points the # operator straight at the compiled configuration. The check is structural (namespace/name); # whether the type truly exists is still confirmed by dsc.exe itself below. Kept inline so the # engine action stays self-contained (it is also driven directly by the CI smoke test). $v3TypePattern = '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*/[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)*$' if ($resourceType -notmatch $v3TypePattern) { throw "[Actions/Engine/DscV3] Resource type '$resourceType' is not a DSC v3 identifier. DSC v3 types are 'namespace/name' (for example 'Microsoft.Windows/Registry'); a compiled configuration targeting the DscV3 engine must use DSC v3 resource types." } $verb = switch ($Context.Method) { 'Test' { 'test' } 'Set' { 'set' } 'Get' { 'get' } default { throw "[Actions/Engine/DscV3] Unsupported method '$($Context.Method)'." } } $property = $Context.Property if ($null -eq $property) { $property = @{} } # DscV2/CIM marshals a [pscredential]-typed property natively (MSFT_Credential) over the # encrypted WinRM transport. JSON has no credential type, so DSC v3 needs the plaintext # resolved immediately before building --input (#57 §5) - this is the one place it is ever # revealed, and only into the JSON payload sent either to the local dsc.exe process or, when # $Context.Session is set, over the already-encrypted Invoke-Command -Session channel; it is # never written to disk via --file. A [pscredential] becomes {username, password}; a bare # [securestring] becomes its plaintext value. Both cases are still covered by the # Test-SensitivePropertyName redaction below, so the plaintext never reaches the log. function ConvertTo-DscV3CredentialSafeProperty { param([hashtable]$InputHashTable, [System.Collections.Generic.HashSet[string]]$ForceRedactKeys) $resolved = @{} foreach ($key in $InputHashTable.Keys) { $value = $InputHashTable[$key] if ($value -is [System.Management.Automation.PSCredential]) { $resolved[$key] = @{ username = $value.UserName password = (Unprotect-SecureString -SecureString $value.Password) } $null = $ForceRedactKeys.Add($key) } elseif ($value -is [securestring]) { $resolved[$key] = Unprotect-SecureString -SecureString $value $null = $ForceRedactKeys.Add($key) } elseif ($value -is [hashtable]) { $resolved[$key] = ConvertTo-DscV3CredentialSafeProperty -InputHashTable $value -ForceRedactKeys $ForceRedactKeys } else { $resolved[$key] = $value } } return $resolved } # Ground-truth secret keys (originally a [pscredential]/[securestring]) are force-redacted # in the log below regardless of whether their name happens to match the # Test-SensitivePropertyName heuristic (e.g. a property named 'SAPwd' would otherwise slip # through the name-substring check). $forceRedactKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $property = ConvertTo-DscV3CredentialSafeProperty -InputHashTable $property -ForceRedactKeys $forceRedactKeys $inputJson = $property | ConvertTo-Json -Depth 32 -Compress $arguments = @('resource', $verb, '--resource', $resourceType, '--input', $inputJson) # Redact-for-logging only: the real --input JSON above is unredacted (dsc.exe needs the # actual secret), but the verbose breadcrumb must not echo it. Routes through the same # Test-SensitivePropertyName/Protect-SensitiveValue heuristic used elsewhere (#34), plus the # ground-truth $forceRedactKeys collected above, rather than logging $inputJson directly, # which used to leak every property value verbatim. function ConvertTo-RedactedPropertyTable { param([hashtable]$InputHashTable, [System.Collections.Generic.HashSet[string]]$ForceRedactKeys) $redacted = @{} foreach ($key in $InputHashTable.Keys) { $value = $InputHashTable[$key] if ($ForceRedactKeys.Contains($key)) { $redacted[$key] = '[REDACTED]' } elseif ($value -is [hashtable]) { $redacted[$key] = ConvertTo-RedactedPropertyTable -InputHashTable $value -ForceRedactKeys $ForceRedactKeys } elseif (Test-SensitivePropertyName -Name $key) { $redacted[$key] = '[REDACTED]' } else { $redacted[$key] = $value } } return $redacted } if ($VerbosePreference -ne 'SilentlyContinue') { $redactedJson = (ConvertTo-RedactedPropertyTable -InputHashTable $property -ForceRedactKeys $forceRedactKeys) | ConvertTo-Json -Depth 32 -Compress $redactedArguments = @('resource', $verb, '--resource', $resourceType, '--input', $redactedJson) Write-Verbose "[Actions/Engine/DscV3] dsc $($redactedArguments -join ' ')" } $remoteSession = $null if ($null -ne $Context.Session -and $null -ne $Context.Session.PSSession) { $remoteSession = $Context.Session.PSSession Write-Verbose "[Actions/Engine/DscV3] Using remote PSSession for [$resourceType]." } $run = Invoke-DscExecutable -Arguments $arguments -Session $remoteSession if ($run.ExitCode -ne 0) { throw "[Actions/Engine/DscV3] 'dsc resource $verb' failed for [$resourceType] (exit $($run.ExitCode)): $($run.Output)" } $parsed = $null if (-not [string]::IsNullOrWhiteSpace($run.Output)) { try { $parsed = $run.Output | ConvertFrom-Json } catch { throw "[Actions/Engine/DscV3] Could not parse dsc.exe output as JSON for [$resourceType]: $($_.Exception.Message)" } } # DSC v3 result shapes: # test -> { desiredState, actualState, inDesiredState, differingProperties } # set -> { beforeState, afterState, changedProperties } # get -> { actualState } (or the state object directly) $inDesiredState = $true $message = $null if ($Context.Method -eq 'Test') { if ($null -ne $parsed -and $null -ne $parsed.PSObject.Properties['inDesiredState']) { $inDesiredState = [bool]$parsed.inDesiredState } if ($null -ne $parsed -and $parsed.PSObject.Properties['differingProperties'] -and $parsed.differingProperties) { $message = "Differing properties: {0}" -f ($parsed.differingProperties -join ', ') } } elseif ($Context.Method -eq 'Set') { if ($null -ne $parsed -and $parsed.PSObject.Properties['changedProperties'] -and $parsed.changedProperties) { $message = "Changed properties: {0}" -f ($parsed.changedProperties -join ', ') } } # Reboot-pending signal: dsc.exe's exact shape for this is not yet confirmed against a # real resource that sets it (#57 §3), so this reads tolerantly from either of the shapes # documented/observed for Microsoft.DSC-family resources rather than assuming one - # a top-level 'rebootRequired' boolean, or a nested 'metadata.Microsoft.DSC.rebootRequired' # (the dsc.exe metadata envelope some resources attach to their result). Absent either, # this defaults to $false, matching today's behavior for every resource that never signals it. $rebootRequired = $false if ($null -ne $parsed) { if ($null -ne $parsed.PSObject.Properties['rebootRequired']) { $rebootRequired = [bool]$parsed.rebootRequired } elseif ($parsed.PSObject.Properties['metadata'] -and $parsed.metadata.PSObject.Properties['Microsoft.DSC'] -and $parsed.metadata.'Microsoft.DSC'.PSObject.Properties['rebootRequired']) { $rebootRequired = [bool]$parsed.metadata.'Microsoft.DSC'.rebootRequired } } return [pscustomobject]@{ InDesiredState = $inDesiredState RebootRequired = $rebootRequired Message = $message Raw = $parsed } |