Public/Unlock-PSUTerraformStateAWS.ps1

<#
.SYNOPSIS
    Unlocks a Terraform state lock for an AWS-backed Terraform project.
 
.DESCRIPTION
    Validates a Terraform working directory, initializes its backend, selects a
    workspace, and runs Terraform force-unlock for the supplied lock ID. AWS
    credentials can be supplied explicitly for an S3 backend or resolved by the
    Terraform and AWS tooling.
 
.PARAMETER Path
    The Terraform working directory. Defaults to the current directory.
 
.PARAMETER LockId
    The Terraform state lock identifier. The function prompts when omitted.
 
.PARAMETER AccessKey
    An optional AWS access key used when initializing an S3 backend.
 
.PARAMETER SecretKey
    An optional AWS secret key used when initializing an S3 backend.
 
.PARAMETER Region
    The AWS region used for an S3 backend. Defaults to us-east-2.
 
.PARAMETER Force
    Runs Terraform force-unlock without an additional confirmation prompt.
 
.EXAMPLE
    Unlock-PSUTerraformStateAWS -Path 'C:\Terraform\Project' -LockId 'abc123'
 
    Unlocks the specified Terraform state after confirming the operation.
 
.OUTPUTS
    None. The function invokes Terraform and writes operation status to the console.
 
.NOTES
    Author: Lakshmanachari Panuganti
    Version: 1.0
    Requires the Terraform CLI. Force-unlock can corrupt state when another
    Terraform operation is still active.
    Explicit credentials remain string parameters for compatibility. They are
    supplied to Terraform through temporary process environment variables and
    restored after execution. Secure credential acquisition is handled in a
    later migration.
#>

