fix(pikaoa): 初始化附件备份仓库

This commit is contained in:
Codex
2026-07-15 08:32:09 +02:00
parent 7a428e0656
commit 21f359e1e2
4 changed files with 104 additions and 16 deletions
+1
View File
@@ -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
@@ -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 pass0 fail。
- `bun test ./scripts/src/pikaoa-attachments-backup.test.ts`3 pass0 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`
+19 -1
View File
@@ -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" });
});
});
+76 -8
View File
@@ -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<string, unknown> {
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<string, unknown> {
"日常备份、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<string, unknown>;
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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
@@ -301,17 +368,18 @@ function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record<string, unknow
function next(spec: PikaoaAttachmentsBackupSpec): Record<string, string> {
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 ?? "<missing>");
if (!isAction(action)) throw inputError("action 必须是 plan、status、init、apply、backup-now 或 restore-smoke", action ?? "<missing>");
let configPath = DEFAULT_CONFIG_PATH;
let targetId: string | null = null;
let confirm = false;
@@ -349,8 +417,8 @@ function render(options: Options, payload: Record<string, unknown>): 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<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as Record<string, unknown> }
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 }