Private/WebVerification.ps1

# WebVerification.ps1
# Verificación post-deploy de un sitio web estático: comprueba que se esté sirviendo LO QUE
# SE ACABA DE DESPLEGAR, no solo que algo responda en un puerto (issue #76).
#
# Dividido en cuatro, como el resto del módulo, para que la decisión sea testeable sin servidor:
# New-WebVerificationScript — genera el bash del sondeo. Puro.
# ConvertFrom-WebProbeOutput — salida cruda del sondeo -> objeto. Puro.
# ConvertTo-WebVerification — sondeo + versión esperada -> resultado con severidad. Puro.
# Invoke-WebVerification — composición: ejecuta el sondeo por SSH, lee e interpreta.
#
# El corte entre el SSH y el parseo es el que permite que la prueba de contenedor lleve la salida
# de un nginx real hasta el veredicto, sin necesitar un servidor con SSH.
#
# Qué reemplaza
# -------------
# El check anterior pedía 200 y trataba todo lo demás como sospechoso:
#
# WARNING: HTTP 3048 respondió 301 (puede necesitar tiempo para iniciar)
#
# Cinco de siete sitios devuelven 301, porque el puerto declarado pertenece a un bloque que
# solo redirige y la web la sirve el :443. El aviso saltaba siempre y por eso dejó de leerse.
#
# Y aunque aceptara el 3xx, no probaba nada: un redirect responde igual con el symlink movido
# o sin mover. El caso de 'micro' lo demuestra —servía desde un directorio plano, así que
# mover 'current' no cambiaba nada— y el check habría terminado en verde.

function New-WebVerificationScript {
    <#
    .SYNOPSIS
    Genera el sondeo remoto: sigue la redirección y lee la versión servida.
    #>

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

    # --resolve no es un detalle: sin él, un 'curl -L' sale hacia el DNS público y puede
    # fallar por enrutamiento, salida a internet o hairpin NAT — motivos que no tienen nada
    # que ver con el despliegue. Se fuerza a resolver contra el propio servidor.
    #
    # -k es necesario porque el certificado es del dominio y la conexión va a 127.0.0.1.
    return @"
#!/bin/bash
# No lleva 'set -e': un código de error de curl es un dato a reportar, no un motivo para
# abortar el sondeo. Interpretar es tarea de ConvertTo-WebVerification.
sleep 1
 
BASE="http://127.0.0.1:$Port"
# Se pregunta por vacio en vez de encadenar '|| echo 000': cuando curl falla imprime su propio
# '000' Y ADEMAS sale con error, asi que el encadenado devolvia '000000' y el mensaje al
# operador terminaba diciendo 'HTTP 000000' en lugar de 'sin respuesta'.
HTTP=`$(curl -s -o /dev/null -w '%{http_code}' "`$BASE/" 2>/dev/null)
[ -z "`$HTTP" ] && HTTP=000
echo "HTTP:`$HTTP"
 
FINAL_URL="`$BASE"
if [ "`$HTTP" -ge 300 ] 2>/dev/null && [ "`$HTTP" -lt 400 ] 2>/dev/null; then
    LOC=`$(curl -s -o /dev/null -w '%{redirect_url}' "`$BASE/" 2>/dev/null)
    echo "LOCATION:`$LOC"
    FINAL_URL="`${LOC%/}"
fi
 
# El host del destino se resuelve contra 127.0.0.1: se verifica ESTE servidor, no el que
# devuelva el DNS.
HOST=`$(echo "`$FINAL_URL" | sed -E 's#^https?://##; s#[:/].*##')
SCHEME=`$(echo "`$FINAL_URL" | sed -E 's#^(https?)://.*#\1#')
 
# El puerto del destino puede venir explicito en la redireccion ('host:8100'). Si se
# descarta, el --resolve se arma para el puerto por defecto y NO aplica: curl sale al DNS
# publico, que es justo lo que --resolve existe para evitar.
PUERTO_URL=`$(echo "`$FINAL_URL" | sed -E 's#^https?://[^:/]+##; s#^:([0-9]+).*#\1#; s#^[^0-9].*##')
if [ -n "`$PUERTO_URL" ]; then
    PUERTO="`$PUERTO_URL"
elif [ "`$SCHEME" = "http" ]; then
    PUERTO=80
else
    PUERTO=443
fi
 
RESOLVE=""
[ -n "`$HOST" ] && RESOLVE="--resolve `$HOST:`$PUERTO:127.0.0.1"
 
FINAL=`$(curl -sk `$RESOLVE -o /dev/null -w '%{http_code}' "`$FINAL_URL/" 2>/dev/null)
[ -z "`$FINAL" ] && FINAL=000
echo "FINAL:`$FINAL"
 
