LibreDevOpsHelpers.Terraform/LibreDevOpsHelpers.Terraform.psm1

Set-StrictMode -Version Latest

function Invoke-LdoTerraformValidate {
    <#
    .SYNOPSIS
        Runs 'terraform validate' against a Terraform configuration folder.

    .DESCRIPTION
        Changes into the configuration folder, runs terraform validate, and throws when the
        command reports an error. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .EXAMPLE
        Invoke-LdoTerraformValidate -CodePath ./terraform

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath
    )

    if (-not (Test-Path $CodePath)) {
        throw "Terraform code not found: $CodePath"
    }

    $orig = Get-Location
    try {
        Set-Location $CodePath
        Write-LdoLog -Level INFO -Message "Validating Terraform: $CodePath"
        & terraform validate
        Assert-LdoLastExitCode -Operation 'terraform validate'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoTerraformFmtCheck {
    <#
    .SYNOPSIS
        Runs 'terraform fmt -check' against a Terraform configuration folder.

    .DESCRIPTION
        Changes into the configuration folder, runs terraform fmt -check, and throws when any
        file is not correctly formatted. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .EXAMPLE
        Invoke-LdoTerraformFmtCheck -CodePath ./terraform

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath
    )

    if (-not (Test-Path $CodePath)) {
        throw "Terraform code not found: $CodePath"
    }

    $orig = Get-Location
    try {
        Set-Location $CodePath
        Write-LdoLog -Level INFO -Message "Checking Terraform formatting: $CodePath"
        & terraform fmt -check -recursive
        Assert-LdoLastExitCode -Operation 'terraform fmt -check'
    }
    finally {
        Set-Location $orig
    }
}

function Get-LdoTerraformStackFolders {
    <#
    .SYNOPSIS
        Resolves an ordered list of Terraform stack folders to run.

    .DESCRIPTION
        Inspects the immediate child folders of a code root and builds a lookup keyed by stack
        name. Folders named like '01-network' are treated as numbered stacks with an execution
        order. Passing 'all' returns every numbered stack in numeric order, otherwise the named
        stacks are returned in the order requested.

    .PARAMETER CodeRoot
        Folder containing the stack subfolders.

    .PARAMETER StacksToRun
        One or more stack names, or the single value 'all'.

    .EXAMPLE
        Get-LdoTerraformStackFolders -CodeRoot ./stacks -StacksToRun all

    .EXAMPLE
        Get-LdoTerraformStackFolders -CodeRoot ./stacks -StacksToRun network,compute

    .OUTPUTS
        System.String. The resolved stack folder paths in execution order.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingularNouns', '', Justification = 'Returns multiple stack folders.')]
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodeRoot,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string[]]$StacksToRun
    )

    if (-not (Test-Path $CodeRoot)) {
        throw "Code root not found: $CodeRoot"
    }

    $allDirs = Get-ChildItem -Path $CodeRoot -Directory
    if (-not $allDirs) {
        throw "No stack folders found underneath $CodeRoot"
    }

    $stackLookup = @{ }
    foreach ($dir in $allDirs) {
        if ($dir.Name -match '^(?<order>\d+)[-_](?<name>.+)$') {
            $stackLookup[$matches.name.ToLower()] = @{
                Path = $dir.FullName
                Order = [int]$matches.order
                IsNumbered = $true
            }
        }
        elseif ($dir.Name -match '^allstackskip[-_](?<rest>.+)$') {
            $stackName = $matches.rest -replace '^\d+[-_]', ''
            $stackLookup[$stackName.ToLower()] = @{
                Path = $dir.FullName
                Order = 9999
                IsStackSkip = $true
                IsNumbered = $false
            }
        }
        else {
            $stackLookup[$dir.Name.ToLower()] = @{
                Path = $dir.FullName
                Order = 9999
                IsNumbered = $false
            }
        }
    }

    $requested = @(
        $StacksToRun |
            ForEach-Object { $_.Trim() } |
            Where-Object { $_ }
    )

    if ($requested -contains 'all' -and $requested.Count -gt 1) {
        Write-LdoLog -Level WARN -Message "'all' cannot be combined with explicit stack names; ignoring 'all' and using the named stacks only."
        $requested = $requested | Where-Object { $_.ToLower() -ne 'all' }
    }

    $result = [System.Collections.Generic.List[string]]::new()

    if (($requested.Count -eq 1) -and ($requested[0].ToLower() -eq 'all')) {
        Write-LdoLog -Level INFO -Message 'Running ALL stacks in numeric order.'
        $stackLookup.GetEnumerator() |
            Where-Object { $_.Value.IsNumbered -eq $true -and (-not ($_.Value.PSObject.Properties['IsStackSkip'] -and $_.Value.IsStackSkip)) } |
            Sort-Object { $_.Value.Order } |
            ForEach-Object { [void]$result.Add($_.Value.Path) }
    }
    else {
        foreach ($stack in $requested) {
            $key = $stack.ToLower()
            if (-not $stackLookup.ContainsKey($key)) {
                throw "Stack '$stack' not found under $CodeRoot"
            }
            [void]$result.Add($stackLookup[$key].Path)
        }
    }

    Write-LdoLog -Level DEBUG -Message "Stack execution order: $($result -join ', ')"
    return $result.ToArray()
}

