From 21f359e1e273024e21e7bd3cc656d43f3ae8a248 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:32:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(pikaoa):=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E5=A4=87=E4=BB=BD=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/pikaoa.yaml | 1 + .../R1.6.1.2_Task_Report.md | 15 ++-- scripts/src/pikaoa-attachments-backup.test.ts | 20 ++++- scripts/src/pikaoa-attachments-backup.ts | 84 +++++++++++++++++-- 4 files changed, 104 insertions(+), 16 deletions(-) diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index 5d4a038f..4597f71a 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -316,6 +316,7 @@ delivery: emptyDirSizeLimit: 10Gi ttlSecondsAfterFinished: 3600 jobs: + initJobPrefix: pikaoa-attachments-restic-init backupCronJobName: pikaoa-attachments-backup checkCronJobName: pikaoa-attachments-restic-check forgetPruneCronJobName: pikaoa-attachments-restic-forget-prune diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md index 2a97c53e..9b855212 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md @@ -8,13 +8,13 @@ PikaOA 生产附件离机备份已完成首阶段代码、owning YAML、受控 C - `config/pikaoa.yaml`:声明 04:17 日常 Restic 备份、retention、只读附件 PVC、`.tmp` 排除、PK01 SFTP 目标、独立 check 与 forget/prune schedule、临时目录 restore smoke 和非阻塞策略。 - `config/secrets-distribution.yaml`:声明 Restic 密码 env 与 SSH 私钥、`known_hosts` 两个 raw-file 的 `sourceRef`、`sourceKey` 与 `targetKey`,禁止从运行面反解或手写 Secret。 -- `scripts/src/pikaoa-attachments-backup.ts`:提供 `plan`、`status`、`apply`、`backup-now`、`restore-smoke`,长 apply 使用异步 UniDesk job;手动备份和恢复提交 Kubernetes Job 后立即返回并通过 `status` 轮询。 +- `scripts/src/pikaoa-attachments-backup.ts`:提供 `plan`、`status`、`init`、`apply`、`backup-now`、`restore-smoke`;`init` 通过独立短生命周期 Job 精确区分已初始化、仓库不存在和认证/网络失败,且不挂载生产 PVC;长 apply 使用异步 UniDesk job,手动备份和恢复提交 Kubernetes Job 后立即返回并通过 `status` 轮询。 - `scripts/src/pikaoa-test-target.ts`:把 `pikaoa attachments-backup` 接入现有 PikaOA CLI dispatcher。 -- `scripts/src/pikaoa-attachments-backup.test.ts`:覆盖独立 CronJob、只读 PVC、`.tmp` 排除、retention、Secret 不泄露与临时 `emptyDir` restore smoke。 +- `scripts/src/pikaoa-attachments-backup.test.ts`:覆盖独立 CronJob、只读 PVC、`.tmp` 排除、retention、Secret 不泄露、init 必须 confirm、init 已存在 no-op/精确 fresh init 语义与临时 `emptyDir` restore smoke。 ## 验证 -- `bun test ./scripts/src/pikaoa-attachments-backup.test.ts`:2 pass,0 fail。 +- `bun test ./scripts/src/pikaoa-attachments-backup.test.ts`:3 pass,0 fail,覆盖备份、init 与 restore smoke 安全边界。 - `pikaoa attachments-backup plan --target NC01 --output json`:`ok=true`、`mode=local-render`、4 个对象、schedule 为 04:17、晚于数据库 03:37、`valuesPrinted=false`。 - `pikaoa attachments-backup apply --target NC01 --output json`:默认 dry-run,`mutation=false`。 - `pikaoa attachments-backup status --target NC01 --output json`:真实只读观察返回 `prerequisite-waiting`、`businessBlocking=false`,未发现生产 namespace、附件 PVC、备份 Secret 和 PK01 目标目录。 @@ -26,10 +26,11 @@ PikaOA 生产附件离机备份已完成首阶段代码、owning YAML、受控 C 1. `bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --target pikaoa-nc01 --confirm` 2. `bun scripts/cli.ts pikaoa attachments-backup status --target NC01` -3. `bun scripts/cli.ts pikaoa attachments-backup apply --target NC01 --confirm` -4. `bun scripts/cli.ts pikaoa attachments-backup backup-now --target NC01 --confirm` -5. `bun scripts/cli.ts pikaoa attachments-backup status --target NC01` +3. `bun scripts/cli.ts pikaoa attachments-backup init --target NC01 --confirm` +4. `bun scripts/cli.ts pikaoa attachments-backup apply --target NC01 --confirm` +5. `bun scripts/cli.ts pikaoa attachments-backup backup-now --target NC01 --confirm` 6. `bun scripts/cli.ts pikaoa attachments-backup restore-smoke --target NC01 --confirm` -7. `bun scripts/cli.ts pikaoa attachments-backup status --target NC01` + +`init`、`backup-now` 与 `restore-smoke` 提交后均使用 `bun scripts/cli.ts pikaoa attachments-backup status --target NC01` 轮询,不改变上述主流程顺序。 只有首备 snapshot 摘要与临时目录 restore smoke 成功后,才可把 MDTODO R1.6.1.2 从 `in_progress` 更新为 `completed`。 diff --git a/scripts/src/pikaoa-attachments-backup.test.ts b/scripts/src/pikaoa-attachments-backup.test.ts index 94d14b11..877c676b 100644 --- a/scripts/src/pikaoa-attachments-backup.test.ts +++ b/scripts/src/pikaoa-attachments-backup.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { readPikaoaAttachmentsBackupSpec, renderPikaoaAttachmentsBackupManifest, renderPikaoaAttachmentsRestoreSmokeJob } from "./pikaoa-attachments-backup"; +import type { UniDeskConfig } from "./config"; +import { readPikaoaAttachmentsBackupSpec, renderPikaoaAttachmentsBackupManifest, renderPikaoaAttachmentsInitJob, renderPikaoaAttachmentsRestoreSmokeJob, runPikaoaAttachmentsBackupCommand } from "./pikaoa-attachments-backup"; describe("PikaOA attachments backup", () => { test("owning YAML renders separate read-only backup and maintenance CronJobs", () => { @@ -29,4 +30,21 @@ describe("PikaOA attachments backup", () => { expect(manifest).not.toContain(`mountPath: ${spec.source.mountPath}`); expect(Bun.YAML.parse(manifest)).toBeTruthy(); }); + + test("repository init is explicit, idempotent and never mounts the production PVC", async () => { + const spec = readPikaoaAttachmentsBackupSpec("config/pikaoa.yaml"); + const manifest = renderPikaoaAttachmentsInitJob(spec, "pikaoa-attachments-restic-init-test"); + + expect(manifest).toContain('cat config 2>&1'); + expect(manifest).toContain('if [ "$cat_status" -eq 0 ]'); + expect(manifest).toContain("result=already-initialized mutation=false"); + expect(manifest).toContain('if [ "$cat_status" -ne 10 ]'); + expect(manifest).toContain('restic -o sftp.command="$RESTIC_SFTP_COMMAND" init'); + expect(manifest).not.toContain("persistentVolumeClaim:"); + expect(manifest).not.toContain(`claimName: ${spec.source.claimName}`); + expect(manifest).not.toContain(`mountPath: ${spec.source.mountPath}`); + expect(Bun.YAML.parse(manifest)).toBeTruthy(); + + await expect(runPikaoaAttachmentsBackupCommand({} as UniDeskConfig, ["init"])).rejects.toMatchObject({ code: "confirmation-required" }); + }); }); diff --git a/scripts/src/pikaoa-attachments-backup.ts b/scripts/src/pikaoa-attachments-backup.ts index 73e4bff0..1e7e03e8 100644 --- a/scripts/src/pikaoa-attachments-backup.ts +++ b/scripts/src/pikaoa-attachments-backup.ts @@ -7,7 +7,7 @@ import type { RenderedCliResult } from "./output"; import { CliInputError } from "./output"; import { capture, compactCapture, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library"; -type Action = "plan" | "status" | "apply" | "backup-now" | "restore-smoke"; +type Action = "plan" | "status" | "init" | "apply" | "backup-now" | "restore-smoke"; interface Options { action: Action; configPath: string; targetId: string | null; confirm: boolean; wait: boolean; output: "text" | "json" } export interface PikaoaAttachmentsBackupSpec { targetId: string; route: string; namespace: string; enabled: boolean; nonBlocking: true; image: string; schedule: string; @@ -17,7 +17,7 @@ export interface PikaoaAttachmentsBackupSpec { retention: { keepDaily: number; keepWeekly: number; keepMonthly: number }; maintenance: { checkSchedule: string; forgetPruneSchedule: string }; restoreSmoke: { jobPrefix: string; emptyDirSizeLimit: string; ttlSecondsAfterFinished: number }; - jobs: { backupCronJobName: string; checkCronJobName: string; forgetPruneCronJobName: string; serviceAccountName: string; fieldManager: string }; + jobs: { initJobPrefix: string; backupCronJobName: string; checkCronJobName: string; forgetPruneCronJobName: string; serviceAccountName: string; fieldManager: string }; } interface Prerequisites { namespace: boolean; sourcePvc: boolean; secret: boolean; destinationDirectory: boolean; ready: boolean; waiting: string[] } @@ -30,6 +30,7 @@ export function pikaoaAttachmentsBackupHelp(): Record { usage: [ "bun scripts/cli.ts pikaoa attachments-backup plan [--target id] [--output json]", "bun scripts/cli.ts pikaoa attachments-backup status [--target id] [--output json]", + "bun scripts/cli.ts pikaoa attachments-backup init [--target id] --confirm", "bun scripts/cli.ts pikaoa attachments-backup apply [--target id] [--confirm] [--wait]", "bun scripts/cli.ts pikaoa attachments-backup backup-now [--target id] --confirm", "bun scripts/cli.ts pikaoa attachments-backup restore-smoke [--target id] --confirm", @@ -40,6 +41,7 @@ export function pikaoaAttachmentsBackupHelp(): Record { "日常备份、check 与 forget/prune 使用独立 CronJob。", "restore-smoke 只恢复到临时 emptyDir,绝不挂载生产 PVC。", "Secret 只经 YAML sourceRef/targetKey 分发,输出不打印值。", + "init 仅在 restic cat config 精确报告 repository-not-found 时创建仓库;已初始化时结构化 no-op,认证或网络错误保持失败。", "前置条件未就绪时返回 prerequisite-waiting,且不阻塞业务、PaC 或 Argo。", ], }; @@ -52,6 +54,7 @@ export async function runPikaoaAttachmentsBackupCommand(config: UniDeskConfig, a let payload: Record; if (options.action === "plan") payload = planPayload(spec); else if (options.action === "status") payload = await statusPayload(config, spec); + else if (options.action === "init") payload = await initPayload(config, spec, options); else if (options.action === "apply") payload = await applyPayload(config, spec, options); else if (options.action === "backup-now") payload = await backupNowPayload(config, spec, options); else payload = await restoreSmokePayload(config, spec, options); @@ -87,7 +90,7 @@ export function readPikaoaAttachmentsBackupSpec(configPath = DEFAULT_CONFIG_PATH retention: { keepDaily: integer(retention.keepDaily, "retention.keepDaily"), keepWeekly: integer(retention.keepWeekly, "retention.keepWeekly"), keepMonthly: integer(retention.keepMonthly, "retention.keepMonthly") }, maintenance: { checkSchedule: cron(maintenance.checkSchedule, "maintenance.checkSchedule"), forgetPruneSchedule: cron(maintenance.forgetPruneSchedule, "maintenance.forgetPruneSchedule") }, restoreSmoke: { jobPrefix: dns(smoke.jobPrefix, "restoreSmoke.jobPrefix"), emptyDirSizeLimit: text(smoke.emptyDirSizeLimit, "restoreSmoke.emptyDirSizeLimit"), ttlSecondsAfterFinished: integer(smoke.ttlSecondsAfterFinished, "restoreSmoke.ttlSecondsAfterFinished") }, - jobs: { backupCronJobName: dns(jobs.backupCronJobName, "jobs.backupCronJobName"), checkCronJobName: dns(jobs.checkCronJobName, "jobs.checkCronJobName"), forgetPruneCronJobName: dns(jobs.forgetPruneCronJobName, "jobs.forgetPruneCronJobName"), serviceAccountName: dns(jobs.serviceAccountName, "jobs.serviceAccountName"), fieldManager: name(jobs.fieldManager, "jobs.fieldManager") }, + jobs: { initJobPrefix: dns(jobs.initJobPrefix, "jobs.initJobPrefix"), backupCronJobName: dns(jobs.backupCronJobName, "jobs.backupCronJobName"), checkCronJobName: dns(jobs.checkCronJobName, "jobs.checkCronJobName"), forgetPruneCronJobName: dns(jobs.forgetPruneCronJobName, "jobs.forgetPruneCronJobName"), serviceAccountName: dns(jobs.serviceAccountName, "jobs.serviceAccountName"), fieldManager: name(jobs.fieldManager, "jobs.fieldManager") }, }; if (!spec.source.excludes.includes(".tmp")) throw new Error("attachmentsBackup.source.excludes must include .tmp"); return spec; @@ -143,6 +146,59 @@ ${indentBlock(resticPrelude(spec), 14)} `; } +export function renderPikaoaAttachmentsInitJob(spec: PikaoaAttachmentsBackupSpec, jobName: string): string { + return `apiVersion: batch/v1 +kind: Job +metadata: + name: ${jobName} + namespace: ${spec.namespace} + labels: + app.kubernetes.io/name: pikaoa-attachments-backup + app.kubernetes.io/component: repository-init +spec: + ttlSecondsAfterFinished: ${spec.restoreSmoke.ttlSecondsAfterFinished} + template: + metadata: + labels: + app.kubernetes.io/name: pikaoa-attachments-backup + app.kubernetes.io/component: repository-init + spec: + restartPolicy: Never + serviceAccountName: ${spec.jobs.serviceAccountName} + containers: + - name: repository-init + image: ${spec.image} + command: ["/bin/sh", "-ceu"] + args: + - | +${indentBlock(resticPrelude(spec), 14)} + set +e + cat_output="$(restic -o sftp.command="$RESTIC_SFTP_COMMAND" cat config 2>&1)" + cat_status="$?" + set -e + if [ "$cat_status" -eq 0 ]; then + printf 'event=pikaoa.attachments.repository-init result=already-initialized mutation=false\\n' + exit 0 + fi + if [ "$cat_status" -ne 10 ]; then + printf 'event=pikaoa.attachments.repository-init result=probe-failed mutation=false catConfigExitCode=%s\\n' "$cat_status" >&2 + printf '%s\\n' "$cat_output" >&2 + exit "$cat_status" + fi + restic -o sftp.command="$RESTIC_SFTP_COMMAND" init + printf 'event=pikaoa.attachments.repository-init result=initialized mutation=true\\n' + volumeMounts: + - name: backup-secret + mountPath: /backup-secret + readOnly: true + volumes: + - name: backup-secret + secret: + secretName: ${spec.secret.secretName} + defaultMode: 0400 +`; +} + function planPayload(spec: PikaoaAttachmentsBackupSpec): Record { const manifest = renderPikaoaAttachmentsBackupManifest(spec); return { @@ -162,8 +218,19 @@ async function statusPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBacku for (const cronJobName of [spec.jobs.backupCronJobName, spec.jobs.checkCronJobName, spec.jobs.forgetPruneCronJobName]) { cronJobs[cronJobName] = await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "cronjob", cronJobName, "-o", "name"]); } + const initJobs = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "jobs", "-l", "app.kubernetes.io/component=repository-init", "--sort-by=.metadata.creationTimestamp", "-o", "custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed", "--no-headers"], ""); const recentJobs = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "jobs", "-l", "app.kubernetes.io/name=pikaoa-attachments-backup", "--sort-by=.metadata.creationTimestamp", "-o", "custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed", "--no-headers"], ""); - return { ok: true, action: "pikaoa-attachments-backup-status", mode: prerequisites.ready ? "ready" : "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, resources: { cronJobs, recentJobs: compactCapture(recentJobs, { full: true }) }, secret: secretSummary(spec), next: next(spec) }; + return { ok: true, action: "pikaoa-attachments-backup-status", mode: prerequisites.ready ? "ready" : "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, resources: { cronJobs, initJobs: compactCapture(initJobs, { full: true }), recentJobs: compactCapture(recentJobs, { full: true }) }, secret: secretSummary(spec), next: next(spec) }; +} + +async function initPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise> { + requireConfirm(options, "init"); + const prerequisites = await inspectPrerequisites(config, spec); + if (!prerequisites.ready) return { ...waiting("pikaoa-attachments-repository-init", prerequisites, spec), productionPvcMounted: false }; + const jobName = `${spec.jobs.initJobPrefix}-${Date.now().toString(36)}`.slice(0, 63); + const manifest = renderPikaoaAttachmentsInitJob(spec, jobName); + const result = await capture(config, spec.route, ["sh"], `kubectl apply --server-side --field-manager=${shQuote(spec.jobs.fieldManager)} -f - <<'YAML'\n${manifest}YAML\n`); + return { ok: result.exitCode === 0, action: "pikaoa-attachments-repository-init", mode: "submitted", mutation: result.exitCode === 0, businessBlocking: false, target: targetSummary(spec), productionPvcMounted: false, jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` }; } async function applyPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise> { @@ -301,17 +368,18 @@ function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record { return { secrets: `bun scripts/cli.ts secrets sync --config ${spec.secret.configRef} --target ${spec.secret.targetId} --confirm`, - plan: `bun scripts/cli.ts pikaoa attachments-backup plan --target ${spec.targetId}`, status: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}`, + init: `bun scripts/cli.ts pikaoa attachments-backup init --target ${spec.targetId} --confirm`, apply: `bun scripts/cli.ts pikaoa attachments-backup apply --target ${spec.targetId} --confirm`, backupNow: `bun scripts/cli.ts pikaoa attachments-backup backup-now --target ${spec.targetId} --confirm`, restoreSmoke: `bun scripts/cli.ts pikaoa attachments-backup restore-smoke --target ${spec.targetId} --confirm`, + plan: `bun scripts/cli.ts pikaoa attachments-backup plan --target ${spec.targetId}`, }; } function parseOptions(args: string[]): Options { const action = args[0]; - if (!isAction(action)) throw inputError("action 必须是 plan、status、apply、backup-now 或 restore-smoke", action ?? ""); + if (!isAction(action)) throw inputError("action 必须是 plan、status、init、apply、backup-now 或 restore-smoke", action ?? ""); let configPath = DEFAULT_CONFIG_PATH; let targetId: string | null = null; let confirm = false; @@ -349,8 +417,8 @@ function render(options: Options, payload: Record): RenderedCli function requireConfirm(options: Options, action: string): void { if (!options.confirm) throw new CliInputError(`${action} 必须显式使用 --confirm`, { code: "confirmation-required", argument: action, usage: `bun scripts/cli.ts pikaoa attachments-backup ${action} --confirm` }); } -function isAction(value: string | undefined): value is Action { return value === "plan" || value === "status" || value === "apply" || value === "backup-now" || value === "restore-smoke" } -function inputError(message: string, argument: string): CliInputError { return new CliInputError(message, { code: "invalid-pikaoa-attachments-backup-input", argument, usage: "bun scripts/cli.ts pikaoa attachments-backup plan|status|apply|backup-now|restore-smoke [options]" }) } +function isAction(value: string | undefined): value is Action { return value === "plan" || value === "status" || value === "init" || value === "apply" || value === "backup-now" || value === "restore-smoke" } +function inputError(message: string, argument: string): CliInputError { return new CliInputError(message, { code: "invalid-pikaoa-attachments-backup-input", argument, usage: "bun scripts/cli.ts pikaoa attachments-backup plan|status|init|apply|backup-now|restore-smoke [options]" }) } function object(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as Record } function text(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); return value } function bool(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be boolean`); return value }