fix: prewarm d601 dev k3s images

This commit is contained in:
Codex
2026-05-17 18:33:25 +00:00
parent 465f4a626b
commit bfaea963ad
6 changed files with 187 additions and 10 deletions
+181 -7
View File
@@ -1,10 +1,17 @@
import { readFileSync } from "node:fs";
import { runCommand } from "./command";
import { repoRoot, rootPath } from "./config";
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",
@@ -30,17 +37,36 @@ interface ManifestDocument {
namespace: string | null;
}
interface DevEnvOptions {
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 parseOptions(args: string[]): DevEnvOptions {
const options: DevEnvOptions = { manifestPath: defaultManifest, kubectlDryRun: false };
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") {
@@ -57,6 +83,51 @@ function parseOptions(args: string[]): DevEnvOptions {
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;
@@ -135,17 +206,92 @@ function kubectlDryRun(manifestPath: string): unknown {
};
}
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}`,
"kubeconfig=/etc/rancher/k3s/k3s.yaml",
"ctr_address=/run/k3s/containerd/containerd.sock",
"export DOCKER_CONFIG=/tmp/unidesk-dev-env-docker-config",
"mkdir -p \"$DOCKER_CONFIG\"",
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
"printf 'dev_env_k3s_context='",
"KUBECONFIG=\"$kubeconfig\" kubectl config current-context",
"printf 'dev_env_k3s_nodes='",
"KUBECONFIG=\"$kubeconfig\" 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 validate",
usage: "bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]",
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 or backend-core-dev/frontend-dev 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",
"prewarm-images imports dev foundation images from Docker into native k3s containerd on D601",
],
};
}
@@ -153,9 +299,37 @@ function devEnvHelp(): Record<string, unknown> {
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]");
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 = parseOptions(args.slice(1));
const options = parseValidateOptions(args.slice(1));
const manifestPath = rootPath(options.manifestPath);
const manifestText = readFileSync(manifestPath, "utf8");
const docs = parseManifestDocuments(manifestText);