- Add webhook endpoints (setup/teardown/status) for batch management - Add step 10 (webhook) to upsert pipeline for automatic setup - Add DELETE /routes endpoint for Traefik route removal - Add giteaFetch helper for Gitea API calls - Document webhook flow, CIFS hooks fix, IPv6 healthcheck gotcha - Update port allocation table (all 11 apps deployed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
854 lines
33 KiB
JavaScript
854 lines
33 KiB
JavaScript
import { Router } from 'express';
|
|
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
|
import { join, basename } from 'path';
|
|
import { loadConfig } from '../lib/config.js';
|
|
import { coolifyFetch } from '../lib/coolify-client.js';
|
|
import { sshExec } from '../lib/ssh.js';
|
|
|
|
const router = Router();
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────
|
|
|
|
function readCoolifyJson(projectPath) {
|
|
const cfgPath = join(projectPath, 'coolify.json');
|
|
if (!existsSync(cfgPath)) return null;
|
|
const parsed = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
return Array.isArray(parsed) ? parsed : [parsed];
|
|
}
|
|
|
|
async function resolveServer(config, serverName) {
|
|
if (!serverName) {
|
|
return { uuid: config.coolify?.serverUuid, ip: config.coolify?.targetIp || '192.168.69.5', isLocal: false };
|
|
}
|
|
const servers = await coolifyFetch(config, '/servers');
|
|
const match = servers.find(s => s.name === serverName);
|
|
if (!match) throw new Error(`Server "${serverName}" not found in Coolify. Available: ${servers.map(s => s.name).join(', ')}`);
|
|
const isLocal = match.ip === 'host.docker.internal' || match.name === 'localhost';
|
|
const ip = isLocal ? (config.coolify?.sshHost || '192.168.69.4') : match.ip;
|
|
return { uuid: match.uuid, ip, isLocal, name: match.name };
|
|
}
|
|
|
|
/**
|
|
* Insert a Traefik route into custom.yml safely.
|
|
* Parses the three top-level sections (routers, middlewares, services) by
|
|
* finding lines that match /^ \w+:$/ (exactly 2-space indent, no deeper).
|
|
* This avoids the substring-match bug where " middlewares:" inside a
|
|
* router definition gets confused with the " middlewares:" section header.
|
|
*
|
|
* Returns the new YAML string, or throws if structure is unrecognized.
|
|
*/
|
|
function insertTraefikRoute(currentYml, routerBlock, serviceBlock) {
|
|
const lines = currentYml.split('\n');
|
|
|
|
// Find exact line indices for the 3 top-level sections under http:
|
|
// They are always indented with exactly 2 spaces: " routers:", " middlewares:", " services:"
|
|
let routersLine = -1, middlewaresLine = -1, servicesLine = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (/^ routers:\s*$/.test(line)) routersLine = i;
|
|
else if (/^ middlewares:\s*$/.test(line)) middlewaresLine = i;
|
|
else if (/^ services:\s*$/.test(line)) servicesLine = i;
|
|
}
|
|
|
|
if (routersLine === -1 || servicesLine === -1) {
|
|
throw new Error('Traefik custom.yml missing required sections (routers/services). Aborting to avoid corruption.');
|
|
}
|
|
|
|
// Insert router block: just before middlewares (if exists) or just before services
|
|
const insertRouterBefore = middlewaresLine !== -1 ? middlewaresLine : servicesLine;
|
|
|
|
// Build new lines array
|
|
const before = lines.slice(0, insertRouterBefore);
|
|
// Remove trailing blank lines from the routers section
|
|
while (before.length > 0 && before[before.length - 1].trim() === '') before.pop();
|
|
before.push(''); // single blank line separator
|
|
before.push(...routerBlock.split('\n'));
|
|
before.push(''); // blank line before next section
|
|
|
|
const middle = lines.slice(insertRouterBefore, servicesLine);
|
|
const servicesSection = lines.slice(servicesLine);
|
|
// Remove trailing blank lines from services
|
|
while (servicesSection.length > 0 && servicesSection[servicesSection.length - 1].trim() === '') servicesSection.pop();
|
|
servicesSection.push(''); // blank line before new service
|
|
servicesSection.push(...serviceBlock.split('\n'));
|
|
servicesSection.push(''); // trailing newline
|
|
|
|
const newLines = [...before, ...middle, ...servicesSection];
|
|
const result = newLines.join('\n');
|
|
|
|
// Sanity check: verify the 3 sections still exist
|
|
if (!result.includes(' routers:') || !result.includes(' services:')) {
|
|
throw new Error('Traefik YAML validation failed after insertion. Aborting to avoid corruption.');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function wrap(fn) {
|
|
return async (req, res) => {
|
|
try {
|
|
const result = await fn(req, res);
|
|
if (!res.headersSent) res.json(result);
|
|
} catch (err) {
|
|
res.status(err.status || 500).json({ error: err.message });
|
|
}
|
|
};
|
|
}
|
|
|
|
async function giteaFetch(config, path, options = {}) {
|
|
const giteaUrl = config.gitea?.url || `http://${config.coolify?.sshHost}:2004`;
|
|
const giteaToken = config.gitea?.token || config.token;
|
|
const res = await fetch(`${giteaUrl}/api/v1${path}`, {
|
|
...options,
|
|
headers: {
|
|
'Authorization': `token ${giteaToken}`,
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Gitea API ${res.status}: ${text}`);
|
|
}
|
|
const text = await res.text();
|
|
return text ? JSON.parse(text) : null;
|
|
}
|
|
|
|
// ─── Routes ────────────────────────────────────────────────────────
|
|
|
|
// GET /apps — list all Coolify apps (enriched with HOST_PORT)
|
|
router.get('/apps', wrap(async () => {
|
|
const config = loadConfig();
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
const enriched = await Promise.all(apps.map(async (app) => {
|
|
try {
|
|
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
|
|
const hostPort = envs.find(e => e.key === 'HOST_PORT');
|
|
return { ...app, _host_port: hostPort ? hostPort.value : null, _envs: envs };
|
|
} catch {
|
|
return { ...app, _host_port: null, _envs: [] };
|
|
}
|
|
}));
|
|
return enriched;
|
|
}));
|
|
|
|
// GET /apps/find/:name — find app by name
|
|
router.get('/apps/find/:name', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
return apps.find(a => a.name === req.params.name) || null;
|
|
}));
|
|
|
|
// POST /apps — create a new Coolify application
|
|
router.post('/apps', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { name, buildpack, gitRepo, gitBranch, isStatic, publishDir, portsExposes, baseDirectory, dockerComposeLocation, serverUuid } = req.body;
|
|
const body = {
|
|
project_uuid: config.coolify?.projectUuid,
|
|
server_uuid: serverUuid || config.coolify?.serverUuid,
|
|
environment_name: 'production',
|
|
name,
|
|
git_repository: gitRepo,
|
|
git_branch: gitBranch || 'master',
|
|
build_pack: buildpack || 'nixpacks',
|
|
ports_exposes: portsExposes || '3000',
|
|
instant_deploy: false,
|
|
private_key_uuid: config.coolify?.privateKeyUuid || 'j0w08woc8s8c0sgok8ccow4w',
|
|
base_directory: baseDirectory || '/',
|
|
};
|
|
if (buildpack === 'dockercompose') {
|
|
body.docker_compose_location = dockerComposeLocation || '/docker-compose.yml';
|
|
}
|
|
if (isStatic) {
|
|
body.is_static = true;
|
|
body.publish_directory = publishDir || 'dist';
|
|
}
|
|
return coolifyFetch(config, '/applications/private-deploy-key', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body)
|
|
});
|
|
}));
|
|
|
|
// PATCH /apps/:uuid — update an existing app
|
|
router.patch('/apps/:uuid', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
return coolifyFetch(config, `/applications/${req.params.uuid}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(req.body)
|
|
});
|
|
}));
|
|
|
|
// DELETE /apps/:uuid — delete an app
|
|
router.delete('/apps/:uuid', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
return coolifyFetch(config, `/applications/${req.params.uuid}`, { method: 'DELETE' });
|
|
}));
|
|
|
|
// GET /apps/:uuid/envs — list env vars for an app
|
|
router.get('/apps/:uuid/envs', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
return coolifyFetch(config, `/applications/${req.params.uuid}/envs`);
|
|
}));
|
|
|
|
// POST /apps/:uuid/envs — set an env var
|
|
router.post('/apps/:uuid/envs', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { key, value } = req.body;
|
|
return coolifyFetch(config, `/applications/${req.params.uuid}/envs`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ key, value, is_preview: false })
|
|
});
|
|
}));
|
|
|
|
// POST /apps/:uuid/deploy — trigger deployment
|
|
router.post('/apps/:uuid/deploy', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
return coolifyFetch(config, `/deploy?uuid=${req.params.uuid}&force=true`);
|
|
}));
|
|
|
|
// GET /servers — list all Coolify servers
|
|
router.get('/servers', wrap(async () => {
|
|
const config = loadConfig();
|
|
return coolifyFetch(config, '/servers');
|
|
}));
|
|
|
|
// GET /next-port — get next available HOST_PORT
|
|
router.get('/next-port', wrap(async () => {
|
|
const config = loadConfig();
|
|
const ports = [];
|
|
|
|
try {
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
for (const app of apps) {
|
|
try {
|
|
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
|
|
const hp = envs.find(e => e.key === 'HOST_PORT');
|
|
if (hp && hp.value) ports.push(parseInt(hp.value, 10));
|
|
} catch { /* skip */ }
|
|
}
|
|
} catch { /* fallback */ }
|
|
|
|
try {
|
|
const traefikPath = config.coolify?.traefikPath;
|
|
const raw = await sshExec(config, `sudo cat "${traefikPath}"`);
|
|
const portMatches = raw.match(/http:\/\/192\.168\.69\.\d+:(\d+)/g) || [];
|
|
for (const m of portMatches) {
|
|
const p = parseInt(m.match(/:(\d+)$/)[1], 10);
|
|
if (!ports.includes(p)) ports.push(p);
|
|
}
|
|
} catch { /* SSH may not be configured */ }
|
|
|
|
const maxPort = ports.length > 0 ? Math.max(...ports) : 2006;
|
|
return { nextPort: maxPort + 1, usedPorts: ports.sort((a, b) => a - b) };
|
|
}));
|
|
|
|
// POST /routes — add a Traefik route via SSH
|
|
router.post('/routes', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { routeName, domain, port } = req.body;
|
|
if (!routeName || !domain || !port) {
|
|
return { error: 'Missing required fields: routeName, domain, port' };
|
|
}
|
|
const traefikPath = config.coolify?.traefikPath;
|
|
const targetIp = config.coolify?.targetIp || '192.168.69.5';
|
|
|
|
const current = await sshExec(config, `sudo cat "${traefikPath}"`);
|
|
|
|
const routerBlock = [
|
|
` ${routeName}:`,
|
|
` rule: "Host(\`${domain}\`)"`,
|
|
` entryPoints:`,
|
|
` - https`,
|
|
` service: ${routeName}`,
|
|
` tls:`,
|
|
` certResolver: letsencrypt`,
|
|
``,
|
|
` ${routeName}-http:`,
|
|
` rule: "Host(\`${domain}\`)"`,
|
|
` entryPoints:`,
|
|
` - http`,
|
|
` middlewares:`,
|
|
` - redirect-to-https`,
|
|
` service: ${routeName}`,
|
|
].join('\n');
|
|
|
|
const serviceBlock = [
|
|
` ${routeName}:`,
|
|
` loadBalancer:`,
|
|
` servers:`,
|
|
` - url: "http://${targetIp}:${port}"`
|
|
].join('\n');
|
|
|
|
if (current.includes(`${routeName}:`) && current.includes(`${targetIp}:${port}`)) {
|
|
return { added: false, reason: 'route_exists' };
|
|
}
|
|
|
|
const newYml = insertTraefikRoute(current, routerBlock, serviceBlock);
|
|
const b64 = Buffer.from(newYml).toString('base64');
|
|
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
|
return { added: true };
|
|
}));
|
|
|
|
// DELETE /routes — remove a Traefik route by routeName
|
|
router.delete('/routes', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { routeName } = req.body;
|
|
if (!routeName) {
|
|
return { error: 'Missing required field: routeName' };
|
|
}
|
|
const traefikPath = config.coolify?.traefikPath;
|
|
const current = await sshExec(config, `sudo cat "${traefikPath}"`);
|
|
|
|
if (!current.includes(` ${routeName}:`)) {
|
|
return { removed: false, reason: 'route_not_found' };
|
|
}
|
|
|
|
const lines = current.split('\n');
|
|
const filtered = [];
|
|
let skip = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trimStart();
|
|
const indent = line.length - trimmed.length;
|
|
|
|
// Match routeName: or routeName-http: at 4-space indent (router/service level)
|
|
if (indent === 4 && (trimmed === routeName + ':' || trimmed.startsWith(routeName + ':') || trimmed === routeName + '-http:' || trimmed.startsWith(routeName + '-http:'))) {
|
|
skip = true;
|
|
continue;
|
|
}
|
|
|
|
if (skip) {
|
|
if (line.trim() === '' || indent > 4) continue;
|
|
skip = false;
|
|
}
|
|
|
|
filtered.push(line);
|
|
}
|
|
|
|
// Clean up double blank lines
|
|
const cleaned = [];
|
|
for (let i = 0; i < filtered.length; i++) {
|
|
if (filtered[i].trim() === '' && i > 0 && cleaned.length > 0 && cleaned[cleaned.length - 1].trim() === '') continue;
|
|
cleaned.push(filtered[i]);
|
|
}
|
|
|
|
const newYml = cleaned.join('\n');
|
|
|
|
// Validate structure
|
|
if (!newYml.includes(' routers:') || !newYml.includes(' services:')) {
|
|
throw new Error('Traefik YAML validation failed after removal. Aborting to avoid corruption.');
|
|
}
|
|
// Verify route is gone
|
|
if (newYml.includes(` ${routeName}:`)) {
|
|
throw new Error(`Route ${routeName} still found after removal. Aborting.`);
|
|
}
|
|
|
|
const b64 = Buffer.from(newYml).toString('base64');
|
|
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
|
return { removed: true, routeName };
|
|
}));
|
|
|
|
// POST /drift — check drift between coolify.json and live Coolify state
|
|
router.post('/drift', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { projectPath, appName } = req.body;
|
|
|
|
const configs = readCoolifyJson(projectPath);
|
|
if (!configs || configs.length === 0) return { exists: false, diffs: [], configs: [] };
|
|
|
|
const appConfig = appName ? configs.find(c => c.name === appName) : configs[0];
|
|
if (!appConfig) return { exists: false, diffs: [], configs };
|
|
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
const app = apps.find(a => a.name === appConfig.name);
|
|
if (!app) return { exists: false, diffs: [], appConfig, configs };
|
|
|
|
const diffs = [];
|
|
const compare = (field, localVal, remoteVal, label) => {
|
|
if (String(localVal || '') !== String(remoteVal || '')) {
|
|
diffs.push({ field, label, local: localVal, remote: remoteVal });
|
|
}
|
|
};
|
|
|
|
compare('buildpack', appConfig.buildpack, app.build_pack, 'Buildpack');
|
|
compare('branch', appConfig.branch || 'master', app.git_branch, 'Branch');
|
|
compare('baseDirectory', appConfig.baseDirectory || '/', app.base_directory, 'Base directory');
|
|
|
|
if (appConfig.buildpack === 'dockercompose') {
|
|
compare('dockerComposeLocation', appConfig.dockerComposeLocation || '/docker-compose.yml', app.docker_compose_location, 'Compose file');
|
|
}
|
|
if (appConfig.buildpack === 'nixpacks') {
|
|
compare('static', appConfig.static ? 'true' : 'false', app.is_static ? 'true' : 'false', 'Static site');
|
|
if (appConfig.static) {
|
|
compare('publishDir', appConfig.publishDir || 'dist', app.publish_directory, 'Publish directory');
|
|
}
|
|
}
|
|
|
|
const repoName = appConfig.repo || basename(projectPath);
|
|
const gitOwner = appConfig.gitOwner || config.owner;
|
|
const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${gitOwner}/${repoName}.git`;
|
|
compare('repo', expectedGitRepo, app.git_repository, 'Git repository');
|
|
|
|
try {
|
|
const envs = await coolifyFetch(config, `/applications/${app.uuid}/envs`);
|
|
const hostPort = envs.find(e => e.key === 'HOST_PORT');
|
|
if (hostPort) {
|
|
compare('port', String(appConfig.port), hostPort.value, 'HOST_PORT');
|
|
} else if (appConfig.port) {
|
|
diffs.push({ field: 'port', label: 'HOST_PORT', local: String(appConfig.port), remote: '(not set)' });
|
|
}
|
|
} catch { /* envs may not be available */ }
|
|
|
|
if (appConfig.domain && app.fqdn) {
|
|
const expectedFqdn = `https://${appConfig.domain}`;
|
|
compare('domain', expectedFqdn, app.fqdn, 'Domain (FQDN)');
|
|
}
|
|
|
|
return { exists: true, app: { uuid: app.uuid, name: app.name }, diffs, appConfig, configs };
|
|
}));
|
|
|
|
// GET /config?path=<projectPath> — read coolify.json from a project directory
|
|
router.get('/config', wrap(async (req) => {
|
|
const projectPath = req.query.path;
|
|
if (!projectPath) throw Object.assign(new Error('Missing "path" query parameter'), { status: 400 });
|
|
return readCoolifyJson(projectPath) || { error: 'No coolify.json found' };
|
|
}));
|
|
|
|
// POST /upsert — full pipeline: read config → find/create app → env → route → deploy
|
|
router.post('/upsert', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { projectPath, appName } = req.body;
|
|
const steps = [];
|
|
const changelogChanges = [];
|
|
|
|
const step = (name, status, detail) => {
|
|
const idx = steps.findIndex(s => s.step === name);
|
|
const entry = { step: name, status, detail };
|
|
if (idx >= 0) steps[idx] = entry; else steps.push(entry);
|
|
};
|
|
|
|
// Step 1: Read coolify.json
|
|
step('config', 'running', 'Reading coolify.json...');
|
|
const configs = readCoolifyJson(projectPath);
|
|
if (!configs || configs.length === 0) throw new Error('No coolify.json found in project');
|
|
const appConfig = appName ? configs.find(c => c.name === appName) : configs[0];
|
|
if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`);
|
|
|
|
const repoName = appConfig.repo || basename(projectPath);
|
|
const gitOwner = appConfig.gitOwner || config.owner;
|
|
const gitRepo = `git@${config.coolify?.sshHost}:2004/${gitOwner}/${repoName}.git`;
|
|
const branch = appConfig.branch || 'master';
|
|
const baseDir = appConfig.baseDirectory || '/';
|
|
const composeLocation = appConfig.dockerComposeLocation || '/docker-compose.yml';
|
|
|
|
step('config', 'done', `name=${appConfig.name} buildpack=${appConfig.buildpack} port=${appConfig.port}`);
|
|
|
|
// Step 2: Resolve server
|
|
step('server', 'running', `Resolving server "${appConfig.server || 'default'}"...`);
|
|
const server = await resolveServer(config, appConfig.server);
|
|
step('server', 'done', `uuid=${server.uuid} ip=${server.ip} local=${server.isLocal}`);
|
|
|
|
// Step 3: Check if app exists
|
|
step('check', 'running', 'Searching Coolify applications...');
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
let existing = apps.find(a => a.name === appConfig.name);
|
|
|
|
if (existing) {
|
|
const detail = await coolifyFetch(config, `/applications/${existing.uuid}`);
|
|
existing = detail;
|
|
step('check', 'done', `FOUND — will UPDATE uuid=${existing.uuid}`);
|
|
} else {
|
|
step('check', 'done', `NOT FOUND — will CREATE`);
|
|
}
|
|
|
|
// Step 4: Validate
|
|
step('validate', 'running', 'Comparing config...');
|
|
if (existing) {
|
|
const check = (field, localVal, remoteVal, label) => {
|
|
if (String(localVal ?? '') !== String(remoteVal ?? '')) {
|
|
changelogChanges.push({ field, label, old: remoteVal, new: localVal });
|
|
}
|
|
};
|
|
check('build_pack', appConfig.buildpack, existing.build_pack, 'Buildpack');
|
|
check('git_repository', gitRepo, existing.git_repository, 'Git repository');
|
|
check('git_branch', branch, existing.git_branch, 'Branch');
|
|
check('base_directory', baseDir, existing.base_directory, 'Base directory');
|
|
if (appConfig.buildpack === 'dockercompose') {
|
|
check('docker_compose_location', composeLocation, existing.docker_compose_location, 'Compose file');
|
|
}
|
|
if (appConfig.buildpack === 'nixpacks') {
|
|
check('is_static', appConfig.static || false, existing.is_static || false, 'Static site');
|
|
if (appConfig.static) check('publish_directory', appConfig.publishDir || 'dist', existing.publish_directory, 'Publish dir');
|
|
}
|
|
}
|
|
step('validate', 'done', changelogChanges.length > 0 ? `${changelogChanges.length} field(s) will change` : existing ? 'No changes needed' : 'New app');
|
|
|
|
// Step 5: Create or Update
|
|
let appUuid;
|
|
if (existing) {
|
|
step('sync', 'running', `Updating ${existing.uuid}...`);
|
|
const updates = {
|
|
build_pack: appConfig.buildpack,
|
|
git_repository: gitRepo,
|
|
git_branch: branch,
|
|
base_directory: baseDir,
|
|
is_static: appConfig.static || false,
|
|
publish_directory: appConfig.publishDir || (appConfig.static ? 'dist' : '/'),
|
|
ports_exposes: '3000'
|
|
};
|
|
if (appConfig.buildpack === 'dockercompose') updates.docker_compose_location = composeLocation;
|
|
await coolifyFetch(config, `/applications/${existing.uuid}`, { method: 'PATCH', body: JSON.stringify(updates) });
|
|
appUuid = existing.uuid;
|
|
step('sync', 'done', `UPDATED ${appUuid}`);
|
|
} else {
|
|
step('sync', 'running', 'Creating new app...');
|
|
const body = {
|
|
project_uuid: config.coolify?.projectUuid,
|
|
server_uuid: server.uuid,
|
|
environment_name: 'production',
|
|
name: appConfig.name,
|
|
git_repository: gitRepo,
|
|
git_branch: branch,
|
|
build_pack: appConfig.buildpack || 'nixpacks',
|
|
base_directory: baseDir,
|
|
ports_exposes: '3000',
|
|
instant_deploy: false,
|
|
private_key_uuid: config.coolify?.privateKeyUuid || 'j0w08woc8s8c0sgok8ccow4w'
|
|
};
|
|
if (appConfig.buildpack === 'dockercompose') body.docker_compose_location = composeLocation;
|
|
const created = await coolifyFetch(config, '/applications/private-deploy-key', { method: 'POST', body: JSON.stringify(body) });
|
|
appUuid = created.uuid;
|
|
changelogChanges.push({ field: '_action', label: 'Action', old: null, new: 'CREATED' });
|
|
|
|
// Post-create PATCH
|
|
const postPatch = {};
|
|
if (appConfig.static) {
|
|
postPatch.is_static = true;
|
|
postPatch.publish_directory = appConfig.publishDir || '/dist';
|
|
}
|
|
if (appConfig.buildpack !== 'dockercompose') postPatch.domains = '';
|
|
if (Object.keys(postPatch).length > 0) {
|
|
await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify(postPatch) });
|
|
}
|
|
step('sync', 'done', `CREATED ${appUuid}`);
|
|
}
|
|
|
|
// Step 6: Set HOST_PORT env var
|
|
step('env', 'running', `Setting HOST_PORT=${appConfig.port}...`);
|
|
let existingEnvs = [];
|
|
try { existingEnvs = await coolifyFetch(config, `/applications/${appUuid}/envs`); } catch { /* ok */ }
|
|
const existingHostPort = existingEnvs.find(e => e.key === 'HOST_PORT');
|
|
|
|
if (existingHostPort && existingHostPort.value === String(appConfig.port)) {
|
|
step('env', 'done', `HOST_PORT=${appConfig.port} (unchanged)`);
|
|
} else {
|
|
if (existingHostPort) {
|
|
changelogChanges.push({ field: 'HOST_PORT', label: 'HOST_PORT', old: existingHostPort.value, new: String(appConfig.port) });
|
|
try {
|
|
await coolifyFetch(config, `/applications/${appUuid}/envs/${existingHostPort.id}`, {
|
|
method: 'PATCH', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
|
|
});
|
|
} catch {
|
|
try { await coolifyFetch(config, `/applications/${appUuid}/envs/${existingHostPort.id}`, { method: 'DELETE' }); } catch { /* ok */ }
|
|
await coolifyFetch(config, `/applications/${appUuid}/envs`, {
|
|
method: 'POST', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
|
|
});
|
|
}
|
|
} else {
|
|
changelogChanges.push({ field: 'HOST_PORT', label: 'HOST_PORT', old: null, new: String(appConfig.port) });
|
|
await coolifyFetch(config, `/applications/${appUuid}/envs`, {
|
|
method: 'POST', body: JSON.stringify({ key: 'HOST_PORT', value: String(appConfig.port), is_preview: false })
|
|
});
|
|
}
|
|
step('env', 'done', `HOST_PORT=${appConfig.port} (set)`);
|
|
}
|
|
|
|
// Step 7: Routing
|
|
if (!appConfig.domain) {
|
|
step('route', 'skipped', 'No domain configured');
|
|
} else if (server.isLocal) {
|
|
await coolifyFetch(config, `/applications/${appUuid}`, {
|
|
method: 'PATCH', body: JSON.stringify({ fqdn: `https://${appConfig.domain}` })
|
|
});
|
|
step('route', 'done', `FQDN set: https://${appConfig.domain}`);
|
|
} else {
|
|
if (appConfig.buildpack !== 'dockercompose') {
|
|
try { await coolifyFetch(config, `/applications/${appUuid}`, { method: 'PATCH', body: JSON.stringify({ domains: '' }) }); } catch { /* ok */ }
|
|
}
|
|
|
|
const routeName = appConfig.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
const traefikPath = config.coolify?.traefikPath;
|
|
const currentYml = await sshExec(config, `sudo cat "${traefikPath}"`);
|
|
|
|
if (currentYml.includes('Host(`' + appConfig.domain + '`)')) {
|
|
step('route', 'done', `Traefik route already exists for ${appConfig.domain}`);
|
|
} else {
|
|
const routerBlock = [
|
|
` ${routeName}:`,
|
|
` rule: "Host(\`${appConfig.domain}\`)"`,
|
|
` entryPoints:`,
|
|
` - https`,
|
|
` service: ${routeName}`,
|
|
` tls:`,
|
|
` certResolver: letsencrypt`,
|
|
``,
|
|
` ${routeName}-http:`,
|
|
` rule: "Host(\`${appConfig.domain}\`)"`,
|
|
` entryPoints:`,
|
|
` - http`,
|
|
` middlewares:`,
|
|
` - redirect-to-https`,
|
|
` service: ${routeName}`,
|
|
].join('\n');
|
|
const serviceBlock = [
|
|
` ${routeName}:`,
|
|
` loadBalancer:`,
|
|
` servers:`,
|
|
` - url: "http://${server.ip}:${appConfig.port}"`,
|
|
].join('\n');
|
|
|
|
const newYml = insertTraefikRoute(currentYml, routerBlock, serviceBlock);
|
|
|
|
const b64 = Buffer.from(newYml).toString('base64');
|
|
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
|
changelogChanges.push({ field: 'traefik_route', label: 'Traefik route', old: null, new: `${appConfig.domain} → ${server.ip}:${appConfig.port}` });
|
|
step('route', 'done', `Route added: ${appConfig.domain} → ${server.ip}:${appConfig.port}`);
|
|
}
|
|
}
|
|
|
|
// Step 8: Changelog
|
|
step('changelog', 'running', 'Writing changelog...');
|
|
const changelogPath = join(projectPath, 'coolify.changelog.json');
|
|
let changelog = [];
|
|
if (existsSync(changelogPath)) {
|
|
try { changelog = JSON.parse(readFileSync(changelogPath, 'utf8')); } catch { changelog = []; }
|
|
}
|
|
const entry = {
|
|
timestamp: new Date().toISOString(),
|
|
action: existing ? 'update' : 'create',
|
|
appName: appConfig.name,
|
|
uuid: appUuid,
|
|
changes: changelogChanges,
|
|
configSnapshot: { ...appConfig },
|
|
};
|
|
changelog.push(entry);
|
|
writeFileSync(changelogPath, JSON.stringify(changelog, null, 2), 'utf8');
|
|
step('changelog', 'done', `${changelogChanges.length} change(s) logged`);
|
|
|
|
// Step 9: Deploy
|
|
step('deploy', 'running', 'Triggering deployment...');
|
|
const deployResult = await coolifyFetch(config, `/deploy?uuid=${appUuid}&force=true`);
|
|
step('deploy', 'done', `Deployment triggered — ${appConfig.domain ? `https://${appConfig.domain}` : appUuid}`);
|
|
|
|
// Step 10: Webhook — set Coolify secret + create Gitea webhook
|
|
const webhookSecret = config.coolify?.webhookSecret;
|
|
if (webhookSecret) {
|
|
step('webhook', 'running', 'Configuring auto-deploy webhook...');
|
|
const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010';
|
|
const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`;
|
|
const giteaOwner = appConfig.gitOwner || config.owner || 'clint';
|
|
|
|
try {
|
|
// Set secret on Coolify app
|
|
await coolifyFetch(config, `/applications/${appUuid}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ manual_webhook_secret_gitea: webhookSecret }),
|
|
});
|
|
|
|
// Create/update Gitea webhook
|
|
const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`);
|
|
const existing = hooks.find(h => h.config?.url === webhookUrl);
|
|
if (existing) {
|
|
await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${existing.id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
config: { url: webhookUrl, content_type: 'json', secret: webhookSecret },
|
|
active: true,
|
|
}),
|
|
});
|
|
step('webhook', 'done', `Webhook updated (hook ${existing.id}) → auto-deploy on push`);
|
|
} else {
|
|
const created = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'gitea',
|
|
active: true,
|
|
config: { url: webhookUrl, content_type: 'json', secret: webhookSecret },
|
|
events: ['push'],
|
|
}),
|
|
});
|
|
step('webhook', 'done', `Webhook created (hook ${created.id}) → auto-deploy on push`);
|
|
}
|
|
} catch (err) {
|
|
step('webhook', 'done', `Warning: webhook setup failed — ${err.message}`);
|
|
}
|
|
} else {
|
|
step('webhook', 'skipped', 'No webhookSecret in config — skipping auto-deploy setup');
|
|
}
|
|
|
|
return { success: true, uuid: appUuid, domain: appConfig.domain, changelogEntry: entry, steps };
|
|
}));
|
|
|
|
// ─── Webhook Management ─────────────────────────────────────────
|
|
|
|
// POST /webhooks/setup — configure webhooks on Coolify apps + Gitea repos
|
|
// Sets manual_webhook_secret_gitea on each Coolify app, then creates/updates
|
|
// a Gitea webhook on the corresponding repo pointing to Coolify's internal URL.
|
|
router.post('/webhooks/setup', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { appName } = req.body; // optional — if omitted, sets up ALL apps
|
|
const webhookSecret = config.coolify?.webhookSecret;
|
|
if (!webhookSecret) throw new Error('Missing coolify.webhookSecret in config.json');
|
|
|
|
const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010';
|
|
const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`;
|
|
const giteaOwner = config.owner || 'clint';
|
|
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
const targets = appName ? apps.filter(a => a.name === appName) : apps;
|
|
const results = [];
|
|
|
|
for (const app of targets) {
|
|
const result = { app: app.name, uuid: app.uuid, coolify: null, gitea: null };
|
|
|
|
// 1. Set webhook secret on Coolify app
|
|
try {
|
|
await coolifyFetch(config, `/applications/${app.uuid}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ manual_webhook_secret_gitea: webhookSecret }),
|
|
});
|
|
result.coolify = 'secret_set';
|
|
} catch (err) {
|
|
result.coolify = `error: ${err.message}`;
|
|
}
|
|
|
|
// 2. Create/update webhook on Gitea repo
|
|
const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/);
|
|
if (!gitRepoMatch) {
|
|
result.gitea = 'skipped: no git repo configured';
|
|
results.push(result);
|
|
continue;
|
|
}
|
|
const repoName = gitRepoMatch[1];
|
|
|
|
try {
|
|
const existingHooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`);
|
|
const existing = existingHooks.find(h => h.config?.url === webhookUrl);
|
|
|
|
if (existing) {
|
|
await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${existing.id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
config: { url: webhookUrl, content_type: 'json', secret: webhookSecret },
|
|
active: true,
|
|
}),
|
|
});
|
|
result.gitea = `updated (hook ${existing.id})`;
|
|
} else {
|
|
const created = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'gitea',
|
|
active: true,
|
|
config: { url: webhookUrl, content_type: 'json', secret: webhookSecret },
|
|
events: ['push'],
|
|
}),
|
|
});
|
|
result.gitea = `created (hook ${created.id})`;
|
|
}
|
|
} catch (err) {
|
|
result.gitea = `error: ${err.message}`;
|
|
}
|
|
|
|
results.push(result);
|
|
}
|
|
|
|
return { webhookUrl, appsConfigured: results.length, results };
|
|
}));
|
|
|
|
// DELETE /webhooks/teardown — remove webhooks from all (or specified) apps
|
|
router.delete('/webhooks/teardown', wrap(async (req) => {
|
|
const config = loadConfig();
|
|
const { appName } = req.body;
|
|
const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010';
|
|
const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`;
|
|
const giteaOwner = config.owner || 'clint';
|
|
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
const targets = appName ? apps.filter(a => a.name === appName) : apps;
|
|
const results = [];
|
|
|
|
for (const app of targets) {
|
|
const result = { app: app.name, uuid: app.uuid, coolify: null, gitea: null };
|
|
|
|
try {
|
|
await coolifyFetch(config, `/applications/${app.uuid}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ manual_webhook_secret_gitea: '' }),
|
|
});
|
|
result.coolify = 'secret_cleared';
|
|
} catch (err) {
|
|
result.coolify = `error: ${err.message}`;
|
|
}
|
|
|
|
const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/);
|
|
if (!gitRepoMatch) {
|
|
result.gitea = 'skipped';
|
|
results.push(result);
|
|
continue;
|
|
}
|
|
const repoName = gitRepoMatch[1];
|
|
|
|
try {
|
|
const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks`);
|
|
const matching = hooks.filter(h => h.config?.url === webhookUrl);
|
|
for (const hook of matching) {
|
|
await giteaFetch(config, `/repos/${giteaOwner}/${repoName}/hooks/${hook.id}`, { method: 'DELETE' });
|
|
}
|
|
result.gitea = matching.length > 0 ? `removed ${matching.length} hook(s)` : 'none found';
|
|
} catch (err) {
|
|
result.gitea = `error: ${err.message}`;
|
|
}
|
|
|
|
results.push(result);
|
|
}
|
|
|
|
return { appsConfigured: results.length, results };
|
|
}));
|
|
|
|
// GET /webhooks/status — check webhook status across all apps
|
|
router.get('/webhooks/status', wrap(async () => {
|
|
const config = loadConfig();
|
|
const coolifyUrl = config.coolify?.apiUrl || 'http://192.168.69.4:2010';
|
|
const webhookUrl = `${coolifyUrl}/webhooks/source/gitea/events/manual`;
|
|
const giteaOwner = config.owner || 'clint';
|
|
|
|
const apps = await coolifyFetch(config, '/applications');
|
|
const results = [];
|
|
|
|
for (const app of apps) {
|
|
const result = { app: app.name, uuid: app.uuid, coolifySecretSet: false, giteaWebhookActive: false, repo: null };
|
|
|
|
try {
|
|
const detail = await coolifyFetch(config, `/applications/${app.uuid}`);
|
|
result.coolifySecretSet = !!detail.manual_webhook_secret_gitea;
|
|
} catch { /* skip */ }
|
|
|
|
const gitRepoMatch = app.git_repository?.match(/\/([^/]+?)\.git$/);
|
|
if (gitRepoMatch) {
|
|
result.repo = gitRepoMatch[1];
|
|
try {
|
|
const hooks = await giteaFetch(config, `/repos/${giteaOwner}/${result.repo}/hooks`);
|
|
result.giteaWebhookActive = hooks.some(h => h.config?.url === webhookUrl && h.active);
|
|
} catch { /* skip */ }
|
|
}
|
|
|
|
results.push(result);
|
|
}
|
|
|
|
return { webhookUrl, results };
|
|
}));
|
|
|
|
export default router;
|