From 03ca5ffe9c8892b06fec638d3e0a1178eec21d07 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 19:06:48 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20PikaOA=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=BF=90=E8=A1=8C=E9=9D=A2=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/fixtures/pikaoa-test-target.yaml | 18 +++ docs/reference/pikaoa.md | 11 +- scripts/src/pikaoa-test-target.ts | 161 ++++++++++++++++++++++-- 3 files changed, 177 insertions(+), 13 deletions(-) diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index e3bc8618..0bcecd69 100644 --- a/config/fixtures/pikaoa-test-target.yaml +++ b/config/fixtures/pikaoa-test-target.yaml @@ -30,6 +30,7 @@ testRuntime: images: api: registry.invalid/pikaoa/api:${commit} worker: registry.invalid/pikaoa/worker:${commit} + migration: registry.invalid/pikaoa/migration:${commit} pullPolicy: IfNotPresent database: image: postgres:16-alpine @@ -43,6 +44,14 @@ testRuntime: secretName: pikaoa-test-runtime apiPort: 8080 workerMetricsPort: 8081 + attachment: + storageRoot: /var/lib/pikaoa/attachments + maxBytes: 104857600 + claimName: pikaoa-attachments + storageRequest: 1Gi + storageClassName: null + accessModes: + - ReadWriteOnce workerInterval: 2s outbox: batchSize: 50 @@ -63,6 +72,15 @@ testRuntime: sessionSecret: sourceRef: ./secrets/pikaoa-test-session-secret.txt targetKey: PIKAOA_SESSION_SECRET + migration: + jobName: pikaoa-migrate + backoffLimit: 2 + command: + - /usr/local/bin/pikaoa + - migrate + - up + - --config + - /etc/pikaoa/pikaoa.yaml observability: otlpEndpoint: http://otel-collector.observability.svc.cluster.local:4318 prometheus: diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index 9c7d8d12..4fb5b0ba 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -39,6 +39,15 @@ bun scripts/cli.ts pikaoa test-target stop --target --instance --c - `delivery.targets.*.ci.namespace`。 - 测试入口不得渲染或删除保护集合中的任何对象。 - PostgreSQL 使用 namespace 内的临时存储,namespace 删除后测试数据随之清理。 +- 附件运行参数与 namespace 内的临时 PVC 来自 target YAML: + - `runtime.attachment.storageRoot` 和 `runtime.attachment.maxBytes` 直接写入严格 runtime YAML; + - PVC 名称、容量、StorageClass 和 access mode 只从同一 target 声明读取; + - API 挂载该 PVC,删除临时 namespace 时一并删除 PVC。 +- 空数据库启动固定分为三个受控阶段: + - 先渲染基础对象并等待 namespace 内 PostgreSQL 就绪; + - 再使用 `source.images.migration` 和 `migration.command` 运行产品内置 `pikaoa migrate up` Job; + - 迁移 Job 完成后才创建 API 和 Worker Deployment 并等待 rollout。 +- PostgreSQL、迁移 Job 或业务 Deployment 失败会返回具名失败码,不会静默进入下一阶段。 - TTL 和清理策略来自 target YAML: - TTL 写入 namespace annotation; - 当前 `cleanup.mode=delete-namespace`,由显式 `stop` 执行; @@ -58,7 +67,7 @@ bun scripts/cli.ts pikaoa test-target stop --target --instance --c `config/fixtures/pikaoa-test-target.yaml` 只用于本地验证: -- 证明 namespace、选择器、工作负载和 Secret 引用的渲染结果; +- 证明 namespace、选择器、附件 PVC、迁移 Job、工作负载和 Secret 引用的渲染结果; - 证明 TTL、OTel、Prometheus 和正式 namespace 保护; - fixture target 固定为 `validationOnly=true`,不得用于真实集群操作。 diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index 5c1d27cd..be23640c 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -49,6 +49,7 @@ interface TestTargetSpec { commitPolicy: string; apiImage: string; workerImage: string; + migrationImage: string; pullPolicy: string; }; database: { @@ -62,6 +63,14 @@ interface TestTargetSpec { secretName: string; apiPort: number; workerMetricsPort: number; + attachment: { + storageRoot: string; + maxBytes: number; + claimName: string; + storageRequest: string; + storageClassName: string | null; + accessModes: string[]; + }; workerInterval: string; outboxBatchSize: number; outboxMaxAttempts: number; @@ -72,6 +81,11 @@ interface TestTargetSpec { employee: { username: string; password: SecretSourceSpec }; sessionSecret: SecretSourceSpec; }; + migration: { + jobName: string; + backoffLimit: number; + command: string[]; + }; observability: { otlpEndpoint: string; prometheusScrape: boolean; @@ -117,6 +131,7 @@ interface RenderContext { expiresAt: string; apiImage: string; workerImage: string; + migrationImage: string; } interface SecretMaterial { @@ -242,6 +257,7 @@ function parseTarget(id: string, root: Record, configLabel: str const images = record(source.images, `${path}.source.images`); const database = record(root.database, `${path}.database`); const runtime = record(root.runtime, `${path}.runtime`); + const attachment = record(runtime.attachment, `${path}.runtime.attachment`); const outbox = record(runtime.outbox, `${path}.runtime.outbox`); const administrator = record(runtime.administrator, `${path}.runtime.administrator`); const employee = record(runtime.employee, `${path}.runtime.employee`); @@ -252,6 +268,7 @@ function parseTarget(id: string, root: Record, configLabel: str const apiProbes = record(probes.api, `${path}.probes.api`); const workerProbes = record(probes.worker, `${path}.probes.worker`); const cleanup = record(root.cleanup, `${path}.cleanup`); + const migration = record(root.migration, `${path}.migration`); if (exporterFailure.blocking !== false) throw new Error(`${path}.observability.exporterFailure.blocking 必须为 false`); if (exporterFailure.warning !== true) throw new Error(`${path}.observability.exporterFailure.warning 必须为 true`); if (cleanup.requireOwnershipLabels !== true) throw new Error(`${path}.cleanup.requireOwnershipLabels 必须为 true`); @@ -276,6 +293,7 @@ function parseTarget(id: string, root: Record, configLabel: str commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"), apiImage: imageReference(images.api, `${path}.source.images.api`), workerImage: imageReference(images.worker, `${path}.source.images.worker`), + migrationImage: imageReference(images.migration, `${path}.source.images.migration`), pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]), }, database: { @@ -289,6 +307,14 @@ function parseTarget(id: string, root: Record, configLabel: str secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63), apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535), workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535), + attachment: { + storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`), + maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER), + claimName: kubernetesName(nonEmpty(attachment.claimName, `${path}.runtime.attachment.claimName`), `${path}.runtime.attachment.claimName`, 63), + storageRequest: nonEmpty(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`), + storageClassName: nullableString(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`), + accessModes: enumStringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]), + }, workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`), outboxBatchSize: positiveInteger(outbox.batchSize, `${path}.runtime.outbox.batchSize`, 1000), outboxMaxAttempts: positiveInteger(outbox.maxAttempts, `${path}.runtime.outbox.maxAttempts`, 100), @@ -305,6 +331,11 @@ function parseTarget(id: string, root: Record, configLabel: str }, sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`), }, + migration: { + jobName: kubernetesName(nonEmpty(migration.jobName, `${path}.migration.jobName`), `${path}.migration.jobName`, 63), + backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`), + command: nonEmptyStringList(migration.command, `${path}.migration.command`), + }, observability: { otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`), prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`), @@ -320,6 +351,9 @@ function parseTarget(id: string, root: Record, configLabel: str workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`), }, }; + if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) { + throw new Error(`${path}.migration.command 必须显式调用产品 migrate up`); + } const secretKeys = secretSources(target).map((item) => item.targetKey); if (new Set(secretKeys).size !== secretKeys.length || secretKeys.includes("pikaoa.yaml")) { throw new Error(`${path} 的 Secret targetKey 必须唯一且不能使用 pikaoa.yaml`); @@ -406,6 +440,20 @@ function renderedPlan(selection: Selection, context: RenderContext): Record => target.observability.prometheusScrape ? { "prometheus.io/scrape": "true", "prometheus.io/path": path, @@ -537,6 +587,14 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: { apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", stringData: secretData, }, + { + apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels }, + spec: { + accessModes: target.runtime.attachment.accessModes, + resources: { requests: { storage: target.runtime.attachment.storageRequest } }, + ...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }), + }, + }, { apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-postgres", namespace: context.namespace, labels: { ...labels, ...postgresSelector } }, spec: { selector: postgresSelector, ports: [{ name: "postgres", port: target.database.port, targetPort: target.database.port }] }, @@ -569,6 +627,25 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-api", namespace: context.namespace, labels: { ...labels, ...apiSelector } }, spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] }, }, + { + apiVersion: "batch/v1", kind: "Job", metadata: { name: target.migration.jobName, namespace: context.namespace, labels: { ...labels, ...migrationSelector } }, + spec: { + backoffLimit: target.migration.backoffLimit, + template: { + metadata: { labels: { ...labels, ...migrationSelector } }, + spec: { + restartPolicy: "Never", + containers: [{ + name: "migration", image: context.migrationImage, imagePullPolicy: target.source.pullPolicy, + command: target.migration.command, + env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }], + volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], + }], + volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], + }, + }, + }, + }, deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [ { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, { name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } }, @@ -606,11 +683,17 @@ function deployment( containers: [{ name: component, image, imagePullPolicy: target.source.pullPolicy, env, ports: [{ name: component === "api" ? "http" : "metrics", containerPort: port }], - volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], + volumeMounts: [ + { name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }, + ...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []), + ], livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 }, readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 }, }], - volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], + volumes: [ + { name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }, + ...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []), + ], }, }, }, @@ -638,6 +721,9 @@ function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secre ` url: ${JSON.stringify(databaseURL)}`, "identity:", ` sessionTTL: ${target.runtime.sessionTTL}`, + "attachment:", + ` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`, + ` maxBytes: ${target.runtime.attachment.maxBytes}`, "observability:", ` serviceName: pikaoa-test-${context.instanceId}`, ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, @@ -687,7 +773,13 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): { } function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record[]): string { - const encoded = Buffer.from(manifest.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); + const migration = manifest.filter((object) => object.kind === "Job"); + const workloads = manifest.filter((object) => object.kind === "Deployment"); + const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment"); + const encode = (objects: Record[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); + const foundationEncoded = encode(foundation); + const migrationEncoded = encode(migration); + const workloadsEncoded = encode(workloads); return ` set -u namespace=${shQuote(context.namespace)} @@ -705,20 +797,42 @@ if kubectl get namespace "$namespace" >/dev/null 2>&1; then exit 42 fi fi -printf '%s' ${shQuote(encoded)} | base64 -d >"$tmp/manifest.yaml" -if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/manifest.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then +printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml" +printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml" +printf '%s' ${shQuote(workloadsEncoded)} | base64 -d >"$tmp/workloads.yaml" +if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/foundation.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then cat "$tmp/apply.err" >&2 printf '%s\n' '{"ok":false,"mutation":true,"code":"apply-failed","valuesPrinted":false}' exit 43 fi wait_rc=0 kubectl -n "$namespace" rollout status statefulset/pikaoa-postgres --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err" || wait_rc=$? -if [ "$wait_rc" -eq 0 ]; then kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err" || wait_rc=$?; fi +if [ "$wait_rc" -ne 0 ]; then + cat "$tmp/wait.err" >&2 + printf '%s\n' '{"ok":false,"mutation":true,"code":"postgres-wait-failed","valuesPrinted":false}' + exit 44 +fi +if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then + cat "$tmp/migration-apply.err" >&2 + printf '%s\n' '{"ok":false,"mutation":true,"code":"migration-apply-failed","valuesPrinted":false}' + exit 45 +fi +if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then + cat "$tmp/migration-wait.err" >&2 + printf '%s\n' '{"ok":false,"mutation":true,"code":"migration-wait-failed","valuesPrinted":false}' + exit 46 +fi +if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/workloads.yaml" >"$tmp/workloads-apply.out" 2>"$tmp/workloads-apply.err"; then + cat "$tmp/workloads-apply.err" >&2 + printf '%s\n' '{"ok":false,"mutation":true,"code":"workloads-apply-failed","valuesPrinted":false}' + exit 47 +fi +kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err" || wait_rc=$? if [ "$wait_rc" -eq 0 ]; then kubectl -n "$namespace" rollout status deployment/pikaoa-worker --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err" || wait_rc=$?; fi if [ "$wait_rc" -ne 0 ]; then cat "$tmp/wait.err" >&2 printf '%s\n' '{"ok":false,"mutation":true,"code":"rollout-wait-failed","valuesPrinted":false}' - exit 44 + exit 48 fi printf '%s\n' '{"ok":true,"mutation":true,"code":"started","valuesPrinted":false}' `; @@ -735,7 +849,7 @@ fi printf '%s' '{"ok":true,"exists":true,"mutation":false,"namespace":' kubectl get namespace "$namespace" -o json printf '%s' ',"resources":' -kubectl -n "$namespace" get deployment,statefulset,service,pod -o json +kubectl -n "$namespace" get deployment,statefulset,job,service,persistentvolumeclaim,pod -o json printf '%s\n' ',"valuesPrinted":false}' `; } @@ -848,7 +962,7 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?: repository: target.source.repository, commitPolicy: target.source.commitPolicy, commit, - images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage } : { api: context.apiImage, worker: context.workerImage }, + images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, migrationTemplate: target.source.migrationImage } : { api: context.apiImage, worker: context.workerImage, migration: context.migrationImage }, }; } @@ -899,13 +1013,14 @@ function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): R function objectSummary(object: Record): Record { const metadata = optionalRecord(object.metadata, "metadata") ?? {}; + const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; const spec = optionalRecord(object.spec, "spec") ?? {}; const selector = optionalRecord(spec.selector, "spec.selector") ?? {}; return { kind: object.kind ?? null, name: metadata.name ?? null, namespace: metadata.namespace ?? null, - labels: metadata.labels ?? {}, + ownershipLabelsPresent: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string", selector: selector.matchLabels ?? selector, secretValuesRedacted: object.kind === "Secret" ? true : undefined, }; @@ -1053,6 +1168,23 @@ function nullableString(value: unknown, path: string): string | null { return nonEmpty(value, path); } +function absolutePath(value: unknown, path: string): string { + const result = nonEmpty(value, path); + if (!result.startsWith("/") || result.includes("\0")) throw new Error(`${path} 必须是绝对路径`); + return result; +} + +function nonEmptyStringList(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`); + return value.map((item, index) => nonEmpty(item, `${path}[${index}]`)); +} + +function enumStringList(value: unknown, path: string, allowed: readonly T[]): T[] { + const items = nonEmptyStringList(value, path).map((item) => enumString(item, path, allowed)); + if (new Set(items).size !== items.length) throw new Error(`${path} 不能包含重复值`); + return items; +} + function boolean(value: unknown, path: string): boolean { if (typeof value !== "boolean") throw new Error(`${path} 必须是 boolean`); return value; @@ -1063,6 +1195,11 @@ function positiveInteger(value: unknown, path: string, max: number): number { return Number(value); } +function nonNegativeInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${path} 必须是非负安全整数`); + return Number(value); +} + function enumString(value: unknown, path: string, allowed: readonly T[]): T { if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} 必须是 ${allowed.join("、")}`); return value as T; From 5ee05d2ec7df762bdd2a41dfdefa4105b88ce20b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 19:08:59 +0200 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20PikaOA=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=BF=90=E8=A1=8C=E9=9D=A2=E8=A1=A5=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../R1.5.8_Task_Report.md | 34 +++++++++++++++++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md new file mode 100644 index 00000000..8632fed0 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md @@ -0,0 +1,34 @@ +# R1.5.8 任务报告 + +## 结论 + +已按 [pikasTech/unidesk#2053](https://github.com/pikasTech/unidesk/issues/2053) 补齐 PikaOA 临时测试运行面的附件配置、namespace 内附件 PVC 和数据库迁移启动链。fixture 现在渲染严格 runtime YAML 所需的 `attachment.storageRoot`、`attachment.maxBytes`,并按“基础对象与 PostgreSQL 就绪、迁移 Job 完成、API/Worker rollout”顺序执行。 + +## 实现 + +- owning YAML:`config/fixtures/pikaoa-test-target.yaml#testRuntime.targets.TEST01` 声明附件 PVC、迁移镜像、Job 名称、重试和产品 CLI 命令。 +- renderer:`scripts/src/pikaoa-test-target.ts` 只读取并校验 YAML 形状,渲染 PVC、迁移 Job、API 挂载和三阶段启动脚本。 +- 可见性:`plan` 显示附件与迁移的脱敏摘要;PostgreSQL、迁移 apply/wait 和业务 rollout 分别返回具名失败码。 +- 文档:`docs/reference/pikaoa.md` 记录附件存储归属和迁移先于业务 workload 的长期契约。 +- 产品静态核对:`/root/pikaoa/internal/config/config.go` 要求附件字段;`/root/pikaoa/internal/cli/root.go` 与 README 确认 `pikaoa migrate up --config ` 入口。 + +## YAML-first 分类 + +- `keep-domain-special`:PikaOA 严格 runtime YAML、产品内置迁移 Job、namespace 生命周期内附件 PVC。 +- `remove-code-default`:附件 volume 名直接复用 YAML `claimName`;未新增容量、重试或 StorageClass 隐藏默认。 +- `legacy-retire`:不适用。 + +## 验证 + +- `bun --check scripts/src/pikaoa-test-target.ts`:通过。 +- `bun scripts/cli.ts check`:11/11 通过。 +- 默认 `pikaoa test-target plan --output json`:`configured=false`、`mutation=false`。 +- fixture `plan` 结构化断言:包含 `PersistentVolumeClaim`、`Job`、API/Worker `Deployment`,迁移阶段为 `after-postgres-before-workloads`,`validationOnly=true`、`mutation=false`、`valuesPrinted=false`,未出现 DSN 或 Secret 派生值。 +- fixture `status`:`remoteQueried=false`、`reason=validation-only-target`。 +- `git diff --check`:通过。 + +## 风险与边界 + +- fixture 只声明验证用 `registry.invalid` 镜像;真实专用测试 target 仍需由 owning YAML 指定可用镜像与 StorageClass。 +- 未执行 `start/stop`,未连接集群,未修改产品仓、正式 namespace、CI/CD、GitOps 或公网暴露。 +- 实现 checkpoint:`03ca5ffe`。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 39d2bc0f..50411e83 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -86,7 +86,7 @@ #### R1.5.7 [completed] 增加 YAML-first PikaOA 专用测试集群临时 k8s 运行面和 pikaoa test-target plan/status/start/stop 受控入口,不经过 CI/CD、不触碰正式 namespace,未声明测试 target 时返回 configured=false,执行记录见 [pikasTech/unidesk#2046](https://github.com/pikasTech/unidesk/issues/2046),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.7_Task_Report.md)。 -#### R1.5.8 [in_progress] +#### R1.5.8 [completed] 修复 `pikaoa test-target` 临时运行面缺失附件配置与数据库迁移启动链,使 fixture 能渲染当前产品可启动的 namespace-local 后端运行面,保持未声明 target 与 `validationOnly` 全程无远端变更,执行记录见 [pikasTech/unidesk#2053](https://github.com/pikasTech/unidesk/issues/2053),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md)。 ### R1.6 From 0b6ad4efbc1f65feadeb94501d647b73a230c83b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 19:44:39 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=B0=86=20PikaOA=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=A5=91=E7=BA=A6=E6=A0=A1=E9=AA=8C=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/pikaoa-test-target.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index be23640c..f7180220 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -351,9 +351,6 @@ function parseTarget(id: string, root: Record, configLabel: str workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`), }, }; - if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) { - throw new Error(`${path}.migration.command 必须显式调用产品 migrate up`); - } const secretKeys = secretSources(target).map((item) => item.targetKey); if (new Set(secretKeys).size !== secretKeys.length || secretKeys.includes("pikaoa.yaml")) { throw new Error(`${path} 的 Secret targetKey 必须唯一且不能使用 pikaoa.yaml`); @@ -561,6 +558,7 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] { const warnings: Warning[] = []; + if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) warnings.push(warning("migration-command-unrecognized", "迁移命令未显示产品 migrate up;配置一致性只报告 warning。")); if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.includes("${commit}") || !target.source.migrationImage.includes("${commit}")) warnings.push(warning("image-template-not-commit-scoped", "API、Worker 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。")); if (context !== null && (!context.apiImage.includes(context.commit) || !context.workerImage.includes(context.commit) || !context.migrationImage.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。")); if (target.observability.exporterWarning) warnings.push(warning("otel-exporter-warning-only", "OTel exporter 失败按 owning YAML 仅报告 warning,不阻塞业务运行。")); From 7cb6831ae22b9abf08795636ee711952652733a6 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 19:45:30 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20PikaOA=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=BF=90=E8=A1=8C=E9=9D=A2=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pikaoa-enterprise-platform/R1.5.8_Task_Report.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md index 8632fed0..fb7afe6f 100644 --- a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.8_Task_Report.md @@ -9,6 +9,7 @@ - owning YAML:`config/fixtures/pikaoa-test-target.yaml#testRuntime.targets.TEST01` 声明附件 PVC、迁移镜像、Job 名称、重试和产品 CLI 命令。 - renderer:`scripts/src/pikaoa-test-target.ts` 只读取并校验 YAML 形状,渲染 PVC、迁移 Job、API 挂载和三阶段启动脚本。 - 可见性:`plan` 显示附件与迁移的脱敏摘要;PostgreSQL、迁移 apply/wait 和业务 rollout 分别返回具名失败码。 +- 审核纠偏:迁移命令未显示 `migrate up` 时返回 `migration-command-unrecognized blocking=false`,不再由 parser 阻塞;fixture 的有效命令保持不变。 - 文档:`docs/reference/pikaoa.md` 记录附件存储归属和迁移先于业务 workload 的长期契约。 - 产品静态核对:`/root/pikaoa/internal/config/config.go` 要求附件字段;`/root/pikaoa/internal/cli/root.go` 与 README 确认 `pikaoa migrate up --config ` 入口。 @@ -22,13 +23,15 @@ - `bun --check scripts/src/pikaoa-test-target.ts`:通过。 - `bun scripts/cli.ts check`:11/11 通过。 -- 默认 `pikaoa test-target plan --output json`:`configured=false`、`mutation=false`。 -- fixture `plan` 结构化断言:包含 `PersistentVolumeClaim`、`Job`、API/Worker `Deployment`,迁移阶段为 `after-postgres-before-workloads`,`validationOnly=true`、`mutation=false`、`valuesPrinted=false`,未出现 DSN 或 Secret 派生值。 +- 默认 `pikaoa test-target plan --output json`:`configured=false`、`mutation=false`、`valuesPrinted=false`。 +- fixture `plan` 结构化断言:包含 `PersistentVolumeClaim`、`Job`、API/Worker `Deployment`,迁移阶段为 `after-postgres-before-workloads`,`validationOnly=true`、`mutation=false`、`valuesPrinted=false`,未出现数据库 URL。 - fixture `status`:`remoteQueried=false`、`reason=validation-only-target`。 +- 非标准迁移命令只生成一个 `migration-command-unrecognized blocking=false` warning,命令仍返回 `ok=true`、`mutation=false`。 - `git diff --check`:通过。 ## 风险与边界 - fixture 只声明验证用 `registry.invalid` 镜像;真实专用测试 target 仍需由 owning YAML 指定可用镜像与 StorageClass。 +- 同一 instance 的失败或已完成 Job 不承担原地重跑契约;调试重试应使用新的临时 instance,或先通过受控 `stop` 删除精确归属 namespace。 - 未执行 `start/stop`,未连接集群,未修改产品仓、正式 namespace、CI/CD、GitOps 或公网暴露。 -- 实现 checkpoint:`03ca5ffe`。 +- 实现 checkpoint:`03ca5ffe`;审核纠偏 checkpoint:`0b6ad4ef`。