Public/Set-AzLocalClusterUpdateRingTagFromCsv.ps1
|
function Set-AzLocalClusterUpdateRingTagFromCsv { <# .SYNOPSIS Runs the Step.2 "Manage UpdateRing Tags" pipeline workload: validates the operator-edited CSV, applies UpdateRing tags via Set-AzLocalClusterUpdateRingTag, writes a JSON results sidecar plus markdown step summary, and emits step outputs for the v0.8.5 thin-YAML Step.2 pipeline. .DESCRIPTION Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline `run: |` body of the v0.8.4 `Step.2_manage-updatering-tags.yml` (GitHub Actions + Azure DevOps) into a single cmdlet call so the per-platform yml shrinks to a few lines and the workload becomes unit-testable against a mocked Set-AzLocalClusterUpdateRingTag. The cmdlet: 1. Validates that the CSV file exists. When it does not, writes the operator-recovery instructions to host (the same TWO-STAGE workflow guidance the v0.8.4 yml used) and throws so the pipeline step fails with a clear message. 2. Validates that the CSV contains the required columns (`ResourceId`, `UpdateRing`) and at least one row with a non-empty `UpdateRing` value. 3. Resolves the output directory (defaults to `./artifacts` on GitHub Actions / local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` on Azure DevOps - matching the v0.8.4 yml). 4. Calls `Set-AzLocalClusterUpdateRingTag -InputCsvPath <csv> -LogFolderPath <out> -PassThru` (with `-Force` / `-WhatIf` propagated from this cmdlet) ONCE. 5. Serialises the per-cluster results to `UpdateRingTag_Results.json` next to the logs (the canonical sidecar the v0.8.4 Summary step consumed). 6. Emits the markdown step summary (Settings table + Result breakdown table + per-cluster details) via `Add-AzLocalPipelineStepSummary`. 7. Emits step outputs via `Set-AzLocalPipelineOutput`: `total_count`, `created_count`, `updated_count`, `already_in_sync_count`, `skipped_count`, `failed_count`, `whatif_count`, `results_json_path`. Internal reuse (per the v0.8.5 thin-YAML consistency contract): * `Set-AzLocalClusterUpdateRingTag` for the actual tag write. * `Add-AzLocalPipelineStepSummary` for the rendered markdown. * `Set-AzLocalPipelineOutput` for the step outputs. * `Get-AzLocalPipelineHost` is implicit (the above branch on it). .PARAMETER InputCsvPath Path to the operator-edited CSV produced by Step.1 (Invoke-AzLocalClusterInventory). Must contain at minimum the columns `ResourceId` and `UpdateRing`. Rows with an empty `UpdateRing` are skipped by the inner cmdlet. .PARAMETER OutputDirectory Directory to write the JSON results sidecar and per-cluster log files into. Created if it does not exist. Defaults to `./artifacts` (which is what the v0.8.4 GH yml uses) or, on AzureDevOps, to `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` if that env var is set (matching the v0.8.4 ADO yml). .PARAMETER Force Propagated to `Set-AzLocalClusterUpdateRingTag -Force`. When set, existing UpdateRing tag values are overwritten. Without it, clusters that already carry an UpdateRing value are skipped with a Status of 'Skipped'. .PARAMETER ResultsJsonFileName Filename for the per-cluster JSON results sidecar. Default `UpdateRingTag_Results.json` - matches the v0.8.4 yml. .PARAMETER InstalledModuleVersion Optional [string] surfaced in the step summary footer ('Generated by AzLocal.UpdateManagement v<x>'). .PARAMETER SummaryFileName Per-task summary filename used by `Add-AzLocalPipelineStepSummary` on Azure DevOps and Local hosts. Default `updatering-tag-summary.md`. .PARAMETER PassThru When set, returns a single PSCustomObject summarising the run (TotalCount, CreatedCount, UpdatedCount, AlreadyInSyncCount, SkippedCount, FailedCount, WhatIfCount, ResultsJsonPath, Results). Without -PassThru the cmdlet emits nothing to the pipeline; the artifacts and step outputs are still produced. .OUTPUTS Nothing by default. When -PassThru is set, a single PSCustomObject: TotalCount [int] CreatedCount [int] UpdatedCount [int] AlreadyInSyncCount [int] SkippedCount [int] FailedCount [int] WhatIfCount [int] ResultsJsonPath [string] Results [PSCustomObject[]] .EXAMPLE Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath ./config/ClusterUpdateRings.csv -WhatIf Previews the tag changes that would be applied without writing anything to Azure. Emits the same markdown summary + JSON sidecar as a real run (every row is Status='WhatIf'). .EXAMPLE Set-AzLocalClusterUpdateRingTagFromCsv -InputCsvPath ./config/ClusterUpdateRings.csv -Force -PassThru Applies the tags, overwriting any existing UpdateRing values, and returns the per-cluster summary object. .NOTES Module: AzLocal.UpdateManagement (v0.8.5+) Roadmap: Step.2 - Manage UpdateRing Tags. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$InputCsvPath, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$OutputDirectory, [Parameter(Mandatory = $false)] [switch]$Force, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ResultsJsonFileName = 'UpdateRingTag_Results.json', [Parameter(Mandatory = $false)] [AllowEmptyString()] [AllowNull()] [string]$InstalledModuleVersion, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$SummaryFileName = 'updatering-tag-summary.md', [Parameter(Mandatory = $false)] [switch]$PassThru ) $pipelineHost = Get-AzLocalPipelineHost # ----- 1. CSV existence guard -------------------------------------- if (-not (Test-Path -LiteralPath $InputCsvPath -PathType Leaf)) { Write-Host '' Write-Host '===========================================================================' Write-Host ' UpdateRing tagging is a TWO-STAGE workflow. You must run the inventory' Write-Host ' pipeline first, edit the CSV, commit it, then re-run THIS pipeline.' Write-Host '===========================================================================' Write-Host '' Write-Host 'STEP 1 - Generate the cluster inventory (Step.1 pipeline).' Write-Host 'STEP 2 - Edit ClusterUpdateRings.csv in Excel and fill in the UpdateRing column.' Write-Host 'STEP 3 - Commit the edited CSV into your ops repo (recommended path config/ClusterUpdateRings.csv).' Write-Host 'STEP 4 - Re-run THIS pipeline pointing at the committed CSV.' Write-Host '' throw "CSV file not found at path: $InputCsvPath. See operator instructions above." } # ----- 2. CSV column / value guard --------------------------------- $rows = @(Import-Csv -LiteralPath $InputCsvPath) $requiredColumns = @('ResourceId', 'UpdateRing') if ($rows.Count -gt 0) { $columns = $rows[0].PSObject.Properties.Name foreach ($col in $requiredColumns) { if ($col -notin $columns) { throw "Required column '$col' not found in CSV: $InputCsvPath" } } } else { throw "CSV file is empty (no rows): $InputCsvPath. Re-run the Step.1 inventory pipeline to regenerate the file." } $rowsWithValues = @($rows | Where-Object { $_.UpdateRing -and $_.UpdateRing.Trim() -ne '' }).Count Write-Host ("CSV File: {0}" -f $InputCsvPath) Write-Host ("Total Rows: {0}" -f $rows.Count) Write-Host ("Rows with UpdateRing values: {0}" -f $rowsWithValues) if ($rowsWithValues -eq 0) { throw "No rows have UpdateRing values set. Edit the CSV and set UpdateRing values before running this pipeline." } # ----- 3. Output directory ----------------------------------------- if (-not $OutputDirectory) { if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) { $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY } else { $OutputDirectory = './artifacts' } } if (-not (Test-Path -LiteralPath $OutputDirectory)) { New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null } # ----- 4. Apply tags via Set-AzLocalClusterUpdateRingTag ----------- $applyParams = @{ InputCsvPath = $InputCsvPath LogFolderPath = $OutputDirectory PassThru = $true } if ($Force) { $applyParams['Force'] = $true Write-Host 'Force mode enabled - will overwrite existing tags' } if ($WhatIfPreference) { $applyParams['WhatIf'] = $true Write-Host 'DRY RUN MODE - No changes will be applied' } Write-Host '' Write-Host '========================================' Write-Host 'Applying UpdateRing Tags' Write-Host '========================================' $results = @(Set-AzLocalClusterUpdateRingTag @applyParams) # Defence-in-depth: filter to only per-cluster result objects so any stray # Format-Table formatter objects (header / row / footer wrappers) from # older module versions cannot inflate $results.Count. A genuine result row # always exposes a ClusterName string property; formatter objects do not. $results = @($results | Where-Object { $_ -and $_.PSObject.Properties['ClusterName'] -and $_.ClusterName -is [string] }) # ----- 5. JSON sidecar --------------------------------------------- $resultsJsonPath = Join-Path -Path $OutputDirectory -ChildPath $ResultsJsonFileName if ($results.Count -gt 0) { $results | ConvertTo-Json -Depth 5 | Out-File -FilePath $resultsJsonPath -Encoding utf8 Write-Host ("Wrote {0} per-cluster result row(s) to: {1}" -f $results.Count, $resultsJsonPath) } else { '[]' | Out-File -FilePath $resultsJsonPath -Encoding utf8 Write-Host ("No per-cluster results returned (empty CSV?). Wrote empty array to: {0}" -f $resultsJsonPath) } # ----- 6. Tally outcomes ------------------------------------------- $total = $results.Count $created = @($results | Where-Object { $_.Action -eq 'Created' -and $_.Status -eq 'Success' }).Count $updated = @($results | Where-Object { $_.Action -eq 'Updated' -and $_.Status -eq 'Success' }).Count $alreadyInSync = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }).Count $skipped = @($results | Where-Object { $_.Status -eq 'Skipped' }).Count $failed = @($results | Where-Object { $_.Status -eq 'Failed' }).Count $whatIfCount = @($results | Where-Object { $_.Status -eq 'WhatIf' }).Count Write-Host '' Write-Host '========================================' Write-Host 'Tag management complete' Write-Host '========================================' Write-Host ("Total clusters processed: {0}" -f $total) Write-Host ("Tags created : {0}" -f $created) Write-Host ("Tags updated : {0}" -f $updated) Write-Host ("Already in sync : {0}" -f $alreadyInSync) Write-Host ("Skipped (no -Force) : {0}" -f $skipped) Write-Host ("WhatIf (dry-run) : {0}" -f $whatIfCount) Write-Host ("Failed : {0}" -f $failed) # ----- 7. Step outputs --------------------------------------------- Set-AzLocalPipelineOutput -Name 'total_count' -Value ([string]$total) Set-AzLocalPipelineOutput -Name 'created_count' -Value ([string]$created) Set-AzLocalPipelineOutput -Name 'updated_count' -Value ([string]$updated) Set-AzLocalPipelineOutput -Name 'already_in_sync_count' -Value ([string]$alreadyInSync) Set-AzLocalPipelineOutput -Name 'skipped_count' -Value ([string]$skipped) Set-AzLocalPipelineOutput -Name 'failed_count' -Value ([string]$failed) Set-AzLocalPipelineOutput -Name 'whatif_count' -Value ([string]$whatIfCount) Set-AzLocalPipelineOutput -Name 'results_json_path' -Value ([string]$resultsJsonPath) # ----- 8. Markdown step summary ------------------------------------ $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine('## Step.2 - UpdateRing Tag Management Summary') [void]$sb.AppendLine('') [void]$sb.AppendLine('| Setting | Value |') [void]$sb.AppendLine('|---------|-------|') [void]$sb.AppendLine(("| Dry Run | {0} |" -f $WhatIfPreference.ToString())) [void]$sb.AppendLine(("| Force Overwrite | {0} |" -f $Force.IsPresent)) [void]$sb.AppendLine(("| Input CSV | {0} |" -f $InputCsvPath)) [void]$sb.AppendLine('') [void]$sb.AppendLine('### Result breakdown') [void]$sb.AppendLine('') [void]$sb.AppendLine('| Outcome | Count |') [void]$sb.AppendLine('|---------|------:|') [void]$sb.AppendLine(("| Total clusters processed | {0} |" -f $total)) [void]$sb.AppendLine(("| Tags created | {0} |" -f $created)) [void]$sb.AppendLine(("| Tags updated | {0} |" -f $updated)) [void]$sb.AppendLine(("| Already in sync (no-op) | {0} |" -f $alreadyInSync)) [void]$sb.AppendLine(("| Skipped (UpdateRing differs, no -Force) | {0} |" -f $skipped)) if ($whatIfCount -gt 0) { [void]$sb.AppendLine(("| WhatIf (dry-run preview) | {0} |" -f $whatIfCount)) } [void]$sb.AppendLine(("| Failed | {0} |" -f $failed)) if ($total -gt 0) { # Bucket the per-cluster results so the summary UI is easier to scan: # - "Tag Updates Applied" : rows that actually changed at least one # managed tag (Status=Success or WhatIf). # - "No Tag Updates (no-op)" : steady-state rows where every managed # tag already matched desired state (Status=AlreadyInSync). # - "Skipped / Failed" : rows that need operator attention. $appliedRows = @($results | Where-Object { $_.Status -eq 'Success' -or $_.Status -eq 'WhatIf' }) $noopRows = @($results | Where-Object { $_.Status -eq 'AlreadyInSync' }) $issueRows = @($results | Where-Object { $_.Status -eq 'Skipped' -or $_.Status -eq 'Failed' }) $renderRows = { param($rows) [void]$sb.AppendLine('| Cluster | Action | Previous | New | Status | Message |') [void]$sb.AppendLine('|---------|--------|----------|-----|--------|---------|') foreach ($r in $rows) { $cn = ([string]$r.ClusterName) -replace '\|','\|' $act = ([string]$r.Action) -replace '\|','\|' $pv = ([string]$r.PreviousTagValue) -replace '\|','\|' $nv = ([string]$r.NewTagValue) -replace '\|','\|' $st = ([string]$r.Status) -replace '\|','\|' $msg = (([string]$r.Message) -replace '\|','\|') -replace '\r?\n',' ' [void]$sb.AppendLine(("| {0} | {1} | {2} | {3} | {4} | {5} |" -f $cn, $act, $pv, $nv, $st, $msg)) } } if ($appliedRows.Count -gt 0) { [void]$sb.AppendLine('') [void]$sb.AppendLine(("<details open><summary>Clusters with Tag Updates Applied ({0} rows)</summary>" -f $appliedRows.Count)) [void]$sb.AppendLine('') & $renderRows $appliedRows [void]$sb.AppendLine('') [void]$sb.AppendLine('</details>') } if ($issueRows.Count -gt 0) { [void]$sb.AppendLine('') [void]$sb.AppendLine(("<details open><summary>Clusters Skipped or Failed ({0} rows)</summary>" -f $issueRows.Count)) [void]$sb.AppendLine('') & $renderRows $issueRows [void]$sb.AppendLine('') [void]$sb.AppendLine('</details>') } if ($noopRows.Count -gt 0) { [void]$sb.AppendLine('') [void]$sb.AppendLine(("<details><summary>Clusters with No Tag Updates (no-op) ({0} rows)</summary>" -f $noopRows.Count)) [void]$sb.AppendLine('') & $renderRows $noopRows [void]$sb.AppendLine('') [void]$sb.AppendLine('</details>') } } if ($WhatIfPreference) { [void]$sb.AppendLine('') [void]$sb.AppendLine('**This was a dry run. No changes were applied.**') } if ($InstalledModuleVersion) { [void]$sb.AppendLine('') [void]$sb.AppendLine(("_Generated by AzLocal.UpdateManagement v{0}._" -f $InstalledModuleVersion)) } Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName | Out-Null if ($PassThru) { return [pscustomobject]@{ TotalCount = $total CreatedCount = $created UpdatedCount = $updated AlreadyInSyncCount = $alreadyInSync SkippedCount = $skipped FailedCount = $failed WhatIfCount = $whatIfCount ResultsJsonPath = $resultsJsonPath Results = $results } } } |