Add project registry and Coolify sync endpoints

- GET/PUT/DELETE /:project/meta — project metadata (gitea repo, coolify app ID)
- GET /projects — list all registered projects
- POST /:project/:env/sync-coolify — push secrets as env vars to Coolify app
- New CONFIG_META KV namespace
- Env bindings for COOLIFY_API_TOKEN and COOLIFY_BASE_URL
This commit is contained in:
marauder-actual
2026-06-10 17:56:06 +02:00
parent 339f68de4b
commit e7129d259c
5 changed files with 235 additions and 3 deletions
+38 -3
View File
@@ -2,6 +2,8 @@ import type { Env } from "./types";
import { authenticate, json } from "./auth"; import { authenticate, json } from "./auth";
import { getSecret, putSecret, deleteSecret, listSecrets } from "./routes/secrets"; import { getSecret, putSecret, deleteSecret, listSecrets } from "./routes/secrets";
import { getFile, putFile, deleteFile, listFiles } from "./routes/files"; import { getFile, putFile, deleteFile, listFiles } from "./routes/files";
import { getProject, putProject, deleteProject, listProjects } from "./routes/projects";
import { syncCoolify } from "./routes/coolify";
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env): Promise<Response> {
@@ -13,13 +15,46 @@ export default {
const method = request.method; const method = request.method;
const path = url.pathname; const path = url.pathname;
// Parse: /:project/:env/secrets[/:key] // GET /projects — list all registered projects
// Parse: /:project/:env/files[/:path+] if (path === "/projects" && method === "GET") {
return listProjects(request, env.CONFIG_META);
}
// /:project/meta — project registry
const metaMatch = path.match(/^\/([a-zA-Z0-9_-]+)\/meta$/);
if (metaMatch) {
const [, project] = metaMatch;
switch (method) {
case "GET":
return getProject(project, env.CONFIG_META);
case "PUT":
return putProject(project, request, env.CONFIG_META);
case "DELETE":
return deleteProject(project, env.CONFIG_META);
default:
return json(405, { error: "Method not allowed" });
}
}
// POST /:project/:env/sync-coolify — push secrets to Coolify
const syncMatch = path.match(/^\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/sync-coolify$/);
if (syncMatch && method === "POST") {
const [, project, environment] = syncMatch;
return syncCoolify(project, environment, env);
}
// /:project/:env/secrets[/:key] or /:project/:env/files[/:path+]
const match = path.match(/^\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/(secrets|files)(?:\/(.+))?$/); const match = path.match(/^\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)\/(secrets|files)(?:\/(.+))?$/);
if (!match) { if (!match) {
return json(404, { return json(404, {
error: "Not found", error: "Not found",
usage: "/:project/:env/secrets[/:key] or /:project/:env/files[/:path]", routes: [
"GET /projects",
"GET|PUT|DELETE /:project/meta",
"GET|PUT|DELETE /:project/:env/secrets[/:key]",
"GET|PUT|DELETE /:project/:env/files[/:path]",
"POST /:project/:env/sync-coolify",
],
}); });
} }
+112
View File
@@ -0,0 +1,112 @@
import type { Env, ProjectMeta } from "../types";
import { json } from "../auth";
/**
* POST /:project/:env/sync-coolify
*
* Reads all secrets for project/env from KV, fetches project metadata
* for the Coolify app UUID, then PATCHes the env vars on the Coolify app.
*
* Coolify API: PATCH /api/v1/applications/{uuid}/envs
* Body: { key, value, is_preview: false } (one per env var)
*
* We bulk-create/update each secret as a Coolify env var.
*/
export async function syncCoolify(
project: string,
environment: string,
env: Env,
): Promise<Response> {
// 1. Load project metadata
const metaRaw = await env.CONFIG_META.get(`project:${project}`);
if (!metaRaw) {
return json(404, { error: `Project '${project}' not registered. PUT /${project}/meta first.` });
}
const meta: ProjectMeta = JSON.parse(metaRaw);
if (!meta.coolifyAppId) {
return json(400, {
error: `Project '${project}' has no coolifyAppId set. Update /${project}/meta with coolifyAppId.`,
});
}
if (!env.COOLIFY_API_TOKEN || !env.COOLIFY_BASE_URL) {
return json(500, { error: "COOLIFY_API_TOKEN or COOLIFY_BASE_URL not configured as worker secrets." });
}
// 2. List all secrets for this project/env
const prefix = `secret:${project}:${environment}:`;
const allKeys: { name: string }[] = [];
let cursor: string | undefined;
do {
const page = await env.CONFIG_SECRETS.list({ prefix, cursor, limit: 1000 });
allKeys.push(...page.keys);
cursor = page.list_complete ? undefined : page.cursor;
} while (cursor);
if (allKeys.length === 0) {
return json(200, {
project,
env: environment,
coolifyAppId: meta.coolifyAppId,
synced: 0,
message: "No secrets found for this project/env",
});
}
// 3. Fetch values and push to Coolify
const results: { key: string; status: "ok" | "error"; detail?: string }[] = [];
for (const kv of allKeys) {
const secretKey = kv.name.slice(prefix.length);
const value = await env.CONFIG_SECRETS.get(kv.name);
if (value === null) continue;
// Convert key format: "api-key" -> "API_KEY" for env var convention
const envVarName = secretKey.toUpperCase().replace(/-/g, "_");
try {
const resp = await fetch(
`${env.COOLIFY_BASE_URL}/api/v1/applications/${meta.coolifyAppId}/envs`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${env.COOLIFY_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
key: envVarName,
value,
is_preview: false,
is_build_time: false,
}),
},
);
if (resp.ok) {
results.push({ key: envVarName, status: "ok" });
} else {
const body = await resp.text();
results.push({ key: envVarName, status: "error", detail: `${resp.status}: ${body.slice(0, 200)}` });
}
} catch (e) {
results.push({
key: envVarName,
status: "error",
detail: e instanceof Error ? e.message : String(e),
});
}
}
const okCount = results.filter((r) => r.status === "ok").length;
const errors = results.filter((r) => r.status === "error");
return json(errors.length > 0 ? 207 : 200, {
project,
env: environment,
coolifyAppId: meta.coolifyAppId,
synced: okCount,
errors: errors.length > 0 ? errors : undefined,
total: results.length,
});
}
+70
View File
@@ -0,0 +1,70 @@
import type { Env, ProjectMeta } from "../types";
import { json } from "../auth";
const PREFIX = "project";
function kvKey(project: string): string {
return `${PREFIX}:${project}`;
}
export async function getProject(project: string, kv: KVNamespace): Promise<Response> {
const raw = await kv.get(kvKey(project));
if (!raw) {
return json(404, { error: "Project not found" });
}
return json(200, JSON.parse(raw));
}
export async function putProject(project: string, request: Request, kv: KVNamespace): Promise<Response> {
const body = await request.json<Partial<ProjectMeta>>().catch(() => null);
if (!body) {
return json(400, { error: "Invalid JSON body" });
}
if (!body.gitea || typeof body.gitea !== "string") {
return json(400, { error: "Field 'gitea' is required (e.g. git@git.saiden.dev:org/repo.git)" });
}
// Merge with existing if present
const existing = await kv.get(kvKey(project));
const prev: Partial<ProjectMeta> = existing ? JSON.parse(existing) : {};
const meta: ProjectMeta = {
project,
gitea: body.gitea ?? prev.gitea ?? "",
coolifyAppId: body.coolifyAppId ?? prev.coolifyAppId,
description: body.description ?? prev.description,
updatedAt: new Date().toISOString(),
};
await kv.put(kvKey(project), JSON.stringify(meta));
return json(200, { ...meta, stored: true });
}
export async function deleteProject(project: string, kv: KVNamespace): Promise<Response> {
await kv.delete(kvKey(project));
return json(200, { project, deleted: true });
}
export async function listProjects(request: Request, kv: KVNamespace): Promise<Response> {
const url = new URL(request.url);
const cursor = url.searchParams.get("cursor") ?? undefined;
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "100", 10), 1000);
const prefix = `${PREFIX}:`;
const result = await kv.list({ prefix, cursor, limit });
const projects: ProjectMeta[] = [];
for (const key of result.keys) {
const raw = await kv.get(key.name);
if (raw) {
projects.push(JSON.parse(raw));
}
}
return json(200, {
projects,
cursor: result.list_complete ? undefined : result.cursor,
complete: result.list_complete,
});
}
+11
View File
@@ -1,7 +1,10 @@
export interface Env { export interface Env {
CONFIG_SECRETS: KVNamespace; CONFIG_SECRETS: KVNamespace;
CONFIG_FILES: KVNamespace; CONFIG_FILES: KVNamespace;
CONFIG_META: KVNamespace;
API_KEY: string; API_KEY: string;
COOLIFY_API_TOKEN: string;
COOLIFY_BASE_URL: string;
} }
export interface RouteParams { export interface RouteParams {
@@ -28,3 +31,11 @@ export interface FileMetadata {
updatedAt: string; updatedAt: string;
size: number; size: number;
} }
export interface ProjectMeta {
project: string;
gitea: string; // e.g. git@git.saiden.dev:madcat-os/campus-os.git
coolifyAppId?: string; // Coolify application UUID
description?: string;
updatedAt: string;
}
+4
View File
@@ -13,3 +13,7 @@ id = "425dd5e9d641479e8130d20b83fa0224"
[[kv_namespaces]] [[kv_namespaces]]
binding = "CONFIG_FILES" binding = "CONFIG_FILES"
id = "f2c224cbfce04852ba4616d51fa545b7" id = "f2c224cbfce04852ba4616d51fa545b7"
[[kv_namespaces]]
binding = "CONFIG_META"
id = "6ab687ed121e471990160f3a8e46cf18"