Functions/Publish-DockerStack.ps1
|
<#
.SYNOPSIS Despliega un stack de Docker Compose a un servidor Linux remoto vía SSH. .DESCRIPTION `Publish-DockerStack` lleva el mismo modelo Init/Plan/Apply de `Publish-NodeApi` (ADR 0002) al despliegue de stacks Docker Compose sobre un servidor existente por SSH: - Lee la configuración de `stack.yaml` (versionado, SIN secretos). - Empaqueta el compose + el contexto declarado en `include:` y lo sube versionado (`/opt/stacks/<name>/releases/<release>` con symlink `current` para rollback). - Sube `.env` (gitignored) al servidor como el env-file del stack (secretos fuera del repo). - Levanta el stack con `docker compose -p <name> up -d` en uno de tres modos de build: server (build en el servidor), transfer (build local + `docker save`/`load`) o none. - Espera a que el stack quede sano (contenedor `healthy` o una URL) y corre hooks `postDeploy`. Compose ya es declarativo e idempotente (`up -d` reconcilia contra el estado deseado); este cmdlet es el transporte + ciclo de vida + verificación alrededor de él. Es la contraparte de `Publish-NodeApi` para infraestructura contenedorizada. Se ejecuta desde el directorio donde viven `stack.yaml`, el compose y `.env`. .PARAMETER Init Genera `stack.yaml` y `.env` en el directorio actual, y agrega `.env` a `.gitignore`. .PARAMETER Plan Dry-run: muestra lo que hará -Apply (release, servidor, modo de build) y consulta el estado remoto (release actual y contenedores) sin realizar cambios. .PARAMETER Apply Ejecuta el despliegue completo al servidor configurado en `stack.yaml`. .PARAMETER AutoApprove Omite la confirmación interactiva (uso desatendido / CI, ADR 0002). .PARAMETER AllowDirty Permite desplegar con el árbol de git sucio (marca el release con `-dirty`). .EXAMPLE Publish-DockerStack -Init .EXAMPLE Publish-DockerStack -Plan .EXAMPLE Publish-DockerStack -Apply .NOTES Versión: 1.0.0 Autor: @ccisnedev Requiere: - Alias del host en ~/.ssh/config (Host, HostName, User, IdentityFile[, Port]) - Docker + plugin compose en el servidor remoto - Docker local solo para el modo build:transfer - Módulo powershell-yaml para parsear stack.yaml #> function Publish-DockerStack { [CmdletBinding(DefaultParameterSetName = 'Apply')] param( [Parameter(Mandatory, ParameterSetName = 'Init', HelpMessage = "Generate configuration files (stack.yaml and .env)")] [switch]$Init, [Parameter(Mandatory, ParameterSetName = 'Plan', HelpMessage = "Dry-run: show what -Apply would do, without making changes")] [switch]$Plan, [Parameter(Mandatory, ParameterSetName = 'Apply', HelpMessage = "Execute the deployment to the remote server")] [switch]$Apply, [Parameter(ParameterSetName = 'Apply', HelpMessage = "Skip the confirmation prompt for unattended/CI use (ADR 0002)")] [switch]$AutoApprove, [Parameter(ParameterSetName = 'Apply', HelpMessage = "Allow deploying a dirty git worktree (release is tagged +dirty)")] [switch]$AllowDirty ) begin { $ErrorActionPreference = 'Stop' } process { # Banner Write-Host "" Write-Host "╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ Publish-DockerStack — macss-devops ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "" # ─── helpers compartidos ───────────────────────── . "$PSScriptRoot/../Private/PublishHelpers.ps1" . "$PSScriptRoot/../Private/Read-SSHConfig.ps1" switch ($PSCmdlet.ParameterSetName) { # ═══════════════════════════════════════════════════ # INIT — Generar stack.yaml y .env # ═══════════════════════════════════════════════════ 'Init' { $cwd = (Get-Location).Path $stackYamlPath = Join-Path $cwd "stack.yaml" if (Test-Path $stackYamlPath) { throw "Ya existe stack.yaml en $cwd. Elimínelo primero si desea regenerar la configuración." } $templatePath = Join-Path $PSScriptRoot "..\Resources\Publish-DockerStack\templates\stack.yaml" if (-not (Test-Path $templatePath)) { throw "Template no encontrado: $templatePath" } Copy-Item -Path $templatePath -Destination $stackYamlPath Write-Host " Creado: stack.yaml" -ForegroundColor Green # Crear .env si no existe $envPath = Join-Path $cwd ".env" if (-not (Test-Path $envPath)) { $envContent = @" # .env — Variables/secretos para el stack (consumido por docker compose --env-file). # Este archivo se copia al servidor en cada despliegue. NO versionar (está en .gitignore). # EJEMPLO: # SA_PASSWORD=change-me "@ Set-Content -Path $envPath -Value $envContent -Encoding UTF8 Write-Host " Creado: .env" -ForegroundColor Green } else { Write-Host " Existe: .env (no se modificó)" -ForegroundColor Yellow } # Agregar .env a .gitignore $gitignorePath = Join-Path $cwd ".gitignore" if (Test-Path $gitignorePath) { $gitignoreContent = Get-Content $gitignorePath -Raw if ($gitignoreContent -notmatch '(?m)^\.env\s*$') { Add-Content -Path $gitignorePath -Value "`n.env" Write-Host " Actualizado: .gitignore (+.env)" -ForegroundColor Green } } else { Set-Content -Path $gitignorePath -Value ".env`n" -Encoding UTF8 Write-Host " Creado: .gitignore" -ForegroundColor Green } Write-Host "" Write-Host " Configuración creada. Próximos pasos:" -ForegroundColor Green Write-Host " 1. Edite stack.yaml → 'server' (alias SSH), 'stack.name', 'composeFile', 'include', 'health'" -ForegroundColor DarkGray Write-Host " 2. Edite .env → secretos/variables del stack" -ForegroundColor DarkGray Write-Host " 3. Ejecute: Publish-DockerStack -Plan (dry-run)" -ForegroundColor DarkGray Write-Host " 4. Ejecute: Publish-DockerStack -Apply" -ForegroundColor DarkGray Write-Host "" } # ═══════════════════════════════════════════════════ # APPLY — Despliegue completo # ═══════════════════════════════════════════════════ 'Apply' { $cwd = (Get-Location).Path Ensure-YamlModule $cfg = Get-DockerStackConfig -ProjectRoot $cwd $release = Get-DockerStackRelease -ProjectRoot $cwd -Version $cfg.Version -AllowDirty:$AllowDirty Write-Host " Stack: $($cfg.Name)" -ForegroundColor Cyan Write-Host " Release: $release" -ForegroundColor Cyan Write-Host " Compose: $($cfg.ComposeFile)" -ForegroundColor Cyan Write-Host " Build: $($cfg.Build)$(if ($cfg.Build -eq 'transfer') { " ($($cfg.Image))" })" -ForegroundColor Cyan Write-Host " Servidor: $($cfg.Server)" -ForegroundColor Cyan if ($cfg.HealthMode) { Write-Host " Health: $($cfg.HealthMode) → $($cfg.HealthTarget)" -ForegroundColor Cyan } Write-Host "" if (-not (Confirm-MacssChange -Action "Deploy stack '$($cfg.Name)' $release to '$($cfg.Server)' (build:$($cfg.Build))" -AutoApprove:$AutoApprove)) { Write-Host " Apply cancelado." -ForegroundColor Yellow return } # ─── SSH ───────────────────────────────────── $ssh = Read-SSHConfig -HostAlias $cfg.Server $user = $ssh.User $ip = $ssh.HostName $sshPort = $ssh.Port $keyPath = $ssh.IdentityFile # ─── Constantes remotas ────────────────────── $remoteRoot = "/opt/stacks" $stackDir = "$remoteRoot/$($cfg.Name)" $releaseDir = "$stackDir/releases/$release" $currentLink = "$stackDir/current" $tarballName = "$($cfg.Name)-$release.tar.gz" $remoteTar = "/tmp/$tarballName" $remoteEnv = "/tmp/$($cfg.Name).env" # ─── Empaquetar (compose + include) ────────── Write-Host " Empaquetando stack..." -ForegroundColor Cyan $items = @($cfg.ComposeFile) + $cfg.Include | Select-Object -Unique foreach ($it in $items) { if (-not (Test-Path (Join-Path $cwd $it))) { throw "El elemento de 'include'/compose no existe: $it (relativo a $cwd)" } } $localTar = Join-Path $env:TEMP $tarballName $tarArgs = @('-czf', $localTar, '-C', $cwd) + $items & tar.exe @tarArgs if (-not (Test-Path $localTar)) { throw "Error al crear el tarball del stack." } $tarMB = [math]::Round((Get-Item $localTar).Length / 1MB, 1) Write-Host " $tarballName ($tarMB MB)" -ForegroundColor Green # ─── build:transfer → build local + save ───── $imageTar = $null if ($cfg.Build -eq 'transfer') { Write-Host " Build local (transfer)..." -ForegroundColor Cyan & docker compose -f (Join-Path $cwd $cfg.ComposeFile) build if ($LASTEXITCODE -ne 0) { throw "docker compose build (local) falló con código $LASTEXITCODE" } Write-Host " Serializando imagen $($cfg.Image) (docker save)..." -ForegroundColor Cyan $imageTar = Join-Path $env:TEMP "$($cfg.Name)-image-$release.tar" & docker save -o $imageTar $cfg.Image if ($LASTEXITCODE -ne 0 -or -not (Test-Path $imageTar)) { throw "docker save de $($cfg.Image) falló." } $imgMB = [math]::Round((Get-Item $imageTar).Length / 1MB, 1) Write-Host " imagen $imgMB MB" -ForegroundColor Green } try { # ─── Subir artefactos ──────────────────── Write-Host " Subiendo a $ip..." -ForegroundColor Cyan & scp -i $keyPath -P $sshPort $localTar "$($user)@$($ip):$remoteTar" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "Error al subir el tarball (scp exit: $LASTEXITCODE)" } $envRaw = Get-Content (Join-Path $cwd '.env') -Raw $tmpEnv = New-UnixTempFile -Content $envRaw -Prefix "psdevops_dockenv_" & scp -i $keyPath -P $sshPort $tmpEnv "$($user)@$($ip):$remoteEnv" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "Error al subir .env (scp exit: $LASTEXITCODE)" } Remove-Item -LiteralPath $tmpEnv -ErrorAction SilentlyContinue Write-Host " tarball + .env subidos" -ForegroundColor Green if ($cfg.Build -eq 'transfer') { $remoteImg = "/tmp/$($cfg.Name)-image-$release.tar" & scp -i $keyPath -P $sshPort $imageTar "$($user)@$($ip):$remoteImg" 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "Error al subir la imagen (scp exit: $LASTEXITCODE)" } Write-Host " Cargando imagen en el servidor (docker load)..." -ForegroundColor Cyan $loadRc = Invoke-RemoteScript -ScriptContent "set -e`ndocker load -i '$remoteImg'`nrm -f '$remoteImg'" ` -User $user -IP $ip -Port $sshPort -KeyPath $keyPath -ScriptPrefix "psdevops_dockload_" if ($loadRc -ne 0) { throw "docker load en el servidor falló con código $loadRc" } } # ─── Instalar release + up ─────────────── Write-Host " Instalando release y levantando el stack..." -ForegroundColor Cyan $buildFlag = switch ($cfg.Build) { 'server' { '--build' } 'transfer' { '--no-build' } default { '' } } $deployScript = Get-BashScript -ScriptName "Deploy-DockerStack.sh" -Placeholders @{ '__NAME__' = $cfg.Name '__STACK_DIR__' = $stackDir '__RELEASE_DIR__' = $releaseDir '__CURRENT_LINK__' = $currentLink '__TARBALL__' = $remoteTar '__REMOTE_ENV__' = $remoteEnv '__COMPOSE_FILE__' = $cfg.ComposeFile '__BUILD_FLAG__' = $buildFlag '__RELEASE_ID__' = $release } $rc = Invoke-RemoteScript -ScriptContent $deployScript ` -User $user -IP $ip -Port $sshPort -KeyPath $keyPath -ScriptPrefix "psdevops_dockdeploy_" if ($rc -ne 0) { throw "El despliegue del stack falló con código $rc." } # ─── Healthcheck ───────────────────────── if ($cfg.HealthMode) { Write-Host " Verificando salud ($($cfg.HealthMode) → $($cfg.HealthTarget))..." -ForegroundColor Cyan $healthScript = Get-BashScript -ScriptName "Wait-StackHealth.sh" -Placeholders @{ '__MODE__' = $cfg.HealthMode '__NAME__' = $cfg.Name '__TARGET__' = $cfg.HealthTarget '__RETRIES__' = "$($cfg.HealthRetries)" '__INTERVAL__' = "$($cfg.HealthInterval)" } $hrc = Invoke-RemoteScript -ScriptContent $healthScript ` -User $user -IP $ip -Port $sshPort -KeyPath $keyPath -ScriptPrefix "psdevops_dockhealth_" if ($hrc -ne 0) { throw "Healthcheck falló. Revise: ssh $($cfg.Server) 'cd $currentLink && docker compose -p $($cfg.Name) logs --tail 80'" } } else { Write-Host " (sin healthcheck configurado)" -ForegroundColor Yellow } # ─── postDeploy ────────────────────────── if ($cfg.PostDeploy -and $cfg.PostDeploy.Count -gt 0) { Write-Host " Ejecutando postDeploy ($($cfg.PostDeploy.Count) paso(s))..." -ForegroundColor Cyan $lines = @("set -e", "cd '$currentLink'") $lines += $cfg.PostDeploy $prc = Invoke-RemoteScript -ScriptContent ($lines -join "`n") ` -User $user -IP $ip -Port $sshPort -KeyPath $keyPath -ScriptPrefix "psdevops_dockpost_" if ($prc -ne 0) { throw "Un paso de postDeploy falló con código $prc." } } Write-Host "" Write-Host "══════════════════════════════════════════════════" -ForegroundColor Green Write-Host " Deploy completado: $($cfg.Name) $release" -ForegroundColor Green Write-Host "══════════════════════════════════════════════════" -ForegroundColor Green Write-Host " Servidor: $ip ($($cfg.Server))" -ForegroundColor White Write-Host " Release: $releaseDir" -ForegroundColor White Write-Host " Rollback: ssh $($cfg.Server) 'ln -sfn <release-anterior> $currentLink && cd $currentLink && docker compose -p $($cfg.Name) up -d'" -ForegroundColor White Write-Host "══════════════════════════════════════════════════" -ForegroundColor Green Write-Host "" } finally { Remove-Item -LiteralPath $localTar -ErrorAction SilentlyContinue if ($imageTar) { Remove-Item -LiteralPath $imageTar -ErrorAction SilentlyContinue } } } # ═══════════════════════════════════════════════════ # PLAN — Dry-run # ═══════════════════════════════════════════════════ 'Plan' { $cwd = (Get-Location).Path Ensure-YamlModule $cfg = Get-DockerStackConfig -ProjectRoot $cwd $release = Get-DockerStackRelease -ProjectRoot $cwd -Version $cfg.Version -AllowDirty $ssh = Read-SSHConfig -HostAlias $cfg.Server $user = $ssh.User; $ip = $ssh.HostName; $sshPort = $ssh.Port; $keyPath = $ssh.IdentityFile Write-Host " Modo: SOLO REPORTE (no se realizarán cambios)" -ForegroundColor Yellow Write-Host "" Write-Host " ─── Configuración local ───" -ForegroundColor Cyan Write-Host " Stack: $($cfg.Name)" -ForegroundColor White Write-Host " Release: $release" -ForegroundColor White Write-Host " Compose: $($cfg.ComposeFile)" -ForegroundColor White Write-Host " Build: $($cfg.Build)" -ForegroundColor White Write-Host " Servidor: $($cfg.Server) ($ip)" -ForegroundColor White if ($cfg.HealthMode) { Write-Host " Health: $($cfg.HealthMode) → $($cfg.HealthTarget)" -ForegroundColor White } Write-Host "" Write-Host " ─── Estado del servidor ───" -ForegroundColor Cyan $remoteRoot = "/opt/stacks" $stackDir = "$remoteRoot/$($cfg.Name)" $reportScript = @" #!/bin/bash if [ -L "$stackDir/current" ]; then echo "CURRENT:`$(readlink "$stackDir/current" | xargs basename)" else echo "CURRENT:none" fi if [ -d "$stackDir/releases/$release" ]; then echo "RELEASE:exists"; else echo "RELEASE:new"; fi if command -v docker >/dev/null 2>&1; then echo "--- docker compose ps ($($cfg.Name)) ---" docker compose -p "$($cfg.Name)" ps 2>/dev/null || echo "(sin contenedores para el proyecto)" else echo "DOCKER:missing" fi "@ $tmpLocal = New-UnixTempFile -Content $reportScript -Prefix "psdevops_dockreport_" try { $remoteName = [IO.Path]::GetFileName($tmpLocal) $remotePath = "/tmp/$remoteName" & scp -i $keyPath -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 $keyPath -p $sshPort "$($user)@$($ip)" $remoteCmd 2>&1 } finally { Remove-Item -LiteralPath $tmpLocal -ErrorAction SilentlyContinue } foreach ($line in $output) { if ($line -match '^CURRENT:(.+)$') { if ($Matches[1] -eq 'none') { Write-Host " Current: (primer deploy)" -ForegroundColor Yellow } else { Write-Host " Current: $($Matches[1])" -ForegroundColor White } } elseif ($line -match '^RELEASE:(.+)$') { if ($Matches[1] -eq 'exists') { Write-Host " Release: $release ya existe (se sobreescribirá)" -ForegroundColor Yellow } else { Write-Host " Release: $release (nueva)" -ForegroundColor Green } } else { Write-Host " $line" -ForegroundColor DarkGray } } Write-Host "" Write-Host " ─── Acciones que realizará -Apply ───" -ForegroundColor Cyan Write-Host " 1. Empaquetar compose + include en tar.gz" -ForegroundColor White if ($cfg.Build -eq 'transfer') { Write-Host " 2. Build local + docker save/load de $($cfg.Image) al servidor" -ForegroundColor White } Write-Host " 3. Subir a ${ip}:$stackDir/releases/$release/ y apuntar 'current'" -ForegroundColor White Write-Host " 4. docker compose -p $($cfg.Name) up -d ($($cfg.Build))" -ForegroundColor White if ($cfg.HealthMode) { Write-Host " 5. Healthcheck ($($cfg.HealthMode))" -ForegroundColor White } if ($cfg.PostDeploy.Count -gt 0) { Write-Host " 6. postDeploy: $($cfg.PostDeploy.Count) paso(s)" -ForegroundColor White } Write-Host "" } } } } |