Private/Deployment.ps1
|
# What the hand-written deployment cmdlets share: reading a template and its parameters, the what-if text, # refusing an invalid template with its diagnostics, and running a deployment (in the foreground or as a job). $script:VMetricTemplateMaxBytes = 1MB function Get-VMetricTemplateText { # The template's text: -Template as given, or -TemplateFile read as UTF-8. At most 1 MiB, as the API allows. [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Management.Automation.PSCmdlet] $Cmdlet, [AllowNull()] [AllowEmptyString()] [string] $TemplateFile, [AllowNull()] [AllowEmptyString()] [string] $Template ) if ($TemplateFile) { $path = $Cmdlet.GetUnresolvedProviderPathFromPSPath($TemplateFile) if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw (New-VMetricException -Message "The template file '$TemplateFile' was not found." -Code 'TemplateFileNotFound' -Category ObjectNotFound) } $text = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) } else { $text = [string]$Template } if ([string]::IsNullOrWhiteSpace($text)) { throw (New-VMetricException -Message 'The template is empty.' -Code 'TemplateEmpty' -Category InvalidArgument) } if ([System.Text.Encoding]::UTF8.GetByteCount($text) -gt $script:VMetricTemplateMaxBytes) { throw (New-VMetricException -Message 'The template is larger than 1 MiB, the most a deployment takes.' -Code 'TemplateTooLarge' -Category LimitsExceeded) } $text } function Get-VMetricTemplateParameter { # The deployment's parameters: -TemplateParameterFile first (a deployment parameters file, # {"parameters": {"name": {"value": ...}}}, or a plain JSON object), then -TemplateParameterObject over it. # SecureStrings stay SecureStrings until the request is serialized. [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [Parameter(Mandatory)] [System.Management.Automation.PSCmdlet] $Cmdlet, [AllowNull()] [System.Collections.IDictionary] $ParameterObject, [AllowNull()] [AllowEmptyString()] [string] $ParameterFile ) $parameters = [ordered]@{} if ($ParameterFile) { $path = $Cmdlet.GetUnresolvedProviderPathFromPSPath($ParameterFile) if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw (New-VMetricException -Message "The parameters file '$ParameterFile' was not found." -Code 'ParameterFileNotFound' -Category ObjectNotFound) } if ($path.EndsWith('.bicepparam', [System.StringComparison]::OrdinalIgnoreCase)) { throw (New-VMetricException -Message 'Bicep parameter files (.bicepparam) are not supported; give a JSON parameters file or -TemplateParameterObject.' ` -Code 'ParameterFileUnsupported' -Category InvalidArgument) } $content = ConvertFrom-VMetricJson -Json ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)) -AsHashtable if ($content -isnot [System.Collections.IDictionary]) { throw (New-VMetricException -Message "The parameters file '$ParameterFile' does not hold a JSON object." -Code 'ParameterFileInvalid' -Category InvalidData) } $source = $content $wrapped = Get-VMetricMember -InputObject $content -Name 'parameters' if ($wrapped -is [System.Collections.IDictionary]) { $source = [ordered]@{} foreach ($key in $wrapped.Keys) { $entry = $wrapped[$key] if ($entry -is [System.Collections.IDictionary] -and (Get-VMetricDictionaryKey -Dictionary $entry -Name 'value')) { $source[$key] = $entry[(Get-VMetricDictionaryKey -Dictionary $entry -Name 'value')] } elseif ($entry -is [System.Collections.IDictionary] -and (Get-VMetricDictionaryKey -Dictionary $entry -Name 'reference')) { throw (New-VMetricException -Message "Parameter '$key' is a Key Vault reference, which a VirtualMetric deployment cannot resolve; give its value." ` -Code 'ParameterFileUnsupported' -Category InvalidArgument) } else { $source[$key] = $entry } } } foreach ($key in $source.Keys) { if ($key -notin @('$schema', 'contentVersion')) { $parameters[$key] = $source[$key] } } } if ($ParameterObject) { foreach ($key in $ParameterObject.Keys) { $existing = Get-VMetricDictionaryKey -Dictionary $parameters -Name ([string]$key) if ($existing) { $parameters.Remove($existing) } $parameters[[string]$key] = $ParameterObject[$key] } } $parameters } function Assert-VMetricTemplateValid { # Refuses a template the engine finds invalid, with every error diagnostic in the message; writes its # warnings. Answers the validate result. [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.PSCmdlet] $Cmdlet, [Parameter(Mandatory)] [string] $Template, [Parameter(Mandatory)] [System.Collections.IDictionary] $Parameters, [System.Collections.IDictionary] $Session, # The text of the secure parameters, masked in what is written. [AllowNull()] [string[]] $Secret ) $request = @{ Method = 'POST'; Path = '/deployments/validate'; Body = [ordered]@{ template = $Template; parameters = $Parameters } } if ($Session) { $request.Session = $Session } $result = Invoke-VMetricRequest @request $errors = [System.Collections.Generic.List[string]]::new() foreach ($diagnostic in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $result -Name 'diagnostics'))) { $severity = [string](Get-VMetricMember -InputObject $diagnostic -Name 'severity') $text = Hide-VMetricSecretText -Text (Format-VMetricDiagnosticText -Diagnostic $diagnostic) -Secret $Secret if ($severity -eq 'error') { $errors.Add($text) } elseif ($severity -eq 'warning') { $Cmdlet.WriteWarning($text) } } if (-not [bool](Get-VMetricMember -InputObject $result -Name 'valid') -or $errors.Count -gt 0) { $lines = @('The template is invalid:') + @($errors | ForEach-Object { " $_" }) $details = $result if ($Secret) { $details = $null } throw (New-VMetricException -Message ($lines -join [Environment]::NewLine) -Code 'DEPLOYMENT_TEMPLATE_INVALID' ` -Category InvalidData -Details $details) } $result } function Format-VMetricWhatIfText { # A what-if as the -WhatIf and -Confirm text shows it, one line per resource and one per property change: # + create VirtualMetric/devices 'syslog-udp' # ~ modify VirtualMetric/targets 'sentinel' # properties.port: 514 -> 1514 [CmdletBinding()] [OutputType([string[]])] param( [AllowNull()] [object] $Result, [AllowNull()] [string[]] $Secret ) $arrow = [string][char]0x2192 $symbols = @{ create = '+'; modify = '~'; noChange = '='; read = '>'; action = '!'; ignore = '*'; unknown = '?' } $counts = [ordered]@{} $lines = [System.Collections.Generic.List[string]]::new() foreach ($change in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $Result -Name 'changes'))) { $changeType = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'changeType')) $symbol = $symbols[$changeType] if (-not $symbol) { $symbol = '?' } $counts[$changeType] = 1 + [int]$counts[$changeType] $type = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'type')) $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'name')) if (-not $name) { $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $change -Name 'symbolicName')) } $lines.Add(('{0} {1,-9} {2} ''{3}''' -f $symbol, $changeType, $type, $name)) foreach ($delta in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $change -Name 'delta'))) { $path = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $delta -Name 'path')) # Masked before encoding, so a secret JSON would escape, or a short one, is masked too. $before = Format-VMetricValue -InputObject (Hide-VMetricSecretValue -InputObject (Get-VMetricMember -InputObject $delta -Name 'before') -Secret $Secret) $after = Format-VMetricValue -InputObject (Hide-VMetricSecretValue -InputObject (Get-VMetricMember -InputObject $delta -Name 'after') -Secret $Secret) $lines.Add(" ${path}: $before $arrow $after") } $failure = [string](Get-VMetricMember -InputObject $change -Name 'error') if ($failure) { $lines.Add(" error: $(ConvertTo-VMetricSafeText -Text $failure)") } } $words = @{ create = 'to create'; modify = 'to modify'; noChange = 'unchanged'; read = 'read'; action = 'actions'; ignore = 'ignored'; unknown = 'unknown' } $summary = foreach ($key in $counts.Keys) { $word = $words[$key] if (-not $word) { $word = $key } "$($counts[$key]) $word" } if ($lines.Count -eq 0) { $lines.Add('No resources.') } else { $lines.Add('Changes: ' + (@($summary) -join ', ') + '.') } $masked = foreach ($line in $lines) { Hide-VMetricSecretText -Text $line -Secret $Secret } return , [string[]]@($masked) } function Invoke-VMetricDeploymentRun { # POST /deployments, then (unless -NoWait) poll until it ends. Answers the raw deployment. [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, [AllowNull()] [AllowEmptyString()] [string] $Name, [Parameter(Mandatory)] [string] $Template, [Parameter(Mandatory)] [System.Collections.IDictionary] $Parameters, [switch] $NoWait ) $body = [ordered]@{} if ($Name) { $body['name'] = $Name } $body['template'] = $Template $body['parameters'] = $Parameters $deployment = Invoke-VMetricRequest -Method POST -Path '/deployments' -Body $body -Session $Session if ($NoWait) { return $deployment } try { Wait-VMetricDeploymentCompletion -Deployment $deployment -Session $Session } catch { if (Test-VMetricFlowControl -ErrorRecord $_) { throw } # The deployment goes on on the server whatever stopped the polling here (the job's session ended, the # network failed): say how to follow it. $inner = $_.Exception $id = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $deployment -Name 'id')) if (-not $id -or ($inner -is [VMetricException] -and $inner.Code -eq 'DeploymentStillRunning')) { throw } $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $deployment -Name 'name')) $code = 'DeploymentNotFollowed' $category = [System.Management.Automation.ErrorCategory]::NotSpecified $terminating = $false if ($inner -is [VMetricException]) { $code = $inner.Code $category = $inner.Category $terminating = $inner.IsTerminating } throw (New-VMetricException -Message "$($inner.Message) Deployment '$name' ($id) goes on running on the server: follow it with Get-VMetricDeployment -Id $id." ` -Code $code -Category $category -InnerException $inner -Terminating:$terminating) } } function Write-VMetricDeploymentOutcome { # After a deployment: the one-time outputs warning for each operation that has them, and an error when the # deployment did not succeed (the object has already been written). [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.PSCmdlet] $Cmdlet, [Parameter(Mandatory)] [object] $Deployment, # The text of the secure parameters, masked in the error. [AllowNull()] [string[]] $Secret ) $name = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Deployment -Name 'name')) $status = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Deployment -Name 'status')) $operations = ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $Deployment -Name 'operations') for ($index = 0; $index -lt $operations.Count; $index++) { $operation = $operations[$index] $names = [System.Collections.Generic.List[string]]::new() foreach ($entry in (Get-VMetricEntryList -InputObject (Get-VMetricMember -InputObject $operation -Name 'outputs'))) { $names.Add((ConvertTo-VMetricSafeText -Text $entry.Name)) } if ($names.Count -gt 0) { $label = "$(ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'type'))) '$(ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'name')))'" $Cmdlet.WriteWarning("$label returned $($names -join ' and ') (in operations[$index].outputs): shown once; copy it now. It cannot be shown again.") } } if ($status -in @('succeeded', 'running')) { return } $code = 'DEPLOYMENT_FAILED' $message = "The deployment ended $status." $failure = Get-VMetricMember -InputObject $Deployment -Name 'error' if ($failure) { $failureCode = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $failure -Name 'code')) $failureMessage = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $failure -Name 'message')) if ($failureCode) { $code = $failureCode } if ($failureMessage) { $message = $failureMessage } } foreach ($operation in $operations) { $operationError = Get-VMetricMember -InputObject $operation -Name 'error' if ((Get-VMetricMember -InputObject $operation -Name 'status') -eq 'failed' -and $operationError) { $message += [Environment]::NewLine + (" {0} '{1}': {2}: {3}" -f (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'type'))), (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operation -Name 'name'))), (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operationError -Name 'code'))), (ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $operationError -Name 'message')))) } } $category = if ($status -eq 'canceled') { 'OperationStopped' } else { 'InvalidOperation' } $message = Hide-VMetricSecretText -Text $message -Secret $Secret $exception = New-VMetricException -Message "Deployment '$name' $($status): $message ($code)" -Code $code -Category $category $Cmdlet.WriteError((New-VMetricErrorRecord -Exception $exception -TargetObject $Deployment)) } function Resolve-VMetricExportReference { # The {type, id} an object from the pipeline stands for: a catalog type's object (its PSTypeName names the # resource type through the catalog), a deployment operation (type, resourceId), or anything with # resourceType and id (the GET answers of the newer public modules). [CmdletBinding()] [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [Parameter(Mandatory)] [object] $InputObject ) $typeNames = $InputObject.psobject.TypeNames if ($typeNames -contains 'VirtualMetric.DeploymentOperation') { $type = [string](Get-VMetricMember -InputObject $InputObject -Name 'type') $resourceId = [string](Get-VMetricMember -InputObject $InputObject -Name 'resourceId') if ($type -and $resourceId) { return [ordered]@{ type = $type; id = $resourceId } } } else { $declared = [string](Get-VMetricMember -InputObject $InputObject -Name 'resourceType') $id = [string](Get-VMetricMember -InputObject $InputObject -Name 'id') if ($declared -like 'VirtualMetric/*' -and $id) { return [ordered]@{ type = $declared; id = $id } } $catalog = Get-VMetricCatalog foreach ($typeName in $typeNames) { $resourceType = $null if ($catalog.ResourceTypes.TryGetValue($typeName, [ref] $resourceType)) { $id = [string](Get-VMetricMember -InputObject $InputObject -Name (Get-VMetricTypeField -TypeName $typeName -Field 'idField')) if ($id) { return [ordered]@{ type = $resourceType; id = $id } } } } } throw (New-VMetricException -Message 'Cannot tell which resource an input object is. Pipe objects from the Get-VMetric cmdlets or deployment operations, or use -Type and -Id.' ` -Code 'UnknownResource' -Category InvalidArgument) } $script:VMetricJobTokenSeconds = 50 * 60 function Start-VMetricDeploymentJob { # New-VMetricDeployment -AsJob: the deployment in a thread job that imports this module. The job gets a view # of the session, never a token of its own (New-VMetricJobSession): it reads the session's current access # token for every request and never refreshes, so the single-use refresh token is spent in this runspace # only (see Private/Session.ps1). A refresh here reaches the job; and since nothing may refresh while the # job runs, the token is refreshed first when less than 50 minutes are left, which outlasts the job's # 45 minutes of polling. Receive-Job gives the VirtualMetric.Deployment. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-VMetricDeployment asks ShouldProcess before it calls this.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'The job block takes its values through -ArgumentList into its own param() block.')] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, [AllowNull()] [AllowEmptyString()] [string] $Name, [Parameter(Mandatory)] [string] $Template, [Parameter(Mandatory)] [System.Collections.IDictionary] $Parameters, [string] $JobName ) if (-not (Get-Command -Name 'Start-ThreadJob' -ErrorAction SilentlyContinue)) { throw (New-VMetricException -Message '-AsJob needs Start-ThreadJob, which ships with PowerShell 7 (the ThreadJob module). Install-Module ThreadJob, or run without -AsJob.' ` -Code 'ThreadJobUnavailable' -Category NotInstalled) } if ($Session.RefreshToken) { Update-VMetricAccessToken -Session $Session -WithinSeconds $script:VMetricJobTokenSeconds } $view = New-VMetricJobSession -Session $Session $manifest = Join-Path -Path $script:ModuleRoot -ChildPath 'VirtualMetric.psd1' $request = [pscustomobject]@{ Name = $Name; Template = $Template; Parameters = $Parameters } if (-not $JobName) { $JobName = if ($Name) { "VMetricDeployment $Name" } else { 'VMetricDeployment' } } Start-ThreadJob -Name $JobName -ArgumentList $manifest, $view, $request -ScriptBlock { param($Manifest, $Snapshot, $Request) $module = Import-Module -Name $Manifest -PassThru -ErrorAction Stop & $module { param($JobSession, $JobRequest) Invoke-VMetricDeploymentJob -Session $JobSession -Request $JobRequest } $Snapshot $Request } } function Invoke-VMetricDeploymentJob { # Runs inside the thread job: the deployment, then the VirtualMetric.Deployment, then any failure as an error. [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.IDictionary] $Session, [Parameter(Mandatory)] [object] $Request ) Set-VMetricCurrentSession -Session $Session $secrets = Get-VMetricSecretText -InputObject $Request.Parameters $raw = Invoke-VMetricDeploymentRun -Session $Session -Name $Request.Name -Template $Request.Template -Parameters $Request.Parameters $deployment = ConvertTo-VMetricDeploymentObject -InputObject $raw Hide-VMetricSecretInObject -InputObject $deployment -Secret $secrets $deployment Write-VMetricDeploymentOutcome -Cmdlet $PSCmdlet -Deployment $deployment -Secret $secrets } |