218 lines
9.2 KiB
JavaScript
Executable File
218 lines
9.2 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
function option(name) {
|
|
const index = process.argv.indexOf(name);
|
|
if (index === -1) return null;
|
|
const value = process.argv[index + 1];
|
|
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function record(value, path) {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value;
|
|
}
|
|
|
|
function required(value, path) {
|
|
if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${path} must be a non-empty single-line string`);
|
|
return value;
|
|
}
|
|
|
|
function run(command, args, cwd, allowFailure = false) {
|
|
const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
|
if (result.error !== undefined) throw result.error;
|
|
if (result.status !== 0 && !allowFailure) {
|
|
const detail = `${result.stderr || result.stdout || "command failed"}`.trim().slice(-2000);
|
|
throw new Error(`${command} ${args.join(" ")} failed (${result.status}): ${detail}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function safeManifestPath(value) {
|
|
const path = required(value, "delivery.gitops.manifestPath");
|
|
if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.manifestPath must be a safe relative path");
|
|
return path;
|
|
}
|
|
|
|
function safeReleaseStatePath(value) {
|
|
const path = required(value, "delivery.gitops.releaseStatePath");
|
|
if (path.startsWith("/") || path.split("/").includes("..")) throw new Error("delivery.gitops.releaseStatePath must be a safe relative path");
|
|
return path;
|
|
}
|
|
|
|
function releaseValue(releaseDir, name) {
|
|
return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`);
|
|
}
|
|
|
|
function removeFile(path) {
|
|
try {
|
|
unlinkSync(path);
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
function readReleaseState(path, serviceRef) {
|
|
let value;
|
|
try {
|
|
value = record(JSON.parse(readFileSync(path, "utf8")), path);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
if (value.kind !== "UniDeskHostReleaseState") throw new Error(`${path}.kind must be UniDeskHostReleaseState`);
|
|
if (value.serviceRef !== serviceRef) throw new Error(`${path}.serviceRef must match delivery.serviceRef`);
|
|
const sourceCommit = required(value.sourceCommit, `${path}.sourceCommit`);
|
|
const digest = required(value.digest, `${path}.digest`);
|
|
const digestRef = required(value.digestRef, `${path}.digestRef`);
|
|
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error(`${path}.sourceCommit must be a full Git commit SHA`);
|
|
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error(`${path}.digest must be sha256:<64 hex>`);
|
|
return { sourceCommit, digest, digestRef };
|
|
}
|
|
|
|
function main() {
|
|
const configPath = resolve(option("--config") ?? "config/unidesk-host-k8s.yaml");
|
|
const sourceRoot = resolve(option("--source-root") ?? process.cwd());
|
|
const metadataPath = resolve(required(option("--metadata"), "--metadata"));
|
|
const releaseDir = resolve(required(option("--release-dir"), "--release-dir"));
|
|
const sourceCommit = required(option("--source-commit"), "--source-commit");
|
|
const worktree = resolve(option("--worktree") ?? "/workspace/unidesk-host-gitops");
|
|
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full Git commit SHA");
|
|
|
|
const config = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configPath);
|
|
const delivery = record(config.delivery, "delivery");
|
|
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
|
|
const enabled = delivery.enabled === true;
|
|
const action = releaseValue(releaseDir, "action");
|
|
const reason = releaseValue(releaseDir, "reason");
|
|
const baselineSourceCommit = readFileSync(resolve(releaseDir, "baseline-source-commit"), "utf8").trim() || null;
|
|
if (!new Set(["build", "skip", "disabled"]).has(action)) throw new Error(`${releaseDir}/action must be build, skip, or disabled`);
|
|
if ((enabled && action === "disabled") || (!enabled && action !== "disabled")) throw new Error("release action does not match delivery.enabled");
|
|
const serviceRef = required(delivery.serviceRef, "delivery.serviceRef");
|
|
const imageConfig = record(delivery.image, "delivery.image");
|
|
const gitops = record(delivery.gitops, "delivery.gitops");
|
|
const author = record(gitops.author, "delivery.gitops.author");
|
|
const readUrl = required(gitops.readUrl, "delivery.gitops.readUrl");
|
|
const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl");
|
|
const branch = required(gitops.branch, "delivery.gitops.branch");
|
|
const manifestPath = safeManifestPath(gitops.manifestPath);
|
|
const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath);
|
|
let digest = null;
|
|
let digestRef = null;
|
|
let manifest = null;
|
|
let runtimeSourceCommit = null;
|
|
|
|
if (action === "build") {
|
|
const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath);
|
|
digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`);
|
|
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("containerimage.digest must be sha256:<64 hex>");
|
|
digestRef = `${required(imageConfig.repository, "delivery.image.repository")}@${digest}`;
|
|
runtimeSourceCommit = sourceCommit;
|
|
const render = run("bun", [
|
|
"scripts/native/deploy/render-unidesk-host-service.mjs",
|
|
"--config",
|
|
configPath,
|
|
"--service-ref",
|
|
serviceRef,
|
|
"--image",
|
|
digestRef,
|
|
"--source-commit",
|
|
sourceCommit,
|
|
], sourceRoot);
|
|
manifest = render.stdout;
|
|
if (!manifest.includes("kind: Deployment") || !manifest.includes("kind: Service")) throw new Error("service renderer did not emit Service and Deployment objects");
|
|
}
|
|
|
|
rmSync(worktree, { recursive: true, force: true });
|
|
run("git", ["clone", "--no-checkout", readUrl, worktree], sourceRoot);
|
|
const fetched = run("git", ["fetch", "origin", branch], worktree, true).status === 0;
|
|
if (fetched) {
|
|
run("git", ["checkout", "-B", branch, `origin/${branch}`], worktree);
|
|
} else {
|
|
run("git", ["checkout", "--orphan", branch], worktree);
|
|
run("git", ["rm", "-rf", "."], worktree, true);
|
|
}
|
|
|
|
const targetPath = resolve(worktree, manifestPath);
|
|
const statePath = resolve(worktree, releaseStatePath);
|
|
if (!targetPath.startsWith(`${worktree}/`)) throw new Error("resolved manifest path escaped the GitOps worktree");
|
|
if (!statePath.startsWith(`${worktree}/`)) throw new Error("resolved release state path escaped the GitOps worktree");
|
|
const existingState = readReleaseState(statePath, serviceRef);
|
|
if (action === "skip") {
|
|
if (existingState === null) throw new Error("skip requires an existing GitOps release state");
|
|
digest = existingState.digest;
|
|
digestRef = existingState.digestRef;
|
|
runtimeSourceCommit = existingState.sourceCommit;
|
|
} else if (action === "build" && manifest !== null) {
|
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
writeFileSync(targetPath, manifest, "utf8");
|
|
mkdirSync(dirname(statePath), { recursive: true });
|
|
writeFileSync(statePath, `${JSON.stringify({
|
|
version: 1,
|
|
kind: "UniDeskHostReleaseState",
|
|
serviceRef,
|
|
sourceCommit,
|
|
digest,
|
|
digestRef,
|
|
}, null, 2)}\n`, "utf8");
|
|
} else {
|
|
removeFile(targetPath);
|
|
removeFile(statePath);
|
|
}
|
|
|
|
run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree);
|
|
run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree);
|
|
run("git", ["add", "-A"], worktree);
|
|
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
|
|
if (changed) {
|
|
const commitAction = action === "build" ? "deploy" : "remove";
|
|
run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree);
|
|
}
|
|
|
|
run("git", ["remote", "set-url", "origin", writeUrl], worktree);
|
|
let pushAttempts = 0;
|
|
if (changed) {
|
|
let pushed = false;
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
pushAttempts = attempt;
|
|
const push = run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true);
|
|
if (push.status === 0) {
|
|
pushed = true;
|
|
break;
|
|
}
|
|
run("git", ["fetch", "origin", branch], worktree);
|
|
const rebase = run("git", ["rebase", `origin/${branch}`], worktree, true);
|
|
if (rebase.status !== 0) {
|
|
run("git", ["rebase", "--abort"], worktree, true);
|
|
throw new Error(`GitOps rebase failed after push attempt ${attempt}`);
|
|
}
|
|
}
|
|
if (!pushed) throw new Error("GitOps push failed after 3 attempts");
|
|
}
|
|
|
|
const head = run("git", ["rev-parse", "HEAD"], worktree, !enabled && !changed);
|
|
const gitopsCommit = head.status === 0 ? head.stdout.trim() : null;
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: true,
|
|
phase: "gitops-publish",
|
|
action,
|
|
reason,
|
|
status: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
|
|
imageStatus: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
|
|
sourceCommit,
|
|
baselineSourceCommit,
|
|
runtimeSourceCommit,
|
|
digest,
|
|
digestRef,
|
|
gitopsCommit,
|
|
changed,
|
|
pushAttempts,
|
|
valuesPrinted: false,
|
|
})}\n`);
|
|
}
|
|
|
|
if (!process.execArgv.includes("--check")) main();
|