VERSION=`$(curl -sk `$RESOLVE "`$FINAL_URL/version.json" 2>/dev/null \
           | grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
           | sed -E 's/.*"([^"]*)"`$/\1/')
echo "VERSION:`$VERSION"
"@

}

function ConvertFrom-WebProbeOutput {
    <#
    .SYNOPSIS
    Lee la salida del sondeo remoto y la convierte en un objeto.
    #>

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

    # La salida puede llegar como arreglo o como una sola cadena según cómo se consuma ssh; son
    # la misma salida y no pueden dar veredictos distintos.
    $lineas = @($Lines) | ForEach-Object { "$_" -split "`r?`n" }

    $probe = [pscustomobject]@{ HttpCode = ''; FinalCode = ''; ServedVersion = ''; Location = '' }
    foreach ($linea in $lineas) {
        # Solo se leen las claves conocidas: un banner del shell remoto o un aviso de ssh no
        # puede corromper la lectura.
        switch -Regex ("$linea".Trim()) {
            '^HTTP:(.*)$'     { $probe.HttpCode      = $Matches[1] }
            '^FINAL:(.*)$'    { $probe.FinalCode     = $Matches[1] }
            '^VERSION:(.*)$'  { $probe.ServedVersion = $Matches[1] }
            '^LOCATION:(.*)$' { $probe.Location      = $Matches[1] }
        }
    }

    return $probe
}

function ConvertTo-WebVerification {
    <#
    .SYNOPSIS
    Interpreta el sondeo: responde si se está sirviendo lo desplegado.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]$Probe,
        [Parameter(Mandatory = $true)][string]$ExpectedVersion
    )

    $final = "$($Probe.FinalCode)".Trim()
    $servida = "$($Probe.ServedVersion)".Trim()

    # El build number ('+349') es metadato de Dart y no aparece en el version.json servido,
    # que solo lleva 'version'. Compararlo daría un falso desajuste en cada release.
    $esperada = ($ExpectedVersion -split '\+')[0].Trim().TrimStart('v')
    $servidaN = ($servida -split '\+')[0].Trim().TrimStart('v')

    # 2xx y 3xx son respuestas sanas de nginx. Solo 4xx, 5xx y la ausencia de respuesta son
    # anomalías: exigir 200 convertía en aviso permanente algo que era correcto.
    $codigoOk = $false
    if ($final -match '^\d+$') { $codigoOk = ([int]$final -ge 200 -and [int]$final -lt 400) }

    if (-not $codigoOk) {
        $visto = if ($final -eq '000' -or -not $final) { 'sin respuesta' } else { "HTTP $final" }
        return [pscustomobject]@{
            Level = 'error'
            Text  = "el sitio no responde correctamente ($visto)"
        }
    }

    # Sin version.json no se puede afirmar nada, ni bien ni mal. Es 'warn' y no 'error': el
    # despliegue puede estar perfecto y el sitio simplemente no publicar ese archivo.
    if (-not $servidaN) {
        return [pscustomobject]@{
            Level = 'warn'
            Text  = "responde correctamente, pero no se pudo comprobar qué versión sirve (sin version.json)"
        }
    }

    if ($servidaN -ne $esperada) {
        return [pscustomobject]@{
            Level = 'error'
            Text  = "se desplegó $esperada pero el sitio sirve $servidaN"
        }
    }

    return [pscustomobject]@{
        Level = 'ok'
        Text  = "sirviendo $servidaN"
    }
}

function Invoke-WebVerification {
    <#
    .SYNOPSIS
    Ejecuta el sondeo por SSH e interpreta el resultado.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)][int]$Port,
        [Parameter(Mandatory = $true)][string]$ExpectedVersion,
        [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,
    # así que al parser le llegaba un 0 en vez de las líneas y la verificación fallaba siempre.
    $salida = Invoke-RemoteScriptCapture -ScriptContent (New-WebVerificationScript -Port $Port) `
                                         -User $User -IP $IP -Port $SshPort -KeyPath $KeyPath `
                                         -ScriptPrefix "psdevops_verify_web_"

    $probe = ConvertFrom-WebProbeOutput -Lines $salida

    return ConvertTo-WebVerification -Probe $probe -ExpectedVersion $ExpectedVersion
}