function Invoke-LdoTerraformInit {
    <#
    .SYNOPSIS
        Runs 'terraform init', optionally computing a deterministic backend state key.

    .DESCRIPTION
        Runs terraform init in a configuration folder. When -CreateBackendKey is set and no
        backend key is already supplied in -InitArgs, a key of the form
        <repo>-<stack>[-<suffix>].tfstate is computed from the folder layout and appended as a
        -backend-config=key= argument. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER InitArgs
        Additional arguments passed through to terraform init.

    .PARAMETER CreateBackendKey
        When set, computes and appends a backend state key unless one is already present.

    .PARAMETER BackendKeyPrefix
        Overrides the auto-detected repository name used as the key prefix.

    .PARAMETER BackendKeySuffix
        Optional suffix appended to the computed backend key.

    .PARAMETER StackFolderName
        Optional fully resolved stack folder path used to compute the stack portion of the key.

    .EXAMPLE
        Invoke-LdoTerraformInit -CodePath ./stacks/01-network -CreateBackendKey

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string[]]$InitArgs = @(),
        [switch]$CreateBackendKey,
        [string]$BackendKeyPrefix,
        [string]$BackendKeySuffix,
        [string]$StackFolderName
    )

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }

        Set-Location $CodePath

        $backendKeyPassed = $InitArgs | Where-Object { $_ -match '^-backend-config=key=' }

        if ($CreateBackendKey -and (-not $backendKeyPassed)) {
            $codeItem = Get-Item $CodePath

            if ($BackendKeyPrefix) {
                $repoName = $BackendKeyPrefix.ToLower() -replace '\.', '-'
            }
            elseif ($codeItem.Parent -and $codeItem.Parent.Parent) {
                $repoName = $codeItem.Parent.Parent.Name.ToLower() -replace '\.', '-'
            }
            else {
                $repoName = $codeItem.Parent.Name.ToLower() -replace '\.', '-'
            }

            if ($StackFolderName) {
                $stackPath = (Resolve-Path $StackFolderName).ToString()
            }
            else {
                $stackPath = (Resolve-Path $CodePath).ToString()
            }

            if ($codeItem.Parent -and $codeItem.Parent.Parent) {
                $repoRoot = $codeItem.Parent.Parent.FullName
            }
            else {
                $repoRoot = $codeItem.Parent.FullName
            }

            if ($stackPath -like "$repoRoot*") {
                $stackRelative = $stackPath.Substring($repoRoot.Length)
            }
            else {
                $stackRelative = Split-Path -Path $stackPath -Leaf
            }

            $stackRelative = $stackRelative.TrimStart('\', '/')

            $stackNormalized = $stackRelative.ToLower() `
                -replace '[\\\/]+', '-' `
                -replace '[_\.]+', '-' `
                -replace '-{2,}', '-' `
                -replace '^-', ''

            $backendKey = "$repoName-$stackNormalized"
            if ($BackendKeySuffix) { $backendKey += "-$BackendKeySuffix" }
            $backendKey += '.tfstate'

            Write-LdoLog -Level DEBUG -Message "Computed backend key name: $backendKey"
            $InitArgs += "-backend-config=key=$backendKey"
        }

        Write-LdoLog -Level INFO -Message "Running terraform init in: $CodePath"
        & terraform init @InitArgs
        Assert-LdoLastExitCode -Operation 'terraform init'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoTerraformWorkspaceSelect {
    <#
    .SYNOPSIS
        Selects a Terraform workspace, creating it if it does not exist.

    .DESCRIPTION
        Runs 'terraform workspace select -or-create=true' in a configuration folder and throws
        on failure. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER WorkspaceName
        Name of the workspace to select or create.

    .EXAMPLE
        Invoke-LdoTerraformWorkspaceSelect -CodePath ./terraform -WorkspaceName dev

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$WorkspaceName
    )

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }

        Write-LdoLog -Level INFO -Message "Selecting workspace '$WorkspaceName' (auto-create) in $CodePath"
        Set-Location $CodePath
        & terraform workspace select -or-create=true $WorkspaceName
        Assert-LdoLastExitCode -Operation 'terraform workspace select'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoTerraformPlan {
    <#
    .SYNOPSIS
        Runs 'terraform plan' and writes a binary plan file.

    .DESCRIPTION
        Runs terraform plan with -input=false and -out, then throws on failure. The original
        working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER PlanFile
        Output plan file name. Defaults to tfplan.plan.

    .PARAMETER PlanArgs
        Additional arguments passed through to terraform plan.

    .EXAMPLE
        Invoke-LdoTerraformPlan -CodePath ./terraform -PlanArgs '-var-file=dev.tfvars'

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$PlanFile = 'tfplan.plan',
        [string[]]$PlanArgs = @()
    )

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }

        Write-LdoLog -Level INFO -Message "terraform plan to $PlanFile"
        Set-Location $CodePath

        $tfArgs = @('plan', '-input=false', '-out', $PlanFile) + $PlanArgs
        & terraform @tfArgs
        Assert-LdoLastExitCode -Operation 'terraform plan'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoTerraformPlanDestroy {
    <#
    .SYNOPSIS
        Runs 'terraform plan -destroy' and writes a binary plan file.

    .DESCRIPTION
        Runs terraform plan -destroy with -input=false and -out, then throws on failure. The
        original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER PlanFile
        Output plan file name. Defaults to tfplan.plan.destroy.

    .PARAMETER PlanArgs
        Additional arguments passed through to terraform plan.

    .EXAMPLE
        Invoke-LdoTerraformPlanDestroy -CodePath ./terraform

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$PlanFile = 'tfplan.plan.destroy',
        [string[]]$PlanArgs = @()
    )

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }

        Write-LdoLog -Level INFO -Message "terraform plan -destroy to $PlanFile"
        Set-Location $CodePath

        $tfArgs = @('plan', '-destroy', '-input=false', '-out', $PlanFile) + $PlanArgs
        & terraform @tfArgs
        Assert-LdoLastExitCode -Operation 'terraform plan -destroy'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoTerraformApply {
    <#
    .SYNOPSIS
        Applies a saved Terraform plan file.

    .DESCRIPTION
        Runs terraform apply against a saved plan file, auto-approving unless -SkipApprove is
        set, and throws on failure. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER PlanFile
        Plan file to apply. Defaults to tfplan.plan.

    .PARAMETER SkipApprove
        When set, does not pass -auto-approve.

    .PARAMETER ApplyArgs
        Additional arguments passed through to terraform apply.

    .EXAMPLE
        Invoke-LdoTerraformApply -CodePath ./terraform -PlanFile tfplan.plan

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$PlanFile = 'tfplan.plan',
        [switch]$SkipApprove,
        [string[]]$ApplyArgs = @()
    )

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }

        Write-LdoLog -Level INFO -Message "terraform apply $PlanFile"
        Set-Location $CodePath

        $cmd = @('apply')
        if (-not $SkipApprove) {
            $cmd += '-auto-approve'
        }
        $cmd += @($PlanFile) + $ApplyArgs

        & terraform @cmd
        Assert-LdoLastExitCode -Operation 'terraform apply'
    }
    finally {
        Set-Location $orig
    }
}

