feat: add d601 dev environment foundation
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runCommand } from "./command";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
|
||||
const defaultManifest = "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml";
|
||||
const devNamespace = "unidesk-dev";
|
||||
const prodNamespace = "unidesk";
|
||||
const requiredKinds = new Set([
|
||||
"Namespace/unidesk-dev",
|
||||
"Secret/unidesk-dev-runtime-secrets",
|
||||
"ConfigMap/unidesk-dev-runtime-config",
|
||||
"ConfigMap/unidesk-dev-db-guard",
|
||||
"ConfigMap/unidesk-dev-db-init",
|
||||
"Service/postgres-dev",
|
||||
"StatefulSet/postgres-dev",
|
||||
"Job/unidesk-dev-db-migrate",
|
||||
]);
|
||||
|
||||
interface ManifestDocument {
|
||||
index: number;
|
||||
raw: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace: string | null;
|
||||
}
|
||||
|
||||
interface DevEnvOptions {
|
||||
manifestPath: string;
|
||||
kubectlDryRun: boolean;
|
||||
}
|
||||
|
||||
function isHelpArg(arg: string | undefined): boolean {
|
||||
return arg === "help" || arg === "--help" || arg === "-h";
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): DevEnvOptions {
|
||||
const options: DevEnvOptions = { manifestPath: defaultManifest, kubectlDryRun: false };
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--manifest") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error("--manifest requires a path");
|
||||
options.manifestPath = value;
|
||||
index += 1;
|
||||
} else if (arg === "--kubectl-dry-run") {
|
||||
options.kubectlDryRun = true;
|
||||
} else if (!isHelpArg(arg)) {
|
||||
throw new Error(`unknown dev-env option: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function scalarAfter(text: string, key: string): string | null {
|
||||
const match = text.match(new RegExp(`^\\s*${key}:\\s*"?([^"\\n#]+)"?\\s*(?:#.*)?$`, "mu"));
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function namespaceFromDoc(text: string): string | null {
|
||||
const metadataIndex = text.search(/^metadata:\s*$/mu);
|
||||
if (metadataIndex < 0) return null;
|
||||
const metadataBlock = text.slice(metadataIndex);
|
||||
const match = metadataBlock.match(/^ {2}namespace:\s*"?([^"\n#]+)"?\s*(?:#.*)?$/mu);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function parseManifestDocuments(text: string): ManifestDocument[] {
|
||||
return text.split(/^---\s*$/mu)
|
||||
.map((raw, index) => ({ raw: raw.trim(), index }))
|
||||
.filter((doc) => doc.raw.length > 0)
|
||||
.map(({ raw, index }) => {
|
||||
const kind = scalarAfter(raw, "kind") ?? "";
|
||||
const name = (() => {
|
||||
const metadataIndex = raw.search(/^metadata:\s*$/mu);
|
||||
if (metadataIndex < 0) return "";
|
||||
const metadataBlock = raw.slice(metadataIndex);
|
||||
const match = metadataBlock.match(/^ {2}name:\s*"?([^"\n#]+)"?\s*(?:#.*)?$/mu);
|
||||
return match?.[1]?.trim() ?? "";
|
||||
})();
|
||||
return { index, raw, kind, name, namespace: namespaceFromDoc(raw) };
|
||||
});
|
||||
}
|
||||
|
||||
function databaseUrls(text: string): string[] {
|
||||
const urls: string[] = [];
|
||||
const pattern = /postgres(?:ql)?:\/\/[^\s"']+/gu;
|
||||
for (const match of text.matchAll(pattern)) urls.push(match[0] ?? "");
|
||||
return urls.filter((url) => url.length > 0 && !url.includes("*") && !url.includes("$"));
|
||||
}
|
||||
|
||||
function validateDatabaseUrl(url: string): { ok: boolean; url: string; reason: string | null } {
|
||||
if (url.includes("d601-tcp-egress-gateway") || url.includes("74.48.78.17:15432") || url.includes("database:5432/unidesk")) {
|
||||
return { ok: false, url, reason: "matches production database route" };
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { ok: false, url, reason: "invalid URL" };
|
||||
}
|
||||
const hostOk = [
|
||||
"postgres-dev",
|
||||
"postgres-dev.unidesk-dev",
|
||||
"postgres-dev.unidesk-dev.svc",
|
||||
"postgres-dev.unidesk-dev.svc.cluster.local",
|
||||
].includes(parsed.hostname);
|
||||
const database = parsed.pathname.replace(/^\/+/u, "");
|
||||
if (!hostOk) return { ok: false, url, reason: `host ${parsed.hostname} is not postgres-dev` };
|
||||
if (database !== "unidesk_dev") return { ok: false, url, reason: `database ${database} is not unidesk_dev` };
|
||||
return { ok: true, url, reason: null };
|
||||
}
|
||||
|
||||
function kubectlDryRun(manifestPath: string): unknown {
|
||||
const result = runCommand(["kubectl", "apply", "--dry-run=client", "--validate=false", "-f", manifestPath], repoRoot, { timeoutMs: 60_000 });
|
||||
return {
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
ok: result.exitCode === 0,
|
||||
stdoutTail: result.stdout.slice(-4000),
|
||||
stderrTail: result.stderr.slice(-4000),
|
||||
};
|
||||
}
|
||||
|
||||
function devEnvHelp(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
command: "dev-env validate",
|
||||
usage: "bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]",
|
||||
defaultManifest,
|
||||
checks: [
|
||||
"all namespaced resources must target unidesk-dev",
|
||||
"required dev namespace, postgres-dev, secret/config, guard, migration resources must exist",
|
||||
"dev DATABASE_URL values must target postgres-dev/unidesk_dev and not production routes",
|
||||
"--kubectl-dry-run optionally asks kubectl to client-dry-run the manifest without applying it",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function runDevEnvCommand(args: string[]): unknown {
|
||||
const action = args[0];
|
||||
if (action === undefined || isHelpArg(action)) return devEnvHelp();
|
||||
if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]");
|
||||
|
||||
const options = parseOptions(args.slice(1));
|
||||
const manifestPath = rootPath(options.manifestPath);
|
||||
const manifestText = readFileSync(manifestPath, "utf8");
|
||||
const docs = parseManifestDocuments(manifestText);
|
||||
const resources = docs.map((doc) => `${doc.kind}/${doc.name}`);
|
||||
const namespacedViolations = docs
|
||||
.filter((doc) => doc.kind !== "Namespace")
|
||||
.filter((doc) => doc.namespace !== devNamespace)
|
||||
.map((doc) => ({ index: doc.index, kind: doc.kind, name: doc.name, namespace: doc.namespace }));
|
||||
const namespaceObjectViolations = docs
|
||||
.filter((doc) => doc.kind === "Namespace")
|
||||
.filter((doc) => doc.name !== devNamespace)
|
||||
.map((doc) => ({ index: doc.index, kind: doc.kind, name: doc.name }));
|
||||
const productionNamespaceTouches = docs
|
||||
.filter((doc) => doc.namespace === prodNamespace || (doc.kind === "Namespace" && doc.name === prodNamespace))
|
||||
.map((doc) => ({ kind: doc.kind, name: doc.name }));
|
||||
const missingRequiredResources = Array.from(requiredKinds).filter((resource) => !resources.includes(resource));
|
||||
const urlChecks = databaseUrls(manifestText).map(validateDatabaseUrl);
|
||||
const badUrls = urlChecks.filter((check) => !check.ok);
|
||||
const forbiddenProductionTextHits = [
|
||||
"namespace: unidesk\n",
|
||||
"d601-tcp-egress-gateway.unidesk.svc.cluster.local:15432/unidesk",
|
||||
"74.48.78.17:15432/unidesk",
|
||||
].filter((needle) => manifestText.includes(needle));
|
||||
|
||||
const staticOk = namespacedViolations.length === 0
|
||||
&& namespaceObjectViolations.length === 0
|
||||
&& productionNamespaceTouches.length === 0
|
||||
&& missingRequiredResources.length === 0
|
||||
&& badUrls.length === 0
|
||||
&& forbiddenProductionTextHits.length === 0;
|
||||
const kubectl = options.kubectlDryRun ? kubectlDryRun(manifestPath) : { skipped: true, enableWith: "--kubectl-dry-run" };
|
||||
const kubectlOk = typeof kubectl === "object" && kubectl !== null && "ok" in kubectl ? (kubectl as { ok: boolean }).ok : true;
|
||||
|
||||
return {
|
||||
ok: staticOk && kubectlOk,
|
||||
manifest: options.manifestPath,
|
||||
namespace: devNamespace,
|
||||
staticChecks: {
|
||||
ok: staticOk,
|
||||
resources,
|
||||
namespacedViolations,
|
||||
namespaceObjectViolations,
|
||||
productionNamespaceTouches,
|
||||
missingRequiredResources,
|
||||
databaseUrlChecks: urlChecks,
|
||||
forbiddenProductionTextHits,
|
||||
},
|
||||
kubectlDryRun: kubectl,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user