Private/EnvContract.ps1

# EnvContract.ps1
# El .env como sharedPath, con .env.example como contrato verificable (issue #79).
#
# La configuración de runtime de producción vivía solo en la máquina del operador: se subía por
# scp en cada -Apply. CI no podía desplegar la API porque no había origen del que copiarla, y si
# esa máquina se perdía, se perdía la configuración de producción.
#
# El mecanismo para resolverlo ya existía: las llaves RSA de impulsa son un sharedPath, viven en
# /opt/app/<app>/shared/ y el despliegue solo las enlaza. El .env era la excepción, sin razón.
#
# Pero mover el .env a shared/ sin verificar nada empeora la situación: el archivo persiste entre
# releases, así que una versión que introduzca una variable nueva se desplegaría en verde y
# fallaría en runtime. Por eso .env.example —que sí está versionado, y por tanto viaja con el
# código— pasa a ser el contrato que el plan hace cumplir.
#
# Solo se comparan NOMBRES de claves. El plan no lee secretos, y el sondeo los corta en el
# servidor: la garantía tiene que estar donde se lee el archivo, no en quien recibe la salida.

function Get-DotEnvKeys {
    <#
    .SYNOPSIS
    Nombres de las claves declaradas en un archivo de entorno. Nunca los valores.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyCollection()]$Lines
    )

    $claves = [System.Collections.Generic.List[string]]::new()
    foreach ($linea in @($Lines)) {
        # El '=' cierra la captura: lo que venga después es el valor y no se mira.
        if ("$linea" -match '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=') {
            if (-not $claves.Contains($Matches[1])) { $claves.Add($Matches[1]) }
        }
    }

    return $claves.ToArray()
}

function New-RemoteEnvKeysScript {
    <#
    .SYNOPSIS
    Genera el bash que lista las claves del .env del servidor, sin transportar los valores.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)][string]$SharedEnvPath
    )

    # El sed recorta todo lo que sigue al '=' antes de imprimir: los valores no salen del
    # servidor, así que no terminan en la salida del ssh ni en ningún log que la capture.
    return @"
if [ -f "$SharedEnvPath" ]; then
    echo "ENVFILE:present"
    sed -nE 's/^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=.*/ENVKEY:\2/p' "$SharedEnvPath"
else
    echo "ENVFILE:absent"
fi
"@

}

function ConvertTo-EnvContractState {
    <#
    .SYNOPSIS
    Compara las claves que el código declara contra las que el servidor tiene.
    #>

    [CmdletBinding()]
    param(
        # $null = no hay .env.example (no se puede afirmar nada)
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyCollection()]$ExampleKeys,
        # $null = no hay .env en el servidor
        [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyCollection()]$ServerKeys
    )

    $vacio = [string[]]@()

    if ($null -eq $ExampleKeys) {
        return [pscustomobject]@{
            Level = 'warn'; Missing = $vacio; Extra = $vacio
            Text  = "no hay .env.example: no se puede comprobar que el servidor tenga lo que el código necesita"
        }
    }

    if ($null -eq $ServerKeys) {
        return [pscustomobject]@{
            Level = 'error'; Missing = @($ExampleKeys); Extra = $vacio
            Text  = "el servidor no tiene shared/.env: súbelo con 'Publish-NodeApi -PushShared' antes de desplegar"
        }
    }

    $faltan = @(@($ExampleKeys) | Where-Object { $_ -notin @($ServerKeys) })
    $sobran = @(@($ServerKeys)  | Where-Object { $_ -notin @($ExampleKeys) })

    # Lo que falta bloquea; lo que sobra avisa. Una clave ausente hace que la release nueva
    # falle en runtime; una de más es configuración obsoleta, y eso no impide que corra.
    if ($faltan.Count -gt 0) {
        return [pscustomobject]@{
            Level = 'error'; Missing = $faltan; Extra = $sobran
            Text  = "el servidor no tiene $($faltan.Count) clave(s) que el código declara: $($faltan -join ', ')"
        }
    }

    if ($sobran.Count -gt 0) {
        return [pscustomobject]@{
            Level = 'warn'; Missing = $vacio; Extra = $sobran
            Text  = "el servidor tiene $($sobran.Count) clave(s) que .env.example no declara: $($sobran -join ', ')"
        }
    }

    return [pscustomobject]@{
        Level = 'ok'; Missing = $vacio; Extra = $vacio
        Text  = "el servidor tiene las $(@($ExampleKeys).Count) clave(s) que .env.example declara"
    }
}

