Merge pull request #2056 from pikasTech/fix/2053-pikaoa-test-runtime-readiness
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

修复 PikaOA 临时测试运行面的启动前置
This commit is contained in:
Lyon
2026-07-15 02:01:24 +08:00
committed by GitHub
5 changed files with 213 additions and 14 deletions
+18
View File
@@ -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:
@@ -0,0 +1,37 @@
# 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 分别返回具名失败码。
- 审核纠偏:迁移命令未显示 `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 <path>` 入口。
## 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``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`0b6ad4ef`
+1 -1
View File
@@ -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.5.9 [in_progress]
+10 -1
View File
@@ -39,6 +39,15 @@ bun scripts/cli.ts pikaoa test-target stop --target <id> --instance <run-id> --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 <id> --instance <run-id> --c
`config/fixtures/pikaoa-test-target.yaml` 只用于本地验证:
- 证明 namespace、选择器、工作负载和 Secret 引用的渲染结果;
- 证明 namespace、选择器、附件 PVC、迁移 Job、工作负载和 Secret 引用的渲染结果;
- 证明 TTL、OTel、Prometheus 和正式 namespace 保护;
- fixture target 固定为 `validationOnly=true`,不得用于真实集群操作。
+147 -12
View File
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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`),
@@ -406,6 +437,20 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
prometheusScrape: target.observability.prometheusScrape,
exporterFailure: { warning: target.observability.exporterWarning, blocking: false },
},
attachment: {
storageRoot: target.runtime.attachment.storageRoot,
maxBytes: target.runtime.attachment.maxBytes,
claimName: target.runtime.attachment.claimName,
storageRequest: target.runtime.attachment.storageRequest,
storageClassName: target.runtime.attachment.storageClassName,
accessModes: target.runtime.attachment.accessModes,
},
migration: {
jobName: target.migration.jobName,
image: context.migrationImage,
command: target.migration.command,
applyPhase: "after-postgres-before-workloads",
},
manifestFingerprint: structuralFingerprint(structuralManifest),
mutation: false,
};
@@ -489,6 +534,7 @@ function renderContext(options: TestTargetOptions, target: TestTargetSpec, requi
expiresAt,
apiImage: renderImage(target.source.apiImage, commit),
workerImage: renderImage(target.source.workerImage, commit),
migrationImage: renderImage(target.source.migrationImage, commit),
};
}
@@ -512,8 +558,9 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex
function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] {
const warnings: Warning[] = [];
if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.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))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
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,不阻塞业务运行。"));
if (!target.observability.prometheusScrape) warnings.push(warning("prometheus-scrape-disabled", "Prometheus scrape 已在 target YAML 中关闭。"));
return warnings;
@@ -525,6 +572,7 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
const apiSelector = componentSelector("api", context.instanceId);
const workerSelector = componentSelector("worker", context.instanceId);
const postgresSelector = componentSelector("postgres", context.instanceId);
const migrationSelector = componentSelector("migration", context.instanceId);
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
"prometheus.io/scrape": "true",
"prometheus.io/path": path,
@@ -537,6 +585,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 +625,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 +681,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 +719,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 +771,13 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): {
}
function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[]): 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, unknown>[]): 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 +795,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 +847,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 +960,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 +1011,14 @@ function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): R
function objectSummary(object: Record<string, unknown>): Record<string, unknown> {
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 +1166,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<T extends string>(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 +1193,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<T extends string>(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;