430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { runCommand } from "./command";
|
|
import { repoRoot, rootPath } from "./config";
|
|
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "./d601-k3s-guard";
|
|
import { startJob } from "./jobs";
|
|
|
|
const defaultManifest = "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml";
|
|
const devNamespace = "unidesk-dev";
|
|
const prodNamespace = "unidesk";
|
|
const defaultProviderId = "D601";
|
|
const defaultProxyUrl = "http://127.0.0.1:18789";
|
|
const defaultPrewarmImages = [
|
|
"postgres:16-alpine",
|
|
"rancher/mirrored-library-busybox:1.36.1",
|
|
];
|
|
const foundationRequiredKinds = 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",
|
|
]);
|
|
const coreRequiredKinds = new Set([
|
|
"Service/backend-core-dev",
|
|
"Deployment/backend-core-dev",
|
|
"Service/frontend-dev",
|
|
"Deployment/frontend-dev",
|
|
]);
|
|
const codeQueueRequiredKinds = new Set([
|
|
"Service/code-queue-scheduler-dev",
|
|
"Service/code-queue-read-dev",
|
|
"Service/code-queue-write-dev",
|
|
"Service/d601-dev-provider-egress-proxy",
|
|
"ConfigMap/d601-dev-provider-egress-proxy",
|
|
"Deployment/d601-dev-provider-egress-proxy",
|
|
"Deployment/code-queue-scheduler-dev",
|
|
"Deployment/code-queue-read-dev",
|
|
"Deployment/code-queue-write-dev",
|
|
]);
|
|
const mdtodoRequiredKinds = new Set([
|
|
"Service/mdtodo-dev",
|
|
"Deployment/mdtodo-dev",
|
|
]);
|
|
const claudeqqRequiredKinds = new Set([
|
|
"Service/claudeqq-dev",
|
|
"Deployment/claudeqq-dev",
|
|
]);
|
|
|
|
interface ManifestDocument {
|
|
index: number;
|
|
raw: string;
|
|
kind: string;
|
|
name: string;
|
|
namespace: string | null;
|
|
}
|
|
|
|
interface ValidateOptions {
|
|
manifestPath: string;
|
|
kubectlDryRun: boolean;
|
|
}
|
|
|
|
interface PrewarmImagesOptions {
|
|
providerId: string;
|
|
images: string[];
|
|
proxyUrl: string;
|
|
pullMissing: boolean;
|
|
pullTimeoutMs: number;
|
|
dryRun: boolean;
|
|
}
|
|
|
|
function isHelpArg(arg: string | undefined): boolean {
|
|
return arg === "help" || arg === "--help" || arg === "-h";
|
|
}
|
|
|
|
function positiveInteger(value: string | undefined, option: string): number {
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function rejectUnsafeToken(value: string, option: string): void {
|
|
if (/[\s\x00-\x1f\x7f]/u.test(value)) throw new Error(`${option} must not contain whitespace or control characters`);
|
|
}
|
|
|
|
function parseValidateOptions(args: string[]): ValidateOptions {
|
|
const options: ValidateOptions = { 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 parsePrewarmImagesOptions(args: string[]): PrewarmImagesOptions {
|
|
const images: string[] = [];
|
|
const options: PrewarmImagesOptions = {
|
|
providerId: defaultProviderId,
|
|
images,
|
|
proxyUrl: defaultProxyUrl,
|
|
pullMissing: true,
|
|
pullTimeoutMs: 300_000,
|
|
dryRun: false,
|
|
};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--provider-id") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error("--provider-id requires a value");
|
|
rejectUnsafeToken(value, "--provider-id");
|
|
options.providerId = value;
|
|
index += 1;
|
|
} else if (arg === "--image") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error("--image requires a value");
|
|
rejectUnsafeToken(value, "--image");
|
|
images.push(value);
|
|
index += 1;
|
|
} else if (arg === "--proxy-url") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0) throw new Error("--proxy-url requires a value");
|
|
rejectUnsafeToken(value, "--proxy-url");
|
|
options.proxyUrl = value;
|
|
index += 1;
|
|
} else if (arg === "--pull-timeout-ms") {
|
|
options.pullTimeoutMs = positiveInteger(args[index + 1], "--pull-timeout-ms");
|
|
index += 1;
|
|
} else if (arg === "--no-pull") {
|
|
options.pullMissing = false;
|
|
} else if (arg === "--dry-run") {
|
|
options.dryRun = true;
|
|
} else if (!isHelpArg(arg)) {
|
|
throw new Error(`unknown dev-env prewarm-images option: ${arg}`);
|
|
}
|
|
}
|
|
if (options.images.length === 0) options.images = [...defaultPrewarmImages];
|
|
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 requiredResourcesFor(resources: string[]): Set<string> {
|
|
const resourceSet = new Set(resources);
|
|
if (resourceSet.has("Deployment/code-queue-scheduler-dev") || resourceSet.has("Service/code-queue-scheduler-dev")) return codeQueueRequiredKinds;
|
|
if (resourceSet.has("Deployment/mdtodo-dev") || resourceSet.has("Service/mdtodo-dev")) return mdtodoRequiredKinds;
|
|
if (resourceSet.has("Deployment/claudeqq-dev") || resourceSet.has("Service/claudeqq-dev")) return claudeqqRequiredKinds;
|
|
if (resourceSet.has("Deployment/backend-core-dev") || resourceSet.has("Deployment/frontend-dev")) return coreRequiredKinds;
|
|
return foundationRequiredKinds;
|
|
}
|
|
|
|
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 kubeconfig = d601NativeKubeconfig;
|
|
const guardScript = d601K3sGuardShellLines(kubeconfig).join("\n");
|
|
const guard = runCommand(["sh", "-lc", guardScript], repoRoot, {
|
|
timeoutMs: 60_000,
|
|
env: { ...process.env, KUBECONFIG: kubeconfig },
|
|
});
|
|
const guarded = guard.exitCode === 0;
|
|
if (!guarded) {
|
|
return {
|
|
command: ["sh", "-lc", "d601 native k3s guard"],
|
|
kubeconfig,
|
|
exitCode: guard.exitCode,
|
|
signal: guard.signal,
|
|
timedOut: guard.timedOut,
|
|
ok: false,
|
|
guard: "d601-native-k3s",
|
|
stdoutTail: guard.stdout.slice(-4000),
|
|
stderrTail: guard.stderr.slice(-4000),
|
|
};
|
|
}
|
|
const result = runCommand(["kubectl", "apply", "--dry-run=client", "--validate=false", "-f", manifestPath], repoRoot, {
|
|
timeoutMs: 60_000,
|
|
env: { ...process.env, KUBECONFIG: kubeconfig },
|
|
});
|
|
return {
|
|
command: [`KUBECONFIG=${kubeconfig}`, ...result.command],
|
|
kubeconfig,
|
|
exitCode: result.exitCode,
|
|
signal: result.signal,
|
|
timedOut: result.timedOut,
|
|
ok: result.exitCode === 0,
|
|
stdoutTail: result.stdout.slice(-4000),
|
|
stderrTail: result.stderr.slice(-4000),
|
|
};
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function prewarmImagesScript(options: PrewarmImagesOptions): string {
|
|
const imageArray = options.images.map(shellQuote).join(" ");
|
|
const pullTimeoutSeconds = Math.max(1, Math.ceil(options.pullTimeoutMs / 1000));
|
|
return [
|
|
"set -euo pipefail",
|
|
"root_exec() {",
|
|
" if [ \"$(id -u)\" = \"0\" ]; then \"$@\"; return; fi",
|
|
" if sudo -n true >/dev/null 2>&1; then sudo -n \"$@\"; return; fi",
|
|
" if [ -x /mnt/c/Windows/System32/wsl.exe ]; then /mnt/c/Windows/System32/wsl.exe -u root -- \"$@\"; return; fi",
|
|
" echo 'dev_env_native_k3s_root_access=missing' >&2",
|
|
" return 1",
|
|
"}",
|
|
"normalize_image() {",
|
|
" image=\"$1\"",
|
|
" case \"$image\" in",
|
|
" *@*) printf '%s\\n' \"$image\" ;;",
|
|
" docker.io/*|ghcr.io/*|gcr.io/*|quay.io/*|cgr.dev/*|registry.*/*|localhost/*|*.*/*) printf '%s\\n' \"$image\" ;;",
|
|
" */*) printf 'docker.io/%s\\n' \"$image\" ;;",
|
|
" *) printf 'docker.io/library/%s\\n' \"$image\" ;;",
|
|
" esac",
|
|
"}",
|
|
`images=(${imageArray})`,
|
|
`proxy_url=${shellQuote(options.proxyUrl)}`,
|
|
`pull_missing=${options.pullMissing ? "1" : "0"}`,
|
|
`pull_timeout_seconds=${pullTimeoutSeconds}`,
|
|
"ctr_address=/run/k3s/containerd/containerd.sock",
|
|
...d601K3sGuardShellLines(),
|
|
"export DOCKER_CONFIG=/tmp/unidesk-dev-env-docker-config",
|
|
"mkdir -p \"$DOCKER_CONFIG\"",
|
|
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
|
|
"printf 'dev_env_k3s_nodes='",
|
|
"kubectl get nodes -o name | tr '\\n' ' '",
|
|
"printf '\\n'",
|
|
"for image in \"${images[@]}\"; do",
|
|
" if docker image inspect \"$image\" >/dev/null 2>&1; then",
|
|
" echo dev_env_image_cached=$image",
|
|
" elif [ \"$pull_missing\" = \"1\" ]; then",
|
|
" echo dev_env_image_pull=$image",
|
|
" timeout \"$pull_timeout_seconds\" env HTTP_PROXY=\"$proxy_url\" HTTPS_PROXY=\"$proxy_url\" ALL_PROXY=\"$proxy_url\" NO_PROXY=\"localhost,127.0.0.1,::1,host.docker.internal\" docker pull --platform linux/amd64 \"$image\"",
|
|
" else",
|
|
" echo dev_env_image_missing=$image >&2",
|
|
" exit 1",
|
|
" fi",
|
|
"done",
|
|
"archive=$(mktemp /tmp/unidesk-dev-env-images.XXXXXX.tar)",
|
|
"list_file=$(mktemp /tmp/unidesk-dev-env-ctr-images.XXXXXX.txt)",
|
|
"trap 'rm -f \"$archive\" \"$list_file\"' EXIT",
|
|
"docker save \"${images[@]}\" -o \"$archive\"",
|
|
"root_exec ctr --address \"$ctr_address\" -n k8s.io images import --digests --all-platforms \"$archive\"",
|
|
"root_exec ctr --address \"$ctr_address\" -n k8s.io images ls > \"$list_file\"",
|
|
"missing=0",
|
|
"for image in \"${images[@]}\"; do",
|
|
" needle=$(normalize_image \"$image\")",
|
|
" if grep -F \"$needle\" \"$list_file\" >/dev/null || grep -F \"$image\" \"$list_file\" >/dev/null; then",
|
|
" echo dev_env_containerd_image_ready=$image",
|
|
" else",
|
|
" echo dev_env_containerd_image_missing=$image needle=$needle >&2",
|
|
" missing=1",
|
|
" fi",
|
|
"done",
|
|
"test \"$missing\" = \"0\"",
|
|
].join("\n");
|
|
}
|
|
|
|
function devEnvHelp(): Record<string, unknown> {
|
|
return {
|
|
ok: true,
|
|
command: "dev-env",
|
|
usage: [
|
|
"bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]",
|
|
"bun scripts/cli.ts dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]",
|
|
],
|
|
defaultManifest,
|
|
defaultPrewarmImages,
|
|
checks: [
|
|
"all namespaced resources must target unidesk-dev",
|
|
"required foundation resources, backend-core-dev/frontend-dev resources, or code-queue-dev resources must exist",
|
|
"mdtodo-dev and claudeqq-dev service manifests may be validated independently when those dev workload manifests are selected",
|
|
"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",
|
|
"prewarm-images imports dev foundation images from Docker into native k3s containerd on D601",
|
|
],
|
|
};
|
|
}
|
|
|
|
export function runDevEnvCommand(args: string[]): unknown {
|
|
const action = args[0];
|
|
if (action === undefined || isHelpArg(action)) return devEnvHelp();
|
|
if (action === "prewarm-images") {
|
|
const options = parsePrewarmImagesOptions(args.slice(1));
|
|
const script = prewarmImagesScript(options);
|
|
const command = [process.execPath, "scripts/cli.ts", "ssh", options.providerId, "argv", "bash", "-lc", script];
|
|
if (options.dryRun) {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
providerId: options.providerId,
|
|
images: options.images,
|
|
proxyUrl: options.proxyUrl,
|
|
pullMissing: options.pullMissing,
|
|
pullTimeoutMs: options.pullTimeoutMs,
|
|
command,
|
|
};
|
|
}
|
|
const job = startJob("dev_env_prewarm_images", command, `Prewarm ${options.images.length} dev foundation image(s) into ${options.providerId} native k3s containerd`);
|
|
return {
|
|
ok: true,
|
|
providerId: options.providerId,
|
|
images: options.images,
|
|
proxyUrl: options.proxyUrl,
|
|
pullMissing: options.pullMissing,
|
|
pullTimeoutMs: options.pullTimeoutMs,
|
|
job,
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
};
|
|
}
|
|
if (action !== "validate") throw new Error("dev-env usage: bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run] OR dev-env prewarm-images");
|
|
|
|
const options = parseValidateOptions(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 requiredKinds = requiredResourcesFor(resources);
|
|
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,
|
|
};
|
|
}
|