Public/Update-AvmModuleVersion.ps1

function Update-AvmModuleVersion {
    <#
    .SYNOPSIS
        Applies approved AVM version bumps, validates with bicep build/lint or terraform
        init/validate/plan, and rolls back per-file failures.

    .DESCRIPTION
        For each item in the approved plan:
        1. Backs up the original file.
        2. Rewrites ONLY the version token at the recorded file:line.
        3. Runs validation (bicep build+lint or terraform init+validate+plan).
        4. If validation fails, restores the backup (rollback).
        5. Parses terraform plan output for DESTROY/REPLACE actions and flags them as WARN.
        Updates are independent — one failure does not block others. Respects -DryRun.

    .PARAMETER ApprovedPlan
        The approved update items from Approve-AvmUpdate.

    .PARAMETER DryRun
        Edit a temp copy and validate without persisting changes.

    .OUTPUTS
        PSCustomObject with: applied[], rolledBack[], warnings[], isConsistent (bool).

    .EXAMPLE
        $approved = Approve-AvmUpdate -Plan $plan -ApprovalMode local
        Update-AvmModuleVersion -ApprovedPlan $approved

    .EXAMPLE
        Update-AvmModuleVersion -ApprovedPlan $approved -DryRun
    #>

    # ConfirmImpact is 'Medium' (not 'High') so this does not trigger an interactive
    # confirmation prompt under the default $ConfirmPreference ('High') — matching the
    # existing non-interactive behavior of this function. Callers can still opt in to
    # prompts with -Confirm, or preview with -WhatIf.
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject]$ApprovedPlan,

        [Parameter()]
        [switch]$DryRun
    )

    $applied    = [System.Collections.Generic.List[PSCustomObject]]::new()
    $rolledBack = [System.Collections.Generic.List[PSCustomObject]]::new()
    $warnings   = [System.Collections.Generic.List[PSCustomObject]]::new()

    $items = @($ApprovedPlan.approvedItems)
    if ($items.Count -eq 0) {
        Write-Verbose "No approved items to update."
        return [PSCustomObject]@{ applied = @(); rolledBack = @(); warnings = @(); isConsistent = $true }
    }

    foreach ($item in $items) {
        $file        = $item.file
        $lineIndex   = $item.lineNumber - 1
        $oldVersion  = $item.currentVersion
        $newVersion  = $item.latestStable
        $ecosystem   = $item.ecosystem

        # Items where no version line was found during scan cannot be updated automatically.
        if ($item.versionLineMissing) {
            Write-Warning "Skipping $($item.module) ($file): no version = line found in module block — manual update required."
            continue
        }

        if (-not $PSCmdlet.ShouldProcess("$file (line $($item.lineNumber))", "Update AVM module version $oldVersion -> $newVersion")) {
            continue
        }

        # Backup
        $backupPath = "$file.avmbak"
        $origLines  = Get-Content $file -Raw

        # Compute working file (DryRun = temp copy)
        $workFile = if ($DryRun) {
            $tmp = [System.IO.Path]::GetTempFileName() + (Split-Path $file -Extension)
            Set-Content -Path $tmp -Value $origLines -Encoding UTF8 -NoNewline
            $tmp
        } else {
            $file
        }

        if (-not $DryRun) {
            Set-Content -Path $backupPath -Value $origLines -Encoding UTF8 -NoNewline
        }

        # Perform the line-level rewrite
        try {
            $lines = Get-Content $workFile
            $targetLine = $lines[$lineIndex]

            # Guard: verify the target line actually contains the expected version token
            # BEFORE writing. A mismatch means the file was modified between scan and update.
            $versionPresent = if ($ecosystem -eq 'bicep') {
                $targetLine -match [regex]::Escape(":$oldVersion'")
            } else {
                $targetLine -match "version\s*=\s*`"$([regex]::Escape($oldVersion))`""
            }

            if (-not $versionPresent) {
                $errMsg = 'version token not found at expected line — file may have changed since scan'
                Write-Warning "${file}:$($item.lineNumber): $errMsg"
                if (-not $DryRun) { Remove-Item $backupPath -Force -ErrorAction SilentlyContinue }
                if ($DryRun -and (Test-Path $workFile)) { Remove-Item $workFile -Force }
                $rolledBack.Add([PSCustomObject]@{ item = $item; error = $errMsg })
                continue
            }

            $newLine = if ($ecosystem -eq 'bicep') {
                # Replace :oldVersion' with :newVersion'
                $targetLine -replace "`:$([regex]::Escape($oldVersion))'", ":$newVersion'"
            } else {
                # Replace version = "oldVersion" with version = "newVersion"
                $targetLine -replace "version\s*=\s*`"$([regex]::Escape($oldVersion))`"", "version = `"$newVersion`""
            }

            $lines[$lineIndex] = $newLine
            Set-Content -Path $workFile -Value $lines -Encoding UTF8
        } catch {
            Write-Warning "Failed to edit ${file}: $_"
            if ($DryRun -and (Test-Path $workFile)) { Remove-Item $workFile -Force }
            $rolledBack.Add([PSCustomObject]@{ item = $item; error = $_.ToString() })
            continue
        }

        # Validate
        $validationResult = Invoke-AvmValidation -FilePath $workFile -Ecosystem $ecosystem

        switch ($validationResult.outcome) {
            'PASS' {
                if (-not $DryRun) { Remove-Item $backupPath -Force -ErrorAction SilentlyContinue }
                $applied.Add([PSCustomObject]@{ item = $item; outcome = 'PASS'; details = $validationResult.output })
                Write-Verbose "PASS: $($item.module) $oldVersion → $newVersion"
            }
            'WARN' {
                if (-not $DryRun) { Remove-Item $backupPath -Force -ErrorAction SilentlyContinue }
                $applied.Add([PSCustomObject]@{ item = $item; outcome = 'WARN'; details = $validationResult.output })
                $warnings.Add([PSCustomObject]@{ item = $item; reason = $validationResult.warnReason })
                Write-Warning "WARN: $($item.module) — $($validationResult.warnReason)"
            }
            'FAIL' {
                # Roll back
                if (-not $DryRun) {
                    Copy-Item $backupPath $file -Force
                    Remove-Item $backupPath -Force -ErrorAction SilentlyContinue
                }
                $rolledBack.Add([PSCustomObject]@{ item = $item; error = $validationResult.output })
                Write-Warning "FAIL: $($item.module) — rolled back. Error: $($validationResult.output)"
            }
            'SKIP' {
                # Validation skipped (no CLI available) — accept the change with a warning
                if (-not $DryRun) { Remove-Item $backupPath -Force -ErrorAction SilentlyContinue }
                $applied.Add([PSCustomObject]@{ item = $item; outcome = 'SKIP'; details = $validationResult.output })
                $warnings.Add([PSCustomObject]@{ item = $item; reason = $validationResult.warnReason })
                Write-Warning "SKIP validation: $($item.module) — $($validationResult.warnReason)"
            }
        }

        if ($DryRun -and (Test-Path $workFile)) { Remove-Item $workFile -Force }
    }

    $isConsistent = $rolledBack.Count -eq 0

    return [PSCustomObject]@{
        applied      = $applied.ToArray()
        rolledBack   = $rolledBack.ToArray()
        warnings     = $warnings.ToArray()
        isConsistent = $isConsistent
    }
}

