feat(pikaoa): 建立附件离机备份控制面

This commit is contained in:
Codex
2026-07-15 08:03:45 +02:00
parent fe9f707145
commit 7a428e0656
6 changed files with 516 additions and 1 deletions
+47
View File
@@ -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
+32
View File
@@ -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
@@ -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 pass0 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`
@@ -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();
});
});
+365
View File
@@ -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<string, unknown> {
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<RenderedCliResult> {
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<string, unknown>;
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<string, unknown> {
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<Record<string, unknown>> {
const prerequisites = await inspectPrerequisites(config, spec);
const cronJobs: Record<string, boolean> = {};
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Prerequisites> {
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<boolean> {
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<string, unknown> {
return { ok: true, action, mode: "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, next: next(spec) };
}
function targetSummary(spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
return { id: spec.targetId, route: spec.route, namespace: spec.namespace, enabled: spec.enabled, nonBlocking: spec.nonBlocking };
}
function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
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<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}`,
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 ?? "<missing>");
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<string, unknown>): RenderedCliResult {
if (options.output === "json") return renderMachine(`pikaoa attachments-backup ${options.action}`, payload, "json", payload.ok !== false);
const target = payload.target as Record<string, unknown> | 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<string, string> | 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<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 }
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" }
+5 -1
View File
@@ -234,8 +234,12 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
}
export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise<RenderedCliResult> {
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);