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:
123
README.md
123
README.md
@@ -104,10 +104,19 @@ All endpoints return JSON. The Vite dev server proxies `/api/*` to the API serve
|
|||||||
| GET | `/api/coolify/servers` | List Coolify servers |
|
| GET | `/api/coolify/servers` | List Coolify servers |
|
||||||
| GET | `/api/coolify/next-port` | Get next available HOST_PORT |
|
| GET | `/api/coolify/next-port` | Get next available HOST_PORT |
|
||||||
| POST | `/api/coolify/routes` | Add Traefik route via SSH |
|
| POST | `/api/coolify/routes` | Add Traefik route via SSH |
|
||||||
|
| DELETE | `/api/coolify/routes` | Remove Traefik route via SSH |
|
||||||
| POST | `/api/coolify/drift` | Check drift between coolify.json and live state |
|
| POST | `/api/coolify/drift` | Check drift between coolify.json and live state |
|
||||||
| POST | `/api/coolify/upsert` | Full pipeline: config → create/update → env → route → deploy |
|
| POST | `/api/coolify/upsert` | Full pipeline: config → create/update → env → route → webhook → deploy |
|
||||||
| GET | `/api/coolify/config?path=...` | Read coolify.json from a project |
|
| GET | `/api/coolify/config?path=...` | Read coolify.json from a project |
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/coolify/webhooks/setup` | Set Coolify webhook secret + create Gitea webhook on all apps |
|
||||||
|
| DELETE | `/api/coolify/webhooks/teardown` | Remove webhook secret + delete Gitea webhooks |
|
||||||
|
| GET | `/api/coolify/webhooks/status` | Check webhook configuration status per app |
|
||||||
|
|
||||||
### System
|
### System
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -120,7 +129,7 @@ All endpoints return JSON. The Vite dev server proxies `/api/*` to the API serve
|
|||||||
|
|
||||||
### Coolify Config (`gitea.repo.management/config.json`)
|
### Coolify Config (`gitea.repo.management/config.json`)
|
||||||
|
|
||||||
Shared with the gitea.repo.management project. Contains Coolify API credentials, server UUIDs, SSH config:
|
Shared with the gitea.repo.management project. Contains Coolify API credentials, server UUIDs, SSH config, and webhook/Gitea settings:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -134,7 +143,13 @@ Shared with the gitea.repo.management project. Contains Coolify API credentials,
|
|||||||
"sshUser": "clint",
|
"sshUser": "clint",
|
||||||
"sshPassword": "<password>",
|
"sshPassword": "<password>",
|
||||||
"traefikPath": "/home/clint/containers/coolify/data/proxy/dynamic/custom.yml",
|
"traefikPath": "/home/clint/containers/coolify/data/proxy/dynamic/custom.yml",
|
||||||
"targetIp": "192.168.69.5"
|
"targetIp": "192.168.69.5",
|
||||||
|
"domain": "dotrepo.com",
|
||||||
|
"webhookSecret": "<shared-hmac-secret>"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"url": "http://192.168.69.4:2003",
|
||||||
|
"token": "<gitea-api-token>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -172,6 +187,7 @@ The `POST /api/coolify/upsert` endpoint runs the full deploy pipeline:
|
|||||||
7. **route** — Add Traefik route (remote) or set FQDN (local)
|
7. **route** — Add Traefik route (remote) or set FQDN (local)
|
||||||
8. **changelog** — Write coolify.changelog.json to repo
|
8. **changelog** — Write coolify.changelog.json to repo
|
||||||
9. **deploy** — Trigger Coolify deployment
|
9. **deploy** — Trigger Coolify deployment
|
||||||
|
10. **webhook** — Set Coolify webhook secret + create/update Gitea webhook for auto-deploy on push
|
||||||
|
|
||||||
## Docker Deploy Pipeline (Legacy/Direct)
|
## Docker Deploy Pipeline (Legacy/Direct)
|
||||||
|
|
||||||
@@ -201,9 +217,11 @@ The `POST /api/docker/deploy` endpoint handles tar-based deployment:
|
|||||||
| 2004 | Every Noise at Once | Deployed |
|
| 2004 | Every Noise at Once | Deployed |
|
||||||
| 2005 | Learn X In Y | Deployed |
|
| 2005 | Learn X In Y | Deployed |
|
||||||
| 2006 | Actual Budget Report | Deployed |
|
| 2006 | Actual Budget Report | Deployed |
|
||||||
| 2007 | dotrepo-timer | Pending |
|
| 2007 | dotrepo-timer | Deployed |
|
||||||
| 2008 | dotrepo-travel | Pending |
|
| 2008 | dotrepo-travel | Deployed |
|
||||||
| 2009 | dotrepo-racker | Deployed |
|
| 2009 | dotrepo-racker | Deployed |
|
||||||
|
| 2011 | spike-breakdown | Deployed |
|
||||||
|
| 2012 | dotrepo-site | Deployed |
|
||||||
|
|
||||||
## LLM / Agent Usage
|
## LLM / Agent Usage
|
||||||
|
|
||||||
@@ -231,3 +249,98 @@ curl "http://localhost:3100/api/servers/1771305558920/logs?remotePath=~/containe
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Always use this API** to access/change Coolify and Gitea deployment state. Do not call the Coolify API directly — this API wraps it with enrichment (HOST_PORT), safety (drift detection, changelog), and routing (Traefik management).
|
**Always use this API** to access/change Coolify and Gitea deployment state. Do not call the Coolify API directly — this API wraps it with enrichment (HOST_PORT), safety (drift detection, changelog), and routing (Traefik management).
|
||||||
|
|
||||||
|
## Webhook Auto-Deploy
|
||||||
|
|
||||||
|
Every `git push` to Gitea automatically triggers a Coolify deployment. The pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
git push → Gitea post-receive hook → Gitea webhook POST → Coolify webhook handler → Docker build + deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. Each Coolify app has a `manual_webhook_secret_gitea` — a shared HMAC-SHA256 secret
|
||||||
|
2. Each Gitea repo has a webhook pointing to `http://192.168.69.4:2010/webhooks/source/gitea/events/manual`
|
||||||
|
3. On push, Gitea signs the payload with the secret and POSTs to Coolify
|
||||||
|
4. Coolify validates the signature, matches the repo+branch, and queues a deployment
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Webhooks are automatically configured during `upsert` (step 10). To set up webhooks on all existing apps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up webhooks on all Coolify apps
|
||||||
|
curl -X POST http://localhost:3100/api/coolify/webhooks/setup
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
curl http://localhost:3100/api/coolify/webhooks/status
|
||||||
|
|
||||||
|
# Remove all webhooks
|
||||||
|
curl -X DELETE http://localhost:3100/api/coolify/webhooks/teardown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Details
|
||||||
|
|
||||||
|
- **Webhook URL must use internal IP** (`http://192.168.69.4:2010`), NOT the public URL (`https://coolify.clintmasden.duckdns.org`). The public URL fails due to hairpin NAT — Gitea and Coolify are on the same box (192.168.69.4).
|
||||||
|
- **Gitea ports**: 2003 = HTTP API, 2004 = SSH. Do not confuse them.
|
||||||
|
- **Branch**: All repos use `master` (not `main`). Coolify's `git_branch` must match. The upsert pipeline defaults to `master`.
|
||||||
|
|
||||||
|
## Gotchas and Troubleshooting
|
||||||
|
|
||||||
|
### Docker Healthcheck IPv6 Issue
|
||||||
|
|
||||||
|
Alpine Linux resolves `localhost` to `::1` (IPv6) before `127.0.0.1` (IPv4). If your container only listens on IPv4 (e.g., nginx on `0.0.0.0:3000`), healthchecks using `localhost` will fail silently.
|
||||||
|
|
||||||
|
**Fix**: Always use `127.0.0.1` instead of `localhost` in healthcheck commands:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
HEALTHCHECK CMD wget -qO- http://127.0.0.1:3000/ || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea CIFS Hooks Not Executable
|
||||||
|
|
||||||
|
Gitea's git data is stored on a CIFS/SMB mount (`//192.168.69.2/archive`). CIFS doesn't support per-file permissions — all files inherit `file_mode` from the mount options. If `file_mode=0664` (the default), git hooks won't execute because they lack the execute bit. This silently breaks:
|
||||||
|
|
||||||
|
- Webhook delivery (post-receive hook doesn't fire)
|
||||||
|
- Branch tracking (Gitea sees repos as "empty")
|
||||||
|
- All git server-side hooks
|
||||||
|
|
||||||
|
**Fix**: Change `file_mode=0664` to `file_mode=0775` in `/etc/fstab`:
|
||||||
|
|
||||||
|
```
|
||||||
|
//192.168.69.2/archive /home/clint/archive cifs ...,file_mode=0775,... 0 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Then remount: `sudo mount -o remount /home/clint/archive`
|
||||||
|
|
||||||
|
After remounting, run `resync_all_hooks` via Gitea admin API to regenerate hook files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "Authorization: token <token>" \
|
||||||
|
"http://192.168.69.4:2003/api/v1/admin/cron/resync_all_hooks"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea Default Branch Mismatch
|
||||||
|
|
||||||
|
All repos use `master` as their default branch. If Gitea's `default_branch` setting is `main` (the Gitea default for new repos), it will report the repo as `empty: true` and won't deliver webhooks for pushes to `master`.
|
||||||
|
|
||||||
|
**Fix**: Set `default_branch` to `master` via API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH -H "Authorization: token <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"http://192.168.69.4:2003/api/v1/repos/clint/<repo>" \
|
||||||
|
-d '{"default_branch":"master"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### dockerComposeLocation vs baseDirectory
|
||||||
|
|
||||||
|
In coolify.json, `dockerComposeLocation` is **relative to** `baseDirectory`. If your `baseDirectory` is `/myapp`, set `dockerComposeLocation` to `/docker-compose.yml`, NOT `/myapp/docker-compose.yml`.
|
||||||
|
|||||||
@@ -28,6 +28,62 @@ async function resolveServer(config, serverName) {
|
|||||||
return { uuid: match.uuid, ip, isLocal, name: match.name };
|
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) {
|
function wrap(fn) {
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
try {
|
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 ────────────────────────────────────────────────────────
|
// ─── Routes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /apps — list all Coolify apps (enriched with HOST_PORT)
|
// 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) => {
|
router.post('/routes', wrap(async (req) => {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const { routeName, domain, port } = req.body;
|
const { routeName, domain, port } = req.body;
|
||||||
|
if (!routeName || !domain || !port) {
|
||||||
|
return { error: 'Missing required fields: routeName, domain, port' };
|
||||||
|
}
|
||||||
const traefikPath = config.coolify?.traefikPath;
|
const traefikPath = config.coolify?.traefikPath;
|
||||||
const targetIp = config.coolify?.targetIp || '192.168.69.5';
|
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' };
|
return { added: false, reason: 'route_exists' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert router before middlewares section, service before end of services section
|
const newYml = insertTraefikRoute(current, routerBlock, serviceBlock);
|
||||||
let newYml;
|
const b64 = Buffer.from(newYml).toString('base64');
|
||||||
const middlewaresSplit = current.split(' middlewares:');
|
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
||||||
const servicesSplit = current.split(' services:');
|
return { added: true };
|
||||||
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];
|
// DELETE /routes — remove a Traefik route by routeName
|
||||||
// Now add service at the end
|
router.delete('/routes', wrap(async (req) => {
|
||||||
newYml = newYml.trimEnd() + '\n\n' + serviceBlock + '\n';
|
const config = loadConfig();
|
||||||
} else if (servicesSplit.length === 2) {
|
const { routeName } = req.body;
|
||||||
newYml = servicesSplit[0].trimEnd() + '\n' + routerBlock + '\n\n services:' + servicesSplit[1].trimEnd() + '\n' + serviceBlock + '\n';
|
if (!routeName) {
|
||||||
} else {
|
return { error: 'Missing required field: routeName' };
|
||||||
newYml = current.trimEnd() + '\n' + routerBlock + '\n' + serviceBlock + '\n';
|
}
|
||||||
|
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');
|
const b64 = Buffer.from(newYml).toString('base64');
|
||||||
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
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
|
// 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 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');
|
compare('repo', expectedGitRepo, app.git_repository, 'Git repository');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -311,7 +436,8 @@ router.post('/upsert', wrap(async (req) => {
|
|||||||
if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`);
|
if (!appConfig) throw new Error(`App "${appName}" not found in coolify.json`);
|
||||||
|
|
||||||
const repoName = appConfig.repo || basename(projectPath);
|
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 branch = appConfig.branch || 'master';
|
||||||
const baseDir = appConfig.baseDirectory || '/';
|
const baseDir = appConfig.baseDirectory || '/';
|
||||||
const composeLocation = appConfig.dockerComposeLocation || '/docker-compose.yml';
|
const composeLocation = appConfig.dockerComposeLocation || '/docker-compose.yml';
|
||||||
@@ -455,7 +581,7 @@ router.post('/upsert', wrap(async (req) => {
|
|||||||
const traefikPath = config.coolify?.traefikPath;
|
const traefikPath = config.coolify?.traefikPath;
|
||||||
const currentYml = await sshExec(config, `sudo cat "${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}`);
|
step('route', 'done', `Traefik route already exists for ${appConfig.domain}`);
|
||||||
} else {
|
} else {
|
||||||
const routerBlock = [
|
const routerBlock = [
|
||||||
@@ -482,18 +608,7 @@ router.post('/upsert', wrap(async (req) => {
|
|||||||
` - url: "http://${server.ip}:${appConfig.port}"`,
|
` - url: "http://${server.ip}:${appConfig.port}"`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
// Insert router before middlewares section, service at end
|
const newYml = insertTraefikRoute(currentYml, routerBlock, serviceBlock);
|
||||||
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 b64 = Buffer.from(newYml).toString('base64');
|
const b64 = Buffer.from(newYml).toString('base64');
|
||||||
await sshExec(config, `echo '${b64}' | base64 -d | sudo tee "${traefikPath}" > /dev/null`);
|
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`);
|
const deployResult = await coolifyFetch(config, `/deploy?uuid=${appUuid}&force=true`);
|
||||||
step('deploy', 'done', `Deployment triggered — ${appConfig.domain ? `https://${appConfig.domain}` : appUuid}`);
|
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 };
|
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;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user