modules/Invoke-Infracost.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Wrapper for Infracost CLI pre-deploy IaC cost estimation.
.DESCRIPTION
    Runs `infracost breakdown` against Terraform/Bicep source and emits a v1
    wrapper envelope with one finding per resource estimate.
 
    Cloud-first behavior:
    - `-RemoteUrl` clones a remote repo through RemoteClone.ps1 (HTTPS-only,
      host allow-list, token-safe cleanup).
    - `-RepoPath` scans a local directory (fallback mode).
 
    Resilience and security:
    - Infracost CLI invocation is wrapped with Invoke-WithRetry.
    - CLI process is executed through Invoke-WithTimeout with 300s timeout.
    - Any surfaced message is passed through Remove-Credentials.
 
    Never throws -- designed for graceful degradation in the orchestrator.
.PARAMETER RepoPath
    Local directory containing Terraform/Bicep files. Defaults to current dir.
.PARAMETER RemoteUrl
    Remote HTTPS repository URL to clone and scan.
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param (
    [Alias('Path')]
    [string] $RepoPath = '.',
    [Alias('Repository')]
    [string] $RemoteUrl
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sharedDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'modules' 'shared'
if (-not $sharedDir -or -not (Test-Path $sharedDir)) {
    $sharedDir = Join-Path $PSScriptRoot 'shared'
}

$sanitizePath = Join-Path $sharedDir 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$missingToolPath = Join-Path $sharedDir 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }
$retryPath = Join-Path $sharedDir 'Retry.ps1'
if (Test-Path $retryPath) { . $retryPath }
$remoteClonePath = Join-Path $sharedDir 'RemoteClone.ps1'
if (Test-Path $remoteClonePath) { . $remoteClonePath }

$envelopePath = Join-Path $sharedDir 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
$installerPath = Join-Path $sharedDir 'Installer.ps1'
if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue) -and (Test-Path $installerPath)) {
    . $installerPath
}

if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string]$Text) return $Text }
}
if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) {
    function Invoke-WithRetry { param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3) & $ScriptBlock }
}
if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) {
    function Invoke-WithTimeout {
        param (
            [Parameter(Mandatory)][string]$Command,
            [Parameter(Mandatory)][string[]]$Arguments,
            [int]$TimeoutSec = 300
        )
        $output = & $Command @Arguments 2>&1 | Out-String
        [PSCustomObject]@{
            ExitCode = $LASTEXITCODE
            Output   = Remove-Credentials $output
        }
    }
}

function Test-InfracostInstalled {
    return $null -ne (Get-Command infracost -ErrorAction SilentlyContinue)
}