function ConvertFrom-EnvKeysOutput {
    <#
    .SYNOPSIS
    Lee la salida del sondeo. Devuelve $null si el archivo no existe en el servidor.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][AllowNull()]$Lines
    )

    $lineas = @($Lines) | ForEach-Object { "$_" -split "`r?`n" }

    # Ausente y vacío no son lo mismo: uno se arregla con -PushShared y el otro es un archivo
    # que alguien vació. Por eso el sondeo declara la presencia por separado.
    $existe = $false
    $claves = [System.Collections.Generic.List[string]]::new()
    foreach ($linea in $lineas) {
        switch -Regex ("$linea".Trim()) {
            '^ENVFILE:present$' { $existe = $true }
            '^ENVKEY:(.+)$'     { if (-not $claves.Contains($Matches[1])) { $claves.Add($Matches[1]) } }
        }
    }

    if (-not $existe) { return $null }
    return , $claves.ToArray()
}

function Invoke-EnvContractCheck {
    <#
    .SYNOPSIS
    Compara el .env.example local con las claves del shared/.env del servidor.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$SharedEnvPath,
        [Parameter(Mandatory = $true)][string]$ExamplePath,
        [Parameter(Mandatory = $true)][string]$User,
        [Parameter(Mandatory = $true)][string]$IP,
        [Parameter(Mandatory = $true)][string]$SshPort,
        [Parameter(Mandatory = $true)][string]$KeyPath
    )

    # Capture y no Invoke-RemoteScript: aquel imprime la salida y devuelve el código de salida.
    $salida = Invoke-RemoteScriptCapture -ScriptContent ("#!/bin/bash`n" + (New-RemoteEnvKeysScript -SharedEnvPath $SharedEnvPath)) `
                                         -User $User -IP $IP -Port $SshPort -KeyPath $KeyPath `
                                         -ScriptPrefix "psdevops_envkeys_"

    $servidor = ConvertFrom-EnvKeysOutput -Lines $salida

    $ejemplo = $null
    if (Test-Path -LiteralPath $ExamplePath) {
        $ejemplo = Get-DotEnvKeys -Lines @(Get-Content -LiteralPath $ExamplePath)
    }

    return ConvertTo-EnvContractState -ExampleKeys $ejemplo -ServerKeys $servidor
}

function Test-EnvContractOrThrow {
    <#
    .SYNOPSIS
    Guardia de -Apply: aborta antes de desplegar si al servidor le falta una clave.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][string]$SharedEnvPath,
        [Parameter(Mandatory = $true)][string]$ExamplePath,
        [Parameter(Mandatory = $true)][string]$User,
        [Parameter(Mandatory = $true)][string]$IP,
        [Parameter(Mandatory = $true)][string]$SshPort,
        [Parameter(Mandatory = $true)][string]$KeyPath
    )

    $estado = Invoke-EnvContractCheck -SharedEnvPath $SharedEnvPath -ExamplePath $ExamplePath `
                                      -User $User -IP $IP -SshPort $SshPort -KeyPath $KeyPath

    switch ($estado.Level) {
        'ok'   { Write-Host " Config: $($estado.Text)" -ForegroundColor Green }
        'warn' { Write-Host " Config: AVISO: $($estado.Text)" -ForegroundColor Yellow }
        default {
            # Desplegar igual dejaría la app arrancando sin una variable que su código pide: un
            # despliegue en verde y un fallo en runtime. Es justo lo que el contrato evita.
            throw ("El servidor no cumple el contrato de .env.example: " + $estado.Text)
        }
    }

    return $estado
}

function Resolve-SharedSourcePath {
    <#
    .SYNOPSIS
    Ruta local que alimenta a un sharedPath al subirlo con -PushShared.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)][string]$SharedPath,
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [Parameter(Mandatory = $true)][string]$EnvFile
    )

    # El .env del servidor sale del env file elegido, no del .env local. ADR 0004: -EnvFile
    # selecciona el entorno; sin esto, '-PushShared -EnvFile .env.production' subiría la
    # configuración de desarrollo a producción — el accidente que este esquema evita.
    $origen = if ($SharedPath -eq '.env') { $EnvFile } else { $SharedPath }

    if ([IO.Path]::IsPathRooted($origen)) { return $origen }
    return (Join-Path $ProjectRoot $origen)
}