From 6860482caffe54e4a26e6ad071007aeb66a2c38c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 14:23:34 +0200 Subject: [PATCH] feat: add Sub2API image prepull command --- .agents/skills/unidesk-sub2api/SKILL.md | 1 + .../unidesk-sub2api/references/operations.md | 11 +- scripts/src/platform-infra/entry.ts | 1 + scripts/src/platform-infra/image-prepull.ts | 180 ++++++++++++++++++ scripts/src/platform-infra/options.ts | 64 +++++++ 5 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 scripts/src/platform-infra/image-prepull.ts diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index ada9b662..3b596eb1 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -14,6 +14,7 @@ bun scripts/cli.ts platform-infra sub2api report bun scripts/cli.ts platform-infra sub2api status --target PK01 bun scripts/cli.ts platform-infra sub2api validate --target PK01 bun scripts/cli.ts platform-infra sub2api plan --target PK01 +bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm ``` 先看报表和状态,再做计划或变更。详细规则按职责拆在 `references/` 下;不要新增 `full.md`、`all.md`、`guide.md` 这类变相超级文件。 diff --git a/.agents/skills/unidesk-sub2api/references/operations.md b/.agents/skills/unidesk-sub2api/references/operations.md index 0e703d22..d1286bca 100644 --- a/.agents/skills/unidesk-sub2api/references/operations.md +++ b/.agents/skills/unidesk-sub2api/references/operations.md @@ -39,6 +39,8 @@ bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id --confirm` 预拉 YAML 声明的 Sub2API runtime images;不带 `--confirm` 只做 inspect/dry-run。`--confirm` 默认返回 async job,显式 `--wait` 才同步等待。不得用临时 `trans docker pull ...` 作为长期入口。 + ## D601 Egress Proxy D601 的目标级 `egressProxy` 完全由 `config/platform-infra/sub2api.yaml` 控制。当前成熟形态是 master Docker `shadowsocks-rust` 作为加密出站源,D601 k3s 内 `sing-box` 暴露 HTTP/mixed ClusterIP proxy 给 Sub2API 和按 YAML 启用的 sentinel 使用。不要把 endpoint、端口、密码、健康探针或镜像 tag 写进 skill;只以 YAML 和 `config/platform-infra/sub2api-master-egress-proxy.compose.yaml` 为准。 @@ -89,8 +93,9 @@ proxy secret/config 文件只允许放在受控 Secret/state 路径,输出只 2. 只修改目标 target 的 `image.repository`、`image.tag` 或 `pullPolicy`;不要顺手改全局默认、PK01 host-Docker target、standby target 或其它共用 `api2.pikapython.com` 历史 target。 3. 提交前用 `git diff -- config/platform-infra/sub2api.yaml` 和 `rg -n "id: |tag: |publicBaseUrl"` 确认只有目标 target 变化,且要保护的公开入口没有被误改。 4. 执行 `sub2api plan --target ` 和 `sub2api apply --target --dry-run`,确认策略检查通过。 -5. 提交并推送 YAML source truth 后,执行 `sub2api apply --target --confirm`,按返回的 `job status` 命令轮询完成。 -6. 执行 `sub2api status --target `,确认运行镜像等于 YAML 声明;滚动过程中短暂 `0/1` 先等待并复查,不要立即改账号池、Caddy 或 Secret。 -7. 执行 `sub2api validate --target `、目标 public `/health` 和最小 public `/v1/responses` smoke;若需要确认未影响其它公开入口,再跑对应 target 的 `validate`。 +5. 提交并推送 YAML source truth 后,执行 `sub2api image-prepull --target --confirm`,按返回 job 或 `--wait` 输出确认 YAML 声明的镜像已存在于目标 runtime。 +6. 镜像准备好后执行 `sub2api apply --target --confirm`,按返回的 `job status` 命令轮询完成。 +7. 执行 `sub2api status --target `,确认运行镜像等于 YAML 声明;滚动过程中短暂 `0/1` 先等待并复查,不要立即改账号池、Caddy 或 Secret。 +8. 执行 `sub2api validate --target `、目标 public `/health` 和最小 public `/v1/responses` smoke;若需要确认未影响其它公开入口,再跑对应 target 的 `validate`。 不要把镜像版本写进脚本常量、JSON 或 manifest 模板。 diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index b88a84dd..0db5cbf5 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -393,6 +393,7 @@ export function platformInfraHelp(): unknown { output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]", + "bun scripts/cli.ts platform-infra sub2api image-prepull [--target G14|D601|PK01] [--dry-run|--confirm] [--wait]", "bun scripts/cli.ts platform-infra sub2api apply [--target G14|D601] --dry-run", "bun scripts/cli.ts platform-infra sub2api apply [--target G14|D601] --confirm", "bun scripts/cli.ts platform-infra sub2api status [--target G14|D601] [--full|--raw]", diff --git a/scripts/src/platform-infra/image-prepull.ts b/scripts/src/platform-infra/image-prepull.ts new file mode 100644 index 00000000..4b0013d4 --- /dev/null +++ b/scripts/src/platform-infra/image-prepull.ts @@ -0,0 +1,180 @@ +import type { UniDeskConfig } from "../config"; +import { capture, compactCapture, parseJsonOutput, shQuote } from "../platform-infra-public-service"; +import { startJob } from "../jobs"; + +import { readSub2ApiConfig } from "./config"; +import { imageRef, resolveTarget, targetDependencyImages } from "./manifest"; +import type { ApplyOptions } from "./options"; +import { boolField } from "./utils"; + +function imagePrepullScript(images: string[], confirm: boolean): string { + const imageList = images.join("\n"); + const mode = confirm ? "confirm" : "dry-run"; + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mode=${shQuote(mode)} +images_file="$tmp/images.txt" +cat > "$images_file" <<'EOF_IMAGES' +${imageList} +EOF_IMAGES +docker_bin="" +if docker ps >/dev/null 2>&1; then + docker_bin="docker" +elif sudo docker ps >/dev/null 2>&1; then + docker_bin="sudo docker" +fi +crictl_bin="" +if command -v crictl >/dev/null 2>&1; then + crictl_bin="crictl" +elif sudo crictl images >/dev/null 2>&1; then + crictl_bin="sudo crictl" +fi +runtime="none" +if [ -n "$docker_bin" ]; then + runtime="docker" +elif [ -n "$crictl_bin" ]; then + runtime="crictl" +fi +jsonl="$tmp/images.jsonl" +: > "$jsonl" +ok=true +while IFS= read -r image; do + [ -n "$image" ] || continue + present_before=false + present_after=false + pull_rc=0 + inspect_id="" + inspect_digest="" + safe_name="$(printf '%s' "$image" | tr '/:@' '___')" + pull_stdout="$tmp/$safe_name.pull.out" + pull_stderr="$tmp/$safe_name.pull.err" + : > "$pull_stdout" + : > "$pull_stderr" + if [ "$runtime" = "docker" ]; then + if $docker_bin image inspect "$image" >/dev/null 2>&1; then present_before=true; fi + if [ "$mode" = "confirm" ]; then + $docker_bin pull "$image" >"$pull_stdout" 2>"$pull_stderr" + pull_rc=$? + fi + if $docker_bin image inspect "$image" >/dev/null 2>&1; then + present_after=true + inspect_id="$($docker_bin image inspect "$image" --format '{{.Id}}' 2>/dev/null || true)" + inspect_digest="$($docker_bin image inspect "$image" --format '{{join .RepoDigests ","}}' 2>/dev/null || true)" + fi + elif [ "$runtime" = "crictl" ]; then + if $crictl_bin inspecti "$image" >/dev/null 2>&1; then present_before=true; fi + if [ "$mode" = "confirm" ]; then + $crictl_bin pull "$image" >"$pull_stdout" 2>"$pull_stderr" + pull_rc=$? + fi + if $crictl_bin inspecti "$image" >/dev/null 2>&1; then + present_after=true + inspect_id="$($crictl_bin inspecti "$image" 2>/dev/null | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data.get("status",{}).get("id") or data.get("id") or "")' 2>/dev/null || true)" + fi + else + pull_rc=1 + printf '%s\\n' 'neither docker nor crictl is available for image prepull' >"$pull_stderr" + fi + if [ "$mode" = "confirm" ]; then + if [ "$pull_rc" -ne 0 ] || [ "$present_after" != true ]; then ok=false; fi + else + if [ "$runtime" = "none" ]; then ok=false; fi + fi + python3 - "$jsonl" "$image" "$runtime" "$present_before" "$present_after" "$pull_rc" "$inspect_id" "$inspect_digest" "$pull_stdout" "$pull_stderr" <<'PY' +import json +import sys + +path, image, runtime, before, after, pull_rc, inspect_id, inspect_digest, pull_stdout, pull_stderr = sys.argv[1:] + +def text(file_path: str, limit: int = 4000) -> str: + try: + return open(file_path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" + +record = { + "image": image, + "runtime": runtime, + "presentBefore": before == "true", + "presentAfter": after == "true", + "pull": { + "exitCode": int(pull_rc), + "stdout": text(pull_stdout), + "stderr": text(pull_stderr), + }, + "imageId": inspect_id or None, + "repoDigests": [item for item in inspect_digest.split(",") if item], +} +with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False) + "\\n") +PY +done < "$images_file" +python3 - "$jsonl" "$ok" "$mode" "$runtime" <<'PY' +import json +import sys + +jsonl, ok, mode, runtime = sys.argv[1:] +images = [] +try: + with open(jsonl, encoding="utf-8") as handle: + for line in handle: + if line.strip(): + images.append(json.loads(line)) +except FileNotFoundError: + pass +payload = { + "ok": ok == "true", + "mode": mode, + "runtime": runtime, + "images": images, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +`; +} + +export async function imagePrepull(config: UniDeskConfig, options: ApplyOptions): Promise> { + const sub2api = readSub2ApiConfig(); + const target = resolveTarget(sub2api, options.targetId); + const dependencyImages = targetDependencyImages(sub2api, target); + const images = Array.from(new Set([imageRef(sub2api, target), dependencyImages.redis])); + if (options.confirm && !options.wait) { + const job = startJob( + `platform_infra_sub2api_image_prepull_${target.id.toLowerCase()}`, + ["bun", "scripts/cli.ts", "platform-infra", "sub2api", "image-prepull", "--target", target.id, "--confirm", "--wait"], + `Pre-pull ${target.id} Sub2API runtime images from YAML-controlled image refs`, + ); + return { + ok: true, + action: "platform-infra-sub2api-image-prepull", + mode: "async-job", + target: { id: target.id, route: target.route, namespace: target.namespace }, + images, + job, + statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, + next: { + status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, + verify: `bun scripts/cli.ts platform-infra sub2api image-prepull --target ${target.id}`, + apply: `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm`, + }, + }; + } + const result = await capture(config, target.route, ["sh"], imagePrepullScript(images, options.confirm)); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && boolField(parsed, "ok", false), + action: "platform-infra-sub2api-image-prepull", + mode: options.confirm ? "confirmed" : "dry-run", + target: { id: target.id, route: target.route, namespace: target.namespace }, + images, + remote: parsed ?? compactCapture(result, { full: true }), + next: { + apply: `bun scripts/cli.ts platform-infra sub2api apply --target ${target.id} --confirm`, + status: `bun scripts/cli.ts platform-infra sub2api status --target ${target.id}`, + }, + }; +} diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 8f001fc8..8ac373f6 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -17,6 +17,7 @@ import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, import { apply, plan, status } from "./actions"; import { defaultSub2ApiTargetId, readSub2ApiConfig, validateOptions } from "./config"; import { configPath, platformInfraHelp, serviceName } from "./entry"; +import { imagePrepull } from "./image-prepull"; import { isHostDockerTarget, resolveTarget } from "./manifest"; import { validate } from "./policy"; @@ -88,6 +89,11 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin const result = await apply(config, options); return options.full || options.raw ? result : renderSub2ApiApply(result); } + if (action === "image-prepull") { + const options = parseApplyOptions(args.slice(2)); + const result = await imagePrepull(config, options); + return options.full || options.raw ? result : renderSub2ApiImagePrepull(result); + } if (action === "status") { const options = parseDisclosureOptions(args.slice(2)); const result = await status(config, options); @@ -307,6 +313,64 @@ function renderSub2ApiApply(result: Record): RenderedCliResult return rendered(result, "platform-infra sub2api apply", lines); } +function renderSub2ApiImagePrepull(result: Record): RenderedCliResult { + const target = record(result.target); + const mode = stringValue(result.mode); + const targetId = stringValue(target.id, stringValue(result.target)); + const status = result.ok === false ? "failed" : "ok"; + const lines = [ + "PLATFORM-INFRA SUB2API IMAGE PREPULL", + ...table(["TARGET", "ROUTE", "NAMESPACE", "MODE", "STATUS"], [[targetId, stringValue(target.route), stringValue(target.namespace), mode, status]]), + ]; + if (mode === "async-job") { + const job = record(result.job); + const next = record(result.next); + lines.push( + "", + "JOB", + ...table(["ID", "STATUS", "COMMAND"], [[stringValue(job.id), stringValue(job.status, "started"), stringValue(result.statusCommand)]]), + "", + "IMAGES", + ...table(["IMAGE"], arrayValues(result.images).map((image) => [stringValue(image)])), + "", + "NEXT", + ` status: ${stringValue(next.status, stringValue(result.statusCommand))}`, + ` verify: ${stringValue(next.verify, `bun scripts/cli.ts platform-infra sub2api image-prepull --target ${targetId}`)}`, + ` apply: ${stringValue(next.apply, `bun scripts/cli.ts platform-infra sub2api apply --target ${targetId} --confirm`)}`, + ); + return rendered(result, "platform-infra sub2api image-prepull", lines); + } + const remote = record(result.remote); + const imageRows = arrayRecords(remote.images).map((item) => [ + stringValue(item.image), + stringValue(item.runtime), + boolText(item.presentBefore), + boolText(item.presentAfter), + stringValue(record(item.pull).exitCode, "-"), + stringValue(item.imageId, "-").slice(0, 24), + ]); + const next = record(result.next); + lines.push( + "", + "IMAGES", + ...(imageRows.length === 0 ? ["-"] : table(["IMAGE", "RUNTIME", "BEFORE", "AFTER", "PULL", "IMAGE_ID"], imageRows)), + "", + "CHECKS", + ...table(["CHECK", "VALUE"], [ + ["ok", boolText(result.ok !== false)], + ["runtime", stringValue(remote.runtime)], + ["valuesPrinted", "false"], + ]), + "", + "NEXT", + ` apply: ${stringValue(next.apply, `bun scripts/cli.ts platform-infra sub2api apply --target ${targetId} --confirm`)}`, + ` status: ${stringValue(next.status, `bun scripts/cli.ts platform-infra sub2api status --target ${targetId}`)}`, + ` full: bun scripts/cli.ts platform-infra sub2api image-prepull --target ${targetId} ${mode === "confirmed" ? "--confirm --wait" : "--dry-run"} --full`, + "Disclosure: default output is bounded; Secret values are not printed.", + ); + return rendered(result, "platform-infra sub2api image-prepull", lines); +} + function renderApplyRemoteSummary(remote: Record): string[] { const steps = record(remote.steps); const stepRows = Object.entries(steps).map(([name, value]) => {