Files
urieljarethandClaude Opus 4.8 e7d1c33cd5 deploy_skill: soporte para Coolify v4.1.2 (API /applications 404) + flujo UI Playwright
Aprendido desplegando cotizador (Next.js+FastAPI+Postgres, Docker Compose,
repo privado) en coolify.urieljareth.org (v4.1.2):

- Documenta que /applications/* devuelve 404 en esta instancia y que configurar
  apps git build-from-source solo es posible por la UI web.
- Nuevos scripts scripts/coolify-ui/: coolify-login.mjs (guarda storageState) y
  Configure-CoolifyComposeApp.mjs (build pack -> Docker Compose, Reload Compose
  File, env vars por Developer view, dominios por servicio).
- references/coolify-4.1.2-notes.md: mapa de API funcional/404, gotchas (BOM
  UTF-8 rompe el parser YAML de Coolify; Reload Compose File obligatorio; pin de
  dominios por servicio o 503; no encolar deploys concurrentes; PowerShell
  [IO.File]::ReadAllText para payloads de llaves).
- env.local.template.ps1: COOLIFY_EMAIL/COOLIFY_PASSWORD para la UI.
- SKILL.md/TOOLS.md: seccion "instance reality" y flujo UI paso a paso.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 00:42:12 -06:00

320 lines
13 KiB
PowerShell

<#
.SYNOPSIS
Create or update a Coolify SERVICE (Docker Compose multi-container stack)
from a local project's compose file.
.DESCRIPTION
Complements New-CoolifyApplication.ps1 (which only handles single-app
Dockerfile builds via POST /applications/public). This script handles
multi-container stacks via POST /services - the modern replacement for
the deprecated POST /applications/dockercompose.
Workflow:
1. Resolve (or create) the target Coolify Project and Environment.
2. Resolve the local server UUID.
3. Read the docker-compose file from the project (default:
docker-compose.coolify.yml). The compose must already satisfy the
hard rules from docs/AGENTS-coolify-apps.md (no published 80/443,
service-name DB host, healthchecks, named volumes, SERVICE_FQDN_*
placeholders so Coolify can wire routes).
4. POST /services with the raw compose + URL mappings (which service
gets which FQDN).
5. Push env vars from an optional .env file to the service.
6. Trigger an initial deploy (unless -NoDeploy).
Each mutating step asks for confirmation unless -Force is supplied.
.PARAMETER AppPath
Local path of the project that contains the compose file. Required.
.PARAMETER AppName
Service name in Coolify. Required. Becomes the resource name; Coolify
auto-suffixes with a short UUID when it instantiates sub-services.
.PARAMETER ComposeFile
Compose file name (relative to AppPath) or absolute path.
Default: docker-compose.coolify.yml
.PARAMETER Fqdn
Primary public URL, e.g. https://cotizador.urieljareth.org. The FQDN is
mapped to the -PrimaryService in the compose via the `urls` payload.
Required.
.PARAMETER PrimaryService
The compose service name that should receive the -Fqdn. Default: 'web'.
Set this to whatever your compose calls the main app service.
.PARAMETER ExtraUrls
Optional hashtable mapping additional compose service names to FQDNs.
Example: @{ api = 'https://cotizador-api.urieljareth.org' }
Use when your compose exposes more than one public service.
.PARAMETER ProjectName
Coolify project name. Created if missing. Default: 'default'.
.PARAMETER EnvironmentName
Coolify environment name within the project. Default: 'production'.
.PARAMETER EnvFile
Optional .env-formatted file. Each KEY=VALUE line is pushed to Coolify
as a service-level env var. Lines starting with '#' are ignored.
.PARAMETER InstantDeploy
Pass instant_deploy=true to the create call. Coolify starts the stack
immediately after creation. Mutually exclusive with -NoDeploy.
.PARAMETER NoDeploy
Do not trigger a deploy. Service is created with env vars but not started.
Use this when you want to review in the UI before the first start.
.PARAMETER ServiceUuid
Optional. If a service already exists in Coolify, pass its UUID to PATCH
it (compose_raw, urls) in place instead of creating a new one.
.PARAMETER Force
Skip per-step confirmation prompts.
.EXAMPLE
.\New-CoolifyService.ps1 `
-AppPath .\cotizador `
-AppName cotizador `
-Fqdn https://cotizador.urieljareth.org `
-PrimaryService web `
-EnvFile .\cotizador\.env.coolify `
-InstantDeploy
#>
param(
[Parameter(Mandatory = $true)]
[string]$AppPath,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$AppName,
[string]$ComposeFile = "docker-compose.coolify.yml",
[Parameter(Mandatory = $true)]
[string]$Fqdn,
[string]$PrimaryService = "web",
[hashtable]$ExtraUrls,
[string]$ProjectName = "default",
[string]$EnvironmentName = "production",
[string]$EnvFile,
[switch]$InstantDeploy,
[switch]$NoDeploy,
[string]$ServiceUuid,
[switch]$Force
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
if (-not (Test-Path -LiteralPath $coolifyApi)) {
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
}
function Confirm-Step {
param([string]$Action)
if ($Force) { return $true }
$reply = Read-Host "Confirm action: $Action`nProceed? (y/N)"
return ($reply -match '^(y|yes|s|si|sí)$')
}
function Get-CoolifyResource {
param([string]$Path)
& $coolifyApi -Path $Path -Raw -ErrorAction Stop
}
function Resolve-Project {
param([string]$Name)
$projects = Get-CoolifyResource -Path "/projects"
$match = $projects | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if (-not $match) {
if (-not (Confirm-Step "Create project '$Name' in Coolify")) {
throw "Aborted by user before creating project."
}
$body = @{ name = $Name } | ConvertTo-Json -Compress
$created = & $coolifyApi -Method POST -Path "/projects" -BodyJson $body -Raw -ErrorAction Stop
Write-Host "Project created: $($created.uuid)" -ForegroundColor Green
return $created
}
return $match
}
function Resolve-Environment {
param([string]$ProjectUuid, [string]$Name)
$envs = Get-CoolifyResource -Path "/projects/$ProjectUuid/environments"
$match = $envs | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if (-not $match) {
if (-not (Confirm-Step "Create environment '$Name' in project $ProjectUuid")) {
throw "Aborted by user before creating environment."
}
$body = @{ name = $Name } | ConvertTo-Json -Compress
$created = & $coolifyApi -Method POST -Path "/projects/$ProjectUuid/environments" -BodyJson $body -Raw -ErrorAction Stop
Write-Host "Environment created: $($created.uuid)" -ForegroundColor Green
return $created
}
return $match
}
function Resolve-ServerUuid {
$servers = Get-CoolifyResource -Path "/servers"
if (-not $servers) { throw "No Coolify servers configured." }
$srv = $servers | Where-Object { $_.settings.is_usable -eq $true } | Select-Object -First 1
if (-not $srv) { $srv = $servers | Select-Object -First 1 }
return $srv.uuid
}
# ─── Resolve inputs ──────────────────────────────────────────────────────────
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
# Compose file: try as-is (absolute), else relative to AppPath
$composePath = if ([System.IO.Path]::IsPathRooted($ComposeFile)) {
$ComposeFile
} else {
Join-Path $appPath $ComposeFile
}
if (-not (Test-Path -LiteralPath $composePath)) {
throw "Compose file not found: $composePath (resolved from -ComposeFile '$ComposeFile' against AppPath '$appPath')"
}
$composeRaw = Get-Content -LiteralPath $composePath -Raw -ErrorAction Stop
Write-Host "Loaded compose: $composePath ($($composeRaw.Length) bytes)" -ForegroundColor Cyan
# URLs array: primary + extras
$urlsList = @(
[pscustomobject]@{ name = $PrimaryService; url = $Fqdn }
)
if ($ExtraUrls -and $ExtraUrls.Count -gt 0) {
foreach ($k in $ExtraUrls.Keys) {
$urlsList += [pscustomobject]@{ name = $k; url = $ExtraUrls[$k] }
}
}
Write-Host "Resolving Coolify project '$ProjectName'..." -ForegroundColor Cyan
$proj = Resolve-Project -Name $ProjectName
Write-Host "Resolving environment '$EnvironmentName'..." -ForegroundColor Cyan
$env = Resolve-Environment -ProjectUuid $proj.uuid -Name $EnvironmentName
Write-Host "Resolving server..." -ForegroundColor Cyan
$serverUuid = Resolve-ServerUuid
Write-Host "Using server: $serverUuid" -ForegroundColor Cyan
# ─── Create or update the service ────────────────────────────────────────────
if ($ServiceUuid) {
Write-Host "Updating existing service: $ServiceUuid" -ForegroundColor Cyan
$patch = [ordered]@{
name = $AppName
docker_compose_raw = $composeRaw
urls = $urlsList
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
}
if (-not (Confirm-Step "PATCH /services/$ServiceUuid (compose=$($composePath), urls=$($urlsList.Count), fqdn=$Fqdn)")) {
throw "Aborted by user."
}
$result = & $coolifyApi -Method PATCH -Path "/services/$ServiceUuid" -BodyJson ($patch | ConvertTo-Json -Depth 6) -Raw -ErrorAction Stop
$svcUuid = $ServiceUuid
} else {
$createBody = [ordered]@{
type = "one-click-service" # custom service; Coolify accepts any non-empty string here
name = $AppName
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
docker_compose_raw = $composeRaw
urls = $urlsList
instant_deploy = [bool]$InstantDeploy
}
if (-not (Confirm-Step "POST /services with AppName=$AppName, compose=$($composePath), fqdn=$Fqdn -> $PrimaryService, instant_deploy=$InstantDeploy")) {
throw "Aborted by user."
}
$result = & $coolifyApi -Method POST -Path "/services" -BodyJson ($createBody | ConvertTo-Json -Depth 6) -Raw -ErrorAction Stop
$svcUuid = $result.uuid
Write-Host "Service created: $svcUuid" -ForegroundColor Green
}
# Show domains assigned (Coolify echoes the resolved FQDNs)
if ($result.domains) {
Write-Host "Domains assigned:" -ForegroundColor Cyan
$result.domains | ForEach-Object { Write-Host " $_" }
}
# ─── Push env vars ───────────────────────────────────────────────────────────
if ($EnvFile -and (Test-Path -LiteralPath $EnvFile)) {
if (-not (Confirm-Step "Push env vars from $EnvFile to service $svcUuid")) {
Write-Host "Skipping env push." -ForegroundColor Yellow
} else {
$pushed = 0
Get-Content -LiteralPath $EnvFile | ForEach-Object {
$line = $_.Trim()
if (-not $line -or $line.StartsWith("#")) { return }
$eq = $line.IndexOf("=")
if ($eq -le 0) { return }
$k = $line.Substring(0, $eq).Trim()
$v = $line.Substring($eq + 1).Trim().Trim('"').Trim("'")
$body = [ordered]@{
key = $k
value = $v
is_literal = $true
is_shown_once = $false
} | ConvertTo-Json -Compress
try {
& $coolifyApi -Method POST -Path "/services/$svcUuid/envs" -BodyJson $body -ErrorAction Stop | Out-Null
Write-Host " env: $k set" -ForegroundColor DarkGray
$pushed++
} catch {
Write-Host " env: $k FAILED -> $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "Env vars pushed: $pushed" -ForegroundColor Green
}
} elseif ($EnvFile) {
Write-Host "EnvFile specified but not found: $EnvFile" -ForegroundColor Yellow
}
# ─── Deploy trigger (if not instant) ─────────────────────────────────────────
if ($NoDeploy) {
Write-Host "Service created with env vars. -NoDeploy set: skipping deploy trigger." -ForegroundColor Yellow
} elseif ($InstantDeploy -and -not $ServiceUuid) {
Write-Host "instant_deploy=true: Coolify is already starting the stack." -ForegroundColor Green
} else {
if (-not (Confirm-Step "Trigger DEPLOY of service $svcUuid")) {
Write-Host "Skipping deploy trigger. Service is created but not started." -ForegroundColor Yellow
} else {
# Coolify's "deploy" endpoint for services is POST /services/{uuid}/start (or the unified /deploy with type).
# /services/{uuid}/start is the documented happy path; it queues the stack start.
try {
& $coolifyApi -Method POST -Path "/services/$svcUuid/start" -ErrorAction Stop | Out-Null
Write-Host "Deploy triggered (POST /services/$svcUuid/start)." -ForegroundColor Green
} catch {
Write-Host " POST /services/$svcUuid/start failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " Try the Coolify UI 'Deploy' button or POST /services/$svcUuid/start manually." -ForegroundColor Yellow
}
}
}
[PSCustomObject]@{
project_uuid = $proj.uuid
environment_name = $env.name
server_uuid = $serverUuid
service_uuid = $svcUuid
name = $AppName
primary_fqdn = $Fqdn
primary_service = $PrimaryService
compose_source = $composePath
} | Format-List