function Invoke-LdoTerraformDestroy {
    <#
    .SYNOPSIS
        Applies a saved Terraform destroy plan file.

    .DESCRIPTION
        Runs terraform apply against a saved destroy plan file, auto-approving unless
        -SkipApprove is set, and throws on failure. The original working directory is always
        restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER PlanFile
        Destroy plan file to apply. Defaults to tfplan-destroy.plan.

    .PARAMETER SkipApprove
        When set, does not pass -auto-approve.

    .PARAMETER DestroyArgs
        Additional arguments passed through to terraform apply.

    .EXAMPLE
        Invoke-LdoTerraformDestroy -CodePath ./terraform -PlanFile tfplan.plan.destroy

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$PlanFile = 'tfplan-destroy.plan',
        [switch]$SkipApprove,
        [string[]]$DestroyArgs = @()
    )

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }

        Write-LdoLog -Level INFO -Message "terraform apply (destroy) $PlanFile"
        Set-Location $CodePath

        $cmd = @('apply')
        if (-not $SkipApprove) {
            $cmd += '-auto-approve'
        }
        $cmd += @($PlanFile) + $DestroyArgs

        & terraform @cmd
        Assert-LdoLastExitCode -Operation 'terraform apply (destroy)'
    }
    finally {
        Set-Location $orig
    }
}

