Public/Invoke-AzLocalClusterInventory.ps1

function Invoke-AzLocalClusterInventory {
    <#
    .SYNOPSIS
        Runs the Step.1 cluster-inventory pipeline workload: queries the
        fleet, writes timestamped + canonical CSV / JSON artifacts plus the
        operator README, and emits the JUnit-style step summary + step
        outputs for the v0.8.5 thin-YAML Step.1 pipeline.
 
    .DESCRIPTION
        Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the inline
        `run: |` body of the v0.8.4
        `Step.1_inventory-clusters.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
        synthetic Get-AzLocalClusterInventory result.
 
        The cmdlet:
 
          1. Resolves the output directory (defaults to `./artifacts` on
             GitHub Actions / local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY`
             on Azure DevOps - matching the v0.8.4 yml).
          2. Calls `Get-AzLocalClusterInventory -PassThru -ExportPath <csv>`
             ONCE (the v0.8.4 yml called it twice; the cmdlet path
             collapses that into one query + a local JSON serialisation).
          3. Serialises the same in-memory inventory to JSON.
          4. Copies the timestamped CSV to a canonical
             `ClusterUpdateRings.csv` (the filename the Step.2
             'Manage UpdateRing Tags' pipeline consumes by default).
          5. Writes a `README_Instructions.txt` next to the CSV that
             explains the three-step operator flow (edit CSV in Excel,
             commit, run Step.2).
          6. Emits the markdown step summary (Total / WithTag / WithoutTag
             metric table + UpdateRing distribution table) via
             `Add-AzLocalPipelineStepSummary`.
          7. Emits four step outputs via `Set-AzLocalPipelineOutput`:
             `cluster_count`, `with_tag_count`, `without_tag_count`,
             `csv_path`.
 
        Internal reuse (per the v0.8.5 thin-YAML consistency contract):
          * `Get-AzLocalClusterInventory` for the actual inventory query.
          * `Add-AzLocalPipelineStepSummary` for the rendered markdown.
          * `Set-AzLocalPipelineOutput` for the step outputs.
          * `Get-AzLocalPipelineHost` is implicit (the above branch on it).
 
    .PARAMETER OutputDirectory
        Directory to write the CSV / JSON / canonical CSV / README 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 SubscriptionFilter
        Optional. When set and non-empty, passed through as `-SubscriptionId`
        to `Get-AzLocalClusterInventory`. When empty / null, the inventory
        runs across every subscription the pipeline identity can see.
 
    .PARAMETER Timestamp
        DateTime used to build the timestamped CSV / JSON filenames.
        Defaults to `Get-Date`. Tests pass a fixed value so filenames are
        deterministic.
 
    .PARAMETER CanonicalCsvFileName
        Filename for the canonical (no-timestamp) CSV copy. Default
        `ClusterUpdateRings.csv` - matches the Step.2 default
        `csv_file_path` input of `config/ClusterUpdateRings.csv`.
 
    .PARAMETER ReadmeFileName
        Filename for the operator README. Default `README_Instructions.txt`.
 
    .PARAMETER InstalledModuleVersion
        Optional [string] used in the README footer
        ('Generated by AzLocal.UpdateManagement v<x>').
 
    .PARAMETER SummaryFileName
        Per-task summary filename used by `Add-AzLocalPipelineStepSummary`
        on Azure DevOps and Local hosts. Default
        `cluster-inventory-summary.md`.
 
    .PARAMETER PassThru
        When set, returns a single PSCustomObject summarising the run
        (ClusterCount, WithTagCount, WithoutTagCount, CsvPath, JsonPath,
        CanonicalCsvPath, ReadmePath, Clusters, UpdateRingDistribution).
        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:
          ClusterCount [int]
          WithTagCount [int]
          WithoutTagCount [int]
          CsvPath [string]
          JsonPath [string]
          CanonicalCsvPath [string]
          ReadmePath [string]
          Clusters [PSCustomObject[]]
          UpdateRingDistribution [PSCustomObject[]] (Name + Count)
 
    .EXAMPLE
        Invoke-AzLocalClusterInventory -PassThru
 
        Runs the inventory against every subscription the identity can
        see, writes the four artifacts to `./artifacts`, emits the
        markdown summary + step outputs to the active pipeline host,
        and returns the summary object.
 
    .NOTES
        Module: AzLocal.UpdateManagement (v0.8.5+)
        Roadmap: Step.1 - Inventory Azure Local Clusters.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputDirectory,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$SubscriptionFilter,

        [Parameter(Mandatory = $false)]
        [datetime]$Timestamp = (Get-Date),

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$CanonicalCsvFileName = 'ClusterUpdateRings.csv',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$ReadmeFileName = 'README_Instructions.txt',

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$InstalledModuleVersion,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SummaryFileName = 'cluster-inventory-summary.md',

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    $pipelineHost = Get-AzLocalPipelineHost

    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
    }

    $timestampStr     = $Timestamp.ToString('yyyyMMdd_HHmmss')
    $csvPath          = Join-Path -Path $OutputDirectory -ChildPath ("ClusterInventory_{0}.csv"  -f $timestampStr)
    $jsonPath         = Join-Path -Path $OutputDirectory -ChildPath ("ClusterInventory_{0}.json" -f $timestampStr)
    $canonicalCsvPath = Join-Path -Path $OutputDirectory -ChildPath $CanonicalCsvFileName
    $readmePath       = Join-Path -Path $OutputDirectory -ChildPath $ReadmeFileName

    $invParams = @{}
    if ($SubscriptionFilter) {
        $invParams['SubscriptionId'] = $SubscriptionFilter
        Write-Host "Filtering to subscription: $SubscriptionFilter"
    }
    else {
        Write-Host 'Querying all accessible subscriptions'
    }

    Write-Host '--- Running cluster inventory ---'
    $inventory = Get-AzLocalClusterInventory @invParams -ExportPath $csvPath -PassThru
    if ($null -eq $inventory) { $inventory = @() }
    # Force array shape so .Count is reliable even when a single row comes back as a bare PSCustomObject.
    $inventory = @($inventory)

    # Serialise the same in-memory inventory to JSON next to the CSV.
    $inventory | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $jsonPath -Encoding UTF8

    if (Test-Path -LiteralPath $csvPath) {
        Copy-Item -LiteralPath $csvPath -Destination $canonicalCsvPath -Force
        Write-Host "Canonical copy created: $canonicalCsvPath (edit this one)"
    }
    else {
        # Empty fleet: Get-AzLocalClusterInventory does not write a CSV when there
        # are zero rows. Synthesise an empty canonical CSV so Step.2 has a file
        # to read (a header-only CSV is treated as 'no work to do').
        $emptyHeader = 'ClusterName,ResourceGroup,SubscriptionId,SubscriptionName,UpdateRing,HasUpdateRingTag,UpdateStartWindow,UpdateExclusions,UpdateSideloaded,UpdateVersionInProgress,ResourceId'
        Set-Content -LiteralPath $canonicalCsvPath -Value $emptyHeader -Encoding UTF8
        Set-Content -LiteralPath $csvPath          -Value $emptyHeader -Encoding UTF8
        Write-Host "No cluster rows returned - wrote header-only CSV to $canonicalCsvPath."
    }

    # README operator instructions.
    $generatedUtc = $Timestamp.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'")
    $moduleVersionForReadme = if ($InstalledModuleVersion) { $InstalledModuleVersion } else { '(unknown)' }
    $readme = @'
