Add webhook auto-deploy: Gitea push → Coolify build

- 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>
This commit is contained in:
2026-02-27 13:27:14 -06:00
parent 5119c94b37
commit dc8f392ff5
2 changed files with 468 additions and 34 deletions

View File

@@ -28,6 +28,62 @@ async function resolveServer(config, serverName) {
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 {
@@ -39,6 +95,25 @@ function wrap(fn) {
};
}
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)
@@ -171,6 +246,9 @@ router.get('/next-port', wrap(async () => {
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';
@@ -205,24 +283,70 @@ router.post('/routes', wrap(async (req) => {
return { added: false, reason: 'route_exists' };
}
// Insert router before middlewares section, service before end of services section
let newYml;
const middlewaresSplit = current.split(' middlewares:');
const servicesSplit = current.split(' services:');
if (middlewaresSplit.length === 2 && servicesSplit.length === 2) {
// Insert router block before middlewares, service block at end of services
newYml = middlewaresSplit[0].trimEnd() + '\n\n' + routerBlock + '\n\n middlewares:' + middlewaresSplit[1];
// Now add service at the end
newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n';
} else if (servicesSplit.length === 2) {
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
} else {
newYml = current.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n';
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 { added: true };
return { removed: true, routeName };
}));
// POST /drift — check drift between coolify.json and live Coolify state
@@ -262,7 +386,8 @@ router.post('/drift', wrap(async (req) => {
}
const repoName = appConfig.repo || basename(projectPath);
const expectedGitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`;
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 {
@@ -311,7 +436,8 @@ router.post('/upsert', wrap(async (req) => {
if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`);
const repoName = appConfig.repo || basename(projectPath);
const gitRepo = `git@${config.coolify?.sshHost}:2004/${config.owner}/${repoName}.git`;
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';
@@ -455,7 +581,7 @@ router.post('/upsert', wrap(async (req) => {
const traefikPath = config.coolify?.traefikPath;
const currentYml = await sshExec(config, `sudo cat "${traefikPath}"`);
if (currentYml.includes(appConfig.domain)) {
if (currentYml.includes('Host(`' + appConfig.domain + '`)')) {
step('route', 'done', `Traefik route already exists for ${appConfig.domain}`);
} else {
const routerBlock = [
@@ -482,18 +608,7 @@ router.post('/upsert', wrap(async (req) => {
` - url: "http://${server.ip}:${appConfig.port}"`,
].join('\n');
// Insert router before middlewares section, service at end
let newYml;
const middlewaresSplit = currentYml.split(' middlewares:');
const servicesSplit = currentYml.split(' services:');
if (middlewaresSplit.length === 2 && servicesSplit.length === 2) {
newYml = middlewaresSplit[0].trimEnd() + '\n\n' + routerBlock + '\n\n middlewares:' + middlewaresSplit[1];
newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n';
} else if (servicesSplit.length === 2) {
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
} else {
newYml = currentYml.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\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`);
@@ -526,7 +641,213 @@ router.post('/upsert', wrap(async (req) => {
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;