|
|
|
@@ -7,7 +7,7 @@ import type { RenderedCliResult } from "./output";
|
|
|
|
|
import { CliInputError } from "./output";
|
|
|
|
|
import { capture, compactCapture, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
|
|
|
|
|
|
|
|
|
|
type Action = "plan" | "status" | "apply" | "backup-now" | "restore-smoke";
|
|
|
|
|
type Action = "plan" | "status" | "init" | "apply" | "backup-now" | "restore-smoke";
|
|
|
|
|
interface Options { action: Action; configPath: string; targetId: string | null; confirm: boolean; wait: boolean; output: "text" | "json" }
|
|
|
|
|
export interface PikaoaAttachmentsBackupSpec {
|
|
|
|
|
targetId: string; route: string; namespace: string; enabled: boolean; nonBlocking: true; image: string; schedule: string;
|
|
|
|
@@ -17,7 +17,7 @@ export interface PikaoaAttachmentsBackupSpec {
|
|
|
|
|
retention: { keepDaily: number; keepWeekly: number; keepMonthly: number };
|
|
|
|
|
maintenance: { checkSchedule: string; forgetPruneSchedule: string };
|
|
|
|
|
restoreSmoke: { jobPrefix: string; emptyDirSizeLimit: string; ttlSecondsAfterFinished: number };
|
|
|
|
|
jobs: { backupCronJobName: string; checkCronJobName: string; forgetPruneCronJobName: string; serviceAccountName: string; fieldManager: string };
|
|
|
|
|
jobs: { initJobPrefix: string; backupCronJobName: string; checkCronJobName: string; forgetPruneCronJobName: string; serviceAccountName: string; fieldManager: string };
|
|
|
|
|
}
|
|
|
|
|
interface Prerequisites { namespace: boolean; sourcePvc: boolean; secret: boolean; destinationDirectory: boolean; ready: boolean; waiting: string[] }
|
|
|
|
|
|
|
|
|
@@ -30,6 +30,7 @@ export function pikaoaAttachmentsBackupHelp(): Record<string, unknown> {
|
|
|
|
|
usage: [
|
|
|
|
|
"bun scripts/cli.ts pikaoa attachments-backup plan [--target id] [--output json]",
|
|
|
|
|
"bun scripts/cli.ts pikaoa attachments-backup status [--target id] [--output json]",
|
|
|
|
|
"bun scripts/cli.ts pikaoa attachments-backup init [--target id] --confirm",
|
|
|
|
|
"bun scripts/cli.ts pikaoa attachments-backup apply [--target id] [--confirm] [--wait]",
|
|
|
|
|
"bun scripts/cli.ts pikaoa attachments-backup backup-now [--target id] --confirm",
|
|
|
|
|
"bun scripts/cli.ts pikaoa attachments-backup restore-smoke [--target id] --confirm",
|
|
|
|
@@ -40,6 +41,7 @@ export function pikaoaAttachmentsBackupHelp(): Record<string, unknown> {
|
|
|
|
|
"日常备份、check 与 forget/prune 使用独立 CronJob。",
|
|
|
|
|
"restore-smoke 只恢复到临时 emptyDir,绝不挂载生产 PVC。",
|
|
|
|
|
"Secret 只经 YAML sourceRef/targetKey 分发,输出不打印值。",
|
|
|
|
|
"init 仅在 restic cat config 精确报告 repository-not-found 时创建仓库;已初始化时结构化 no-op,认证或网络错误保持失败。",
|
|
|
|
|
"前置条件未就绪时返回 prerequisite-waiting,且不阻塞业务、PaC 或 Argo。",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
@@ -52,6 +54,7 @@ export async function runPikaoaAttachmentsBackupCommand(config: UniDeskConfig, a
|
|
|
|
|
let payload: Record<string, unknown>;
|
|
|
|
|
if (options.action === "plan") payload = planPayload(spec);
|
|
|
|
|
else if (options.action === "status") payload = await statusPayload(config, spec);
|
|
|
|
|
else if (options.action === "init") payload = await initPayload(config, spec, options);
|
|
|
|
|
else if (options.action === "apply") payload = await applyPayload(config, spec, options);
|
|
|
|
|
else if (options.action === "backup-now") payload = await backupNowPayload(config, spec, options);
|
|
|
|
|
else payload = await restoreSmokePayload(config, spec, options);
|
|
|
|
@@ -87,7 +90,7 @@ export function readPikaoaAttachmentsBackupSpec(configPath = DEFAULT_CONFIG_PATH
|
|
|
|
|
retention: { keepDaily: integer(retention.keepDaily, "retention.keepDaily"), keepWeekly: integer(retention.keepWeekly, "retention.keepWeekly"), keepMonthly: integer(retention.keepMonthly, "retention.keepMonthly") },
|
|
|
|
|
maintenance: { checkSchedule: cron(maintenance.checkSchedule, "maintenance.checkSchedule"), forgetPruneSchedule: cron(maintenance.forgetPruneSchedule, "maintenance.forgetPruneSchedule") },
|
|
|
|
|
restoreSmoke: { jobPrefix: dns(smoke.jobPrefix, "restoreSmoke.jobPrefix"), emptyDirSizeLimit: text(smoke.emptyDirSizeLimit, "restoreSmoke.emptyDirSizeLimit"), ttlSecondsAfterFinished: integer(smoke.ttlSecondsAfterFinished, "restoreSmoke.ttlSecondsAfterFinished") },
|
|
|
|
|
jobs: { backupCronJobName: dns(jobs.backupCronJobName, "jobs.backupCronJobName"), checkCronJobName: dns(jobs.checkCronJobName, "jobs.checkCronJobName"), forgetPruneCronJobName: dns(jobs.forgetPruneCronJobName, "jobs.forgetPruneCronJobName"), serviceAccountName: dns(jobs.serviceAccountName, "jobs.serviceAccountName"), fieldManager: name(jobs.fieldManager, "jobs.fieldManager") },
|
|
|
|
|
jobs: { initJobPrefix: dns(jobs.initJobPrefix, "jobs.initJobPrefix"), backupCronJobName: dns(jobs.backupCronJobName, "jobs.backupCronJobName"), checkCronJobName: dns(jobs.checkCronJobName, "jobs.checkCronJobName"), forgetPruneCronJobName: dns(jobs.forgetPruneCronJobName, "jobs.forgetPruneCronJobName"), serviceAccountName: dns(jobs.serviceAccountName, "jobs.serviceAccountName"), fieldManager: name(jobs.fieldManager, "jobs.fieldManager") },
|
|
|
|
|
};
|
|
|
|
|
if (!spec.source.excludes.includes(".tmp")) throw new Error("attachmentsBackup.source.excludes must include .tmp");
|
|
|
|
|
return spec;
|
|
|
|
@@ -143,6 +146,59 @@ ${indentBlock(resticPrelude(spec), 14)}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderPikaoaAttachmentsInitJob(spec: PikaoaAttachmentsBackupSpec, jobName: string): string {
|
|
|
|
|
return `apiVersion: batch/v1
|
|
|
|
|
kind: Job
|
|
|
|
|
metadata:
|
|
|
|
|
name: ${jobName}
|
|
|
|
|
namespace: ${spec.namespace}
|
|
|
|
|
labels:
|
|
|
|
|
app.kubernetes.io/name: pikaoa-attachments-backup
|
|
|
|
|
app.kubernetes.io/component: repository-init
|
|
|
|
|
spec:
|
|
|
|
|
ttlSecondsAfterFinished: ${spec.restoreSmoke.ttlSecondsAfterFinished}
|
|
|
|
|
template:
|
|
|
|
|
metadata:
|
|
|
|
|
labels:
|
|
|
|
|
app.kubernetes.io/name: pikaoa-attachments-backup
|
|
|
|
|
app.kubernetes.io/component: repository-init
|
|
|
|
|
spec:
|
|
|
|
|
restartPolicy: Never
|
|
|
|
|
serviceAccountName: ${spec.jobs.serviceAccountName}
|
|
|
|
|
containers:
|
|
|
|
|
- name: repository-init
|
|
|
|
|
image: ${spec.image}
|
|
|
|
|
command: ["/bin/sh", "-ceu"]
|
|
|
|
|
args:
|
|
|
|
|
- |
|
|
|
|
|
${indentBlock(resticPrelude(spec), 14)}
|
|
|
|
|
set +e
|
|
|
|
|
cat_output="$(restic -o sftp.command="$RESTIC_SFTP_COMMAND" cat config 2>&1)"
|
|
|
|
|
cat_status="$?"
|
|
|
|
|
set -e
|
|
|
|
|
if [ "$cat_status" -eq 0 ]; then
|
|
|
|
|
printf 'event=pikaoa.attachments.repository-init result=already-initialized mutation=false\\n'
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
if [ "$cat_status" -ne 10 ]; then
|
|
|
|
|
printf 'event=pikaoa.attachments.repository-init result=probe-failed mutation=false catConfigExitCode=%s\\n' "$cat_status" >&2
|
|
|
|
|
printf '%s\\n' "$cat_output" >&2
|
|
|
|
|
exit "$cat_status"
|
|
|
|
|
fi
|
|
|
|
|
restic -o sftp.command="$RESTIC_SFTP_COMMAND" init
|
|
|
|
|
printf 'event=pikaoa.attachments.repository-init result=initialized mutation=true\\n'
|
|
|
|
|
volumeMounts:
|
|
|
|
|
- name: backup-secret
|
|
|
|
|
mountPath: /backup-secret
|
|
|
|
|
readOnly: true
|
|
|
|
|
volumes:
|
|
|
|
|
- name: backup-secret
|
|
|
|
|
secret:
|
|
|
|
|
secretName: ${spec.secret.secretName}
|
|
|
|
|
defaultMode: 0400
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function planPayload(spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
|
|
|
|
|
const manifest = renderPikaoaAttachmentsBackupManifest(spec);
|
|
|
|
|
return {
|
|
|
|
@@ -162,8 +218,19 @@ async function statusPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBacku
|
|
|
|
|
for (const cronJobName of [spec.jobs.backupCronJobName, spec.jobs.checkCronJobName, spec.jobs.forgetPruneCronJobName]) {
|
|
|
|
|
cronJobs[cronJobName] = await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "cronjob", cronJobName, "-o", "name"]);
|
|
|
|
|
}
|
|
|
|
|
const initJobs = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "jobs", "-l", "app.kubernetes.io/component=repository-init", "--sort-by=.metadata.creationTimestamp", "-o", "custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed", "--no-headers"], "");
|
|
|
|
|
const recentJobs = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "jobs", "-l", "app.kubernetes.io/name=pikaoa-attachments-backup", "--sort-by=.metadata.creationTimestamp", "-o", "custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed", "--no-headers"], "");
|
|
|
|
|
return { ok: true, action: "pikaoa-attachments-backup-status", mode: prerequisites.ready ? "ready" : "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, resources: { cronJobs, recentJobs: compactCapture(recentJobs, { full: true }) }, secret: secretSummary(spec), next: next(spec) };
|
|
|
|
|
return { ok: true, action: "pikaoa-attachments-backup-status", mode: prerequisites.ready ? "ready" : "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, resources: { cronJobs, initJobs: compactCapture(initJobs, { full: true }), recentJobs: compactCapture(recentJobs, { full: true }) }, secret: secretSummary(spec), next: next(spec) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function initPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise<Record<string, unknown>> {
|
|
|
|
|
requireConfirm(options, "init");
|
|
|
|
|
const prerequisites = await inspectPrerequisites(config, spec);
|
|
|
|
|
if (!prerequisites.ready) return { ...waiting("pikaoa-attachments-repository-init", prerequisites, spec), productionPvcMounted: false };
|
|
|
|
|
const jobName = `${spec.jobs.initJobPrefix}-${Date.now().toString(36)}`.slice(0, 63);
|
|
|
|
|
const manifest = renderPikaoaAttachmentsInitJob(spec, jobName);
|
|
|
|
|
const result = await capture(config, spec.route, ["sh"], `kubectl apply --server-side --field-manager=${shQuote(spec.jobs.fieldManager)} -f - <<'YAML'\n${manifest}YAML\n`);
|
|
|
|
|
return { ok: result.exitCode === 0, action: "pikaoa-attachments-repository-init", mode: "submitted", mutation: result.exitCode === 0, businessBlocking: false, target: targetSummary(spec), productionPvcMounted: false, jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise<Record<string, unknown>> {
|
|
|
|
@@ -301,17 +368,18 @@ function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record<string, unknow
|
|
|
|
|
function next(spec: PikaoaAttachmentsBackupSpec): Record<string, string> {
|
|
|
|
|
return {
|
|
|
|
|
secrets: `bun scripts/cli.ts secrets sync --config ${spec.secret.configRef} --target ${spec.secret.targetId} --confirm`,
|
|
|
|
|
plan: `bun scripts/cli.ts pikaoa attachments-backup plan --target ${spec.targetId}`,
|
|
|
|
|
status: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}`,
|
|
|
|
|
init: `bun scripts/cli.ts pikaoa attachments-backup init --target ${spec.targetId} --confirm`,
|
|
|
|
|
apply: `bun scripts/cli.ts pikaoa attachments-backup apply --target ${spec.targetId} --confirm`,
|
|
|
|
|
backupNow: `bun scripts/cli.ts pikaoa attachments-backup backup-now --target ${spec.targetId} --confirm`,
|
|
|
|
|
restoreSmoke: `bun scripts/cli.ts pikaoa attachments-backup restore-smoke --target ${spec.targetId} --confirm`,
|
|
|
|
|
plan: `bun scripts/cli.ts pikaoa attachments-backup plan --target ${spec.targetId}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseOptions(args: string[]): Options {
|
|
|
|
|
const action = args[0];
|
|
|
|
|
if (!isAction(action)) throw inputError("action 必须是 plan、status、apply、backup-now 或 restore-smoke", action ?? "<missing>");
|
|
|
|
|
if (!isAction(action)) throw inputError("action 必须是 plan、status、init、apply、backup-now 或 restore-smoke", action ?? "<missing>");
|
|
|
|
|
let configPath = DEFAULT_CONFIG_PATH;
|
|
|
|
|
let targetId: string | null = null;
|
|
|
|
|
let confirm = false;
|
|
|
|
@@ -349,8 +417,8 @@ function render(options: Options, payload: Record<string, unknown>): RenderedCli
|
|
|
|
|
function requireConfirm(options: Options, action: string): void {
|
|
|
|
|
if (!options.confirm) throw new CliInputError(`${action} 必须显式使用 --confirm`, { code: "confirmation-required", argument: action, usage: `bun scripts/cli.ts pikaoa attachments-backup ${action} --confirm` });
|
|
|
|
|
}
|
|
|
|
|
function isAction(value: string | undefined): value is Action { return value === "plan" || value === "status" || value === "apply" || value === "backup-now" || value === "restore-smoke" }
|
|
|
|
|
function inputError(message: string, argument: string): CliInputError { return new CliInputError(message, { code: "invalid-pikaoa-attachments-backup-input", argument, usage: "bun scripts/cli.ts pikaoa attachments-backup plan|status|apply|backup-now|restore-smoke [options]" }) }
|
|
|
|
|
function isAction(value: string | undefined): value is Action { return value === "plan" || value === "status" || value === "init" || value === "apply" || value === "backup-now" || value === "restore-smoke" }
|
|
|
|
|
function inputError(message: string, argument: string): CliInputError { return new CliInputError(message, { code: "invalid-pikaoa-attachments-backup-input", argument, usage: "bun scripts/cli.ts pikaoa attachments-backup plan|status|init|apply|backup-now|restore-smoke [options]" }) }
|
|
|
|
|
function object(value: unknown, label: string): Record<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as Record<string, unknown> }
|
|
|
|
|
function text(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); return value }
|
|
|
|
|
function bool(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be boolean`); return value }
|
|
|
|
|