========================================================================
 Cluster Inventory - Next Steps
 AzLocal.UpdateManagement (UpdateRing tagging workflow)
========================================================================
 
This artifact contains the cluster inventory generated by the
'Inventory Azure Local Clusters' pipeline. Use it to assign each
cluster to an update wave (UpdateRing tag), then commit the result
back into your repository so the 'Manage UpdateRing Tags' pipeline
can apply the tags to Azure.
 
------------------------------------------------------------------------
FILES IN THIS ARTIFACT
------------------------------------------------------------------------
 
  ClusterUpdateRings.csv
      <-- EDIT THIS FILE. Canonical name expected by the 'Manage
          UpdateRing Tags' pipeline's default csv_file_path /
          csvFilePath input (config/ClusterUpdateRings.csv).
 
  ClusterInventory_<timestamp>.csv
      Audit / historical copy of this run's inventory. Do NOT edit -
      keep it so you can diff future inventory runs against this
      snapshot.
 
  ClusterInventory_<timestamp>.json
      Machine-readable copy for dashboards / custom integrations.
 
  README_Instructions.txt
      This file.
 
------------------------------------------------------------------------
STEP 1 - Open ClusterUpdateRings.csv in Excel
------------------------------------------------------------------------
 
  - For each cluster row, fill in the 'UpdateRing' column with the
    wave name you want that cluster to belong to. Examples:
        Canary, Pilot, Production
        (or your own scheme, e.g. Wave1, Wave2, Wave3 / Dev, Test, Prod)
  - Leave the 'UpdateRing' column EMPTY for any cluster you do not
    want this run to tag - those rows will be skipped.
  - Save as CSV (UTF-8). Do not change other columns or column order.
  - Do NOT rename the file. Keep it as 'ClusterUpdateRings.csv' so
    the 'Manage UpdateRing Tags' pipeline's default path picks it up.
 
------------------------------------------------------------------------
STEP 2 - Commit ClusterUpdateRings.csv into your ops repository
------------------------------------------------------------------------
 
  - Recommended path: config/ClusterUpdateRings.csv
  - Commit and push (PR review optional but recommended).
 