function Unlock-PSUTerraformStateAWS {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Path = (Get-Location).Path,
        
        [Parameter(Mandatory = $false)]
        [string]$LockId,
        
        [Parameter(Mandatory = $false)]
        [string]$AccessKey,
        
        [Parameter(Mandatory = $false)]
        [string]$SecretKey,
        
        [Parameter(Mandatory = $false)]
        [string]$Region = "us-east-2",
        
        [Parameter(Mandatory = $false)]
        [switch]$Force
    )

    function Get-SanitizedTerraformOutput {
        param (
            [Parameter()]
            [object[]]$Output
        )

        $sanitizedOutput = ($Output | ForEach-Object { [string]$_ }) -join "`n"
        foreach ($sensitiveValue in @($AccessKey, $SecretKey)) {
            if (-not [string]::IsNullOrEmpty($sensitiveValue)) {
                $sanitizedOutput = $sanitizedOutput.Replace($sensitiveValue, '***')
            }
        }
        $sanitizedOutput
    }

    $originalLocation = Get-Location
    $locationChanged = $false
    $credentialsChanged = $false
    $hadAccessKey = Test-Path Env:\AWS_ACCESS_KEY_ID
    $hadSecretKey = Test-Path Env:\AWS_SECRET_ACCESS_KEY
    $originalAccessKey = $env:AWS_ACCESS_KEY_ID
    $originalSecretKey = $env:AWS_SECRET_ACCESS_KEY
    try {
        Write-Host "Validating Terraform directory..."
        if (-not (Test-Path $Path -PathType Container)) {
            throw "Directory does not exist: $Path"
        }

        $terraformFiles = @("*.tf", ".terraform")
        $hasTerraformContent = $false
        foreach ($pattern in $terraformFiles) {
            if (Get-ChildItem -Path $Path -Filter $pattern -ErrorAction SilentlyContinue) {
                $hasTerraformContent = $true
                break
            }
        }
        if (-not $hasTerraformContent) {
            throw "Path does not appear to be a Terraform directory (no .tf files or .terraform directory found): $Path"
        }

        # Detect backend type
        $tfFiles = Get-ChildItem -Path $Path -Filter "*.tf" -Recurse
        $backendType = ( $tfFiles | Get-Content | Select-String -Pattern 'backend\s+"(\w+)"' -AllMatches |
                        ForEach-Object { $_.Matches.Groups[1].Value } |
                        Select-Object -First 1)

        if (-not $backendType) {
            $backendType = "local"
        }
        Write-Host "Detected backend type: $backendType"

        if ($Force) {
            $ConfirmPreference = 'None'
        }
        if (-not $PSCmdlet.ShouldProcess($Path, 'Initialize Terraform and force-unlock remote state')) {
            return
        }

        if (-not $LockId) {
            do {
                $LockId = Read-Host "Enter the Terraform Lock ID"
                if ([string]::IsNullOrWhiteSpace($LockId)) {
                    Write-Host "Lock ID cannot be empty. Please try again."
                }
            } while ([string]::IsNullOrWhiteSpace($LockId))
        }
        Write-Host "Lock ID: $LockId"

        Set-Location -Path $Path
        $locationChanged = $true
        Write-Host "Working in Terraform directory: $Path"

        if ($AccessKey -and $SecretKey) {
            $env:AWS_ACCESS_KEY_ID = $AccessKey
            $env:AWS_SECRET_ACCESS_KEY = $SecretKey
            $credentialsChanged = $true
        }

        Write-Host "Initializing Terraform backend..."
        if ($backendType -eq "s3") {
            if ($AccessKey -and $SecretKey) {
                Write-Host "Using provided AWS credentials..."
                $initArgs = @(
                    "init",
                    "-backend-config=region=$Region"
                )
            } else {
                Write-Host "No AWS credentials provided, using default AWS profile or environment."
                $initArgs = @("init")
            }
        } else {
            Write-Host "Non-S3 backend detected, skipping AWS backend config..."
            $initArgs = @("init", "-get-plugins=false")
        }

        $initResult = & terraform $initArgs 2>&1
        if ($LASTEXITCODE -ne 0) {
            throw "Terraform init failed. Output: $(Get-SanitizedTerraformOutput -Output $initResult)"
        }
        Write-Host "Terraform backend initialized successfully"

        # Workspaces
        Write-Host "Retrieving available workspaces..."
        $workspaceOutput = terraform workspace list 2>&1
        if ($LASTEXITCODE -ne 0) {
            throw "Failed to list workspaces. Output: $(Get-SanitizedTerraformOutput -Output $workspaceOutput)"
        }

        $workspaces = @($workspaceOutput | Where-Object { $_ -match '\S' } | 
                     ForEach-Object { 
                         $workspace = $_.Trim()
                         if ($workspace.StartsWith('* ')) {
                             $workspace.Substring(2).Trim()
                         } else {
                             $workspace.Trim()
                         }
                     } | Where-Object { $_ -ne '' })

        if ($workspaces.Count -eq 0) {
            throw "No workspaces found"
        }

        Write-Host "Available Workspaces:"
        for ($i = 0; $i -lt $workspaces.Count; $i++) {
            Write-Host " [$($i+1)] $(@($workspaces)[$i])"
        }

        $selectedWorkspace = $null
        if ($workspaces.Count -eq 1 -or $workspaces -contains "default") {
            $selectedWorkspace = $workspaces[0]
            Write-Host "Only one workspace or default detected, auto-selecting: $selectedWorkspace"
        } else {
            do {
                $choice = Read-Host "Enter the number of the workspace to select (1-$($workspaces.Count)) or press Enter for default"
                if ([string]::IsNullOrWhiteSpace($choice)) {
                    $selectedWorkspace = "default"
                    break
                }
                if ($choice -notmatch '^\d+$') {
                    Write-Host "Please enter a valid number."
                    continue
                }
                $choiceInt = [int]$choice
                if ($choiceInt -lt 1 -or $choiceInt -gt $workspaces.Count) {
                    Write-Host "Please enter a number between 1 and $($workspaces.Count)."
                    continue
                }
                $selectedWorkspace = @($workspaces)[$choiceInt-1]
                break
            } while ($true)
        }

        Write-Host "Selecting workspace: $selectedWorkspace"
        $selectResult = terraform workspace select $selectedWorkspace 2>&1
        if ($LASTEXITCODE -ne 0) {
            throw "Failed to select workspace '$selectedWorkspace'. Output: $(Get-SanitizedTerraformOutput -Output $selectResult)"
        }
        Write-Host "Workspace '$selectedWorkspace' selected successfully"

        # Only force-unlock if backend = s3
        if ($backendType -eq "s3") {
            Write-Host "Unlocking state with Lock ID: $LockId"
            $unlockResult = terraform force-unlock -force $LockId 2>&1
            if ($LASTEXITCODE -ne 0) {
                if ($unlockResult -match "lock.*not found" -or $unlockResult -match "no lock found") {
                    Write-Host "No lock found with ID '$LockId' - the state may already be unlocked"
                } else {
                    throw "Failed to unlock state. Output: $(Get-SanitizedTerraformOutput -Output $unlockResult)"
                }
            } else {
                Write-Host "State successfully unlocked!"
            }
        } else {
            Write-Host "Local backend detected - no remote lock to unlock."
        }

        Write-Host "Operation completed successfully!"
        Write-Host "Workspace: $selectedWorkspace"
        Write-Host "Directory: $Path"
    }
    catch {
        Write-Error "Operation failed: $($_.Exception.Message)"
        $PSCmdlet.ThrowTerminatingError($_)
    }
    finally {
        if ($credentialsChanged) {
            if ($hadAccessKey) {
                $env:AWS_ACCESS_KEY_ID = $originalAccessKey
            } else {
                Remove-Item Env:\AWS_ACCESS_KEY_ID -ErrorAction SilentlyContinue
            }
            if ($hadSecretKey) {
                $env:AWS_SECRET_ACCESS_KEY = $originalSecretKey
            } else {
                Remove-Item Env:\AWS_SECRET_ACCESS_KEY -ErrorAction SilentlyContinue
            }
        }
        if ($locationChanged) {
            Set-Location -Path $originalLocation
            Write-Host "Restored to original location: $originalLocation"
        }
    }
}