function Invoke-AvmValidation {
    <#
    .SYNOPSIS
        Runs bicep build+lint or terraform init+validate+plan on the given file/directory.
        Returns outcome (PASS|WARN|FAIL), output, and warnReason.
    #>

    [CmdletBinding()]
    param(
        [string]$FilePath,
        [string]$Ecosystem
    )

    $outcome    = 'PASS'
    $output     = ''
    $warnReason = $null

    try {
        if ($Ecosystem -eq 'bicep') {
            $cli = Get-BicepCli
            if (-not $cli) {
                Write-Warning "No Bicep CLI found — skipping validation for ${FilePath}. Install 'bicep' or run 'az bicep install'."
                return [PSCustomObject]@{ outcome = 'SKIP'; output = 'No Bicep CLI available'; warnReason = 'Bicep validation skipped (no CLI)' }
            }

            # standalone 'bicep' uses positional args; 'az bicep' requires --file
            if ($cli.useFileFlag) {
                $buildOut = & az bicep build --file $FilePath 2>&1
                if ($LASTEXITCODE -ne 0) {
                    return [PSCustomObject]@{ outcome = 'FAIL'; output = $buildOut -join "`n"; warnReason = $null }
                }
                $lintOut = & az bicep lint --file $FilePath 2>&1
                if ($LASTEXITCODE -ne 0) {
                    return [PSCustomObject]@{ outcome = 'FAIL'; output = $lintOut -join "`n"; warnReason = $null }
                }
            } else {
                $buildOut = & bicep build $FilePath 2>&1
                if ($LASTEXITCODE -ne 0) {
                    return [PSCustomObject]@{ outcome = 'FAIL'; output = $buildOut -join "`n"; warnReason = $null }
                }
                $lintOut = & bicep lint $FilePath 2>&1
                if ($LASTEXITCODE -ne 0) {
                    return [PSCustomObject]@{ outcome = 'FAIL'; output = $lintOut -join "`n"; warnReason = $null }
                }
            }
            $output = ($buildOut + $lintOut) -join "`n"
        } elseif ($Ecosystem -eq 'terraform') {
            $tfDir = Split-Path $FilePath -Parent

            $initOut = terraform "-chdir=$tfDir" init -upgrade 2>&1
            if ($LASTEXITCODE -ne 0) {
                return [PSCustomObject]@{ outcome = 'FAIL'; output = $initOut -join "`n"; warnReason = $null }
            }

            $validateOut = terraform "-chdir=$tfDir" validate 2>&1
            if ($LASTEXITCODE -ne 0) {
                return [PSCustomObject]@{ outcome = 'FAIL'; output = $validateOut -join "`n"; warnReason = $null }
            }

            $planOut = terraform "-chdir=$tfDir" plan -detailed-exitcode -no-color 2>&1
            $planText = $planOut -join "`n"

            # exit code 1 = error, 2 = changes (acceptable), 0 = no changes
            if ($LASTEXITCODE -eq 1) {
                return [PSCustomObject]@{ outcome = 'FAIL'; output = $planText; warnReason = $null }
            }

            # Check for DESTROY/REPLACE in plan
            if ($planText -match '(?i)(must be replaced|will be destroyed|# .+ will be destroyed|# .+ must be replaced)') {
                $outcome    = 'WARN'
                $warnReason = "Terraform plan includes DESTROY or REPLACE actions — manual review required."
            }
            $output = $planText
        }
    } catch {
        return [PSCustomObject]@{ outcome = 'FAIL'; output = $_.ToString(); warnReason = $null }
    }

    return [PSCustomObject]@{ outcome = $outcome; output = $output; warnReason = $warnReason }
}