Files
pikasTech-unidesk/scripts/native/cicd/deliver-platform-infra-public-edge.mjs
T
2026-07-18 17:59:04 +02:00

238 lines
11 KiB
JavaScript

#!/usr/bin/env bun
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
const options = parseOptions(process.argv.slice(2));
const sourceRoot = resolve(options.sourceRoot);
const configPath = resolve(sourceRoot, options.config);
const root = Bun.YAML.parse(readFileSync(configPath, "utf8"));
const plan = readPlan(root, sourceRoot, configPath);
verifySourceCommit(sourceRoot, options.sourceCommit, plan.sourceCommitEnvironment);
if (options.dryRun) {
output({ ok: true, action: "platform-infra-public-edge-delivery-plan", mutation: false, sourceCommit: options.sourceCommit, ...plan.summary });
process.exit(0);
}
const token = process.env[plan.secret.targetKey]?.trim() ?? "";
if (token.length === 0) fail(`缺少 SecretRef 投影环境变量:${plan.secret.targetKey}`);
const temporaryRoot = mkdtempSync(join(tmpdir(), "unidesk-public-edge-delivery-"));
try {
const archivePath = createArchive(sourceRoot, options.sourceCommit, plan.sourcePaths, temporaryRoot);
const archiveSha256 = sha256File(archivePath);
const route = `${plan.route}:${plan.hostCwd}`;
const remoteArchive = `${plan.archiveRoot}/incoming/public-edge-${options.sourceCommit}.tar.gz`;
const transPath = join(sourceRoot, "scripts/trans");
const env = { ...process.env, UNIDESK_TRANS_REPO_ROOT: sourceRoot };
runTrans(transPath, [route, "sh", "--", `umask 077; mkdir -p ${shellQuote(`${plan.archiveRoot}/incoming`)} ${shellQuote(`${plan.archiveRoot}/tmp`)}`], env);
runTrans(transPath, [route, "upload", archivePath, remoteArchive], env);
const remote = runTrans(transPath, [route, "sh"], env, remoteApplyScript({
archivePath: remoteArchive,
archiveRoot: plan.archiveRoot,
archiveSha256,
authorityId: plan.authorityId,
config: options.config,
entrypoint: plan.entrypoint,
sourceCommit: options.sourceCommit,
targetId: plan.targetId,
}));
const runtime = parseOutput(remote.stdout);
if (runtime.ok !== true) fail("host-local public-edge reconcile 未返回 ok=true");
output({
ok: true,
action: "platform-infra-public-edge-delivery",
mutation: runtime.mutation === true,
sourceCommit: options.sourceCommit,
archive: { sha256: `sha256:${archiveSha256}`, sourcePaths: plan.sourcePaths },
runtime,
...plan.summary,
});
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
function parseOptions(args) {
let sourceRoot = null;
let sourceCommit = null;
let config = "config/platform-infra/public-edge.yaml";
let dryRun = false;
let confirm = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--dry-run") dryRun = true;
else if (arg === "--confirm") confirm = true;
else if (arg === "--source-root" || arg === "--source-commit" || arg === "--config") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) fail(`${arg} 需要参数`);
if (arg === "--source-root") sourceRoot = value;
else if (arg === "--source-commit") sourceCommit = value;
else config = value;
index += 1;
} else fail(`不支持的参数:${arg}`);
}
if (sourceRoot === null) fail("缺少 --source-root");
if (sourceCommit === null || !/^[0-9a-f]{40}$/u.test(sourceCommit)) fail("--source-commit 必须是 40 位 commit");
if (dryRun === confirm) fail("必须且只能选择 --dry-run 或 --confirm");
return { sourceRoot, sourceCommit, config, dryRun, confirm };
}
function readPlan(root, sourceRoot, configPath) {
const delivery = record(root.delivery, "delivery");
if (delivery.enabled !== true) fail("delivery.enabled 必须为 true");
const defaults = record(root.defaults, "defaults");
const targetId = string(defaults.targetId, "defaults.targetId");
const target = record(record(root.targets, "targets")[targetId], `targets.${targetId}`);
const transport = record(delivery.transport, "delivery.transport");
const secret = record(transport.secretRef, "delivery.transport.secretRef");
const helper = record(delivery.hostHelper, "delivery.hostHelper");
const sourcePaths = array(helper.sourcePaths, "delivery.hostHelper.sourcePaths").map((value, index) => relativePath(value, `delivery.hostHelper.sourcePaths[${index}]`));
const entrypoint = relativePath(helper.entrypoint, "delivery.hostHelper.entrypoint");
const configRelative = relativeWithin(sourceRoot, configPath);
if (!sourcePaths.some((path) => entrypoint === path || entrypoint.startsWith(`${path}/`))) fail("host helper 不在 sourcePaths 中");
if (!sourcePaths.some((path) => configRelative === path || configRelative.startsWith(`${path}/`))) fail("public-edge config 不在 sourcePaths 中");
const secretSummary = {
configRef: relativePath(secret.configRef, "delivery.transport.secretRef.configRef"),
targetId: string(secret.targetId, "delivery.transport.secretRef.targetId"),
secretName: string(secret.secretName, "delivery.transport.secretRef.secretName"),
targetKey: string(secret.targetKey, "delivery.transport.secretRef.targetKey"),
};
verifySecretBinding(sourceRoot, secretSummary);
const authorityId = string(delivery.authorityId, "delivery.authorityId");
const archiveRoot = absolutePath(helper.archiveRoot, "delivery.hostHelper.archiveRoot");
return {
authorityId,
sourceCommitEnvironment: string(delivery.sourceCommitEnvironment, "delivery.sourceCommitEnvironment"),
targetId,
route: string(target.route, `targets.${targetId}.route`),
hostCwd: absolutePath(transport.hostCwd, "delivery.transport.hostCwd"),
archiveRoot,
entrypoint,
sourcePaths,
secret: secretSummary,
summary: {
authorityId,
sourceBranch: string(delivery.sourceBranch, "delivery.sourceBranch"),
pipelineId: string(delivery.pipelineId, "delivery.pipelineId"),
configRef: `${configRelative}#targets.${targetId}`,
hostHelper: { archiveRoot, entrypoint, sourcePaths },
secretRef: secretSummary,
},
};
}
function verifySecretBinding(sourceRoot, secret) {
const root = Bun.YAML.parse(readFileSync(resolve(sourceRoot, secret.configRef), "utf8"));
const target = array(root.targets, "secret targets").find((item) => record(item, "secret target").id === secret.targetId);
if (target === undefined || record(target, "secret target").enabled !== true) fail(`Secret target 未启用:${secret.targetId}`);
const binding = array(root.kubernetesSecrets, "kubernetesSecrets").find((item) => record(item, "secret binding").name === secret.secretName);
if (binding === undefined) fail(`Secret binding 不存在:${secret.secretName}`);
const item = array(record(binding, "secret binding").data, "secret data").find((entry) => record(entry, "secret data").targetKey === secret.targetKey);
if (item === undefined) fail(`Secret targetKey 不存在:${secret.targetKey}`);
}
function verifySourceCommit(sourceRoot, sourceCommit, environmentName) {
if ((process.env[environmentName]?.trim() ?? "") !== sourceCommit) fail(`${environmentName} 与 --source-commit 不一致`);
const result = runChecked("git", ["rev-parse", "HEAD"], sourceRoot);
if (result.stdout.trim() !== sourceCommit) fail("source checkout HEAD 与 --source-commit 不一致");
}
function createArchive(sourceRoot, sourceCommit, sourcePaths, temporaryRoot) {
const tarPath = join(temporaryRoot, "source.tar");
const markerPath = join(temporaryRoot, ".unidesk-source-commit");
const ancestryPath = join(temporaryRoot, ".unidesk-source-ancestors");
writeFileSync(markerPath, `${sourceCommit}\n`, { mode: 0o600 });
const ancestry = runChecked("git", ["rev-list", sourceCommit], sourceRoot).stdout;
writeFileSync(ancestryPath, ancestry, { mode: 0o600 });
runChecked("git", ["archive", "--format=tar", "--prefix=source/", `--output=${tarPath}`, sourceCommit, "--", ...sourcePaths], sourceRoot);
runChecked("tar", ["--append", "--file", tarPath, "--directory", temporaryRoot, ".unidesk-source-commit", ".unidesk-source-ancestors"], sourceRoot);
runChecked("gzip", ["-n", tarPath], sourceRoot);
return `${tarPath}.gz`;
}
function remoteApplyScript(input) {
return `set -eu
archive=${shellQuote(input.archivePath)}
archive_root=${shellQuote(input.archiveRoot)}
expected_sha=${shellQuote(input.archiveSha256)}
source_commit=${shellQuote(input.sourceCommit)}
actual_sha="$(sha256sum "$archive" | awk '{print $1}')"
test "$actual_sha" = "$expected_sha"
tmp="$(mktemp -d "$archive_root/tmp/$source_commit.XXXXXX")"
cleanup() { rm -rf "$tmp"; rm -f "$archive"; }
trap cleanup EXIT HUP INT TERM
tar -xzf "$archive" -C "$tmp"
test "$(cat "$tmp/.unidesk-source-commit")" = "$source_commit"
cd "$tmp/source"
bun ${shellQuote(input.entrypoint)} --source-root "$tmp/source" --source-commit "$source_commit" --authority ${shellQuote(input.authorityId)} --config ${shellQuote(input.config)} --target ${shellQuote(input.targetId)} --confirm
`;
}
function runTrans(path, args, env, input) {
const result = spawnSync(path, args, { encoding: "utf8", env, input, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) fail(`trans ${args.slice(1, 3).join(" ")} 失败:${result.stderr || result.stdout}`);
return result;
}
function runChecked(command, args, cwd) {
const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
if (result.status !== 0) fail(`${command} ${args.join(" ")} 失败:${result.stderr || result.stdout}`);
return result;
}
function parseOutput(stdout) {
const text = stdout.trim();
for (const candidate of [text, text.slice(text.lastIndexOf("\n") + 1)]) {
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object") return parsed;
} catch {}
}
return { ok: false, error: "remote-output-invalid", stdoutTail: text.slice(-2000) };
}
function record(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label} 必须是对象`);
return value;
}
function array(value, label) {
if (!Array.isArray(value) || value.length === 0) fail(`${label} 必须是非空数组`);
return value;
}
function string(value, label) {
if (typeof value !== "string" || value.trim().length === 0) fail(`${label} 必须是非空字符串`);
return value.trim();
}
function absolutePath(value, label) {
const path = string(value, label);
if (!isAbsolute(path)) fail(`${label} 必须是绝对路径`);
return path.replace(/\/+$/u, "") || "/";
}
function relativePath(value, label) {
const path = string(value, label);
if (isAbsolute(path) || !/^[A-Za-z0-9._/-]+$/u.test(path) || path.split("/").some((part) => part === "" || part === "..")) fail(`${label} 必须是仓库相对路径`);
return path;
}
function relativeWithin(root, path) {
const prefix = `${root.replace(/\/+$/u, "")}/`;
if (!path.startsWith(prefix)) fail("--config 必须位于 source root");
return path.slice(prefix.length);
}
function sha256File(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function shellQuote(value) {
return `'${String(value).replace(/'/gu, `'\\''`)}'`;
}
function output(value) {
process.stdout.write(`${JSON.stringify({ ...value, valuesPrinted: false }, null, 2)}\n`);
}
function fail(message) {
process.stderr.write(`${JSON.stringify({ ok: false, action: "platform-infra-public-edge-delivery", error: message, valuesPrinted: false })}\n`);
process.exit(1);
}