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:
+38
-3
@@ -2,6 +2,8 @@ import type { Env } from "./types";
|
||||
import { authenticate, json } from "./auth";
|
||||
import { getSecret, putSecret, deleteSecret, listSecrets } from "./routes/secrets";
|
||||
import { getFile, putFile, deleteFile, listFiles } from "./routes/files";
|
||||
import { getProject, putProject, deleteProject, listProjects } from "./routes/projects";
|
||||
import { syncCoolify } from "./routes/coolify";
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
@@ -13,13 +15,46 @@ export default {
|
||||
const method = request.method;
|
||||
const path = url.pathname;
|
||||
|
||||
// Parse: /:project/:env/secrets[/:key]
|
||||
// Parse: /:project/:env/files[/:path+]
|
||||
// GET /projects — list all registered projects
|
||||
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)(?:\/(.+))?$/);
|
||||
if (!match) {
|
||||
return json(404, {
|
||||
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",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
export interface Env {
|
||||
CONFIG_SECRETS: KVNamespace;
|
||||
CONFIG_FILES: KVNamespace;
|
||||
CONFIG_META: KVNamespace;
|
||||
API_KEY: string;
|
||||
COOLIFY_API_TOKEN: string;
|
||||
COOLIFY_BASE_URL: string;
|
||||
}
|
||||
|
||||
export interface RouteParams {
|
||||
@@ -28,3 +31,11 @@ export interface FileMetadata {
|
||||
updatedAt: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,7 @@ id = "425dd5e9d641479e8130d20b83fa0224"
|
||||
[[kv_namespaces]]
|
||||
binding = "CONFIG_FILES"
|
||||
id = "f2c224cbfce04852ba4616d51fa545b7"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "CONFIG_META"
|
||||
id = "6ab687ed121e471990160f3a8e46cf18"
|
||||
|
||||
Reference in New Issue
Block a user