feat: provision k8s namespace and secrets during customer setup
Some checks failed
Build & Release / build (push) Has been cancelled

- k8s.ts: add createNamespace, deleteNamespace, createSecret, createDockerRegistrySecret
- customers.ts: create namespace + DOCR pull secret + app secrets (DATABASE_URL, JWT_SECRET, REDIS_URL) before pushing ArgoCD chart
- customers.ts: delete namespace on deprovision, search name field too
- git.ts: use DOCR OCI chart URL and helm parameters for customer ArgoCD apps
- Add 'namespace' and 'secrets' steps to provisioning step tracker
This commit is contained in:
Ryan Moon
2026-04-03 18:54:11 -05:00
parent cadf0bb191
commit 19135b0520
3 changed files with 102 additions and 31 deletions

View File

@@ -1,5 +1,4 @@
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";
@@ -11,7 +10,7 @@ function token() {
return readFileSync(SA_TOKEN_PATH, "utf-8").trim();
}
async function k8sFetch(path: string, options: RequestInit = {}) {
async function k8sFetch(path: string, options: RequestInit = {}, allowStatuses: number[] = []) {
const res = await fetch(`${K8S_API}${path}`, {
...options,
headers: {
@@ -22,15 +21,16 @@ async function k8sFetch(path: string, options: RequestInit = {}) {
// @ts-ignore - bun supports this
tls: { rejectUnauthorized: false },
});
if (!res.ok) {
if (!res.ok && !allowStatuses.includes(res.status)) {
const body = await res.text();
throw new Error(`k8s API ${options.method ?? "GET"} ${path}${res.status}: ${body}`);
}
return res.json();
return res;
}
export async function getSecret(namespace: string, name: string): Promise<Record<string, string>> {
const secret = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`) as { data: Record<string, string> };
const res = await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`);
const secret = await res.json() as { data: Record<string, string> };
return Object.fromEntries(
Object.entries(secret.data).map(([k, v]) => [k, Buffer.from(v, "base64").toString()])
);
@@ -40,39 +40,85 @@ export async function patchSecret(namespace: string, name: string, data: Record<
const encoded = Object.fromEntries(
Object.entries(data).map(([k, v]) => [k, Buffer.from(v).toString("base64")])
);
return k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`, {
return (await k8sFetch(`/api/v1/namespaces/${namespace}/secrets/${name}`, {
method: "PATCH",
headers: { "Content-Type": "application/strategic-merge-patch+json" },
body: JSON.stringify({ data: encoded }),
})).json();
}
export async function createSecret(namespace: string, name: string, data: Record<string, string>) {
const encoded = Object.fromEntries(
Object.entries(data).map(([k, v]) => [k, Buffer.from(v).toString("base64")])
);
await k8sFetch(`/api/v1/namespaces/${namespace}/secrets`, {
method: "POST",
body: JSON.stringify({
apiVersion: "v1",
kind: "Secret",
metadata: { name, namespace },
data: encoded,
}),
}, [409]); // 409 = already exists, ignore
}
export async function createDockerRegistrySecret(
namespace: string,
name: string,
opts: { server: string; username: string; password: string },
) {
const auth = Buffer.from(`${opts.username}:${opts.password}`).toString("base64");
const dockerConfig = JSON.stringify({
auths: { [opts.server]: { username: opts.username, password: opts.password, auth } },
});
const encoded = Buffer.from(dockerConfig).toString("base64");
await k8sFetch(`/api/v1/namespaces/${namespace}/secrets`, {
method: "POST",
body: JSON.stringify({
apiVersion: "v1",
kind: "Secret",
metadata: { name, namespace },
type: "kubernetes.io/dockerconfigjson",
data: { ".dockerconfigjson": encoded },
}),
}, [409]);
}
export async function createNamespace(name: string) {
await k8sFetch(`/api/v1/namespaces`, {
method: "POST",
body: JSON.stringify({
apiVersion: "v1",
kind: "Namespace",
metadata: { name },
}),
}, [409]); // 409 = already exists, ignore
}
export async function deleteNamespace(name: string) {
await k8sFetch(`/api/v1/namespaces/${name}`, { method: "DELETE" }, [404]);
}
export async function patchConfigMap(namespace: string, name: string, data: Record<string, string>) {
return k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
return (await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`, {
method: "PATCH",
headers: { "Content-Type": "application/strategic-merge-patch+json" },
body: JSON.stringify({ data }),
});
})).json();
}
export async function getConfigMap(namespace: string, name: string): Promise<Record<string, string>> {
const cm = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`) as { data?: Record<string, string> };
const res = await k8sFetch(`/api/v1/namespaces/${namespace}/configmaps/${name}`);
const cm = await res.json() as { data?: Record<string, string> };
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}`, {
return (await 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() } } } },
}),
});
})).json();
}