Files
Proxmox-Coolify-Manager/deploy_skill/scripts/coolify-ui/Configure-CoolifyComposeApp.mjs
T
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

95 lines
6.5 KiB
JavaScript

// Configure-CoolifyComposeApp.mjs
// Drive the Coolify web UI (v4.1.2, Livewire) to configure an EXISTING git-based
// application as a Docker Compose build-pack app. Needed because this instance's
// REST API does NOT expose /applications/* (all 404) — see references/coolify-4.1.2-notes.md.
//
// It performs, idempotently and each guarded by a flag, the steps that the API can't:
// 1. Build Pack -> Docker Compose (+ optional compose location)
// 2. Reload Compose File (MANDATORY once, else deploy crashes Yaml::parse(null))
// 3. Env vars (Developer view bulk -> "Save All Environment Variables")
// 4. Per-service domains (parsedServiceDomains.<svc>.domain) so Traefik routes
// the real FQDN instead of a random *.urieljareth.org subdomain.
//
// Trigger the actual deploy separately via the API: GET /deploy?uuid=<uuid>
// (that endpoint works even though app CRUD is 404). Then verify with Test-ServiceOnline.ps1.
//
// Env / args:
// CF_STATE Playwright storageState from coolify-login.mjs (required)
// CF_APP_URL .../project/{p}/environment/{e}/application/{a} (required)
// CF_PW_BASE package.json whose node_modules has playwright (default: manager repo)
// CF_BUILDPACK=dockercompose set the build pack (optional)
// CF_COMPOSE_LOCATION=/docker-compose.coolify.yml (optional)
// CF_RELOAD=1 click "Reload Compose File" (do this after buildpack switch / repo change)
// CF_ENVBULK="K=V\nK2=V2" bulk env vars to Save All (optional)
// CF_DOMAINS='{"web":"https://x.org","api":"https://y.org"}' per-service domains (optional)
// CF_SHOT=path.png screenshot (optional)
import { createRequire } from 'module';
const require = createRequire(process.env.CF_PW_BASE || 'H:/MegaSync/Proyectos/Proxmox & Coolify Manager/package.json');
const { chromium } = require('playwright');
const STATE = process.env.CF_STATE, URL = process.env.CF_APP_URL;
if (!STATE || !URL) { console.error('Need CF_STATE and CF_APP_URL'); process.exit(2); }
const SHOT = process.env.CF_SHOT || 'cf_configure.png';
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1600, height: 1400 }, ignoreHTTPSErrors: true, storageState: STATE });
const page = await ctx.newPage();
const dismiss = async () => { for (const t of ['Accept and Close','Acknowledge & Disable This Popup','Maybe next time']) { const b = page.getByRole('button',{name:t,exact:false}); if (await b.count().catch(()=>0)) { await b.first().click().catch(()=>{}); await page.waitForTimeout(400);} } };
const clickSave = async (label='Save') => { const b = page.getByRole('button',{name:label,exact:(label==='Save')}); const n = await b.count().catch(()=>0); for (let i=0;i<n;i++){ const x=b.nth(i); if(await x.isVisible().catch(()=>false)){ await x.scrollIntoViewIfNeeded().catch(()=>{}); await x.click().catch(()=>{}); return true;} } return false; };
const go = async () => { await page.goto(URL,{waitUntil:'domcontentloaded',timeout:45000}); await page.waitForTimeout(3500); await dismiss(); };
try {
// 1. Build Pack
if (process.env.CF_BUILDPACK) {
await go();
const bp = page.locator('select:has(option[value="dockercompose"])').first();
await bp.waitFor({ state:'attached', timeout:15000 });
if (await bp.inputValue().catch(()=>'') !== process.env.CF_BUILDPACK) {
await bp.selectOption(process.env.CF_BUILDPACK); await page.waitForTimeout(1500);
await clickSave(); await page.waitForTimeout(2500);
console.log('buildPack ->', process.env.CF_BUILDPACK);
} else console.log('buildPack already', process.env.CF_BUILDPACK);
}
// (optional) compose location
if (process.env.CF_COMPOSE_LOCATION) {
await go();
const inputs = await page.locator('input[type="text"]').all();
for (const inp of inputs) { const v = await inp.inputValue().catch(()=>''); const ph = await inp.getAttribute('placeholder').catch(()=>''); if (/docker-compose\.ya?ml/.test(v)||/docker-compose\.ya?ml/.test(ph||'')) { await inp.fill(process.env.CF_COMPOSE_LOCATION); await inp.blur().catch(()=>{}); break; } }
await clickSave(); await page.waitForTimeout(2000);
console.log('composeLocation ->', process.env.CF_COMPOSE_LOCATION);
}
// 2. Reload Compose File (persists parsed docker_compose; avoids Yaml::parse(null))
if (process.env.CF_RELOAD) {
await go();
const rc = page.getByRole('button',{name:'Reload Compose File',exact:false});
if (await rc.count().catch(()=>0)) { await rc.first().click(); await page.waitForTimeout(4000); await clickSave(); await page.waitForTimeout(2500); console.log('reloaded compose file'); }
else console.log('Reload Compose File button not found');
}
// 3. Env vars (Developer view bulk)
if (process.env.CF_ENVBULK) {
await page.goto(URL.replace(/\/?$/, '') + '/environment-variables', { waitUntil:'domcontentloaded', timeout:45000 });
await page.waitForTimeout(3000); await dismiss();
const dev = page.getByRole('button',{name:'Developer view',exact:false});
if (await dev.count().catch(()=>0)) { await dev.first().click(); await page.waitForTimeout(1200); }
const tas = await page.locator('textarea').all(); let best=null, area=-1;
for (const ta of tas){ if(!(await ta.isVisible().catch(()=>false)))continue; const bb=await ta.boundingBox().catch(()=>null); const a=bb?bb.width*bb.height:0; if(a>area){area=a;best=ta;} }
if (best){ await best.click(); await best.fill(process.env.CF_ENVBULK); await best.blur().catch(()=>{}); await page.waitForTimeout(500); await clickSave('Save All Environment Variables'); await page.waitForTimeout(2500); console.log('env vars saved'); }
else console.log('bulk textarea not found');
}
// 4. Per-service domains
if (process.env.CF_DOMAINS) {
const map = JSON.parse(process.env.CF_DOMAINS);
await go();
for (const [svc, dom] of Object.entries(map)) {
const sel = `input[wire\\:model="parsedServiceDomains.${svc}.domain"]`;
const loc = page.locator(sel).first();
if (await loc.count().catch(()=>0)) { await loc.fill(dom); await loc.blur().catch(()=>{}); await page.waitForTimeout(400); console.log(`domain ${svc} -> ${dom}`); }
else console.log(`domain input for '${svc}' not found`);
}
await clickSave(); await page.waitForTimeout(2500);
}
await page.screenshot({ path: SHOT, fullPage: true }).catch(()=>{});
console.log('DONE');
} catch (e) { console.error('ERR', e.message); await page.screenshot({ path: SHOT }).catch(()=>{}); process.exitCode = 1; }
finally { await browser.close(); }