Private/FlutterWebPlan.ps1

# FlutterWebPlan.ps1
# Builds the Publish-FlutterWeb deploy plan (local config + live server state + actions) as a
# common plan object (DeployPlan.ps1). Shared by -Plan and -Apply so both render the same plan
# (ADR 0002 §"Confirmation flow" step 1, ADR 0009). The remote probe is read-only.
#
# Split in three so the decision logic is testable without a server:
# Invoke-FlutterWebProbe — I/O only: SSH to the server, return the three raw states.
# ConvertTo-FlutterWebPlan — pure: raw states + local config -> plan object (severities/actions).
# Get-FlutterWebPlan — composition of the two; what the cmdlet calls.

function Invoke-FlutterWebProbe {
    <#
    .SYNOPSIS
        Read-only remote probe. Returns the raw server states as an object.
    .DESCRIPTION
        Queries the `current` symlink, whether the target release directory already exists, and
        the nginx site status (exists / port-in-use / will-create). Never mutates the server.
        Unparseable output leaves a field as 'desconocido' rather than guessing.
    .OUTPUTS
        [pscustomobject] with Current, Release and Nginx (all strings).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$AppName,
        [Parameter(Mandatory)][string]$Release,
        [Parameter(Mandatory)][string]$User,
        [Parameter(Mandatory)][string]$IP,
        [Parameter(Mandatory)][int]$SshPort,
        [Parameter(Mandatory)][string]$PrivateKeyPath,
        [Parameter(Mandatory)]$Port,
        [Parameter(Mandatory)][string]$RemoteWebRoot
    )

    $reportScript = @"
#!/bin/bash
# Versión actual (symlink current)
if [ -L "$RemoteWebRoot/$AppName/current" ]; then
    CURRENT=`$(readlink "$RemoteWebRoot/$AppName/current" | xargs basename)
    echo "CURRENT:`$CURRENT"
else
    echo "CURRENT:none"
fi
 
# Release destino ya existe?
if [ -d "$RemoteWebRoot/$AppName/releases/$Release" ]; then
    echo "RELEASE:exists"
else
    echo "RELEASE:new"
fi
 
# Nginx config
if [ -f "/etc/nginx/sites-available/$AppName" ]; then
    echo "NGINX:exists"
else
    if ss -tlnH sport = :$Port | grep -q .; then
        echo "NGINX:port-in-use"
    else
        echo "NGINX:will-create"
    fi
fi
"@


    $tmpLocal = New-UnixTempFile -Content $reportScript -Prefix "psdevops_report_flutterweb_"
    try {
        $remoteName = [IO.Path]::GetFileName($tmpLocal)
        $remotePath = "/tmp/$remoteName"

        & scp -i $PrivateKeyPath -P $SshPort $tmpLocal "$($User)@$($IP):$remotePath" 2>&1 | Out-Null
        if ($LASTEXITCODE -ne 0) { throw "Error al conectar con el servidor (scp exit: $LASTEXITCODE)" }

        $remoteCmd = "bash $remotePath ; rc=`$?; rm -f $remotePath; exit `$rc"
        $output = & ssh -i $PrivateKeyPath -p $SshPort "$($User)@$($IP)" $remoteCmd 2>&1
    }
    finally {
        Remove-Item -LiteralPath $tmpLocal -ErrorAction SilentlyContinue
    }

    $probe = [pscustomobject]@{
        Current = 'desconocido'
        Release = 'desconocido'
        Nginx   = 'desconocido'
    }
    foreach ($line in $output) {
        if ($line -match '^CURRENT:(.+)$') { $probe.Current = $Matches[1] }
        if ($line -match '^RELEASE:(.+)$') { $probe.Release = $Matches[1] }
        if ($line -match '^NGINX:(.+)$') { $probe.Nginx = $Matches[1] }
    }
    return $probe
}

