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 { 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",
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user