function Get-FirstJsonObjectText {
    param([AllowNull()][string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    $start = $Text.IndexOf('{')
    $end = $Text.LastIndexOf('}')
    if ($start -lt 0 -or $end -lt $start) { return $null }
    return $Text.Substring($start, ($end - $start) + 1)
}

function ConvertTo-InfracostDouble {
    param(
        [AllowNull()][object]$Value,
        [double]$Default = 0.0
    )
    if ($null -eq $Value) { return $Default }
    $parsed = 0.0
    if ([double]::TryParse([string]$Value, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) {
        return $parsed
    }
    return $Default
}

function Get-InfracostToolVersion {
    $versionExec = Invoke-WithRetry -MaxAttempts 2 -InitialDelaySeconds 1 -MaxDelaySeconds 5 -ScriptBlock {
        Invoke-WithTimeout -Command 'infracost' -Arguments @('--version') -TimeoutSec 300
    }
    if (-not $versionExec -or $versionExec.ExitCode -ne 0) { return '' }
    $raw = Remove-Credentials ([string]$versionExec.Output)
    if ([string]::IsNullOrWhiteSpace($raw)) { return '' }
    return ($raw -split "(\r?\n)" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1).Trim()
}

function Resolve-InfracostEffort {
    param([string]$ResourceType)
    $normalized = if ($ResourceType) { $ResourceType.ToLowerInvariant() } else { '' }
    if ($normalized -match 'resource_group|tag|diagnostic') { return 'Low' }
    if ($normalized -match 'storage|app_service_plan|public_ip|disk|redis|servicebus') { return 'Low' }
    if ($normalized -match 'kubernetes|aks|sql|postgres|cosmos|firewall|application_gateway|frontdoor') { return 'Medium' }
    return 'Low'
}

function Get-InfracostPropertyValue {
    param(
        [AllowNull()][object]$Object,
        [string]$PropertyName
    )
    if (-not $Object -or [string]::IsNullOrWhiteSpace($PropertyName)) { return $null }
    $property = $Object.PSObject.Properties[$PropertyName]
    if ($property) { return $property.Value }
    return $null
}

function Get-InfracostCloudUrl {
    param(
        [AllowNull()]$Project,
        [AllowNull()]$Resource
    )
    $resourceMetadata = Get-InfracostPropertyValue -Object $Resource -PropertyName 'metadata'
    $projectMetadata = Get-InfracostPropertyValue -Object $Project -PropertyName 'metadata'
    foreach ($candidate in @(
            (Get-InfracostPropertyValue -Object $Resource -PropertyName 'cloudUrl'),
            (Get-InfracostPropertyValue -Object $Resource -PropertyName 'CloudUrl'),
            (Get-InfracostPropertyValue -Object $Resource -PropertyName 'url'),
            (Get-InfracostPropertyValue -Object $Resource -PropertyName 'Url'),
            (Get-InfracostPropertyValue -Object $resourceMetadata -PropertyName 'url'),
            (Get-InfracostPropertyValue -Object $resourceMetadata -PropertyName 'cloudUrl'),
            (Get-InfracostPropertyValue -Object $Project -PropertyName 'cloudUrl'),
            (Get-InfracostPropertyValue -Object $Project -PropertyName 'CloudUrl'),
            (Get-InfracostPropertyValue -Object $Project -PropertyName 'url'),
            (Get-InfracostPropertyValue -Object $Project -PropertyName 'Url'),
            (Get-InfracostPropertyValue -Object $projectMetadata -PropertyName 'url'),
            (Get-InfracostPropertyValue -Object $projectMetadata -PropertyName 'cloudUrl')
        )) {
        if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) { return [string]$candidate }
    }
    return ''
}

function Get-InfracostRemediationSnippets {
    param(
        [string]$ResourceType,
        [double]$MonthlyCost
    )
    if ($MonthlyCost -le 0) { return @() }
    $normalized = if ($ResourceType) { $ResourceType.ToLowerInvariant() } else { '' }
    if ($normalized -match 'kubernetes|aks') {
        return @(
            @{
                language = 'hcl'
                title    = 'AKS SKU right-size'
                code     = "sku_tier = `"Standard`"`n# consider downgrading node VM size for non-prod"
            }
        )
    }
    if ($normalized -match 'storage_account') {
        return @(
            @{
                language = 'hcl'
                title    = 'Storage redundancy right-size'
                code     = "account_tier = `"Standard`"`naccount_replication_type = `"LRS`""
            }
        )
    }
    return @()
}

if (-not (Test-InfracostInstalled)) {
    Write-MissingToolNotice -Tool 'infracost' -Message "infracost CLI is not installed. Skipping Infracost scan."
    return [PSCustomObject]@{
        Source        = 'infracost'
        SchemaVersion = '1.0'
        Status        = 'Skipped'
        Message       = 'infracost CLI not installed. Install from https://www.infracost.io/docs/'
        Findings      = @()
        Errors   = @()
    }
}

$cloneInfo = $null
$cleanupClone = $null
try {
    if ($RemoteUrl) {
        if (-not (Get-Command Invoke-RemoteRepoClone -ErrorAction SilentlyContinue)) {
            Write-Warning "RemoteClone helper not loaded; cannot scan remote repository."
            return [PSCustomObject]@{
                Source        = 'infracost'
                SchemaVersion = '1.0'
                Status        = 'Failed'
                Message       = 'RemoteClone helper unavailable'
                Findings      = @()
                Errors   = @()
            }
        }
        $cloneInfo = Invoke-RemoteRepoClone -RepoUrl $RemoteUrl -TimeoutSec 300
        if (-not $cloneInfo) {
            return [PSCustomObject]@{
                Source        = 'infracost'
                SchemaVersion = '1.0'
                Status        = 'Failed'
                Message       = "Remote clone failed or host not on allow-list: $RemoteUrl"
                Findings      = @()
                Errors   = @()
            }
        }
        $cleanupClone = $cloneInfo.Cleanup
        $RepoPath = $cloneInfo.Path
    }

    if (-not (Test-Path -LiteralPath $RepoPath)) {
        return [PSCustomObject]@{
            Source        = 'infracost'
            SchemaVersion = '1.0'
            Status        = 'Failed'
            Message       = "Path not found: $RepoPath"
            Findings      = @()
            Errors   = @()
        }
    }

    $iacFiles = @(Get-ChildItem -Path $RepoPath -Recurse -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Extension -in @('.tf', '.bicep') })
    if ($iacFiles.Count -eq 0) {
        return [PSCustomObject]@{
            Source        = 'infracost'
            SchemaVersion = '1.0'
            Status        = 'Skipped'
            Message       = 'No Terraform or Bicep files found under scan path.'
            Findings      = @()
            Errors   = @()
        }
    }

    $args = @('breakdown', '--path', $RepoPath, '--format', 'json', '--no-color')
    $exec = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 2 -MaxDelaySeconds 30 -ScriptBlock {
        Invoke-WithTimeout -Command 'infracost' -Arguments $args -TimeoutSec 300
    }

    if (-not $exec -or $exec.ExitCode -ne 0) {
        $safeOutput = if ($exec) { Remove-Credentials ([string]$exec.Output) } else { '' }
        return [PSCustomObject]@{
            Source        = 'infracost'
            SchemaVersion = '1.0'
            Status        = 'Failed'
            Message       = "infracost breakdown failed (exit code $($exec.ExitCode)): $safeOutput"
            Findings      = @()
            Errors   = @()
        }
    }

    $jsonText = Get-FirstJsonObjectText -Text ([string]$exec.Output)
    if (-not $jsonText) {
        return [PSCustomObject]@{
            Source        = 'infracost'
            SchemaVersion = '1.0'
            Status        = 'Failed'
            Message       = 'infracost output did not contain a JSON object.'
            Findings      = @()
            Errors   = @()
        }
    }

    $parsed = $null
    try {
        $parsed = $jsonText | ConvertFrom-Json -Depth 100 -ErrorAction Stop
    } catch {
        return [PSCustomObject]@{
            Source        = 'infracost'
            SchemaVersion = '1.0'
            Status        = 'Failed'
            Message       = Remove-Credentials "Failed to parse infracost JSON: $($_.Exception.Message)"
            Findings      = @()
            Errors   = @()
        }
    }

    $toolVersion = ''
    try {
        $toolVersion = Get-InfracostToolVersion
    } catch {
        $toolVersion = ''
    }

    $breakdownPath = Join-Path $RepoPath 'infracost-breakdown.json'
    $breakdownUri = ''
    try {
        $sanitizedJsonText = Remove-Credentials -Text $jsonText
        Set-Content -LiteralPath $breakdownPath -Value $sanitizedJsonText -Encoding utf8NoBOM -Force
        $resolvedBreakdownPath = (Resolve-Path -LiteralPath $breakdownPath -ErrorAction Stop).Path
        $breakdownUri = "file:///$($resolvedBreakdownPath -replace '\\','/')"
    } catch {
        $breakdownUri = ''
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $aggregateTotalMonthlyCost = 0.0
    $aggregateBaselineMonthlyCost = 0.0
    $aggregateTotalHourlyCost = 0.0
    $summaryCurrency = ''
    $summaryProjectNames = [System.Collections.Generic.List[string]]::new()
    foreach ($project in @($parsed.projects)) {
        if (-not $project) { continue }
        $projectName = if ($project.PSObject.Properties['name'] -and $project.name) { [string]$project.name } else { 'project' }
        $projectPath = if ($project.PSObject.Properties['path'] -and $project.path) { [string]$project.path } else { [string]$RepoPath }
        if (-not [string]::IsNullOrWhiteSpace($projectName)) {
            $summaryProjectNames.Add($projectName) | Out-Null
        }
        $resources = @()
        if ($project.PSObject.Properties['breakdown'] -and $project.breakdown -and
            $project.breakdown.PSObject.Properties['resources']) {
            $resources = @($project.breakdown.resources)
        }

        $projectTotalMonthlyCost = if ($project.PSObject.Properties['breakdown'] -and $project.breakdown) {
            ConvertTo-InfracostDouble -Value $project.breakdown.totalMonthlyCost
        } else {
            0.0
        }
        if ($projectTotalMonthlyCost -le 0 -and $resources.Count -gt 0) {
            $projectTotalMonthlyCost = (@($resources | ForEach-Object { ConvertTo-InfracostDouble -Value $_.monthlyCost }) | Measure-Object -Sum).Sum
        }
        $projectBaselineMonthlyCost = if ($project.PSObject.Properties['pastBreakdown'] -and $project.pastBreakdown) {
            ConvertTo-InfracostDouble -Value $project.pastBreakdown.totalMonthlyCost
        } else {
            0.0
        }
        $projectDiffMonthlyCost = if ($project.PSObject.Properties['diff'] -and $project.diff) {
            ConvertTo-InfracostDouble -Value $project.diff.totalMonthlyCost -Default ($projectTotalMonthlyCost - $projectBaselineMonthlyCost)
        } else {
            $projectTotalMonthlyCost - $projectBaselineMonthlyCost
        }
        $projectTotalHourlyCost = if ($project.PSObject.Properties['breakdown'] -and $project.breakdown) {
            ConvertTo-InfracostDouble -Value $project.breakdown.totalHourlyCost -Default ($projectTotalMonthlyCost / 730.0)
        } else {
            $projectTotalMonthlyCost / 730.0
        }

        $aggregateTotalMonthlyCost += $projectTotalMonthlyCost
        $aggregateBaselineMonthlyCost += $projectBaselineMonthlyCost
        $aggregateTotalHourlyCost += $projectTotalHourlyCost

        foreach ($resource in $resources) {
            if (-not $resource) { continue }
            $resourceName = if ($resource.PSObject.Properties['name'] -and $resource.name) { [string]$resource.name } else { 'resource' }
            $resourceType = if ($resource.PSObject.Properties['resourceType'] -and $resource.resourceType) { [string]$resource.resourceType } else { 'unknown' }
            $monthlyRaw = if ($resource.PSObject.Properties['monthlyCost']) { [string]$resource.monthlyCost } else { '0' }
            $monthlyCost = 0.0
            [void][double]::TryParse($monthlyRaw, [System.Globalization.NumberStyles]::Any, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$monthlyCost)
            $currency = if ($resource.PSObject.Properties['currency'] -and $resource.currency) { [string]$resource.currency } else { 'USD' }
            if ([string]::IsNullOrWhiteSpace($summaryCurrency) -and -not [string]::IsNullOrWhiteSpace($currency)) {
                $summaryCurrency = $currency
            }
            $deepLinkUrl = Get-InfracostCloudUrl -Project $project -Resource $resource
            $entityRefs = @($projectPath)
            $remediationSnippets = Get-InfracostRemediationSnippets -ResourceType $resourceType -MonthlyCost $monthlyCost

            $findings.Add([PSCustomObject]@{
                    Id            = [guid]::NewGuid().ToString()
                    Category      = 'Cost'
                    Title         = "Estimated monthly cost: $([string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0:0.00}', $monthlyCost)) for $resourceType"
                    Severity      = 'Info'
                    Compliant     = $false
                    Detail        = "Infracost estimate for $resourceName in project $projectName."
                    Remediation   = 'Review right-sizing, SKU choice, and environment count before deployment.'
                    ResourceId    = $projectPath
                    LearnMoreUrl  = 'https://www.infracost.io/docs/'
                    ResourceType  = $resourceType
                    ResourceName  = $resourceName
                    ProjectName   = $projectName
                    ProjectPath   = $projectPath
                    MonthlyCost   = [math]::Round($monthlyCost, 2)
                    Currency      = $currency
                    Pillar        = 'Cost'
                    Impact        = ''
                    Effort        = Resolve-InfracostEffort -ResourceType $resourceType
                    DeepLinkUrl   = $deepLinkUrl
                    RemediationSnippets = @($remediationSnippets)
                    EvidenceUris  = if ($breakdownUri) { @($breakdownUri) } else { @() }
                    EntityRefs    = $entityRefs
                    ToolVersion   = $toolVersion
                    ProjectTotalMonthlyCost = [math]::Round($projectTotalMonthlyCost, 2)
                    BaselineMonthlyCost = [math]::Round($projectBaselineMonthlyCost, 2)
                    DiffMonthlyCost = [math]::Round($projectDiffMonthlyCost, 2)
                })
        }
    }

    $summaryProjectName = if ($summaryProjectNames.Count -eq 1) {
        $summaryProjectNames[0]
    } elseif ($summaryProjectNames.Count -gt 1) {
        'multiple'
    } else {
        ''
    }
    $summaryDiffMonthlyCost = $aggregateTotalMonthlyCost - $aggregateBaselineMonthlyCost

    return [PSCustomObject]@{
        Source        = 'infracost'
        SchemaVersion = '1.0'
        Status        = 'Success'
        Message       = "Parsed $($findings.Count) resource cost estimate(s)."
        ToolVersion   = $toolVersion
        ToolSummary   = [PSCustomObject]@{
            Currency            = if ([string]::IsNullOrWhiteSpace($summaryCurrency)) { 'USD' } else { $summaryCurrency }
            TotalMonthlyCost    = [math]::Round($aggregateTotalMonthlyCost, 2)
            TotalHourlyCost     = [math]::Round($aggregateTotalHourlyCost, 4)
            ProjectName         = $summaryProjectName
            BaselineMonthlyCost = [math]::Round($aggregateBaselineMonthlyCost, 2)
            DiffMonthlyCost     = [math]::Round($summaryDiffMonthlyCost, 2)
        }
        Findings      = @($findings)
    }
} catch {
    Write-Warning "Infracost scan failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
    return [PSCustomObject]@{
        Source        = 'infracost'
        SchemaVersion = '1.0'
        Status        = 'Failed'
        Message       = Remove-Credentials -Text ([string]$_.Exception.Message)
        Findings      = @()
        Errors   = @()
    }
} finally {
    if ($cleanupClone) {
        try { & $cleanupClone } catch {
            Write-Verbose "Infracost clone cleanup failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))"
        }
    }
}