function ConvertTo-FlutterWebPlan {
    <#
    .SYNOPSIS
        Pure: turns the raw probe states plus local config into the common plan object.
    .DESCRIPTION
        No I/O — this is where severity is decided, so it is unit-testable without a server.
        'port-in-use' is the only 'error' level: it means the apply is known to fail, which
        Get-DeployPlanBlocker turns into a hard stop in -Apply.
    .PARAMETER Probe
        Object from Invoke-FlutterWebProbe (Current/Release/Nginx).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$Probe,
        [Parameter(Mandatory)][string]$AppName,
        [Parameter(Mandatory)][string]$Release,
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][string]$IP,
        [Parameter(Mandatory)]$Port,
        [Parameter(Mandatory)][string]$RemoteWebRoot
    )

    # ─── Server-state rows (with severity levels) ────────
    if ($Probe.Current -eq 'none') {
        $currentRow = New-DeployPlanRow -Text '(primer deploy)' -Level 'warn'
    }
    else {
        $currentRow = New-DeployPlanRow -Text $Probe.Current -Level 'info'
    }

    if ($Probe.Release -eq 'exists') {
        $releaseRow = New-DeployPlanRow -Text "$Release ya existe (se sobreescribirá)" -Level 'warn'
    }
    else {
        $releaseRow = New-DeployPlanRow -Text "$Release (nueva)" -Level 'ok'
    }

    if ($Probe.Nginx -eq 'exists') {
        $nginxRow = New-DeployPlanRow -Text 'config existe (no se modifica)' -Level 'info'
    }
    elseif ($Probe.Nginx -eq 'port-in-use') {
        $nginxRow = New-DeployPlanRow -Text "PUERTO $Port EN USO — el deploy fallará" -Level 'error'
    }
    else {
        $nginxRow = New-DeployPlanRow -Text "se creará config en puerto $Port" -Level 'ok'
    }

    # ─── Actions -Apply will perform ─────────────────────
    $actions = @(
        'Compilar Flutter Web (Invoke-FlutterBuild -Web)',
        'Comprimir artefactos en zip',
        "Subir zip a ${IP}:/tmp/",
        "Instalar en ${RemoteWebRoot}/${AppName}/releases/${Release}/",
        "Actualizar symlink current → $Release"
    )
    if ($Probe.Nginx -ne 'exists') {
        $actions += "Crear configuración nginx en puerto $Port"
    }

    $sections = [ordered]@{
        'Configuración local' = [ordered]@{
            'Proyecto' = $AppName
            'Versión'  = $Release
            'Servidor' = "$Server ($IP)"
            'Puerto'   = "$Port"
        }
        'Estado del servidor' = [ordered]@{
            'Current' = $currentRow
            'Release' = $releaseRow
            'Nginx'   = $nginxRow
        }
    }

    return New-DeployPlan -Cmdlet 'Publish-FlutterWeb' -Target "$Server ($IP)" `
        -Sections $sections -Actions $actions
}

function Get-FlutterWebPlan {
    <#
    .SYNOPSIS
        Probes the server and returns the Publish-FlutterWeb plan object.
    .DESCRIPTION
        Composition of Invoke-FlutterWebProbe (I/O) and ConvertTo-FlutterWebPlan (pure).
        Called by BOTH -Plan and -Apply so the two render the same plan by construction.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$AppName,
        [Parameter(Mandatory)][string]$Release,
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][string]$User,
        [Parameter(Mandatory)][string]$IP,
        [Parameter(Mandatory)][int]$SshPort,
        [Parameter(Mandatory)][string]$PrivateKeyPath,
        [Parameter(Mandatory)]$Port,
        [Parameter(Mandatory)][string]$RemoteWebRoot
    )

    $probe = Invoke-FlutterWebProbe -AppName $AppName -Release $Release -User $User -IP $IP `
        -SshPort $SshPort -PrivateKeyPath $PrivateKeyPath -Port $Port -RemoteWebRoot $RemoteWebRoot

    return ConvertTo-FlutterWebPlan -Probe $probe -AppName $AppName -Release $Release `
        -Server $Server -IP $IP -Port $Port -RemoteWebRoot $RemoteWebRoot
}