------------------------------------------------------------------------
STEP 3 - Run the 'Manage UpdateRing Tags' pipeline
------------------------------------------------------------------------
 
  - Trigger 'Manage UpdateRing Tags' (Step.2) and point its
    csv_file_path / csvFilePath input at the path you committed in
    STEP 2 (default is 'config/ClusterUpdateRings.csv').
  - RECOMMENDED FIRST RUN: leave 'dry_run' / 'dryRun' = true. The
    pipeline will preview which tags would be set/changed without
    modifying any Azure resources.
  - Once the preview looks correct, re-run with dry-run = false to
    apply the tags. Use force = true only if you need to overwrite
    existing UpdateRing values already set on a cluster.
 
------------------------------------------------------------------------
DOCUMENTATION
------------------------------------------------------------------------
 
  Pipeline examples README (online):
    https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/Automation-Pipeline-Examples/README.md
 
  Module README (online):
    https://github.com/NeilBird/Azure-Local/blob/main/AzLocal.UpdateManagement/README.md
 
  Module on the PowerShell Gallery:
    https://www.powershellgallery.com/packages/AzLocal.UpdateManagement
 
Generated by AzLocal.UpdateManagement v__MODULE_VERSION__
Run timestamp (UTC): __TIMESTAMP__
========================================================================
'@

    $readme = $readme.Replace('__MODULE_VERSION__', $moduleVersionForReadme).Replace('__TIMESTAMP__', $generatedUtc)
    Set-Content -LiteralPath $readmePath -Value $readme -Encoding ASCII
    Write-Host "Instructions written to: $readmePath"

    # Count tag coverage.
    $clusterCount = [int]$inventory.Count
    $withTag      = [int]@($inventory | Where-Object { $_.HasUpdateRingTag -eq 'Yes' }).Count
    $withoutTag   = $clusterCount - $withTag

    # UpdateRing distribution (rows with a non-empty UpdateRing value).
    $ringGroups = @($inventory |
        Where-Object { $_.UpdateRing } |
        Group-Object -Property UpdateRing |
        Sort-Object -Property Name)
    $ringDistribution = @($ringGroups | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Count = $_.Count } })

    Write-Host ''
    Write-Host '========================================'
    Write-Host 'Inventory Complete'
    Write-Host '========================================'
    Write-Host ("Total Clusters : {0}" -f $clusterCount)
    Write-Host ("With UpdateRing Tag : {0}" -f $withTag)
    Write-Host ("Without UpdateRing Tag : {0}" -f $withoutTag)
    Write-Host ("CSV : {0}" -f $csvPath)
    Write-Host ("JSON : {0}" -f $jsonPath)

    # Step outputs.
    Set-AzLocalPipelineOutput -Name 'cluster_count'      -Value ([string]$clusterCount)
    Set-AzLocalPipelineOutput -Name 'with_tag_count'     -Value ([string]$withTag)
    Set-AzLocalPipelineOutput -Name 'without_tag_count'  -Value ([string]$withoutTag)
    Set-AzLocalPipelineOutput -Name 'csv_path'           -Value ([string]$csvPath)

    # Markdown step summary.
    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine('## Step.1 - Cluster Inventory')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('| Metric | Value |')
    [void]$sb.AppendLine('|--------|-------|')
    [void]$sb.AppendLine(("| Total Clusters | {0} |"           -f $clusterCount))
    [void]$sb.AppendLine(("| With UpdateRing Tag | {0} |"      -f $withTag))
    [void]$sb.AppendLine(("| Without UpdateRing Tag | {0} |"   -f $withoutTag))
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('### UpdateRing Distribution')
    [void]$sb.AppendLine('')
    if ($ringDistribution.Count -gt 0) {
        [void]$sb.AppendLine('| UpdateRing | Count |')
        [void]$sb.AppendLine('|------------|-------|')
        foreach ($row in $ringDistribution) {
            [void]$sb.AppendLine(("| {0} | {1} |" -f $row.Name, $row.Count))
        }
    }
    else {
        [void]$sb.AppendLine('No UpdateRing tags found.')
    }
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('### Next Steps')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('1. Download the artifact attached to this run.')
    [void]$sb.AppendLine('2. Open `ClusterUpdateRings.csv` in Excel and fill in the `UpdateRing` column.')
    [void]$sb.AppendLine('3. Commit the edited CSV (recommended path `config/ClusterUpdateRings.csv`).')
    [void]$sb.AppendLine('4. Run the "Manage UpdateRing Tags" pipeline (Step.2).')

    Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName | Out-Null

    if ($PassThru) {
        return [pscustomobject]@{
            ClusterCount           = $clusterCount
            WithTagCount           = $withTag
            WithoutTagCount        = $withoutTag
            CsvPath                = $csvPath
            JsonPath               = $jsonPath
            CanonicalCsvPath       = $canonicalCsvPath
            ReadmePath             = $readmePath
            Clusters               = $inventory
            UpdateRingDistribution = $ringDistribution
        }
    }
}