function Convert-LdoTerraformPlanToJson {
    <#
    .SYNOPSIS
        Converts a binary Terraform plan file to JSON.

    .DESCRIPTION
        Runs 'terraform show -json' against a saved plan file and writes the output to a JSON
        file alongside it. Throws on failure. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER PlanFile
        Binary plan file to convert. Defaults to tfplan.plan.

    .PARAMETER JsonFile
        Output JSON file name. Defaults to <PlanFile>.json.

    .PARAMETER PassThru
        When set, returns the path to the JSON file.

    .EXAMPLE
        Convert-LdoTerraformPlanToJson -CodePath ./terraform -PassThru

    .OUTPUTS
        System.String. The JSON file path, when -PassThru is set.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$PlanFile = 'tfplan.plan',
        [string]$JsonFile,
        [switch]$PassThru
    )

    if (-not $JsonFile) {
        $JsonFile = "$PlanFile.json"
    }

    $orig = Get-Location
    try {
        if (-not (Test-Path $CodePath)) {
            throw "Terraform code not found: $CodePath"
        }
        $planPath = Join-Path $CodePath $PlanFile
        if (-not (Test-Path $planPath)) {
            throw "Plan file not found: $planPath"
        }

        Write-LdoLog -Level INFO -Message "Converting $PlanFile to $JsonFile"
        Set-Location $CodePath

        $jsonPath = Join-Path $CodePath $JsonFile
        terraform show -json $PlanFile | Out-File -FilePath $jsonPath -Encoding utf8
        Assert-LdoLastExitCode -Operation 'terraform show -json'

        if (-not (Test-Path $jsonPath)) {
            throw 'JSON output not created.'
        }

        Write-LdoLog -Level SUCCESS -Message "JSON plan written to $jsonPath"

        if ($PassThru) {
            return $jsonPath
        }
    }
    finally {
        Set-Location $orig
    }
}

Export-ModuleMember -Function `
    Invoke-LdoTerraformValidate, `
    Invoke-LdoTerraformFmtCheck, `
    Get-LdoTerraformStackFolders, `
    Invoke-LdoTerraformInit, `
    Invoke-LdoTerraformWorkspaceSelect, `
    Invoke-LdoTerraformPlan, `
    Invoke-LdoTerraformPlanDestroy, `
    Invoke-LdoTerraformApply, `
    Invoke-LdoTerraformDestroy, `
    Convert-LdoTerraformPlanToJson