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 { 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 { 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, }; }