chore: 初始化 ApiState 服务
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
source_root=${SOURCE_ROOT:?SOURCE_ROOT is required}
|
||||
source_commit=${SOURCE_COMMIT:?SOURCE_COMMIT is required}
|
||||
image_repository=${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}
|
||||
dockerfile=${DOCKERFILE:?DOCKERFILE is required}
|
||||
build_network=${BUILD_NETWORK:?BUILD_NETWORK is required}
|
||||
metadata_file=${BUILD_METADATA_FILE:?BUILD_METADATA_FILE is required}
|
||||
result_file=${BUILD_RESULT_FILE:?BUILD_RESULT_FILE is required}
|
||||
|
||||
case "$source_commit" in
|
||||
*[!0-9a-f]*|'') printf '%s\n' 'SOURCE_COMMIT must be a full lowercase Git SHA' >&2; exit 2 ;;
|
||||
esac
|
||||
[ "${#source_commit}" -eq 40 ] || { printf '%s\n' 'SOURCE_COMMIT must be 40 characters' >&2; exit 2; }
|
||||
[ "$build_network" = host ] || { printf '%s\n' 'BUILD_NETWORK must be host' >&2; exit 2; }
|
||||
case "$dockerfile" in
|
||||
/*|*../*|*/..|..) printf '%s\n' 'DOCKERFILE must be a safe relative path' >&2; exit 2 ;;
|
||||
esac
|
||||
[ -f "$source_root/$dockerfile" ] || { printf '%s\n' 'Dockerfile is missing' >&2; exit 2; }
|
||||
|
||||
image_ref="$image_repository:$(printf '%s' "$source_commit" | cut -c1-12)"
|
||||
rm -f "$metadata_file" "$result_file"
|
||||
|
||||
buildctl-daemonless.sh build \
|
||||
--allow network.host \
|
||||
--frontend dockerfile.v0 \
|
||||
--local context="$source_root" \
|
||||
--local dockerfile="$source_root" \
|
||||
--opt "filename=$dockerfile" \
|
||||
--opt "network=$build_network" \
|
||||
--opt "build-arg:HTTP_PROXY=${HTTP_PROXY:-}" \
|
||||
--opt "build-arg:HTTPS_PROXY=${HTTPS_PROXY:-}" \
|
||||
--opt "build-arg:ALL_PROXY=${ALL_PROXY:-}" \
|
||||
--opt "build-arg:NO_PROXY=${NO_PROXY:-}" \
|
||||
--metadata-file "$metadata_file" \
|
||||
--output "type=image,name=$image_ref,push=true,registry.insecure=true"
|
||||
|
||||
[ -s "$metadata_file" ] || { printf '%s\n' 'BuildKit metadata is missing' >&2; exit 2; }
|
||||
digest=$(tr -d '\n' < "$metadata_file" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
case "$digest" in
|
||||
sha256:????????????????????????????????????????????????????????????????) ;;
|
||||
*) printf '%s\n' 'BuildKit metadata digest is invalid' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
printf '{"ok":true,"phase":"image-build","status":"built","imageStatus":"built","sourceCommit":"%s","imageRef":"%s","digest":"%s","digestRef":"%s@%s","valuesPrinted":false}\n' \
|
||||
"$source_commit" "$image_ref" "$digest" "$image_repository" "$digest" > "$result_file"
|
||||
cat "$result_file"
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { parseAllDocuments } from "yaml";
|
||||
|
||||
const placeholders = {
|
||||
image: "__SUB2RANK_IMAGE_REF__",
|
||||
configBase64: "__SUB2RANK_CONFIG_BASE64__",
|
||||
configSha256: "__SUB2RANK_CONFIG_SHA256__",
|
||||
sourceCommit: "__SUB2RANK_SOURCE_COMMIT__",
|
||||
};
|
||||
|
||||
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 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 record(value, path) {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function safePath(value, path) {
|
||||
const result = required(value, path);
|
||||
if (result.startsWith("/") || result.split(/[\\/]/u).includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(result)) throw new Error(`${path} must be a safe relative path`);
|
||||
return result;
|
||||
}
|
||||
|
||||
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} failed (${result.status}): ${detail}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function replaceAllRequired(template, replacements) {
|
||||
let output = template;
|
||||
for (const [from, to] of replacements) {
|
||||
if (!output.includes(from)) throw new Error(`manifest template is missing ${from}`);
|
||||
output = output.split(from).join(to);
|
||||
}
|
||||
for (const placeholder of Object.values(placeholders)) {
|
||||
if (output.includes(placeholder)) throw new Error(`manifest template retained ${placeholder}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateManifest(manifest, expected) {
|
||||
const documents = parseAllDocuments(manifest);
|
||||
const errors = documents.flatMap((document) => document.errors);
|
||||
if (errors.length > 0) throw new Error(`rendered manifest is invalid YAML: ${errors[0].message}`);
|
||||
const objects = documents.map((document) => document.toJSON()).filter((value) => value !== null).map((value, index) => record(value, `manifest[${index}]`));
|
||||
const deployment = objects.find((item) => item.kind === "Deployment" && item.metadata?.name === "sub2rank");
|
||||
const service = objects.find((item) => item.kind === "Service" && item.metadata?.name === "sub2rank");
|
||||
const configMap = objects.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "sub2rank-config");
|
||||
if (deployment === undefined || service === undefined || configMap === undefined) throw new Error("rendered manifest must contain the Sub2Rank Deployment, Service and ConfigMap");
|
||||
if (deployment.spec?.template?.spec?.containers?.[0]?.image !== expected.digestRef) throw new Error("rendered Deployment image is not the expected digest reference");
|
||||
if (deployment.spec?.template?.metadata?.annotations?.["unidesk.ai/source-commit"] !== expected.sourceCommit) throw new Error("rendered Deployment source commit annotation is incorrect");
|
||||
if (deployment.spec?.template?.metadata?.annotations?.["unidesk.ai/sub2rank-config-sha256"] !== expected.configSha256) throw new Error("rendered Deployment config SHA annotation is incorrect");
|
||||
if (configMap.binaryData?.["sub2rank.yaml"] !== expected.configBase64) throw new Error("rendered ConfigMap does not contain the exact source config");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const sourceRoot = resolve(required(option("--source-root"), "--source-root"));
|
||||
const sourceCommit = required(option("--source-commit"), "--source-commit");
|
||||
const configPath = safePath(required(option("--config-path"), "--config-path"), "--config-path");
|
||||
const metadataPath = resolve(required(option("--metadata"), "--metadata"));
|
||||
const templateB64 = required(option("--manifest-template-b64"), "--manifest-template-b64");
|
||||
const imageRepository = required(option("--image-repository"), "--image-repository");
|
||||
const readUrl = required(option("--gitops-read-url"), "--gitops-read-url");
|
||||
const writeUrl = required(option("--gitops-write-url"), "--gitops-write-url");
|
||||
const branch = required(option("--gitops-branch"), "--gitops-branch");
|
||||
const manifestPath = safePath(required(option("--manifest-path"), "--manifest-path"), "--manifest-path");
|
||||
const releaseStatePath = safePath(required(option("--release-state-path"), "--release-state-path"), "--release-state-path");
|
||||
const maxPushAttempts = Number(required(option("--max-push-attempts"), "--max-push-attempts"));
|
||||
const authorName = required(option("--author-name"), "--author-name");
|
||||
const authorEmail = required(option("--author-email"), "--author-email");
|
||||
const worktree = resolve(required(option("--worktree"), "--worktree"));
|
||||
if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full lowercase Git SHA");
|
||||
if (!Number.isInteger(maxPushAttempts) || maxPushAttempts < 1 || maxPushAttempts > 5) throw new Error("--max-push-attempts must be an integer from 1 to 5");
|
||||
if (run("git", ["rev-parse", "HEAD"], sourceRoot).stdout.trim() !== sourceCommit) throw new Error("source checkout does not match --source-commit");
|
||||
|
||||
const configText = readFileSync(resolve(sourceRoot, configPath), "utf8");
|
||||
const appConfig = record(Bun.YAML.parse(configText), configPath);
|
||||
if (appConfig.kind !== "Sub2Rank") throw new Error(`${configPath}.kind must be Sub2Rank`);
|
||||
const lottery = record(appConfig.lottery, `${configPath}.lottery`);
|
||||
const automaticCredit = record(lottery.automaticCredit, `${configPath}.lottery.automaticCredit`);
|
||||
if (typeof automaticCredit.enabled !== "boolean" || !new Set(["dry-run", "live"]).has(automaticCredit.mode)) throw new Error(`${configPath}.lottery.automaticCredit must declare enabled and mode`);
|
||||
const configBase64 = Buffer.from(configText, "utf8").toString("base64");
|
||||
const configSha256 = sha256(configText);
|
||||
|
||||
const metadata = record(JSON.parse(readFileSync(metadataPath, "utf8")), metadataPath);
|
||||
const digest = required(metadata["containerimage.digest"], `${metadataPath}.containerimage.digest`);
|
||||
if (!/^sha256:[0-9a-f]{64}$/u.test(digest)) throw new Error("BuildKit metadata digest is invalid");
|
||||
const digestRef = `${imageRepository}@${digest}`;
|
||||
const template = Buffer.from(templateB64, "base64").toString("utf8");
|
||||
const manifest = replaceAllRequired(template, [
|
||||
[placeholders.image, digestRef],
|
||||
[placeholders.configBase64, configBase64],
|
||||
[placeholders.configSha256, configSha256],
|
||||
[placeholders.sourceCommit, sourceCommit],
|
||||
]);
|
||||
validateManifest(manifest, { digestRef, sourceCommit, configSha256, configBase64 });
|
||||
|
||||
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}/`) || !statePath.startsWith(`${worktree}/`)) throw new Error("GitOps output path escaped the worktree");
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
writeFileSync(targetPath, manifest, "utf8");
|
||||
mkdirSync(dirname(statePath), { recursive: true });
|
||||
writeFileSync(statePath, `${JSON.stringify({
|
||||
version: 1,
|
||||
kind: "Sub2RankReleaseState",
|
||||
service: "sub2rank",
|
||||
sourceCommit,
|
||||
configSha256,
|
||||
digest,
|
||||
digestRef,
|
||||
manifestPath,
|
||||
automaticCredit: { enabled: automaticCredit.enabled, mode: automaticCredit.mode },
|
||||
}, null, 2)}\n`, "utf8");
|
||||
|
||||
run("git", ["config", "user.name", authorName], worktree);
|
||||
run("git", ["config", "user.email", authorEmail], worktree);
|
||||
run("git", ["add", "--", manifestPath, releaseStatePath], worktree);
|
||||
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
|
||||
if (changed) run("git", ["commit", "-m", `sub2rank: deploy ${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 <= maxPushAttempts; attempt += 1) {
|
||||
pushAttempts = attempt;
|
||||
if (run("git", ["push", "origin", `HEAD:refs/heads/${branch}`], worktree, true).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 ${maxPushAttempts} attempts`);
|
||||
}
|
||||
const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim();
|
||||
const remoteHead = run("git", ["ls-remote", writeUrl, `refs/heads/${branch}`], worktree).stdout.trim().split(/\s+/u)[0] ?? "";
|
||||
if (remoteHead !== gitopsCommit) throw new Error("GitOps remote branch did not converge to the published commit");
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
phase: "gitops-publish",
|
||||
status: "built",
|
||||
imageStatus: "built",
|
||||
sourceCommit,
|
||||
runtimeSourceCommit: sourceCommit,
|
||||
configSha256,
|
||||
automaticCredit: { enabled: automaticCredit.enabled, mode: automaticCredit.mode },
|
||||
digest,
|
||||
digestRef,
|
||||
gitopsCommit,
|
||||
changed,
|
||||
pushAttempts,
|
||||
valuesPrinted: false,
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
if (!process.execArgv.includes("--check")) main();
|
||||
@@ -0,0 +1,136 @@
|
||||
import { AdminHttpClient } from "../../src/admin-http-client";
|
||||
import { createEmbeddedContext } from "../../src/bootstrap";
|
||||
import { loadConfig, type EmbeddedCliTarget, type HttpCliTarget } from "../../src/config";
|
||||
|
||||
interface Parsed {
|
||||
configPath: string;
|
||||
targetId: string | null;
|
||||
command: string[];
|
||||
confirm: boolean;
|
||||
includeRecords: boolean;
|
||||
id: string | null;
|
||||
limit: number | null;
|
||||
draws: number | null;
|
||||
}
|
||||
|
||||
function value(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) return null;
|
||||
const result = args[index + 1];
|
||||
if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Parsed {
|
||||
const configPath = value(args, "--config");
|
||||
if (!configPath) throw new Error("--config is required");
|
||||
const optionNames = new Set(["--config", "--target", "--id", "--limit", "--draws"]);
|
||||
const flags = new Set(["--confirm", "--include-records"]);
|
||||
const command: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const item = args[index]!;
|
||||
if (optionNames.has(item)) { index += 1; continue; }
|
||||
if (flags.has(item)) continue;
|
||||
if (item.startsWith("--")) throw new Error(`unknown option ${item}`);
|
||||
command.push(item);
|
||||
}
|
||||
const integer = (name: string): number | null => {
|
||||
const raw = value(args, name);
|
||||
if (raw === null) return null;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${name} must be a non-negative integer`);
|
||||
return parsed;
|
||||
};
|
||||
return {
|
||||
configPath,
|
||||
targetId: value(args, "--target"),
|
||||
command,
|
||||
confirm: args.includes("--confirm"),
|
||||
includeRecords: args.includes("--include-records"),
|
||||
id: value(args, "--id"),
|
||||
limit: integer("--limit"),
|
||||
draws: integer("--draws"),
|
||||
};
|
||||
}
|
||||
|
||||
function help(): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
usage: "bun scripts/sub2rank-cli.ts --config config/sub2rank.yaml [--target local|production] <command>",
|
||||
commands: [
|
||||
"config validate",
|
||||
"backend check",
|
||||
"lottery status",
|
||||
"lottery draw [--confirm]",
|
||||
"lottery reset [--draws N] [--include-records] --confirm",
|
||||
"records list [--limit N]",
|
||||
"records delete --id <record-id> --confirm",
|
||||
"credit test [--confirm]",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function embedded(parsed: Parsed, config: ReturnType<typeof loadConfig>, target: EmbeddedCliTarget): Promise<unknown> {
|
||||
const context = createEmbeddedContext(config, target);
|
||||
try {
|
||||
const [group, action] = parsed.command;
|
||||
if (group === "backend" && action === "check") return await context.service.status(true);
|
||||
if (group === "lottery" && action === "status") return await context.service.status(false);
|
||||
if (group === "lottery" && action === "draw") return parsed.confirm ? { ok: true, record: await context.service.draw() } : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" };
|
||||
if (group === "lottery" && action === "reset") {
|
||||
const draws = parsed.draws ?? config.lottery.initialDrawCount;
|
||||
return parsed.confirm ? context.service.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "records" && action === "list") return { ok: true, records: context.service.listRecords(parsed.limit ?? config.records.publicLimit) };
|
||||
if (group === "records" && action === "delete") {
|
||||
if (!parsed.id) throw new Error("records delete requires --id");
|
||||
return parsed.confirm ? context.service.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "credit" && action === "test") return await context.service.creditTest(parsed.confirm);
|
||||
throw new Error(`unknown command: ${parsed.command.join(" ")}`);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function remote(parsed: Parsed, config: ReturnType<typeof loadConfig>, target: HttpCliTarget): Promise<unknown> {
|
||||
const client = new AdminHttpClient(config, target);
|
||||
const [group, action] = parsed.command;
|
||||
if (group === "backend" && action === "check") return await client.backendCheck();
|
||||
if (group === "lottery" && action === "status") return await client.status();
|
||||
if (group === "lottery" && action === "draw") return parsed.confirm ? await client.draw() : { ok: true, mutation: false, action: "lottery-draw", hint: "add --confirm to execute" };
|
||||
if (group === "lottery" && action === "reset") {
|
||||
const draws = parsed.draws ?? config.lottery.initialDrawCount;
|
||||
return parsed.confirm ? await client.reset(draws, parsed.includeRecords) : { ok: true, mutation: false, action: "lottery-reset", draws, includeRecords: parsed.includeRecords, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "records" && action === "list") return await client.records(parsed.limit ?? config.records.publicLimit);
|
||||
if (group === "records" && action === "delete") {
|
||||
if (!parsed.id) throw new Error("records delete requires --id");
|
||||
return parsed.confirm ? await client.deleteRecord(parsed.id) : { ok: true, mutation: false, action: "record-delete", id: parsed.id, hint: "add --confirm to execute" };
|
||||
}
|
||||
if (group === "credit" && action === "test") return await client.creditTest(parsed.confirm);
|
||||
throw new Error(`unknown command: ${parsed.command.join(" ")}`);
|
||||
}
|
||||
|
||||
export async function runCli(args: string[]): Promise<void> {
|
||||
try {
|
||||
if (args.includes("--help") || args.length === 0) {
|
||||
console.log(JSON.stringify(help(), null, 2));
|
||||
return;
|
||||
}
|
||||
const parsed = parseArgs(args);
|
||||
const config = loadConfig(parsed.configPath);
|
||||
if (parsed.command.join(" ") === "config validate") {
|
||||
console.log(JSON.stringify({ ok: true, configPath: config.configPath, kind: config.kind, automaticCreditEnabled: config.lottery.automaticCredit.enabled, excludedIdentities: config.lottery.eligibility.excludedIdentities }, null, 2));
|
||||
return;
|
||||
}
|
||||
const targetId = parsed.targetId ?? config.runtime.defaultCliTarget;
|
||||
const target = config.runtime.cliTargets[targetId];
|
||||
if (!target) throw new Error(`runtime.cliTargets.${targetId} does not exist`);
|
||||
const result = target.mode === "embedded" ? await embedded(parsed, config, target) : await remote(parsed, config, target);
|
||||
console.log(JSON.stringify({ target: targetId, ...result as Record<string, unknown> }, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bun
|
||||
import { runCli } from "./src/cli";
|
||||
|
||||
await runCli(process.argv.slice(2));
|
||||
Reference in New Issue
Block a user