feat: initial lunarfront-manager app
This commit is contained in:
20
src/index.ts
Normal file
20
src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Fastify from "fastify";
|
||||
import staticFiles from "@fastify/static";
|
||||
import { join } from "path";
|
||||
import { customerRoutes } from "./routes/customers";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
app.register(staticFiles, {
|
||||
root: join(import.meta.dir, "../frontend"),
|
||||
prefix: "/",
|
||||
});
|
||||
|
||||
app.register(customerRoutes, { prefix: "/api" });
|
||||
|
||||
app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" }, (err) => {
|
||||
if (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
14
src/lib/config.ts
Normal file
14
src/lib/config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
doToken: process.env.DO_API_TOKEN!,
|
||||
doDbClusterId: process.env.DO_DB_CLUSTER_ID!,
|
||||
gitSshKey: process.env.GIT_SSH_KEY!,
|
||||
gitRepoUrl: process.env.GIT_REPO_URL ?? "ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git",
|
||||
dbUrl: process.env.DATABASE_URL!,
|
||||
};
|
||||
|
||||
for (const [key, val] of Object.entries(config)) {
|
||||
if (val === undefined || val === "") {
|
||||
throw new Error(`Missing required env var for config key: ${key}`);
|
||||
}
|
||||
}
|
||||
78
src/lib/k8s.ts
Normal file
78
src/lib/k8s.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
// Read in-cluster service account token and CA
|
||||
const SA_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
|
||||
const K8S_API = process.env.KUBERNETES_SERVICE_HOST
|
||||
? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT}`
|
||||
: "https://kubernetes.default.svc";
|
||||
|
||||
function token() {
|
||||
return readFileSync(SA_TOKEN_PATH, "utf-8").trim();
|
||||
}
|
||||
|
||||
async function k8sFetch(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`${K8S_API}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token()}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
// @ts-ignore - bun supports this
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`k8s API ${options.method ?? "GET"} ${path} → ${res.status}: ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getSecret(namespace: string, name: string): Promise<Record<string, string>> {
|
||||
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`);
|
||||
return Object.fromEntries(
|
||||
Object.entries(secret.data as Record<string, string>).map(([k, v]) => [k, Buffer.from(v, "base64").toString()])
|
||||
);
|
||||
}
|
||||
|
||||
export async function patchSecret(namespace: string, name: string, data: Record<string, string>) {
|
||||
const encoded = Object.fromEntries(
|
||||
Object.entries(data).map(([k, v]) => [k, Buffer.from(v).toString("base64")])
|
||||
);
|
||||
return k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||
body: JSON.stringify({ data: encoded }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchConfigMap(namespace: string, name: string, data: Record<string, string>) {
|
||||
return k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConfigMap(namespace: string, name: string): Promise<Record<string, string>> {
|
||||
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`);
|
||||
return cm.data ?? {};
|
||||
}
|
||||
|
||||
export async function createArgoCDApp(manifest: object) {
|
||||
return k8sFetch(`/apis/argoproj.io/v1alpha1/namespaces/argocd/applications`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rolloutRestart(namespace: string, deployment: string) {
|
||||
return k8sFetch(`/apis/apps/v1/namespaces/${namespace}/deployments/${deployment}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/strategic-merge-patch+json" },
|
||||
body: JSON.stringify({
|
||||
spec: { template: { metadata: { annotations: { "kubectl.kubernetes.io/restartedAt": new Date().toISOString() } } } },
|
||||
}),
|
||||
});
|
||||
}
|
||||
46
src/routes/customers.ts
Normal file
46
src/routes/customers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser } from "../services/do";
|
||||
import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer";
|
||||
import { addCustomerChart, removeCustomerChart } from "../services/git";
|
||||
|
||||
const ProvisionSchema = z.object({
|
||||
name: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"),
|
||||
appVersion: z.string().default("latest"),
|
||||
});
|
||||
|
||||
export async function customerRoutes(app: FastifyInstance) {
|
||||
app.post("/customers", async (req, reply) => {
|
||||
const body = ProvisionSchema.parse(req.body);
|
||||
const slug = body.name;
|
||||
|
||||
app.log.info({ slug }, "provisioning customer");
|
||||
|
||||
const [, user] = await Promise.all([
|
||||
createDatabase(slug),
|
||||
createDatabaseUser(slug),
|
||||
]);
|
||||
|
||||
await addCustomerToPool(slug, user.password);
|
||||
addCustomerChart(slug, body.appVersion);
|
||||
|
||||
app.log.info({ slug }, "customer provisioned");
|
||||
return reply.code(201).send({ slug, status: "provisioned" });
|
||||
});
|
||||
|
||||
app.delete("/customers/:slug", async (req, reply) => {
|
||||
const { slug } = req.params as { slug: string };
|
||||
|
||||
app.log.info({ slug }, "deprovisioning customer");
|
||||
|
||||
removeCustomerChart(slug);
|
||||
await removeCustomerFromPool(slug);
|
||||
await Promise.all([
|
||||
deleteDatabase(slug),
|
||||
deleteDatabaseUser(slug),
|
||||
]);
|
||||
|
||||
app.log.info({ slug }, "customer deprovisioned");
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
48
src/services/do.ts
Normal file
48
src/services/do.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { config } from "../lib/config";
|
||||
|
||||
const DO_API = "https://api.digitalocean.com/v2";
|
||||
|
||||
async function doFetch(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`${DO_API}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.doToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`DO API ${options.method ?? "GET"} ${path} → ${res.status}: ${body}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createDatabase(name: string) {
|
||||
return doFetch(`/databases/${config.doDbClusterId}/dbs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDatabaseUser(name: string): Promise<{ name: string; password: string }> {
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}/users`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return res.user;
|
||||
}
|
||||
|
||||
export async function deleteDatabaseUser(name: string) {
|
||||
return doFetch(`/databases/${config.doDbClusterId}/users/${name}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function deleteDatabase(name: string) {
|
||||
return doFetch(`/databases/${config.doDbClusterId}/dbs/${name}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getDatabaseHost(): Promise<string> {
|
||||
const res = await doFetch(`/databases/${config.doDbClusterId}`);
|
||||
return res.database.connection.host;
|
||||
}
|
||||
76
src/services/git.ts
Normal file
76
src/services/git.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { execSync } from "child_process";
|
||||
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { config } from "../lib/config";
|
||||
|
||||
function withRepo<T>(fn: (dir: string) => T): T {
|
||||
const keyPath = join(tmpdir(), `manager-ssh-key-${Date.now()}`);
|
||||
const dir = join(tmpdir(), `lunarfront-charts-${Date.now()}`);
|
||||
|
||||
writeFileSync(keyPath, config.gitSshKey, { mode: 0o600 });
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o StrictHostKeyChecking=no`,
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`git clone ${config.gitRepoUrl} ${dir}`, { env, stdio: "pipe" });
|
||||
execSync(`git -C ${dir} config user.email "manager@lunarfront.tech"`, { env });
|
||||
execSync(`git -C ${dir} config user.name "lunarfront-manager"`, { env });
|
||||
const result = fn(dir);
|
||||
execSync(`git -C ${dir} push origin main`, { env, stdio: "pipe" });
|
||||
return result;
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(keyPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function addCustomerChart(slug: string, appVersion: string) {
|
||||
withRepo((dir) => {
|
||||
const manifest = buildArgoCDApp(slug, appVersion);
|
||||
writeFileSync(join(dir, "customers", `${slug}.yaml`), manifest);
|
||||
execSync(`git -C ${dir} add customers/${slug}.yaml`);
|
||||
execSync(`git -C ${dir} commit -m "feat: provision customer ${slug}"`, { env: process.env });
|
||||
});
|
||||
}
|
||||
|
||||
export function removeCustomerChart(slug: string) {
|
||||
withRepo((dir) => {
|
||||
rmSync(join(dir, "customers", `${slug}.yaml`), { force: true });
|
||||
execSync(`git -C ${dir} add customers/${slug}.yaml`);
|
||||
execSync(`git -C ${dir} commit -m "chore: deprovision customer ${slug}"`, { env: process.env });
|
||||
});
|
||||
}
|
||||
|
||||
function buildArgoCDApp(slug: string, appVersion: string): string {
|
||||
return `apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: customer-${slug}
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
sources:
|
||||
- repoURL: git.lunarfront.tech/ryan/lunarfront-app
|
||||
chart: lunarfront
|
||||
targetRevision: "${appVersion}"
|
||||
helm:
|
||||
valueFiles:
|
||||
- $values/customers/${slug}.yaml
|
||||
- repoURL: ssh://git@git-ssh.lunarfront.tech/ryan/lunarfront-charts.git
|
||||
targetRevision: main
|
||||
ref: values
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: customer-${slug}
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
`;
|
||||
}
|
||||
50
src/services/pgbouncer.ts
Normal file
50
src/services/pgbouncer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { getConfigMap, getSecret, patchConfigMap, patchSecret, rolloutRestart } from "../lib/k8s";
|
||||
|
||||
const NAMESPACE = "pgbouncer";
|
||||
const DO_HOST = "lunarfront-postgres-do-user-35277853-0.e.db.ondigitalocean.com";
|
||||
const DO_PORT = 25060;
|
||||
|
||||
export async function addCustomerToPool(slug: string, password: string) {
|
||||
await Promise.all([
|
||||
addDbEntry(slug),
|
||||
addUserEntry(slug, password),
|
||||
]);
|
||||
await rolloutRestart(NAMESPACE, "pgbouncer");
|
||||
}
|
||||
|
||||
export async function removeCustomerFromPool(slug: string) {
|
||||
await Promise.all([
|
||||
removeDbEntry(slug),
|
||||
removeUserEntry(slug),
|
||||
]);
|
||||
await rolloutRestart(NAMESPACE, "pgbouncer");
|
||||
}
|
||||
|
||||
async function addDbEntry(slug: string) {
|
||||
const cm = await getConfigMap(NAMESPACE, "pgbouncer-config");
|
||||
const ini = cm["pgbouncer.ini"];
|
||||
const newLine = ` ${slug} = host=${DO_HOST} port=${DO_PORT} dbname=${slug} user=${slug} pool_mode=session pool_size=3`;
|
||||
const updated = ini.replace("[pgbouncer]", `${newLine}\n [pgbouncer]`);
|
||||
await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated });
|
||||
}
|
||||
|
||||
async function removeDbEntry(slug: string) {
|
||||
const cm = await getConfigMap(NAMESPACE, "pgbouncer-config");
|
||||
const ini = cm["pgbouncer.ini"];
|
||||
const updated = ini.split("\n").filter((l) => !l.includes(`dbname=${slug}`)).join("\n");
|
||||
await patchConfigMap(NAMESPACE, "pgbouncer-config", { "pgbouncer.ini": updated });
|
||||
}
|
||||
|
||||
async function addUserEntry(slug: string, password: string) {
|
||||
const secret = await getSecret(NAMESPACE, "pgbouncer-userlist");
|
||||
const userlist = secret["userlist.txt"];
|
||||
const updated = `${userlist}\n"${slug}" "${password}"`;
|
||||
await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated });
|
||||
}
|
||||
|
||||
async function removeUserEntry(slug: string) {
|
||||
const secret = await getSecret(NAMESPACE, "pgbouncer-userlist");
|
||||
const userlist = secret["userlist.txt"];
|
||||
const updated = userlist.split("\n").filter((l) => !l.startsWith(`"${slug}"`)).join("\n");
|
||||
await patchSecret(NAMESPACE, "pgbouncer-userlist", { "userlist.txt": updated });
|
||||
}
|
||||
Reference in New Issue
Block a user