|
|
|
@@ -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" }
|