From 7a428e0656ea51aab894847f91d098f79a89d1eb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 08:03:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(pikaoa):=20=E5=BB=BA=E7=AB=8B=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E7=A6=BB=E6=9C=BA=E5=A4=87=E4=BB=BD=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/pikaoa.yaml | 47 +++ config/secrets-distribution.yaml | 32 ++ .../R1.6.1.2_Task_Report.md | 35 ++ scripts/src/pikaoa-attachments-backup.test.ts | 32 ++ scripts/src/pikaoa-attachments-backup.ts | 365 ++++++++++++++++++ scripts/src/pikaoa-test-target.ts | 6 +- 6 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md create mode 100644 scripts/src/pikaoa-attachments-backup.test.ts create mode 100644 scripts/src/pikaoa-attachments-backup.ts diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index c4f4f167..5d4a038f 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -8,6 +8,8 @@ metadata: spec: PJ2026-03 relatedIssues: - 1952 + - 2110 + - 2128 defaults: targetId: NC01 @@ -274,6 +276,51 @@ delivery: exporterFailure: warning: true blocking: false + attachmentsBackup: + enabled: true + nonBlocking: true + image: restic/restic:0.18.0 + schedule: "17 4 * * *" + source: + namespace: pikaoa + claimName: pikaoa-attachments + mountPath: /source + readOnly: true + excludes: + - .tmp + destination: + type: sftp + route: PK01 + host: 10.0.8.3 + port: 22 + username: pikaoa-restic + directory: /var/backups/unidesk/pikaoa/attachments-restic + secret: + configRef: config/secrets-distribution.yaml + declarationId: pikaoa-attachments-backup + targetId: pikaoa-nc01 + secretName: pikaoa-attachments-backup + keys: + password: RESTIC_PASSWORD + privateKey: SFTP_PRIVATE_KEY + knownHosts: SFTP_KNOWN_HOSTS + retention: + keepDaily: 14 + keepWeekly: 8 + keepMonthly: 12 + maintenance: + checkSchedule: "23 5 * * 6" + forgetPruneSchedule: "41 5 * * 0" + restoreSmoke: + jobPrefix: pikaoa-attachments-restore-smoke + emptyDirSizeLimit: 10Gi + ttlSecondsAfterFinished: 3600 + jobs: + backupCronJobName: pikaoa-attachments-backup + checkCronJobName: pikaoa-attachments-restic-check + forgetPruneCronJobName: pikaoa-attachments-restic-forget-prune + serviceAccountName: pikaoa-attachments-backup + fieldManager: unidesk-pikaoa-attachments-backup bootstrapUsers: administrator: username: admin diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 37a9a8b6..c92b6ae2 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -10,6 +10,8 @@ metadata: - 313 - 2256 - 1964 + - 2110 + - 2128 sources: root: /root/.unidesk/.state/secrets @@ -99,6 +101,12 @@ sources: PIKAOA_SESSION_SECRET: bytes: 32 prefix: poa_ + - sourceRef: platform-infra/pikaoa-attachments-backup.env + type: env + requiredKeys: + - RESTIC_PASSWORD + createIfMissing: + enabled: false - sourceRef: platform-infra/pikaoa-test.env type: env requiredKeys: @@ -143,6 +151,16 @@ sources: createIfMissing: enabled: false externalFiles: + - sourceRef: ~/.unidesk/.ssh/pikaoa-attachments-restic + type: raw-file + required: true + createIfMissing: + enabled: false + - sourceRef: ~/.unidesk/.ssh/pikaoa-attachments-known_hosts + type: raw-file + required: true + createIfMissing: + enabled: false - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt type: raw-file required: true @@ -252,6 +270,20 @@ targets: enabled: true kubernetesSecrets: + - name: pikaoa-attachments-backup + targetId: pikaoa-nc01 + secretName: pikaoa-attachments-backup + type: Opaque + data: + - sourceRef: platform-infra/pikaoa-attachments-backup.env + sourceKey: RESTIC_PASSWORD + targetKey: RESTIC_PASSWORD + - sourceRef: ~/.unidesk/.ssh/pikaoa-attachments-restic + sourceKey: contents + targetKey: SFTP_PRIVATE_KEY + - sourceRef: ~/.unidesk/.ssh/pikaoa-attachments-known_hosts + sourceKey: contents + targetKey: SFTP_KNOWN_HOSTS - name: pikaoa-runtime targetId: pikaoa-nc01 secretName: pikaoa-runtime 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 new file mode 100644 index 00000000..2a97c53e --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md @@ -0,0 +1,35 @@ +# R1.6.1.2 任务报告 + +## 阶段结论 + +PikaOA 生产附件离机备份已完成首阶段代码、owning YAML、受控 CLI、定向测试与 plan 渲染。当前 NC01 生产 `pikaoa` namespace、附件 PVC、备份 Secret 和 PK01 专用 SFTP 目录尚未就绪,因此真实 `apply`、首备和 restore smoke 保持 `prerequisite-waiting`,不冒充成功,也不阻塞 PikaOA、PaC 或 Argo。 + +## 变更 + +- `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-test-target.ts`:把 `pikaoa attachments-backup` 接入现有 PikaOA CLI dispatcher。 +- `scripts/src/pikaoa-attachments-backup.test.ts`:覆盖独立 CronJob、只读 PVC、`.tmp` 排除、retention、Secret 不泄露与临时 `emptyDir` restore smoke。 + +## 验证 + +- `bun test ./scripts/src/pikaoa-attachments-backup.test.ts`:2 pass,0 fail。 +- `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 目标目录。 +- `git diff --check` 通过。 + +## 后续原入口 + +生产附件 PVC 与 PK01 最小权限 SFTP 前置就绪后,主代理按顺序执行: + +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` +6. `bun scripts/cli.ts pikaoa attachments-backup restore-smoke --target NC01 --confirm` +7. `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 new file mode 100644 index 00000000..94d14b11 --- /dev/null +++ b/scripts/src/pikaoa-attachments-backup.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { readPikaoaAttachmentsBackupSpec, renderPikaoaAttachmentsBackupManifest, renderPikaoaAttachmentsRestoreSmokeJob } from "./pikaoa-attachments-backup"; + +describe("PikaOA attachments backup", () => { + test("owning YAML renders separate read-only backup and maintenance CronJobs", () => { + const spec = readPikaoaAttachmentsBackupSpec("config/pikaoa.yaml"); + const manifest = renderPikaoaAttachmentsBackupManifest(spec); + + expect(spec.schedule).toBe("17 4 * * *"); + expect(spec.source.excludes).toContain(".tmp"); + expect(spec.destination.directory).toBe("/var/backups/unidesk/pikaoa/attachments-restic"); + expect(manifest).toContain("name: pikaoa-attachments-backup"); + expect(manifest).toContain("name: pikaoa-attachments-restic-check"); + expect(manifest).toContain("name: pikaoa-attachments-restic-forget-prune"); + expect(manifest).toContain("readOnly: true"); + expect(manifest).toContain("--exclude=.tmp"); + expect(manifest).toContain("forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune"); + expect(manifest).not.toContain("RESTIC_PASSWORD="); + expect(manifest.split("---\n").map((document) => Bun.YAML.parse(document))).toHaveLength(4); + }); + + test("restore smoke only uses a temporary emptyDir", () => { + const spec = readPikaoaAttachmentsBackupSpec("config/pikaoa.yaml"); + const manifest = renderPikaoaAttachmentsRestoreSmokeJob(spec, "pikaoa-attachments-restore-smoke-test"); + + expect(manifest).toContain("--target /restore"); + expect(manifest).toContain("emptyDir:"); + expect(manifest).not.toContain(`claimName: ${spec.source.claimName}`); + expect(manifest).not.toContain(`mountPath: ${spec.source.mountPath}`); + expect(Bun.YAML.parse(manifest)).toBeTruthy(); + }); +}); diff --git a/scripts/src/pikaoa-attachments-backup.ts b/scripts/src/pikaoa-attachments-backup.ts new file mode 100644 index 00000000..73e4bff0 --- /dev/null +++ b/scripts/src/pikaoa-attachments-backup.ts @@ -0,0 +1,365 @@ +// SPEC: pikasTech/unidesk#2128 PikaOA production attachments YAML-first backup. +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import { renderMachine } from "./cicd-render"; +import { startJob } from "./jobs"; +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"; +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; + source: { claimName: string; mountPath: string; readOnly: true; excludes: string[] }; + destination: { type: "sftp"; route: string; host: string; port: number; username: string; directory: string }; + secret: { configRef: string; declarationId: string; targetId: string; secretName: string; keys: { password: string; privateKey: string; knownHosts: string } }; + 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 }; +} +interface Prerequisites { namespace: boolean; sourcePvc: boolean; secret: boolean; destinationDirectory: boolean; ready: boolean; waiting: string[] } + +const DEFAULT_CONFIG_PATH = rootPath("config", "pikaoa.yaml"); + +export function pikaoaAttachmentsBackupHelp(): Record { + return { + command: "pikaoa attachments-backup", + description: "管理 PikaOA 生产附件的 YAML-first、只读 Restic SFTP 离机备份。", + 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 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", + ], + source: "config/pikaoa.yaml#delivery.targets.*.attachmentsBackup", + guarantees: [ + "源 PVC 始终只读挂载并排除 .tmp。", + "日常备份、check 与 forget/prune 使用独立 CronJob。", + "restore-smoke 只恢复到临时 emptyDir,绝不挂载生产 PVC。", + "Secret 只经 YAML sourceRef/targetKey 分发,输出不打印值。", + "前置条件未就绪时返回 prerequisite-waiting,且不阻塞业务、PaC 或 Argo。", + ], + }; +} + +export async function runPikaoaAttachmentsBackupCommand(config: UniDeskConfig, args: string[]): Promise { + if (args.length === 0 || args.includes("--help") || args.includes("-h")) return renderMachine("pikaoa attachments-backup", pikaoaAttachmentsBackupHelp(), "json"); + const options = parseOptions(args); + const spec = readPikaoaAttachmentsBackupSpec(options.configPath, options.targetId); + let payload: Record; + if (options.action === "plan") payload = planPayload(spec); + else if (options.action === "status") payload = await statusPayload(config, spec); + 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); + return render(options, payload); +} + +export function readPikaoaAttachmentsBackupSpec(configPath = DEFAULT_CONFIG_PATH, targetId: string | null = null): PikaoaAttachmentsBackupSpec { + const root = readYamlRecord(configPath, "pikaoa-platform-delivery"); + const delivery = object(root.delivery, "delivery"); + const targets = object(delivery.targets, "delivery.targets"); + const selectedId = targetId ?? text(object(root.defaults, "defaults").targetId, "defaults.targetId"); + const target = object(targets[selectedId], `delivery.targets.${selectedId}`); + const backup = object(target.attachmentsBackup, `delivery.targets.${selectedId}.attachmentsBackup`); + const source = object(backup.source, "attachmentsBackup.source"); + const destination = object(backup.destination, "attachmentsBackup.destination"); + const secret = object(backup.secret, "attachmentsBackup.secret"); + const keys = object(secret.keys, "attachmentsBackup.secret.keys"); + const retention = object(backup.retention, "attachmentsBackup.retention"); + const maintenance = object(backup.maintenance, "attachmentsBackup.maintenance"); + const smoke = object(backup.restoreSmoke, "attachmentsBackup.restoreSmoke"); + const jobs = object(backup.jobs, "attachmentsBackup.jobs"); + const spec: PikaoaAttachmentsBackupSpec = { + targetId: selectedId, + route: text(target.route, "target.route"), + namespace: dns(source.namespace, "source.namespace"), + enabled: bool(backup.enabled, "enabled"), + nonBlocking: yes(backup.nonBlocking, "nonBlocking"), + image: text(backup.image, "image"), + schedule: cron(backup.schedule, "schedule"), + source: { claimName: dns(source.claimName, "source.claimName"), mountPath: path(source.mountPath, "source.mountPath"), readOnly: yes(source.readOnly, "source.readOnly"), excludes: texts(source.excludes, "source.excludes") }, + destination: { type: sftp(destination.type), route: text(destination.route, "destination.route"), host: text(destination.host, "destination.host"), port: integer(destination.port, "destination.port"), username: name(destination.username, "destination.username"), directory: path(destination.directory, "destination.directory") }, + secret: { configRef: text(secret.configRef, "secret.configRef"), declarationId: name(secret.declarationId, "secret.declarationId"), targetId: name(secret.targetId, "secret.targetId"), secretName: dns(secret.secretName, "secret.secretName"), keys: { password: env(keys.password, "keys.password"), privateKey: env(keys.privateKey, "keys.privateKey"), knownHosts: env(keys.knownHosts, "keys.knownHosts") } }, + 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") }, + }; + if (!spec.source.excludes.includes(".tmp")) throw new Error("attachmentsBackup.source.excludes must include .tmp"); + return spec; +} + +export function renderPikaoaAttachmentsBackupManifest(spec: PikaoaAttachmentsBackupSpec): string { + return [serviceAccount(spec), cronJob(spec, "backup"), cronJob(spec, "check"), cronJob(spec, "forget-prune")].join("---\n"); +} + +export function renderPikaoaAttachmentsRestoreSmokeJob(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: restore-smoke +spec: + ttlSecondsAfterFinished: ${spec.restoreSmoke.ttlSecondsAfterFinished} + template: + metadata: + labels: + app.kubernetes.io/name: pikaoa-attachments-backup + app.kubernetes.io/component: restore-smoke + spec: + restartPolicy: Never + serviceAccountName: ${spec.jobs.serviceAccountName} + containers: + - name: restore-smoke + image: ${spec.image} + command: ["/bin/sh", "-ceu"] + args: + - | +${indentBlock(resticPrelude(spec), 14)} + restic -o sftp.command="$RESTIC_SFTP_COMMAND" restore latest --target /restore + objects="$(find /restore -mindepth 1 -type f | wc -l | tr -d ' ')" + printf 'event=pikaoa.attachments.restore-smoke objects=%s target=temporary-empty-dir\\n' "$objects" + volumeMounts: + - name: backup-secret + mountPath: /backup-secret + readOnly: true + - name: restore-target + mountPath: /restore + volumes: + - name: backup-secret + secret: + secretName: ${spec.secret.secretName} + defaultMode: 0400 + - name: restore-target + emptyDir: + sizeLimit: ${spec.restoreSmoke.emptyDirSizeLimit} +`; +} + +function planPayload(spec: PikaoaAttachmentsBackupSpec): Record { + const manifest = renderPikaoaAttachmentsBackupManifest(spec); + return { + ok: true, action: "pikaoa-attachments-backup-plan", mode: "local-render", mutation: false, + target: targetSummary(spec), schedule: { backup: spec.schedule, databaseReference: "03:37", orderedAfterDatabase: Number(spec.schedule.split(/\s+/u)[1]) > 3 }, + retention: spec.retention, source: spec.source, + destination: { ...spec.destination, repositoryCredentialsPrinted: false }, secret: secretSummary(spec), maintenance: spec.maintenance, + manifests: { objects: 4, fingerprint: sha256Fingerprint(manifest), yaml: manifest }, + policy: { nonBlocking: true, sourcePvcReadOnly: true, restoreTarget: "temporary-emptyDir", valuesPrinted: false }, + prerequisites: ["production namespace", "production attachments PVC", "YAML-first Secret sync", "least-privilege PK01 SFTP directory"], next: next(spec), + }; +} + +async function statusPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec): Promise> { + const prerequisites = await inspectPrerequisites(config, spec); + const cronJobs: Record = {}; + 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 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) }; +} + +async function applyPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise> { + if (!options.confirm) return { ...planPayload(spec), action: "pikaoa-attachments-backup-apply", mode: "dry-run" }; + const prerequisites = await inspectPrerequisites(config, spec); + if (!prerequisites.ready) return waiting("pikaoa-attachments-backup-apply", prerequisites, spec); + if (!options.wait) { + const job = startJob("pikaoa_attachments_backup_apply", ["bun", "scripts/cli.ts", "pikaoa", "attachments-backup", "apply", "--config", options.configPath, "--target", spec.targetId, "--confirm", "--wait"], "Apply YAML-declared PikaOA attachments backup CronJobs after prerequisites are ready"); + return { ok: true, action: "pikaoa-attachments-backup-apply", mode: "async-job", mutation: true, target: targetSummary(spec), job, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000` }; + } + const manifest = renderPikaoaAttachmentsBackupManifest(spec); + 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-backup-apply", mode: "confirmed", mutation: true, target: targetSummary(spec), result: compactCapture(result), next: next(spec) }; +} + +async function backupNowPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise> { + requireConfirm(options, "backup-now"); + const prerequisites = await inspectPrerequisites(config, spec); + if (!prerequisites.ready) return waiting("pikaoa-attachments-backup-now", prerequisites, spec); + const jobName = `${spec.jobs.backupCronJobName}-manual-${Date.now().toString(36)}`.slice(0, 63); + const result = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "create", "job", `--from=cronjob/${spec.jobs.backupCronJobName}`, jobName, "-o", "name"], ""); + return { ok: result.exitCode === 0, action: "pikaoa-attachments-backup-now", mode: "submitted", mutation: true, target: targetSummary(spec), jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` }; +} + +async function restoreSmokePayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise> { + requireConfirm(options, "restore-smoke"); + const prerequisites = await inspectPrerequisites(config, spec); + if (!prerequisites.ready) return waiting("pikaoa-attachments-restore-smoke", prerequisites, spec); + const jobName = `${spec.restoreSmoke.jobPrefix}-${Date.now().toString(36)}`.slice(0, 63); + const manifest = renderPikaoaAttachmentsRestoreSmokeJob(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-restore-smoke", mode: "submitted", mutation: true, target: targetSummary(spec), restoreTarget: "temporary-emptyDir", productionPvcMounted: false, jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` }; +} + +async function inspectPrerequisites(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec): Promise { + const namespace = await remoteExists(config, spec.route, ["kubectl", "get", "namespace", spec.namespace, "-o", "name"]); + const sourcePvc = namespace && await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "pvc", spec.source.claimName, "-o", "name"]); + const secret = namespace && await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "secret", spec.secret.secretName, "-o", "name"]); + const destinationDirectory = await remoteExists(config, spec.destination.route, ["sh"], `test -d ${shQuote(spec.destination.directory)}\n`); + const waiting: string[] = []; + if (!namespace) waiting.push("production-namespace"); + if (!sourcePvc) waiting.push("production-attachments-pvc"); + if (!secret) waiting.push("yaml-first-secret-sync"); + if (!destinationDirectory) waiting.push("pk01-sftp-directory"); + return { namespace, sourcePvc, secret, destinationDirectory, ready: waiting.length === 0, waiting }; +} + +async function remoteExists(config: UniDeskConfig, route: string, args: string[], stdin = ""): Promise { + return (await capture(config, route, args, stdin)).exitCode === 0; +} + +function serviceAccount(spec: PikaoaAttachmentsBackupSpec): string { + return `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${spec.jobs.serviceAccountName}\n namespace: ${spec.namespace}\n labels:\n app.kubernetes.io/name: pikaoa-attachments-backup\nautomountServiceAccountToken: false\n`; +} + +function cronJob(spec: PikaoaAttachmentsBackupSpec, operation: "backup" | "check" | "forget-prune"): string { + const nameValue = operation === "backup" ? spec.jobs.backupCronJobName : operation === "check" ? spec.jobs.checkCronJobName : spec.jobs.forgetPruneCronJobName; + const schedule = operation === "backup" ? spec.schedule : operation === "check" ? spec.maintenance.checkSchedule : spec.maintenance.forgetPruneSchedule; + const attachmentMount = operation === "backup" ? ` - name: attachments\n mountPath: ${spec.source.mountPath}\n readOnly: true\n` : ""; + const attachmentVolume = operation === "backup" ? ` - name: attachments\n persistentVolumeClaim:\n claimName: ${spec.source.claimName}\n readOnly: true\n` : ""; + return `apiVersion: batch/v1 +kind: CronJob +metadata: + name: ${nameValue} + namespace: ${spec.namespace} + labels: + app.kubernetes.io/name: pikaoa-attachments-backup + app.kubernetes.io/component: ${operation} +spec: + schedule: ${JSON.stringify(schedule)} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 2 + failedJobsHistoryLimit: 4 + jobTemplate: + spec: + ttlSecondsAfterFinished: 86400 + template: + metadata: + labels: + app.kubernetes.io/name: pikaoa-attachments-backup + app.kubernetes.io/component: ${operation} + spec: + restartPolicy: Never + serviceAccountName: ${spec.jobs.serviceAccountName} + containers: + - name: restic-${operation} + image: ${spec.image} + command: ["/bin/sh", "-ceu"] + args: + - | +${indentBlock(resticPrelude(spec), 18)} + ${resticCommand(spec, operation)} + volumeMounts: + - name: backup-secret + mountPath: /backup-secret + readOnly: true +${attachmentMount} volumes: + - name: backup-secret + secret: + secretName: ${spec.secret.secretName} + defaultMode: 0400 +${attachmentVolume}`; +} + +function resticPrelude(spec: PikaoaAttachmentsBackupSpec): string { + const repository = `sftp:${spec.destination.username}@${spec.destination.host}:${spec.destination.directory}`; + const sshCommand = `ssh -p ${spec.destination.port} -i /backup-secret/${spec.secret.keys.privateKey} -o UserKnownHostsFile=/backup-secret/${spec.secret.keys.knownHosts} -o StrictHostKeyChecking=yes`; + return `export RESTIC_REPOSITORY=${shQuote(repository)}\nexport RESTIC_PASSWORD_FILE=/backup-secret/${spec.secret.keys.password}\nexport RESTIC_SFTP_COMMAND=${shQuote(sshCommand)}`; +} + +function indentBlock(value: string, spaces: number): string { + const indentation = " ".repeat(spaces); + return value.split("\n").map((line) => `${indentation}${line}`).join("\n"); +} + +function resticCommand(spec: PikaoaAttachmentsBackupSpec, operation: "backup" | "check" | "forget-prune"): string { + if (operation === "check") return "restic -o sftp.command=\"$RESTIC_SFTP_COMMAND\" check --read-data-subset=5%"; + if (operation === "forget-prune") return `restic -o sftp.command="$RESTIC_SFTP_COMMAND" forget --keep-daily ${spec.retention.keepDaily} --keep-weekly ${spec.retention.keepWeekly} --keep-monthly ${spec.retention.keepMonthly} --prune`; + const excludes = spec.source.excludes.flatMap((value) => [`--exclude=${value}`, `--exclude=${spec.source.mountPath}/${value}`]).map(shQuote).join(" "); + return `restic -o sftp.command="$RESTIC_SFTP_COMMAND" backup ${shQuote(spec.source.mountPath)} ${excludes} --tag pikaoa-attachments`; +} + +function waiting(action: string, prerequisites: Prerequisites, spec: PikaoaAttachmentsBackupSpec): Record { + return { ok: true, action, mode: "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, next: next(spec) }; +} + +function targetSummary(spec: PikaoaAttachmentsBackupSpec): Record { + return { id: spec.targetId, route: spec.route, namespace: spec.namespace, enabled: spec.enabled, nonBlocking: spec.nonBlocking }; +} + +function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record { + return { configRef: spec.secret.configRef, declarationId: spec.secret.declarationId, secretName: spec.secret.secretName, targetKeys: Object.values(spec.secret.keys), presence: "status-only", fingerprint: sha256Fingerprint(JSON.stringify(spec.secret)), valuesPrinted: false }; +} + +function next(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}`, + 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`, + }; +} + +function parseOptions(args: string[]): Options { + const action = args[0]; + if (!isAction(action)) throw inputError("action 必须是 plan、status、apply、backup-now 或 restore-smoke", action ?? ""); + let configPath = DEFAULT_CONFIG_PATH; + let targetId: string | null = null; + let confirm = false; + let wait = false; + let output: "text" | "json" = "text"; + for (let index = 1; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--config" || arg === "--target" || arg === "--output" || arg === "-o") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, arg); + if (arg === "--config") configPath = rootPath(value); + else if (arg === "--target") targetId = name(value, arg); + else if (value === "json" || value === "text") output = value; + else throw inputError(`${arg} 只支持 text 或 json`, value); + index += 1; + } else if (arg === "--confirm") confirm = true; + else if (arg === "--wait") wait = true; + else if (arg === "--json") output = "json"; + else throw inputError(`不支持的参数:${arg}`, arg); + } + return { action, configPath, targetId, confirm, wait, output }; +} + +function render(options: Options, payload: Record): RenderedCliResult { + if (options.output === "json") return renderMachine(`pikaoa attachments-backup ${options.action}`, payload, "json", payload.ok !== false); + const target = payload.target as Record | undefined; + const prerequisites = payload.prerequisites as Prerequisites | undefined; + const lines = [`PIKAOA ATTACHMENTS BACKUP ${options.action.toUpperCase()} (${String(payload.mode ?? "ok")})`, "", `target=${target?.id ?? "-"} route=${target?.route ?? "-"} namespace=${target?.namespace ?? "-"}`, `mutation=${payload.mutation === true} businessBlocking=${payload.businessBlocking === true} valuesPrinted=false`]; + if (prerequisites !== undefined) lines.push(`prerequisites.ready=${prerequisites.ready} waiting=${prerequisites.waiting.join(",") || "none"}`); + const commands = payload.next as Record | undefined; + if (commands !== undefined) lines.push("", "NEXT", ...Object.entries(commands).map(([key, value]) => `${key}: ${value}`)); + return { ok: payload.ok !== false, command: `pikaoa attachments-backup ${options.action}`, renderedText: `${lines.join("\n")}\n`, contentType: "text/plain", projection: payload }; +} + +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 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 } +function yes(value: unknown, label: string): true { if (value !== true) throw new Error(`${label} must be true`); return true } +function integer(value: unknown, label: string): number { if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${label} must be a positive integer`); return Number(value) } +function texts(value: unknown, label: string): string[] { if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${label} must be a non-empty string array`); return value as string[] } +function path(value: unknown, label: string): string { const parsed = text(value, label); if (!parsed.startsWith("/") || parsed.includes("..")) throw new Error(`${label} must be an absolute path without ..`); return parsed } +function name(value: unknown, label: string): string { const parsed = text(value, label); if (!/^[A-Za-z0-9._-]+$/u.test(parsed)) throw new Error(`${label} has unsupported characters`); return parsed } +function dns(value: unknown, label: string): string { const parsed = text(value, label); if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(parsed) || parsed.length > 63) throw new Error(`${label} must be a DNS label`); return parsed } +function env(value: unknown, label: string): string { const parsed = text(value, label); if (!/^[A-Z_][A-Z0-9_]*$/u.test(parsed)) throw new Error(`${label} must be an environment key`); return parsed } +function cron(value: unknown, label: string): string { const parsed = text(value, label); if (parsed.trim().split(/\s+/u).length !== 5) throw new Error(`${label} must use five-field cron syntax`); return parsed } +function sftp(value: unknown): "sftp" { if (value !== "sftp") throw new Error("destination.type must be sftp"); return "sftp" } diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index 89fe6184..ad24505d 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -234,8 +234,12 @@ export function pikaoaTestTargetHelp(): Record { } export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise { + if (args[0] === "attachments-backup") { + const { runPikaoaAttachmentsBackupCommand } = await import("./pikaoa-attachments-backup"); + return await runPikaoaAttachmentsBackupCommand(config, args.slice(1)); + } if (args.length === 0 || args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json"); - if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target", "unsupported-pikaoa-command", "pikaoa", ["test-target"]); + if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target 或 attachments-backup", "unsupported-pikaoa-command", "pikaoa", ["test-target", "attachments-backup"]); const options = parseOptions(args.slice(1)); const selection = readSelection(options); if (options.action === "plan") return renderResult(planPayload(options, selection), options);