diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index 30bfa952..78ac0df2 100644 --- a/config/fixtures/pikaoa-test-target.yaml +++ b/config/fixtures/pikaoa-test-target.yaml @@ -73,7 +73,7 @@ testRuntime: name: PikaOA Test Fixture email: pikaoa-test-fixture@unidesk.local database: - configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url + configRef: config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-test-database-url name: pikaoa_test username: pikaoa_test schema: pikaoa @@ -82,6 +82,15 @@ testRuntime: sourceKey: DATABASE_URL targetKey: DATABASE_URL runtime: + replicas: + api: 1 + worker: 1 + web: 1 + resources: + api: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } } + worker: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } } + web: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } } + initializer: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } } secretName: pikaoa-test-runtime apiServiceName: pikaoa-api apiPort: 8080 diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index 12723ed1..9028367e 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -94,7 +94,7 @@ testRuntime: name: PikaOA Test NC01 CI email: pikaoa-test-nc01-ci@unidesk.local database: - configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url + configRef: config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-test-database-url name: pikaoa_test username: pikaoa_test schema: pikaoa @@ -253,7 +253,7 @@ releaseRuntime: name: PikaOA NC01 CI email: pikaoa-nc01-ci@unidesk.local database: - configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url + configRef: config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-database-url name: pikaoa username: pikaoa schema: pikaoa @@ -435,7 +435,7 @@ delivery: configRef: config/secrets-distribution.yaml declarationId: pikaoa-runtime database: - configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url + configRef: config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-database-url observability: configRef: config/platform-infra/observability.yaml otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317 @@ -459,12 +459,10 @@ delivery: excludes: - .tmp destination: - type: sftp - route: PK01 - host: 10.0.8.3 - port: 22 - username: pikaoa-restic - directory: /var/backups/unidesk/pikaoa/attachments-restic + type: local-directory + route: NC01 + path: /var/backups/unidesk/pikaoa/attachments-restic + mountPath: /repository secret: configRef: config/secrets-distribution.yaml declarationId: pikaoa-attachments-backup @@ -472,8 +470,6 @@ delivery: secretName: pikaoa-attachments-backup keys: password: RESTIC_PASSWORD - privateKey: SFTP_PRIVATE_KEY - knownHosts: SFTP_KNOWN_HOSTS retention: keepDaily: 14 keepWeekly: 8 diff --git a/config/platform-db/postgres-nc01.yaml b/config/platform-db/postgres-nc01.yaml index 29ba644d..38d5085e 100644 --- a/config/platform-db/postgres-nc01.yaml +++ b/config/platform-db/postgres-nc01.yaml @@ -7,6 +7,7 @@ metadata: owner: unidesk relatedIssues: - 1875 + - 2250 cluster: role: primary @@ -119,6 +120,16 @@ postgres: user: web_probe_monitor address: 10.42.0.0/16 method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 10.42.0.0/16 + method: scram-sha-256 + - type: hostssl + database: pikaoa_test + user: pikaoa_test + address: 10.42.0.0/16 + method: scram-sha-256 secrets: source: master-local @@ -166,6 +177,34 @@ secrets: WEB_PROBE_MONITOR_NC01_DB_NAME: web_probe_monitor randomHex: WEB_PROBE_MONITOR_NC01_DB_PASSWORD: 32 + - name: pikaoa-nc01-db-credentials + sourceRef: platform-db/pikaoa-db.env + type: env + requiredKeys: + - PIKAOA_DB_USER + - PIKAOA_DB_PASSWORD + - PIKAOA_DB_NAME + createIfMissing: + enabled: true + values: + PIKAOA_DB_USER: pikaoa + PIKAOA_DB_NAME: pikaoa + randomHex: + PIKAOA_DB_PASSWORD: 32 + - name: pikaoa-test-nc01-db-credentials + sourceRef: platform-db/pikaoa-test-db.env + type: env + requiredKeys: + - PIKAOA_TEST_DB_USER + - PIKAOA_TEST_DB_PASSWORD + - PIKAOA_TEST_DB_NAME + createIfMissing: + enabled: true + values: + PIKAOA_TEST_DB_USER: pikaoa_test + PIKAOA_TEST_DB_NAME: pikaoa_test + randomHex: + PIKAOA_TEST_DB_PASSWORD: 32 objects: roles: @@ -196,6 +235,24 @@ objects: createdb: false createrole: false superuser: false + - name: pikaoa + passwordRef: + sourceRef: platform-db/pikaoa-db.env + key: PIKAOA_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false + - name: pikaoa_test + passwordRef: + sourceRef: platform-db/pikaoa-test-db.env + key: PIKAOA_TEST_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: agentrun_v02 owner: agentrun_v02 @@ -212,6 +269,16 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: pikaoa + owner: pikaoa + encoding: UTF8 + locale: C.UTF-8 + extensions: [] + - name: pikaoa_test + owner: pikaoa_test + encoding: UTF8 + locale: C.UTF-8 + extensions: [] managedOperations: monitorReset: @@ -267,15 +334,68 @@ exports: - scope: hwlab-web-probe-monitor secret: hwlab-web-probe-monitor-db key: DATABASE_URL + - name: pikaoa-database-url + sourceSecretRef: platform-db/pikaoa-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(PIKAOA_DB_USER):$(PIKAOA_DB_PASSWORD)@$(PGHOST):5432/$(PIKAOA_DB_NAME)?sslmode=require + variables: + PGHOST: 10.42.0.1 + writeToSecretSource: + sourceRef: platform-infra/pikaoa.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: pikaoa + secret: pikaoa-runtime + key: DATABASE_URL + - name: pikaoa-test-database-url + sourceSecretRef: platform-db/pikaoa-test-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(PIKAOA_TEST_DB_USER):$(PIKAOA_TEST_DB_PASSWORD)@$(PGHOST):5432/$(PIKAOA_TEST_DB_NAME)?sslmode=require + variables: + PGHOST: 10.42.0.1 + writeToSecretSource: + sourceRef: platform-infra/pikaoa-test.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: pikaoa-test + secret: pikaoa-test-runtime + key: DATABASE_URL backup: - logicalDump: - enabled: false - schedule: "*-*-* 03:37:00" - database: agentrun_v02 - retentionDays: 7 - destination: - type: local-directory - path: /var/backups/unidesk/platform-db/nc01/agentrun-v02 - encryption: - enabled: false + logicalDumps: + - enabled: false + database: agentrun_v02 + scriptPath: /usr/local/sbin/unidesk-nc01-agentrun-v02-pgdump.sh + serviceName: unidesk-nc01-agentrun-v02-pgdump.service + timerName: unidesk-nc01-agentrun-v02-pgdump.timer + schedule: "*-*-* 03:37:00" + retentionDays: 7 + warningAfterHours: 36 + destination: + type: nc01-local + path: /var/backups/unidesk/platform-db/nc01/agentrun-v02 + pgDump: + host: 127.0.0.1 + format: custom + encryption: + enabled: false + - enabled: true + database: pikaoa + scriptPath: /usr/local/sbin/unidesk-nc01-pikaoa-pgdump.sh + serviceName: unidesk-nc01-pikaoa-pgdump.service + timerName: unidesk-nc01-pikaoa-pgdump.timer + schedule: "*-*-* 03:47:00" + retentionDays: 14 + warningAfterHours: 36 + destination: + type: nc01-local + path: /var/backups/unidesk/platform-db/nc01/pikaoa + pgDump: + host: 127.0.0.1 + format: custom + encryption: + enabled: false diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index e0c80f83..2e8d0056 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -361,31 +361,6 @@ postgres: user: hwlab_jd01_v03_app address: 74.48.78.17/32 method: scram-sha-256 - - type: hostssl - database: pikaoa - user: pikaoa - address: 10.0.8.0/22 - method: scram-sha-256 - - type: hostssl - database: pikaoa - user: pikaoa - address: 74.48.78.17/32 - method: scram-sha-256 - - type: hostssl - database: pikaoa - user: pikaoa - address: 152.53.229.148/32 - method: scram-sha-256 - - type: hostssl - database: pikaoa_test - user: pikaoa_test - address: 10.0.8.0/22 - method: scram-sha-256 - - type: hostssl - database: pikaoa_test - user: pikaoa_test - address: 152.53.229.148/32 - method: scram-sha-256 secrets: source: master-local @@ -489,34 +464,6 @@ secrets: HWLAB_JD01_V03_DB_NAME: hwlab_jd01_v03 randomHex: HWLAB_JD01_V03_DB_PASSWORD: 32 - - name: pikaoa-db-credentials - sourceRef: platform-db/pikaoa-db.env - type: env - requiredKeys: - - PIKAOA_DB_USER - - PIKAOA_DB_PASSWORD - - PIKAOA_DB_NAME - createIfMissing: - enabled: true - values: - PIKAOA_DB_USER: pikaoa - PIKAOA_DB_NAME: pikaoa - randomHex: - PIKAOA_DB_PASSWORD: 32 - - name: pikaoa-test-db-credentials - sourceRef: platform-db/pikaoa-test-db.env - type: env - requiredKeys: - - PIKAOA_TEST_DB_USER - - PIKAOA_TEST_DB_PASSWORD - - PIKAOA_TEST_DB_NAME - createIfMissing: - enabled: true - values: - PIKAOA_TEST_DB_USER: pikaoa_test - PIKAOA_TEST_DB_NAME: pikaoa_test - randomHex: - PIKAOA_TEST_DB_PASSWORD: 32 objects: roles: - name: sub2api @@ -582,24 +529,6 @@ objects: createdb: false createrole: false superuser: false - - name: pikaoa - passwordRef: - sourceRef: platform-db/pikaoa-db.env - key: PIKAOA_DB_PASSWORD - login: true - attributes: - createdb: false - createrole: false - superuser: false - - name: pikaoa_test - passwordRef: - sourceRef: platform-db/pikaoa-test-db.env - key: PIKAOA_TEST_DB_PASSWORD - login: true - attributes: - createdb: false - createrole: false - superuser: false databases: - name: sub2api owner: sub2api @@ -636,16 +565,6 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] - - name: pikaoa - owner: pikaoa - encoding: UTF8 - locale: C.UTF-8 - extensions: [] - - name: pikaoa_test - owner: pikaoa_test - encoding: UTF8 - locale: C.UTF-8 - extensions: [] exports: connectionStrings: @@ -788,38 +707,6 @@ exports: - scope: hwlab-jd01-v03 secret: hwlab-v03-openfga key: DATASTORE_URI - - name: pikaoa-database-url - sourceSecretRef: platform-db/pikaoa-db.env - render: - envKey: DATABASE_URL - format: postgresql://$(PIKAOA_DB_USER):$(PIKAOA_DB_PASSWORD)@$(PGHOST):$(PGPORT)/$(PIKAOA_DB_NAME)?sslmode=require - variables: - PGHOST: 82.156.23.220 - PGPORT: "5432" - writeToSecretSource: - sourceRef: platform-infra/pikaoa.env - key: DATABASE_URL - mode: update-or-insert - consumers: - - scope: pikaoa - secret: pikaoa-runtime - key: DATABASE_URL - - name: pikaoa-test-database-url - sourceSecretRef: platform-db/pikaoa-test-db.env - render: - envKey: DATABASE_URL - format: postgresql://$(PIKAOA_TEST_DB_USER):$(PIKAOA_TEST_DB_PASSWORD)@$(PGHOST):$(PGPORT)/$(PIKAOA_TEST_DB_NAME)?sslmode=require - variables: - PGHOST: 82.156.23.220 - PGPORT: "5432" - writeToSecretSource: - sourceRef: platform-infra/pikaoa-test.env - key: DATABASE_URL - mode: update-or-insert - consumers: - - scope: pikaoa-test - secret: pikaoa-test-runtime - key: DATABASE_URL backup: phase: minimum-restoreable @@ -841,23 +728,6 @@ backup: encryption: enabled: false futureKeyRef: platform-db/backup-age-recipient.txt - - enabled: true - database: pikaoa - scriptPath: /usr/local/sbin/unidesk-pk01-pikaoa-pgdump.sh - serviceName: unidesk-pk01-pikaoa-pgdump.service - timerName: unidesk-pk01-pikaoa-pgdump.timer - schedule: "*-*-* 03:37:00" - retentionDays: 14 - warningAfterHours: 36 - destination: - type: pk01-local - path: /var/backups/unidesk/platform-db/pk01/pikaoa - pgDump: - host: 127.0.0.1 - format: custom - encryption: - enabled: false - futureKeyRef: platform-db/backup-age-recipient.txt physicalWalArchive: enabled: false futureTool: pgbackrest @@ -889,12 +759,6 @@ observability: - kind: psql-app-role database: hwlab_d601_v03 user: hwlab_d601_v03_app - - kind: psql-app-role - database: pikaoa - user: pikaoa - - kind: psql-app-role - database: pikaoa_test - user: pikaoa_test - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index c92b6ae2..b741e3fb 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -106,7 +106,11 @@ sources: requiredKeys: - RESTIC_PASSWORD createIfMissing: - enabled: false + enabled: true + randomBase64Url: + RESTIC_PASSWORD: + bytes: 32 + prefix: poa_restic_ - sourceRef: platform-infra/pikaoa-test.env type: env requiredKeys: @@ -151,16 +155,6 @@ 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 @@ -278,12 +272,6 @@ kubernetesSecrets: - 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 @@ -301,9 +289,6 @@ kubernetesSecrets: - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt sourceKey: contents targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD - - sourceRef: platform-infra/pk01-frp.env - sourceKey: FRP_TOKEN - targetKey: FRP_TOKEN - name: pikaoa-test-runtime targetId: pikaoa-test-nc01 secretName: pikaoa-test-runtime diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index d5218e5f..b9504d2b 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -25,15 +25,18 @@ ## 数据库边界 -- 测试数据库固定使用 PK01 host PostgreSQL。 -- `config/platform-db/postgres-pk01.yaml` 声明独立的: +- 生产与测试数据库固定使用 NC01 host PostgreSQL,不创建 namespace-local PostgreSQL。 +- `config/platform-db/postgres-nc01.yaml` 声明独立的: + - 生产 role/database:`pikaoa`; - role:`pikaoa_test`; - database:`pikaoa_test`; - schema:`pikaoa`; - - `DATABASE_URL` export:`platform-infra/pikaoa-test.env`。 + - 生产 `DATABASE_URL` export:`platform-infra/pikaoa.env`; + - 测试 `DATABASE_URL` export:`platform-infra/pikaoa-test.env`。 - `pikaoa-test` namespace 不创建 PostgreSQL StatefulSet、Service 或临时数据库。 - 数据库生命周期独立于 namespace 和业务 workload。 -- 测试数据清理由 PK01 PostgreSQL 受控入口负责,不依赖删除 namespace。 +- 迁移采用全新 role/database,不复制旧数据。 +- 数据库准备、Secret export 与后续状态查询只走 `platform-db postgres` 受控入口。 ## Secret 初始化 @@ -66,7 +69,7 @@ ## 初始化顺序 -1. 使用 `platform-db postgres` 受控入口准备 PK01 `pikaoa_test` role/database,并 export `DATABASE_URL`。 +1. 使用 `platform-db postgres` 受控入口和 `config/platform-db/postgres-nc01.yaml` 准备 `pikaoa_test` role/database,并 export `DATABASE_URL`。 2. 执行 foundation: ```bash @@ -139,7 +142,14 @@ - API、Worker、Web 并行构建并以 digest 发布到生产 GitOps branch; - Argo Application `pikaoa-nc01` 自动同步 `pikaoa` namespace; - 禁止人工补建 PipelineRun 代替 source merge。 -- 生产使用 PK01 host PostgreSQL 独立 `pikaoa` database/role,不使用 namespace-local PostgreSQL。 +- 生产使用 NC01 host PostgreSQL 独立 `pikaoa` database/role,不使用 namespace-local PostgreSQL。 +- 生产库逻辑备份由 `config/platform-db/postgres-nc01.yaml#backup.logicalDumps` 声明,写入 NC01 host 本地目录。 +- 生产附件主存储保持 NC01 k3s PVC,独立备份由 `config/pikaoa.yaml#delivery.targets.NC01.attachmentsBackup` 声明: + - source PVC 只读挂载; + - Restic repository 使用 NC01 hostPath `/var/backups/unidesk/pikaoa/attachments-restic`; + - `backup`、`check`、`forget/prune` 与 `restore-smoke` 均由 YAML 渲染; + - `restore-smoke` 只恢复到临时 `emptyDir`; + - Secret 只包含 `RESTIC_PASSWORD`,不需要 SFTP key 或 known_hosts。 - PikaOA 公网 exposure 由 `config/pikaoa.yaml` 的产品 target 唯一声明: - `testRuntime.targets.NC01.exposure` 拥有 `oa-dev.hwpod.com` 与 NodePort `32080`; - `releaseRuntime.targets.NC01.exposure` 拥有 `oa.hwpod.com` 与 NodePort `32096`; diff --git a/scripts/src/pikaoa-attachments-backup.test.ts b/scripts/src/pikaoa-attachments-backup.test.ts index 877c676b..f665f148 100644 --- a/scripts/src/pikaoa-attachments-backup.test.ts +++ b/scripts/src/pikaoa-attachments-backup.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; import type { UniDeskConfig } from "./config"; -import { readPikaoaAttachmentsBackupSpec, renderPikaoaAttachmentsBackupManifest, renderPikaoaAttachmentsInitJob, renderPikaoaAttachmentsRestoreSmokeJob, runPikaoaAttachmentsBackupCommand } from "./pikaoa-attachments-backup"; +import { projectPikaoaAttachmentsRepositoryPrerequisite, readPikaoaAttachmentsBackupSpec, renderPikaoaAttachmentsBackupManifest, renderPikaoaAttachmentsInitJob, renderPikaoaAttachmentsRestoreSmokeJob, runPikaoaAttachmentsBackupCommand } from "./pikaoa-attachments-backup"; +import { runSecretsCommand } from "./secrets"; describe("PikaOA attachments backup", () => { test("owning YAML renders separate read-only backup and maintenance CronJobs", () => { @@ -9,23 +12,95 @@ describe("PikaOA attachments backup", () => { expect(spec.schedule).toBe("17 4 * * *"); expect(spec.source.excludes).toContain(".tmp"); - expect(spec.destination.directory).toBe("/var/backups/unidesk/pikaoa/attachments-restic"); + expect(spec.destination).toMatchObject({ + type: "local-directory", + route: "NC01", + path: "/var/backups/unidesk/pikaoa/attachments-restic", + mountPath: "/repository", + }); 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("hostPath:"); + expect(manifest).toContain("path: /var/backups/unidesk/pikaoa/attachments-restic"); + expect(manifest).toContain("type: DirectoryOrCreate"); + expect(manifest).toContain("mountPath: /repository"); 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).not.toContain("RESTIC_SFTP_COMMAND"); + expect(manifest).not.toContain("SFTP_PRIVATE_KEY"); expect(manifest.split("---\n").map((document) => Bun.YAML.parse(document))).toHaveLength(4); }); + test("local directory is created by Kubernetes while missing SFTP directories remain blocking", () => { + expect(projectPikaoaAttachmentsRepositoryPrerequisite("local-directory", false)).toEqual({ + exists: false, + disposition: "will-create", + blocking: false, + }); + expect(projectPikaoaAttachmentsRepositoryPrerequisite("sftp", false)).toEqual({ + exists: false, + disposition: "waiting", + blocking: true, + }); + }); + + test("missing RESTIC_PASSWORD is pending generation instead of blocking secrets plan", async () => { + const fixtureRoot = mkdtempSync(join(process.cwd(), ".pikaoa-attachments-secret-plan-")); + try { + const sourceConfig = Bun.YAML.parse(readFileSync("config/secrets-distribution.yaml", "utf8")) as { + version: unknown; + kind: unknown; + metadata: unknown; + sources: { files: Array> }; + targets: Array>; + kubernetesSecrets: Array>; + }; + const source = sourceConfig.sources.files.find((item: Record) => item.sourceRef === "platform-infra/pikaoa-attachments-backup.env"); + const target = sourceConfig.targets.find((item: Record) => item.id === "pikaoa-nc01"); + const secret = sourceConfig.kubernetesSecrets.find((item: Record) => item.name === "pikaoa-attachments-backup"); + const fixtureConfig = { + version: sourceConfig.version, + kind: sourceConfig.kind, + metadata: sourceConfig.metadata, + sources: { root: join(fixtureRoot, "secrets"), files: [source], externalFiles: [] }, + targets: [target], + kubernetesSecrets: [secret], + }; + const fixturePath = join(fixtureRoot, "secrets-distribution.yaml"); + writeFileSync(fixturePath, Bun.YAML.stringify(fixtureConfig)); + + const planned = await runSecretsCommand({} as UniDeskConfig, ["plan", "--config", relative(process.cwd(), fixturePath), "--scope", "pikaoa", "--target", "pikaoa-nc01"]); + const localSources = planned.localSources as { entries: Array> }; + const desiredSecrets = planned.desiredSecrets as Array>; + + expect(planned.ok).toBeTrue(); + expect(localSources.entries[0]).toMatchObject({ + action: "create", + generatedKeys: ["RESTIC_PASSWORD"], + unmaterializedGeneratedKeys: ["RESTIC_PASSWORD"], + missingKeys: [], + valuesPrinted: false, + }); + expect(desiredSecrets[0]).toMatchObject({ + missingKeys: [], + pendingGeneratedKeys: ["RESTIC_PASSWORD"], + valuesPrinted: false, + }); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + 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).toContain("mountPath: /repository"); expect(manifest).not.toContain(`claimName: ${spec.source.claimName}`); expect(manifest).not.toContain(`mountPath: ${spec.source.mountPath}`); expect(Bun.YAML.parse(manifest)).toBeTruthy(); @@ -39,7 +114,8 @@ describe("PikaOA attachments backup", () => { expect(manifest).toContain('if [ "$cat_status" -eq 0 ]'); expect(manifest).toContain("result=already-initialized mutation=false"); expect(manifest).toContain('if [ "$cat_status" -ne 10 ]'); - expect(manifest).toContain('restic -o sftp.command="$RESTIC_SFTP_COMMAND" init'); + expect(manifest).toContain("restic init"); + expect(manifest).toContain("mountPath: /repository"); expect(manifest).not.toContain("persistentVolumeClaim:"); expect(manifest).not.toContain(`claimName: ${spec.source.claimName}`); expect(manifest).not.toContain(`mountPath: ${spec.source.mountPath}`); diff --git a/scripts/src/pikaoa-attachments-backup.ts b/scripts/src/pikaoa-attachments-backup.ts index 1e7e03e8..21676fc7 100644 --- a/scripts/src/pikaoa-attachments-backup.ts +++ b/scripts/src/pikaoa-attachments-backup.ts @@ -12,21 +12,35 @@ interface Options { action: Action; configPath: string; targetId: string | null; 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 } }; + destination: + | { type: "sftp"; route: string; host: string; port: number; username: string; directory: string } + | { type: "local-directory"; route: string; path: string; mountPath: 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: { 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[] } +interface Prerequisites { + namespace: boolean; + sourcePvc: boolean; + secret: boolean; + repositoryDirectory: PikaoaAttachmentsRepositoryPrerequisite; + ready: boolean; + waiting: string[]; +} +export interface PikaoaAttachmentsRepositoryPrerequisite { + exists: boolean; + disposition: "ready" | "will-create" | "waiting"; + blocking: boolean; +} const DEFAULT_CONFIG_PATH = rootPath("config", "pikaoa.yaml"); export function pikaoaAttachmentsBackupHelp(): Record { return { command: "pikaoa attachments-backup", - description: "管理 PikaOA 生产附件的 YAML-first、只读 Restic SFTP 离机备份。", + description: "管理 PikaOA 生产附件的 YAML-first、只读 Restic 备份。", 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]", @@ -41,6 +55,7 @@ export function pikaoaAttachmentsBackupHelp(): Record { "日常备份、check 与 forget/prune 使用独立 CronJob。", "restore-smoke 只恢复到临时 emptyDir,绝不挂载生产 PVC。", "Secret 只经 YAML sourceRef/targetKey 分发,输出不打印值。", + "repository 可由 YAML 选择本机 hostPath 目录或兼容 SFTP 目标。", "init 仅在 restic cat config 精确报告 repository-not-found 时创建仓库;已初始化时结构化 no-op,认证或网络错误保持失败。", "前置条件未就绪时返回 prerequisite-waiting,且不阻塞业务、PaC 或 Argo。", ], @@ -76,6 +91,15 @@ export function readPikaoaAttachmentsBackupSpec(configPath = DEFAULT_CONFIG_PATH const maintenance = object(backup.maintenance, "attachmentsBackup.maintenance"); const smoke = object(backup.restoreSmoke, "attachmentsBackup.restoreSmoke"); const jobs = object(backup.jobs, "attachmentsBackup.jobs"); + const destinationType = backupDestinationType(destination.type); + const parsedDestination: PikaoaAttachmentsBackupSpec["destination"] = destinationType === "local-directory" + ? { type: destinationType, route: text(destination.route, "destination.route"), path: path(destination.path, "destination.path"), mountPath: path(destination.mountPath, "destination.mountPath") } + : { type: destinationType, 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") }; + const parsedKeys: PikaoaAttachmentsBackupSpec["secret"]["keys"] = { password: env(keys.password, "keys.password") }; + if (destinationType === "sftp") { + parsedKeys.privateKey = env(keys.privateKey, "keys.privateKey"); + parsedKeys.knownHosts = env(keys.knownHosts, "keys.knownHosts"); + } const spec: PikaoaAttachmentsBackupSpec = { targetId: selectedId, route: text(target.route, "target.route"), @@ -85,8 +109,8 @@ export function readPikaoaAttachmentsBackupSpec(configPath = DEFAULT_CONFIG_PATH 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") } }, + destination: parsedDestination, + 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: parsedKeys }, 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") }, @@ -126,7 +150,7 @@ spec: args: - | ${indentBlock(resticPrelude(spec), 14)} - restic -o sftp.command="$RESTIC_SFTP_COMMAND" restore latest --target /restore + ${resticInvocation(spec, "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: @@ -135,6 +159,7 @@ ${indentBlock(resticPrelude(spec), 14)} readOnly: true - name: restore-target mountPath: /restore +${repositoryVolumeMount(spec, 12)} volumes: - name: backup-secret secret: @@ -143,6 +168,7 @@ ${indentBlock(resticPrelude(spec), 14)} - name: restore-target emptyDir: sizeLimit: ${spec.restoreSmoke.emptyDirSizeLimit} +${repositoryVolume(spec, 8)} `; } @@ -173,7 +199,7 @@ spec: - | ${indentBlock(resticPrelude(spec), 14)} set +e - cat_output="$(restic -o sftp.command="$RESTIC_SFTP_COMMAND" cat config 2>&1)" + cat_output="$(${resticInvocation(spec, "cat config")} 2>&1)" cat_status="$?" set -e if [ "$cat_status" -eq 0 ]; then @@ -185,17 +211,19 @@ ${indentBlock(resticPrelude(spec), 14)} printf '%s\\n' "$cat_output" >&2 exit "$cat_status" fi - restic -o sftp.command="$RESTIC_SFTP_COMMAND" init + ${resticInvocation(spec, "init")} printf 'event=pikaoa.attachments.repository-init result=initialized mutation=true\\n' volumeMounts: - name: backup-secret mountPath: /backup-secret readOnly: true +${repositoryVolumeMount(spec, 12)} volumes: - name: backup-secret secret: secretName: ${spec.secret.secretName} defaultMode: 0400 +${repositoryVolume(spec, 8)} `; } @@ -203,12 +231,12 @@ 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 }, + target: targetSummary(spec), schedule: { backup: spec.schedule, check: spec.maintenance.checkSchedule, forgetPrune: spec.maintenance.forgetPruneSchedule }, 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), + requirements: ["production namespace", "production attachments PVC", "YAML-first Secret sync", "YAML-declared repository directory"], next: next(spec), }; } @@ -269,13 +297,20 @@ async function inspectPrerequisites(config: UniDeskConfig, spec: PikaoaAttachmen 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 destinationDirectory = await remoteExists(config, spec.destination.route, ["sh"], `test -d ${shQuote(destinationPath(spec))}\n`); + const repositoryDirectory = projectPikaoaAttachmentsRepositoryPrerequisite(spec.destination.type, destinationDirectory); 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 }; + if (repositoryDirectory.blocking) waiting.push("repository-directory"); + return { namespace, sourcePvc, secret, repositoryDirectory, ready: waiting.length === 0, waiting }; +} + +export function projectPikaoaAttachmentsRepositoryPrerequisite(type: PikaoaAttachmentsBackupSpec["destination"]["type"], exists: boolean): PikaoaAttachmentsRepositoryPrerequisite { + if (exists) return { exists: true, disposition: "ready", blocking: false }; + if (type === "local-directory") return { exists: false, disposition: "will-create", blocking: false }; + return { exists: false, disposition: "waiting", blocking: true }; } async function remoteExists(config: UniDeskConfig, route: string, args: string[], stdin = ""): Promise { @@ -327,30 +362,51 @@ ${indentBlock(resticPrelude(spec), 18)} - name: backup-secret mountPath: /backup-secret readOnly: true -${attachmentMount} volumes: +${repositoryVolumeMount(spec, 16)}${attachmentMount} volumes: - name: backup-secret secret: secretName: ${spec.secret.secretName} defaultMode: 0400 -${attachmentVolume}`; +${repositoryVolume(spec, 12)}${attachmentVolume}`; } function resticPrelude(spec: PikaoaAttachmentsBackupSpec): string { + if (spec.destination.type === "local-directory") { + return `export RESTIC_REPOSITORY=${shQuote(spec.destination.mountPath)}\nexport RESTIC_PASSWORD_FILE=/backup-secret/${spec.secret.keys.password}`; + } 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`; + 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 resticInvocation(spec: PikaoaAttachmentsBackupSpec, args: string): string { + return spec.destination.type === "sftp" ? `restic -o sftp.command="$RESTIC_SFTP_COMMAND" ${args}` : `restic ${args}`; +} + +function destinationPath(spec: PikaoaAttachmentsBackupSpec): string { + return spec.destination.type === "sftp" ? spec.destination.directory : spec.destination.path; +} + +function repositoryVolumeMount(spec: PikaoaAttachmentsBackupSpec, spaces: number): string { + if (spec.destination.type !== "local-directory") return ""; + return `${" ".repeat(spaces)}- name: repository\n${" ".repeat(spaces + 2)}mountPath: ${spec.destination.mountPath}\n`; +} + +function repositoryVolume(spec: PikaoaAttachmentsBackupSpec, spaces: number): string { + if (spec.destination.type !== "local-directory") return ""; + return `${" ".repeat(spaces)}- name: repository\n${" ".repeat(spaces + 2)}hostPath:\n${" ".repeat(spaces + 4)}path: ${spec.destination.path}\n${" ".repeat(spaces + 4)}type: DirectoryOrCreate\n`; +} + 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`; + if (operation === "check") return resticInvocation(spec, "check --read-data-subset=5%"); + if (operation === "forget-prune") return resticInvocation(spec, `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`; + return resticInvocation(spec, `backup ${shQuote(spec.source.mountPath)} ${excludes} --tag pikaoa-attachments`); } function waiting(action: string, prerequisites: Prerequisites, spec: PikaoaAttachmentsBackupSpec): Record { @@ -358,7 +414,7 @@ function waiting(action: string, prerequisites: Prerequisites, spec: PikaoaAttac } function targetSummary(spec: PikaoaAttachmentsBackupSpec): Record { - return { id: spec.targetId, route: spec.route, namespace: spec.namespace, enabled: spec.enabled, nonBlocking: spec.nonBlocking }; + return { id: spec.targetId, route: spec.route, namespace: spec.namespace, enabled: spec.enabled, nonBlocking: spec.nonBlocking, repositoryType: spec.destination.type }; } function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record { @@ -430,4 +486,4 @@ function name(value: unknown, label: string): string { const parsed = text(value 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" } +function backupDestinationType(value: unknown): "sftp" | "local-directory" { if (value !== "sftp" && value !== "local-directory") throw new Error("destination.type must be sftp or local-directory"); return value } diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-test-delivery-renderer.test.ts index c42ceb37..233e4c6e 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-test-delivery-renderer.test.ts @@ -138,6 +138,8 @@ describe("PikaOA test PaC renderer", () => { expect(manifest).toContain("name: initializer"); expect(manifest).not.toContain("kind: Secret"); expect(manifest).toContain("secretName: pikaoa-test-runtime"); + expect(manifest.match(/name: DATABASE_URL/gu)).toHaveLength(3); + expect(manifest.match(/key: DATABASE_URL/gu)).toHaveLength(3); expect(manifest).toContain("image: __PIKAOA_API_IMAGE__"); expect(manifest).toContain("pikaoa.unidesk.io/source-commit: __PIKAOA_SOURCE_COMMIT__"); expect(script).toContain('s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g'); diff --git a/scripts/src/pikaoa-test-runtime-manifest.ts b/scripts/src/pikaoa-test-runtime-manifest.ts index 2a2e8567..4a6cb555 100644 --- a/scripts/src/pikaoa-test-runtime-manifest.ts +++ b/scripts/src/pikaoa-test-runtime-manifest.ts @@ -96,7 +96,10 @@ export function renderPikaoaTestRuntimeManifest( name: "initializer", image: context.initializerImage, imagePullPolicy: target.source.pullPolicy, command: target.initializer.command, resources: target.runtime.resources.initializer, - env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }], + env: [ + { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, + { name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.database.connection.targetKey } } }, + ], volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], }], volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], @@ -106,6 +109,7 @@ export function renderPikaoaTestRuntimeManifest( }, deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, prometheusPodLabels, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [ { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, + { name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.database.connection.targetKey } } }, { name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } }, { name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username }, { name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } }, @@ -114,6 +118,7 @@ export function renderPikaoaTestRuntimeManifest( ], target.probes.apiLivenessPath, target.probes.apiReadinessPath), deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, prometheusPodLabels, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [ { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, + { name: "DATABASE_URL", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.database.connection.targetKey } } }, ], target.probes.workerLivenessPath, target.probes.workerReadinessPath), deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, {}, [ { name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}.${context.namespace}.svc.cluster.local:${target.runtime.apiPort}` }, diff --git a/scripts/src/pikaoa-test-target-async.test.ts b/scripts/src/pikaoa-test-target-async.test.ts index 79bc6dcd..03392143 100644 --- a/scripts/src/pikaoa-test-target-async.test.ts +++ b/scripts/src/pikaoa-test-target-async.test.ts @@ -380,7 +380,7 @@ test("validation-only fixture never invokes remote capture", async () => { const planPayload = JSON.parse(planResult.renderedText) as Record; const plan = planPayload.plan as Record; assert.deepEqual(plan.database, { - configRef: "config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url", + configRef: "config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-test-database-url", name: "pikaoa_test", username: "pikaoa_test", schema: "pikaoa", diff --git a/scripts/src/platform-db-postgres-backups.test.ts b/scripts/src/platform-db-postgres-backups.test.ts index deb73bfe..3cf5de52 100644 --- a/scripts/src/platform-db-postgres-backups.test.ts +++ b/scripts/src/platform-db-postgres-backups.test.ts @@ -1,23 +1,26 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { projectLogicalDumps } from "./platform-db-postgres-backups"; import { readPostgresLogicalDumps, renderPostgresRemoteRunnerForTest } from "./platform-db"; -describe("PK01 PostgreSQL logical dumps", () => { - test("parses independent Sub2API and PikaOA declarations", () => { - const logicalDumps = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml"); +describe("PostgreSQL logical dumps", () => { + test("keeps Sub2API on PK01 and declares PikaOA on NC01", () => { + const pk01LogicalDumps = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml"); + const nc01LogicalDumps = readPostgresLogicalDumps("config/platform-db/postgres-nc01.yaml"); - expect(logicalDumps).toHaveLength(2); - expect(logicalDumps.map((item) => item.database)).toEqual(["sub2api", "pikaoa"]); - expect(logicalDumps[0]).toMatchObject({ + expect(pk01LogicalDumps).toHaveLength(1); + expect(pk01LogicalDumps[0]).toMatchObject({ + database: "sub2api", scriptPath: "/usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh", serviceName: "unidesk-pk01-sub2api-pgdump.service", timerName: "unidesk-pk01-sub2api-pgdump.timer", schedule: "*-*-* 03:17:00", retentionDays: 14, }); - expect(logicalDumps[1]).toMatchObject({ - destination: { path: "/var/backups/unidesk/platform-db/pk01/pikaoa" }, - schedule: "*-*-* 03:37:00", + expect(nc01LogicalDumps.map((item) => item.database)).toEqual(["agentrun_v02", "pikaoa"]); + expect(nc01LogicalDumps[1]).toMatchObject({ + destination: { path: "/var/backups/unidesk/platform-db/nc01/pikaoa" }, + schedule: "*-*-* 03:47:00", retentionDays: 14, }); }); @@ -32,6 +35,13 @@ describe("PK01 PostgreSQL logical dumps", () => { expect(runner).not.toContain("OnCalendar=*-*-* 03:17:00"); }); + test("PK01 backup YAML has no duplicate future encryption key", () => { + const source = readFileSync("config/platform-db/postgres-pk01.yaml", "utf8"); + + expect(source.match(/^\s+futureKeyRef:/gmu)).toHaveLength(1); + expect(() => readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml")).not.toThrow(); + }); + test("reports stale or missing evidence as warnings", () => { const [declaration] = readPostgresLogicalDumps("config/platform-db/postgres-pk01.yaml"); const [projection] = projectLogicalDumps([declaration], [{