fix: rolling deploys + service custom-domain support
Two product gaps surfaced from the twenty-live debugging session:
1. Vibn frontend now has a healthcheck on / port 3000. Coolify will
wait for the new container to be healthy before swapping traffic,
so deploys no longer drop in-flight chat SSE streams. (Setting was
applied via Coolify API; commit just documents.)
2. apps_domains_set now handles SERVICES (template-based apps like
Twenty CRM, n8n) — not just applications. Setting service_apps.fqdn
in the DB alone gets reverted by Coolify's deploy pipeline, so we
replicate the Livewire EditDomain.php save flow via tinker over SSH:
write fqdn → save → updateCompose() → service.parse(). After this
apps_deploy regenerates Traefik labels with the custom domain.
Auto-detects service vs application by uuid lookup. New { port }
parameter lets the AI pin the upstream port for services that
require one (Coolify hard-fails the save without it).
Tool description rewritten with the new behavior + a worked example
so the AI uses the right pipeline first try.
Made-with: Cursor
This commit is contained in:
@@ -2256,7 +2256,7 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
|
||||
if (!appUuid || domainsIn.length === 0) {
|
||||
return NextResponse.json({ error: 'Params "uuid" and "domains[]" are required' }, { status: 400 });
|
||||
}
|
||||
const app = await getApplicationInWorkspace(appUuid, ownedUuids);
|
||||
|
||||
const normalized: string[] = [];
|
||||
for (const d of domainsIn) {
|
||||
if (typeof d !== 'string' || !d.trim()) continue;
|
||||
@@ -2269,13 +2269,82 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
|
||||
}
|
||||
normalized.push(clean);
|
||||
}
|
||||
const buildPack = (app.build_pack ?? 'nixpacks') as string;
|
||||
|
||||
// Try service first — services (template-based, e.g. Twenty CRM) have
|
||||
// a totally different domain mechanism than plain applications. The
|
||||
// ServiceApplication.fqdn field is the source of truth, but writing
|
||||
// it directly without invoking Coolify's updateCompose() + parse()
|
||||
// pipeline gets reverted on next deploy. Replicate the Livewire
|
||||
// EditDomain.php save flow via tinker so Traefik labels actually
|
||||
// regenerate.
|
||||
const composeService =
|
||||
typeof params.service === 'string' && params.service.trim()
|
||||
? params.service.trim()
|
||||
: typeof params.composeService === 'string' && params.composeService.trim()
|
||||
? params.composeService.trim()
|
||||
: undefined;
|
||||
|
||||
let serviceFound = false;
|
||||
try {
|
||||
const svc = await getServiceInWorkspace(appUuid, principal.workspace);
|
||||
if (svc) serviceFound = true;
|
||||
} catch {}
|
||||
|
||||
if (serviceFound) {
|
||||
if (!isCoolifySshConfigured()) {
|
||||
return NextResponse.json({
|
||||
error: 'Setting custom domains on services requires SSH-managed Coolify (not configured on this deploy).',
|
||||
}, { status: 503 });
|
||||
}
|
||||
// Pick the inner app to wire the domain to. Default = first
|
||||
// non-worker / non-job app, mirroring Coolify UI defaults.
|
||||
const inner = composeService ?? 'auto';
|
||||
// Ensure the user-provided FQDN includes a port — Coolify hard-fails
|
||||
// the save if a template exposes a required port and we don't pin it.
|
||||
// Default to the template's required port when known.
|
||||
const port = params.port ? Number(params.port) : null;
|
||||
const fqdnsForUrl = normalized
|
||||
.map((d) => `https://${d}${port ? `:${port}` : ''}`)
|
||||
.join(',');
|
||||
// PHP code: load service, find target inner app by name (or auto-pick),
|
||||
// set fqdn, save, regenerate compose, re-parse. All quoting carefully
|
||||
// escaped because we shell this through ssh.
|
||||
const phpCode = `
|
||||
$service = App\\Models\\Service::where('uuid', '${appUuid}')->first();
|
||||
if (!$service) { echo 'service-not-found'; exit; }
|
||||
$apps = $service->applications()->get();
|
||||
$target = ${inner === 'auto' ? '$apps->first(fn($a) => !str_contains(strtolower($a->name), \'worker\') && !str_contains(strtolower($a->name), \'job\')) ?? $apps->first()' : `$apps->firstWhere('name', '${inner}')`};
|
||||
if (!$target) { echo 'inner-app-not-found'; exit; }
|
||||
$target->fqdn = '${fqdnsForUrl}';
|
||||
$target->save();
|
||||
updateCompose($target);
|
||||
$service->parse();
|
||||
echo 'fqdn-saved=' . $target->fresh()->fqdn;
|
||||
`;
|
||||
const result = await runOnCoolifyHost(
|
||||
`docker exec coolify php artisan tinker --execute=${shellEscape(phpCode)}`
|
||||
);
|
||||
const out = (result.stdout || '').trim();
|
||||
if (out.includes('service-not-found') || out.includes('inner-app-not-found')) {
|
||||
return NextResponse.json({
|
||||
error: out,
|
||||
stderr: result.stderr,
|
||||
}, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: appUuid,
|
||||
kind: 'service',
|
||||
domains: normalized,
|
||||
innerApp: inner,
|
||||
tinkerOut: out,
|
||||
summaryHint: `Custom domain saved on service ${appUuid}. Now call apps_deploy { uuid: "${appUuid}" } to regenerate Traefik labels and bring the new domain live.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const app = await getApplicationInWorkspace(appUuid, ownedUuids);
|
||||
const buildPack = (app.build_pack ?? 'nixpacks') as string;
|
||||
await setApplicationDomains(appUuid, normalized, {
|
||||
forceOverride: true,
|
||||
buildPack,
|
||||
@@ -2284,6 +2353,7 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
|
||||
return NextResponse.json({
|
||||
result: {
|
||||
uuid: appUuid,
|
||||
kind: 'application',
|
||||
domains: normalized,
|
||||
buildPack,
|
||||
routedTo:
|
||||
@@ -2294,6 +2364,13 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
|
||||
});
|
||||
}
|
||||
|
||||
// Minimal POSIX-shell single-quote escape for arbitrary string content.
|
||||
// PHP code can contain any character except a literal `'`; we wrap in
|
||||
// single quotes and break out for embedded singles.
|
||||
function shellEscape(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// Phase 4: databases
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -304,17 +304,27 @@ Pass wipeVolumes: true ONLY if the user explicitly said "nuke the data".`,
|
||||
},
|
||||
{
|
||||
name: 'apps_domains_set',
|
||||
description: 'Replace the domain set for an application. All entries must end with .{workspace}.vibnai.com. For compose apps, use the service parameter to target a specific service.',
|
||||
description: `Set the public domain(s) for an application or service. All entries must end with .{workspace}.vibnai.com.
|
||||
|
||||
Auto-detects whether uuid points to an application (Dockerfile / nixpacks / docker-image / compose buildpack) or a service (template-based, e.g. Twenty CRM, n8n) and uses the right Coolify pipeline for each:
|
||||
|
||||
- Application: writes to applications.fqdn (or docker_compose_domains for compose buildpack).
|
||||
- Service: writes to service_applications.fqdn AND triggers Coolify's updateCompose() + service.parse() so Traefik labels regenerate. Without this dance, the change gets reverted on next deploy. We learned this the hard way with twenty-live.
|
||||
|
||||
For services with a required port (twenty-crm uses 3000), pass { port: 3000 } so the saved fqdn is "https://host:3000" — Coolify hard-fails the save otherwise. Look up the required port via apps_templates_search if you don't know it.
|
||||
|
||||
After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Traefik labels.`,
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
uuid: { type: 'STRING', description: 'The Coolify application UUID.' },
|
||||
uuid: { type: 'STRING', description: 'The Coolify application or service UUID.' },
|
||||
domains: {
|
||||
type: 'ARRAY',
|
||||
description: 'Array of domain strings (e.g. ["myapp.mark.vibnai.com", "api.mark.vibnai.com"]).',
|
||||
description: 'Array of domain strings (e.g. ["myapp.mark.vibnai.com"]).',
|
||||
items: { type: 'STRING' },
|
||||
},
|
||||
service: { type: 'STRING', description: 'For compose apps: the service to attach the domain to (e.g. "server"). Default: "server".' },
|
||||
service: { type: 'STRING', description: 'For compose apps OR services: the inner app/service name to attach the domain to (e.g. "server", "twenty"). Default: auto-pick first non-worker app.' },
|
||||
port: { type: 'NUMBER', description: 'Required for services that need a fixed upstream port (Twenty CRM = 3000, n8n = 5678, Ghost = 2368). Look up via apps_templates_search.' },
|
||||
},
|
||||
required: ['uuid', 'domains'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user