Merge pull request #2123 from pikasTech/feat/pikaoa-test-cicd
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

feat(pikaoa): 添加测试环境自动交付控制面
This commit is contained in:
Lyon
2026-07-15 13:02:28 +08:00
committed by GitHub
14 changed files with 1202 additions and 317 deletions
+40 -3
View File
@@ -30,12 +30,48 @@ testRuntime:
mode: prebuilt-images
repository: pikainc/pikaoa
commitPolicy: cli-required
branch: master
worktreeRemote: https://github.com/pikainc/pikaoa.git
readUrl: https://gitea.example.invalid/mirrors/pikainc-pikaoa.git
snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-test01
images:
api: registry.invalid/pikaoa/api:${commit}
worker: registry.invalid/pikaoa/worker:${commit}
web: registry.invalid/pikaoa/web:${commit}
migration: registry.invalid/pikaoa/migration:${commit}
initializer: registry.invalid/pikaoa/api:${commit}
pullPolicy: IfNotPresent
ci:
lane: pikaoa-test
namespace: pikaoa-ci
pipeline: pikaoa-test-test01-pac
pipelineRunPrefix: pikaoa-test-test01
serviceAccountName: pikaoa-test-test01-tekton-runner
workspaceSize: 1Gi
pipelineTimeout: 20m0s
toolsImage: registry.invalid/tools:latest
buildkitImage: registry.invalid/buildkit:rootless
build:
dockerfiles:
api: deploy/api.Dockerfile
worker: deploy/worker.Dockerfile
web: deploy/web.Dockerfile
images:
api: registry.invalid/pikaoa/api
worker: registry.invalid/pikaoa/worker
web: registry.invalid/pikaoa/web
networkMode: host
gitops:
readUrl: https://gitea.example.invalid/mirrors/pikainc-pikaoa.git
writeUrl: https://gitea.example.invalid/mirrors/pikainc-pikaoa.git
branch: test01-pikaoa-test-gitops
manifestPath: deploy/gitops/test01-test/resources.yaml
credential:
secretName: pac-gitea-pikaoa-test-test01
tokenKey: token
username: unidesk-admin
author:
name: PikaOA Test Fixture
email: pikaoa-test-fixture@unidesk.local
database:
configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url
name: pikaoa_test
@@ -82,8 +118,9 @@ testRuntime:
sourceRef: ./secrets/pikaoa-test.env
sourceKey: PIKAOA_SESSION_SECRET
targetKey: PIKAOA_SESSION_SECRET
migration:
jobName: pikaoa-migrate
initializer:
mode: fresh-database-only
jobName: pikaoa-init
backoffLimit: 2
command:
- /usr/local/bin/pikaoa
+43 -6
View File
@@ -21,7 +21,7 @@ modules:
- audit
extensionContract:
registration: module-descriptor
persistence: schema-owned-migrations
persistence: schema-owned-initialization
api: versioned-http-routes
events: transactional-outbox
workers: named-consumer
@@ -49,12 +49,48 @@ testRuntime:
mode: prebuilt-images
repository: pikainc/pikaoa
commitPolicy: cli-required
branch: master
worktreeRemote: https://github.com/pikainc/pikaoa.git
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01
images:
api: 127.0.0.1:5000/pikaoa/api:${commit}
worker: 127.0.0.1:5000/pikaoa/worker:${commit}
web: 127.0.0.1:5000/pikaoa/web:${commit}
migration: 127.0.0.1:5000/pikaoa/api:${commit}
initializer: 127.0.0.1:5000/pikaoa/api:${commit}
pullPolicy: IfNotPresent
ci:
lane: pikaoa-test
namespace: pikaoa-ci
pipeline: pikaoa-test-nc01-pac
pipelineRunPrefix: pikaoa-test-nc01
serviceAccountName: pikaoa-test-nc01-tekton-runner
workspaceSize: 8Gi
pipelineTimeout: 20m0s
toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1
buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless
build:
dockerfiles:
api: deploy/api.Dockerfile
worker: deploy/worker.Dockerfile
web: deploy/web.Dockerfile
images:
api: 127.0.0.1:5000/pikaoa/api
worker: 127.0.0.1:5000/pikaoa/worker
web: 127.0.0.1:5000/pikaoa/web
networkMode: host
gitops:
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
branch: nc01-pikaoa-test-gitops
manifestPath: deploy/gitops/nc01-test/resources.yaml
credential:
secretName: pac-gitea-pikaoa-test-nc01
tokenKey: token
username: unidesk-admin
author:
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
name: pikaoa_test
@@ -101,8 +137,9 @@ testRuntime:
sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env
sourceKey: PIKAOA_SESSION_SECRET
targetKey: PIKAOA_SESSION_SECRET
migration:
jobName: pikaoa-migrate
initializer:
mode: fresh-database-only
jobName: pikaoa-init
backoffLimit: 2
command:
- /usr/local/bin/pikaoa
@@ -158,9 +195,9 @@ delivery:
source:
repository: pikainc/pikaoa
worktreeRemote: https://github.com/pikainc/pikaoa.git
branch: master
branch: release
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01
snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01
build:
dockerfiles:
api: deploy/api.Dockerfile
+48 -2
View File
@@ -446,7 +446,7 @@ sourceAuthority:
naming: gitea-actions-immutable-source
prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01
legacyGitMirror: null
- key: pikaoa-nc01
- key: pikaoa-test-nc01
targetId: NC01
credentialOverride:
github:
@@ -486,12 +486,58 @@ sourceAuthority:
publicRead: false
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
gitops:
branch: nc01-pikaoa-gitops
branch: nc01-pikaoa-test-gitops
flushDisposition: gitea-writeback
snapshot:
naming: gitea-actions-immutable-source
prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01
legacyGitMirror: null
- key: pikaoa-nc01
targetId: NC01
credentialOverride:
github:
transport: https-token
sourceRef: pikainc-selfmedia-gh-token.txt
sourceKey: GH_TOKEN
format: raw-token
requiredFor:
- upstream-mirror
- mirror-sync
- managed-repository-fetch
- github-head-observe
- github-hooks-list
- github-hooks-reconcile
permissions:
contents: read
metadata: read
webhooks: read-write
gitFetchCredential:
apiVersion: unidesk.ai/v1
kind: GitFetchCredential
authMode: github-https-token
host: github.com
secretRef:
namespace: devops-infra
name: gitea-github-sync-secrets
key: github-token-pikainc-pikaoa
upstream:
repository: pikainc/pikaoa
cloneUrl: https://github.com/pikainc/pikaoa.git
branch: release
visibility: private
gitea:
owner: mirrors
name: pikainc-pikaoa
mirrorMode: controlled-push
publicRead: false
readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
gitops:
branch: nc01-pikaoa-gitops
flushDisposition: gitea-writeback
snapshot:
naming: gitea-actions-immutable-source
prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01
legacyGitMirror: null
targets:
- id: JD01
+66 -12
View File
@@ -213,15 +213,15 @@ repositories:
runtime_service_port: "4317"
health_path: /healthz
health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz
- id: pikaoa-nc01
name: pikaoa-nc01
- id: pikaoa-test-nc01
name: pikaoa-test-nc01
namespace: pikaoa-ci
providerType: gitea
url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa
cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
owner: mirrors
repo: pikainc-pikaoa
secretName: pac-gitea-pikaoa-nc01
secretName: pac-gitea-pikaoa-test-nc01
tokenKey: token
webhookSecretKey: webhook.secret
concurrencyLimit: 1
@@ -230,9 +230,9 @@ repositories:
source_branch: master
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01
node: NC01
pipeline_name: pikaoa-nc01-pac
pipeline_run_prefix: pikaoa-nc01
service_account: pikaoa-nc01-tekton-runner
pipeline_name: pikaoa-test-nc01-pac
pipeline_run_prefix: pikaoa-test-nc01
service_account: pikaoa-test-nc01-tekton-runner
pipeline_timeout: 20m0s
workspace_pvc_size: 8Gi
image_repository: 127.0.0.1:5000/pikaoa/api
@@ -242,16 +242,16 @@ repositories:
registry_probe_base: http://127.0.0.1:5000
gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
gitops_branch: nc01-pikaoa-gitops
gitops_branch: nc01-pikaoa-test-gitops
gitops_username: unidesk-admin
gitops_secret_name: pac-gitea-pikaoa-nc01
gitops_manifest_path: deploy/gitops/nc01/resources.yaml
runtime_namespace: pikaoa
gitops_secret_name: pac-gitea-pikaoa-test-nc01
gitops_manifest_path: deploy/gitops/nc01-test/resources.yaml
runtime_namespace: pikaoa-test
runtime_deployment: pikaoa-api
runtime_service: pikaoa-web
runtime_service_port: "8080"
health_path: /healthz
health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz
health_url: http://pikaoa-web.pikaoa-test.svc.cluster.local:8080/healthz
consumers:
- extends: templates.consumers.agentrunV02
variables:
@@ -467,8 +467,49 @@ consumers:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
fsGroup: 1000
- id: pikaoa-test-nc01
repositoryRef: pikaoa-test-nc01
node: NC01
lane: pikaoa-test
namespace: pikaoa-ci
pipeline: pikaoa-test-nc01-pac
pipelineRunPrefix: pikaoa-test-nc01
argoNamespace: argocd
argoApplication: pikaoa-test-nc01
closeoutGitOpsMirrorFlush: false
argoBootstrap:
project: default
repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
targetRevision: nc01-pikaoa-test-gitops
path: deploy/gitops/nc01-test
destinationNamespace: pikaoa-test
automated: true
repositoryCredential:
secretName: argocd-repo-pikaoa-test-nc01
username: unidesk-admin
deliveryProvenance:
required: true
markerValue: admission-pac-v2:pikaoa-test-nc01
executionServiceAccountName: pikaoa-test-nc01-tekton-runner
gitOps:
repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
targetRevision: nc01-pikaoa-test-gitops
runnerServiceAccount:
name: pikaoa-test-nc01-tekton-runner
automountServiceAccountToken: false
roleBindingName: pikaoa-test-nc01-tekton-runner
sourceArtifact:
mode: embedded-pipeline-spec
renderer: pikaoa-test-runtime
configRef: config/pikaoa.yaml#testRuntime.targets.NC01
pipelineRunPath: .tekton/pikaoa-test-nc01-pac.yaml
maxKeepRuns: 8
taskRunTemplate:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
fsGroup: 1000
- id: pikaoa-nc01
repositoryRef: pikaoa-nc01
repositoryRef: pikaoa-test-nc01
node: NC01
lane: pikaoa
namespace: pikaoa-ci
@@ -477,6 +518,19 @@ consumers:
argoNamespace: argocd
argoApplication: pikaoa-nc01
closeoutGitOpsMirrorFlush: false
params:
source_branch: release
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01
pipeline_name: pikaoa-nc01-pac
pipeline_run_prefix: pikaoa-nc01
service_account: pikaoa-nc01-tekton-runner
gitops_branch: nc01-pikaoa-gitops
gitops_secret_name: pac-gitea-pikaoa-nc01
gitops_manifest_path: deploy/gitops/nc01/resources.yaml
runtime_namespace: pikaoa
runtime_deployment: pikaoa-api
runtime_service: pikaoa-web
health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz
argoBootstrap:
project: default
repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
+9 -7
View File
@@ -78,7 +78,7 @@
5. `secrets sync` 按 YAML 声明补齐允许生成的本地来源,并下发 `pikaoa-test-runtime`。
6. 使用同一 commit 依次执行:
- `--step migration`
- `--step init`,仅初始化全新数据库
- `--step api`
- `--step worker`
- `--step web`。
@@ -87,7 +87,7 @@
## 单步运行
- foundation 不需要 `--commit`,也不检查业务 Secret。
- migration、api、worker、web 和 all 必须显式提供 `--commit`。
- init、api、worker、web 和 all 必须显式提供 `--commit`。
- 每个 `start` 立即返回任务 ID;使用同一 target、instance 和 step 的 `status` 查询终态。
- request identity 包含:
- target
@@ -108,19 +108,21 @@
- 提供新的 `--commit` 修订标识;
- 修改 foundation 的稳定 manifest 结构。
- 不为重跑引入第二状态库、控制器、租约或长期历史存储。
- 迁移、rollout 或运行面查询失败返回具名失败码,不静默进入下一阶段。
- initializer、rollout 或运行面查询失败返回具名失败码,不静默进入下一阶段。
- OTel exporter、配置版本和 commit 对齐漂移只产生 `blocking=false` warning,不恢复 dedicated 门禁。
## 交付阶段
- 当前阶段先稳定 YAML-first CLI 原入口和固定 NC01 测试运行面。
- CLI 闭环通过后,再建设独立 test CI/CD:
- 复用同一 owning YAML
- 按 foundation、Secret sync、migration、api、worker、web 顺序编排
- `pikaoa-test-nc01` 独立 test CI/CD 复用同一 owning YAML
- 产品 `master` 只生成 `.tekton/pikaoa-test-nc01-pac.yaml`
- API、Worker、Web 并行构建并以 digest 发布 GitOps
- initializer 通过 Argo `PreSync` 在 API、Worker、Web 前初始化全新数据库
- 不创建专用测试集群;
- 不生成动态 namespace
- 不创建 namespace-local PostgreSQL
- 不把测试 CI/CD 变成生产交付或用户业务的阻塞门禁。
- GitOps 接管后,手动 `test-target start|stop` mutation 仅用于暂停 Argo 自动同步后的有界调试;禁止 CLI direct-apply 与 Argo 同时写入。
- 生产 `pikaoa-nc01` 保留 `release` source authority,但当前不 bootstrap、不生成 `.tekton`、不创建 PipelineRun。
- PR、构建或单测不能替代固定 NodePort 原入口验收。
## 本地验证
@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { renderPikaoaTestDesiredPipeline } from "./pikaoa-test-delivery-renderer";
function pacDocument(): Record<string, unknown> {
return Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record<string, unknown>;
}
function rendererBinding(): Parameters<typeof renderPikaoaTestDesiredPipeline>[0] {
const document = pacDocument();
const repositories = document.repositories as Array<Record<string, unknown>>;
const consumers = document.consumers as Array<Record<string, unknown>>;
const repository = repositories.find((item) => item.id === "pikaoa-test-nc01");
const consumer = consumers.find((item) => item.id === "pikaoa-test-nc01");
if (repository === undefined || consumer === undefined) throw new Error("PikaOA test PaC fixture is required");
return { consumer, repository } as Parameters<typeof renderPikaoaTestDesiredPipeline>[0];
}
function sourceFixture(): string {
const root = mkdtempSync(resolve(tmpdir(), "pikaoa-test-renderer-"));
mkdirSync(resolve(root, "deploy"), { recursive: true });
for (const file of ["api.Dockerfile", "worker.Dockerfile", "web.Dockerfile"]) writeFileSync(resolve(root, "deploy", file), "FROM scratch\n");
return root;
}
describe("PikaOA test PaC renderer", () => {
test("keeps master test and release production consumers isolated", () => {
const document = pacDocument();
const repositories = document.repositories as Array<Record<string, unknown>>;
const consumers = document.consumers as Array<Record<string, unknown>>;
const repository = repositories.find((item) => item.id === "pikaoa-test-nc01");
const testConsumer = consumers.find((item) => item.id === "pikaoa-test-nc01");
const productionConsumer = consumers.find((item) => item.id === "pikaoa-nc01");
expect((repository?.params as Record<string, unknown>).source_branch).toBe("master");
expect((testConsumer?.sourceArtifact as Record<string, unknown>).pipelineRunPath).toBe(".tekton/pikaoa-test-nc01-pac.yaml");
expect((testConsumer?.sourceArtifact as Record<string, unknown>).renderer).toBe("pikaoa-test-runtime");
expect((productionConsumer?.params as Record<string, unknown>).source_branch).toBe("release");
expect((productionConsumer?.params as Record<string, unknown>).source_snapshot_prefix).toBe("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01");
expect(productionConsumer?.sourceArtifact).toBeUndefined();
});
test("renders parallel digest builds and a PreSync fresh database initializer", () => {
const source = sourceFixture();
try {
const rendered = renderPikaoaTestDesiredPipeline(rendererBinding(), source);
const spec = rendered.pipeline.spec as Record<string, unknown>;
const tasks = spec.tasks as Array<Record<string, unknown>>;
for (const name of ["build-api", "build-worker", "build-web"]) {
const task = tasks.find((item) => item.name === name);
expect(task?.runAfter).toEqual(["source"]);
expect(JSON.stringify(task)).toContain("buildctl-daemonless.sh build");
expect(JSON.stringify(task)).toContain("$(params.revision)");
expect(JSON.stringify(task)).not.toContain("source-token");
}
const sourceTask = tasks.find((item) => item.name === "source");
expect(JSON.stringify(sourceTask)).toContain("source-token");
expect(JSON.stringify(sourceTask)).toContain("GIT_ASKPASS");
expect(JSON.stringify(sourceTask)).toContain("$(params.gitops-secret-name)");
const publish = tasks.find((item) => item.name === "gitops-publish");
expect(publish?.runAfter).toEqual(["build-api", "build-worker", "build-web"]);
const script = ((((publish?.taskSpec as Record<string, unknown>).steps as Array<Record<string, unknown>>)[0]?.script) as string);
for (const evidence of [
'"status":"published"',
'"registryVerified":true',
'"publishedCount":3',
'"requiredServiceCount":3',
'"gitopsCommit"',
'"service":"api"',
'"service":"worker"',
'"service":"web"',
]) expect(script).toContain(evidence);
const encoded = /printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/u.exec(script)?.[1];
if (encoded === undefined) throw new Error("embedded GitOps manifest is required");
const manifest = Buffer.from(encoded, "base64").toString("utf8");
expect(manifest).toContain("argocd.argoproj.io/hook: PreSync");
expect(manifest).toContain("name: pikaoa-init");
expect(manifest).toContain("name: initializer");
expect(manifest).not.toContain("kind: Secret");
expect(manifest).toContain("secretName: pikaoa-test-runtime");
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');
expect(script).toContain("GitOps manifest contains unresolved provenance or image placeholders");
expect(script).toContain("git ls-remote --exit-code --heads");
expect(script).toContain("git -C /workspace/gitops checkout --orphan");
} finally {
rmSync(source, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,360 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import {
pikaoaTestRuntimeConfigRef,
resolvePikaoaTestRuntimeTargetByConfigRef,
type TestTargetSpec,
} from "./pikaoa-test-target";
import { renderPikaoaTestRuntimeManifest, type PikaoaTestRenderContext } from "./pikaoa-test-runtime-manifest";
import { stableJsonSha256 } from "./stable-json";
export interface PikaoaTestRendererBinding {
readonly consumer: {
readonly id: string;
readonly node: string;
readonly lane: string;
readonly namespace: string;
readonly pipeline: string;
readonly pipelineRunPrefix: string;
readonly sourceArtifact: {
readonly configRef: string;
readonly mode: string;
readonly renderer: string;
};
};
readonly repository: {
readonly id: string;
readonly params: Readonly<Record<string, string>>;
};
}
export interface PikaoaTestRenderedPipeline {
readonly pipeline: Record<string, unknown>;
readonly configRef: string;
readonly effectiveConfigSha256: string;
readonly sourceWorktreeRemote: string;
}
export function renderPikaoaTestDesiredPipeline(binding: PikaoaTestRendererBinding, sourceWorktree: string): PikaoaTestRenderedPipeline {
const target = resolvePikaoaTestRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef);
const configRef = pikaoaTestRuntimeConfigRef(target.id);
if (binding.consumer.sourceArtifact.renderer !== "pikaoa-test-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
throw new Error("pikaoa-test-runtime requires embedded-pipeline-spec");
}
if (binding.consumer.sourceArtifact.configRef !== configRef) {
throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}`);
}
assertBinding(target, binding);
assertSourceRepositoryContract(target, sourceWorktree);
return {
pipeline: pipeline(target),
configRef,
effectiveConfigSha256: stableJsonSha256(target),
sourceWorktreeRemote: target.source.worktreeRemote,
};
}
export function pikaoaTestSourceWorktreeRemote(targetId: string): string {
return resolvePikaoaTestRuntimeTargetByConfigRef(pikaoaTestRuntimeConfigRef(targetId)).source.worktreeRemote;
}
function pipeline(target: TestTargetSpec): Record<string, unknown> {
const runtimeManifest = renderGitOpsManifest(target);
return {
apiVersion: "tekton.dev/v1",
kind: "Pipeline",
metadata: { name: target.ci.pipeline, namespace: target.ci.namespace },
spec: {
params: [
{ name: "revision", type: "string" },
{ name: "source-snapshot-prefix", type: "string", default: target.source.snapshotPrefix },
{ name: "git-read-url", type: "string", default: target.source.readUrl },
{ name: "gitops-read-url", type: "string", default: target.gitops.readUrl },
{ name: "gitops-write-url", type: "string", default: target.gitops.writeUrl },
{ name: "gitops-username", type: "string", default: target.gitops.credentialUsername },
{ name: "gitops-secret-name", type: "string", default: target.gitops.credentialSecretName },
],
workspaces: [{ name: "workspace" }],
tasks: [
sourceTask(target),
imageBuildTask(target, "api"),
imageBuildTask(target, "worker"),
imageBuildTask(target, "web"),
gitOpsTask(target, runtimeManifest),
],
},
};
}
function sourceTask(target: TestTargetSpec): Record<string, unknown> {
return {
name: "source",
taskSpec: {
params: [
{ name: "revision", type: "string" },
{ name: "source-snapshot-prefix", type: "string" },
{ name: "git-read-url", type: "string" },
{ name: "gitops-username", type: "string" },
{ name: "gitops-secret-name", type: "string" },
],
workspaces: [{ name: "workspace" }],
steps: [{
name: "checkout",
image: target.ci.toolsImage,
imagePullPolicy: "IfNotPresent",
script: `#!/bin/sh
set -eu
revision="$(params.revision)"
case "$revision" in *[!0-9a-f]*|'') echo 'revision must be a full lowercase source commit' >&2; exit 2;; esac
test "\${#revision}" -eq 40
askpass=/tmp/pikaoa-source-git-askpass
cat >"$askpass" <<'ASKPASS'
#!/bin/sh
case "$1" in
*Username*) printf '%s' "$PIKAOA_GIT_USERNAME" ;;
*Password*) cat /var/run/pikaoa-source/token ;;
*) exit 1 ;;
esac
ASKPASS
chmod 700 "$askpass"
export PIKAOA_GIT_USERNAME="$(params.gitops-username)"
export GIT_ASKPASS="$askpass"
export GIT_TERMINAL_PROMPT=0
rm -rf /workspace/source
git init /workspace/source
git -C /workspace/source remote add origin "$(params.git-read-url)"
git -C /workspace/source fetch --depth=1 origin "$(params.source-snapshot-prefix)/$revision"
git -C /workspace/source checkout --detach FETCH_HEAD
test "$(git -C /workspace/source rev-parse HEAD)" = "$revision"
rm -f "$askpass"
printf '{"ok":true,"phase":"source","sourceCommit":"%s","valuesPrinted":false}\n' "$revision"
`,
securityContext: nonRootSecurity(),
volumeMounts: [
{ name: "workspace", mountPath: "/workspace" },
{ name: "source-token", mountPath: "/var/run/pikaoa-source", readOnly: true },
],
}],
volumes: [{ name: "source-token", secret: { secretName: "$(params.gitops-secret-name)", items: [{ key: target.gitops.credentialTokenKey, path: "token" }] } }],
},
params: [
{ name: "revision", value: "$(params.revision)" },
{ name: "source-snapshot-prefix", value: "$(params.source-snapshot-prefix)" },
{ name: "git-read-url", value: "$(params.git-read-url)" },
{ name: "gitops-username", value: "$(params.gitops-username)" },
{ name: "gitops-secret-name", value: "$(params.gitops-secret-name)" },
],
workspaces: [{ name: "workspace", workspace: "workspace" }],
};
}
function imageBuildTask(target: TestTargetSpec, component: "api" | "worker" | "web"): Record<string, unknown> {
const taskName = `build-${component}`;
const imageRepository = target.build.images[component];
const dockerfile = target.build.dockerfiles[component];
return {
name: taskName,
runAfter: ["source"],
taskSpec: {
params: [{ name: "revision", type: "string" }],
workspaces: [{ name: "workspace" }],
results: [{ name: "digest", type: "string" }, { name: "image", type: "string" }],
steps: [{
name: "build",
image: target.ci.buildkitImage,
imagePullPolicy: "IfNotPresent",
workingDir: "/workspace/source",
env: [{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }],
script: `#!/bin/sh
set -eu
revision="$(params.revision)"
tag=${shellSingleQuote(imageRepository)}:"$revision"
metadata=/tmp/${component}-metadata.json
buildctl-daemonless.sh build \\
--frontend dockerfile.v0 \\
--local context=. \\
--local dockerfile=. \\
--opt filename=${shellSingleQuote(dockerfile)} \\
--opt network=${shellSingleQuote(target.build.networkMode)} \\
--output "type=image,name=$tag,push=true" \\
--metadata-file "$metadata"
digest=$(grep -o '"containerimage.digest":"sha256:[0-9a-f]*"' "$metadata" | head -n1 | cut -d'"' -f4)
case "$digest" in sha256:[0-9a-f]*) ;; *) echo 'build digest missing' >&2; exit 3;; esac
printf '%s' "$digest" > "$(results.digest.path)"
printf '%s@%s' ${shellSingleQuote(imageRepository)} "$digest" > "$(results.image.path)"
printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","digest":"%s","valuesPrinted":false}\n' "$revision" "$tag" "$digest"
`,
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
volumeMounts: [{ name: "workspace", mountPath: "/workspace" }],
}],
},
params: [{ name: "revision", value: "$(params.revision)" }],
workspaces: [{ name: "workspace", workspace: "workspace" }],
};
}
function gitOpsTask(target: TestTargetSpec, runtimeManifest: string): Record<string, unknown> {
const manifestB64 = Buffer.from(runtimeManifest, "utf8").toString("base64");
return {
name: "gitops-publish",
runAfter: ["build-api", "build-worker", "build-web"],
taskSpec: {
params: [
{ name: "revision", type: "string" },
{ name: "gitops-read-url", type: "string" },
{ name: "gitops-write-url", type: "string" },
{ name: "gitops-username", type: "string" },
{ name: "gitops-secret-name", type: "string" },
{ name: "api-image", type: "string" },
{ name: "worker-image", type: "string" },
{ name: "web-image", type: "string" },
],
workspaces: [{ name: "workspace" }],
steps: [{
name: "publish",
image: target.ci.toolsImage,
imagePullPolicy: "IfNotPresent",
env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }],
script: `#!/bin/sh
set -eu
askpass=/tmp/pikaoa-git-askpass
cat >"$askpass" <<'ASKPASS'
#!/bin/sh
case "$1" in
*Username*) printf '%s' "$PIKAOA_GIT_USERNAME" ;;
*Password*) cat /var/run/pikaoa-gitops/token ;;
*) exit 1 ;;
esac
ASKPASS
chmod 700 "$askpass"
export PIKAOA_GIT_USERNAME="$(params.gitops-username)"
export GIT_ASKPASS="$askpass"
export GIT_TERMINAL_PROMPT=0
case "$SOURCE_COMMIT" in *[!0-9a-f]*|'') echo 'source commit must be a full lowercase commit' >&2; exit 2;; esac
test "\${#SOURCE_COMMIT}" -eq 40
api_image="$(params.api-image)"
worker_image="$(params.worker-image)"
web_image="$(params.web-image)"
for image in "$api_image" "$worker_image" "$web_image"; do
case "$image" in *@sha256:[0-9a-f]*) ;; *) echo 'required digestRef missing' >&2; exit 4;; esac
done
rm -rf /workspace/gitops
mkdir -p /workspace/gitops
git -C /workspace/gitops init
git -C /workspace/gitops remote add origin "$(params.gitops-read-url)"
if git ls-remote --exit-code --heads "$(params.gitops-read-url)" ${shellSingleQuote(target.gitops.branch)} >/dev/null 2>&1; then
git -C /workspace/gitops fetch --depth=1 origin ${shellSingleQuote(target.gitops.branch)}
git -C /workspace/gitops checkout -B ${shellSingleQuote(target.gitops.branch)} FETCH_HEAD
else
git -C /workspace/gitops checkout --orphan ${shellSingleQuote(target.gitops.branch)}
git -C /workspace/gitops rm -rf . >/dev/null 2>&1 || true
fi
manifest=/workspace/gitops/${shellSingleQuote(target.gitops.manifestPath)}
mkdir -p "$(dirname "$manifest")"
printf '%s' ${shellSingleQuote(manifestB64)} | base64 -d > "$manifest"
sed -i \\
-e "s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g" \\
-e "s|__PIKAOA_API_IMAGE__|$(params.api-image)|g" \\
-e "s|__PIKAOA_WORKER_IMAGE__|$(params.worker-image)|g" \\
-e "s|__PIKAOA_WEB_IMAGE__|$(params.web-image)|g" \\
"$manifest"
if grep -Eq '\$\(params\.revision\)|__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__' "$manifest"; then
echo 'GitOps manifest contains unresolved provenance or image placeholders' >&2
exit 6
fi
git -C /workspace/gitops config user.name ${shellSingleQuote(target.gitops.authorName)}
git -C /workspace/gitops config user.email ${shellSingleQuote(target.gitops.authorEmail)}
git -C /workspace/gitops add -- ${shellSingleQuote(target.gitops.manifestPath)}
if git -C /workspace/gitops diff --cached --quiet; then
changed=false
else
git -C /workspace/gitops commit -m "chore(gitops): deploy pikaoa test $SOURCE_COMMIT"
git -C /workspace/gitops push "$(params.gitops-write-url)" HEAD:${shellSingleQuote(target.gitops.branch)}
changed=true
fi
gitops_commit=$(git -C /workspace/gitops rev-parse HEAD)
case "$gitops_commit" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) ;; *) echo 'gitops commit missing' >&2; exit 5;; esac
rm -f "$askpass"
printf '{"ok":true,"status":"published","phase":"gitops-publish","sourceCommitId":"%s","registryVerified":true,"publishedCount":3,"reusedCount":0,"requiredServiceCount":3,"services":[{"service":"api","digestRef":"%s","digest":"%s"},{"service":"worker","digestRef":"%s","digest":"%s"},{"service":"web","digestRef":"%s","digest":"%s"}],"gitopsCommit":"%s","changed":%s,"valuesPrinted":false}\n' \
"$SOURCE_COMMIT" "$api_image" "\${api_image##*@}" "$worker_image" "\${worker_image##*@}" "$web_image" "\${web_image##*@}" "$gitops_commit" "$changed"
`,
securityContext: nonRootSecurity(),
volumeMounts: [
{ name: "workspace", mountPath: "/workspace" },
{ name: "gitops-token", mountPath: "/var/run/pikaoa-gitops", readOnly: true },
],
}],
volumes: [{ name: "gitops-token", secret: { secretName: "$(params.gitops-secret-name)", items: [{ key: target.gitops.credentialTokenKey, path: "token" }] } }],
},
params: [
{ name: "revision", value: "$(params.revision)" },
{ name: "gitops-read-url", value: "$(params.gitops-read-url)" },
{ name: "gitops-write-url", value: "$(params.gitops-write-url)" },
{ name: "gitops-username", value: "$(params.gitops-username)" },
{ name: "gitops-secret-name", value: "$(params.gitops-secret-name)" },
{ name: "api-image", value: "$(tasks.build-api.results.image)" },
{ name: "worker-image", value: "$(tasks.build-worker.results.image)" },
{ name: "web-image", value: "$(tasks.build-web.results.image)" },
],
workspaces: [{ name: "workspace", workspace: "workspace" }],
};
}
function renderGitOpsManifest(target: TestTargetSpec): string {
const context: PikaoaTestRenderContext = {
instanceId: "default",
commit: "__PIKAOA_SOURCE_COMMIT__",
step: "all",
namespace: target.namespace,
expiresAt: "gitops-managed",
apiImage: "__PIKAOA_API_IMAGE__",
workerImage: "__PIKAOA_WORKER_IMAGE__",
webImage: "__PIKAOA_WEB_IMAGE__",
initializerImage: "__PIKAOA_API_IMAGE__",
};
const manifest = renderPikaoaTestRuntimeManifest(target, context, { secretMode: "existing", initializerHook: "argo-presync" });
return manifest.map((object) => Bun.YAML.stringify(object, null, 2).trim()).join("\n---\n") + "\n";
}
function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBinding): void {
const expected: Readonly<Record<string, string>> = {
id: "pikaoa-test-nc01",
node: target.node,
lane: target.ci.lane,
namespace: target.ci.namespace,
pipeline: target.ci.pipeline,
pipelineRunPrefix: target.ci.pipelineRunPrefix,
sourceBranch: target.source.branch,
gitopsBranch: target.gitops.branch,
serviceAccount: target.ci.serviceAccountName,
};
const actual: Readonly<Record<string, string | undefined>> = {
id: binding.consumer.id,
node: binding.consumer.node,
lane: binding.consumer.lane,
namespace: binding.consumer.namespace,
pipeline: binding.consumer.pipeline,
pipelineRunPrefix: binding.consumer.pipelineRunPrefix,
sourceBranch: binding.repository.params.source_branch,
gitopsBranch: binding.repository.params.gitops_branch,
serviceAccount: binding.repository.params.service_account,
};
for (const [key, value] of Object.entries(expected)) {
if (actual[key] !== value) throw new Error(`pikaoa-test-runtime binding ${key} must equal owning YAML value ${value}`);
}
}
function assertSourceRepositoryContract(target: TestTargetSpec, sourceWorktree: string): void {
for (const path of Object.values(target.build.dockerfiles)) {
if (!existsSync(resolve(sourceWorktree, path))) throw new Error(`PikaOA source worktree is missing ${path}`);
}
}
function nonRootSecurity(): Record<string, unknown> {
return { allowPrivilegeEscalation: false, runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000 };
}
function shellSingleQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
+254
View File
@@ -0,0 +1,254 @@
import type { TestTargetSpec } from "./pikaoa-test-target";
export type PikaoaTestTargetStep = "all" | "foundation" | "init" | "api" | "worker" | "web";
export interface PikaoaTestRenderContext {
instanceId: string;
commit: string;
step: PikaoaTestTargetStep;
namespace: string;
expiresAt: string;
apiImage: string;
workerImage: string;
webImage: string;
initializerImage: string;
}
export interface PikaoaTestSecretMaterial {
databaseURL: string;
sessionSecret: string;
administratorPassword: string;
employeePassword: string;
}
export interface PikaoaTestManifestOptions {
secretMode: "materialized" | "existing";
secrets?: PikaoaTestSecretMaterial;
initializerHook?: "argo-presync";
}
const MANAGED_BY = "unidesk-pikaoa-test-target";
const TEST_RUNTIME_LABEL = "pikaoa.unidesk.io/test-runtime";
const TARGET_LABEL = "pikaoa.unidesk.io/target";
const INSTANCE_LABEL = "pikaoa.unidesk.io/instance";
export function renderPikaoaTestRuntimeManifest(
target: TestTargetSpec,
context: PikaoaTestRenderContext,
options: PikaoaTestManifestOptions,
): Record<string, unknown>[] {
if (options.secretMode === "materialized" && options.secrets === undefined) {
throw new Error("materialized PikaOA test manifest requires secret material");
}
const labels = ownershipLabels(target, context);
const apiSelector = componentSelector("api", context.instanceId);
const workerSelector = componentSelector("worker", context.instanceId);
const webSelector = componentSelector("web", context.instanceId);
const initializerSelector = componentSelector("initializer", context.instanceId);
const prometheusPodLabels = target.observability.prometheusScrape ? {
[target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value,
} : {};
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
"prometheus.io/scrape": "true",
"prometheus.io/path": path,
"prometheus.io/port": String(port),
} : {};
const initializerAnnotations = options.initializerHook === "argo-presync" ? {
"argocd.argoproj.io/hook": "PreSync",
"argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded",
} : {};
return [
{
apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) },
},
...(options.secretMode === "materialized" ? [{
apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque",
stringData: runtimeSecretData(target, context, options.secrets!),
}] : []),
{
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: target.runtime.apiServiceName, namespace: context.namespace, labels: { ...labels, ...apiSelector } },
spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] },
},
{
apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } },
spec: {
type: target.exposure.serviceType,
selector: webSelector,
ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }],
},
},
{
apiVersion: "batch/v1", kind: "Job",
metadata: { name: target.initializer.jobName, namespace: context.namespace, labels: { ...labels, ...initializerSelector }, annotations: initializerAnnotations },
spec: {
backoffLimit: target.initializer.backoffLimit,
template: {
metadata: { labels: { ...labels, ...initializerSelector } },
spec: {
restartPolicy: "Never",
containers: [{
name: "initializer", image: context.initializerImage, imagePullPolicy: target.source.pullPolicy,
command: target.initializer.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, prometheusPodLabels, 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 } } },
{ 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 } } },
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username },
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } },
], 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" },
], 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}` },
], target.probes.webLivenessPath, target.probes.webReadinessPath),
];
}
export function pikaoaManifestForStep(manifest: Record<string, unknown>[], step: PikaoaTestTargetStep): Record<string, unknown>[] {
if (step === "all") return manifest;
return manifest.filter((object) => {
if (object.kind === "Namespace" || object.kind === "PersistentVolumeClaim") return true;
if (step === "foundation") return false;
if (object.kind === "Secret") return true;
if (step === "init") return object.kind === "Job";
const metadata = optionalRecord(object.metadata);
const labels = optionalRecord(metadata?.labels);
return labels?.["app.kubernetes.io/component"] === step;
});
}
export function ownershipLabels(target: TestTargetSpec, context: PikaoaTestRenderContext): Record<string, string> {
return {
"app.kubernetes.io/name": "pikaoa",
"app.kubernetes.io/part-of": "pikaoa-test-runtime",
"app.kubernetes.io/managed-by": MANAGED_BY,
[TEST_RUNTIME_LABEL]: "true",
[TARGET_LABEL]: target.id,
[INSTANCE_LABEL]: context.instanceId,
};
}
export function componentSelector(component: string, instanceId: string): Record<string, string> {
return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId };
}
export function namespaceAnnotations(target: TestTargetSpec, context: PikaoaTestRenderContext): Record<string, string> {
return {
"pikaoa.unidesk.io/source-repository": target.source.repository,
"pikaoa.unidesk.io/source-commit": context.commit,
"pikaoa.unidesk.io/expires-at": context.expiresAt,
"pikaoa.unidesk.io/ttl-seconds": String(target.ttlSeconds),
};
}
export function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string {
const databaseURL = new URL(sourceURL);
databaseURL.searchParams.set("search_path", target.database.schema);
return databaseURL.toString();
}
export function placeholderPikaoaSecrets(): PikaoaTestSecretMaterial {
return {
databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require",
sessionSecret: "<redacted>",
administratorPassword: "<redacted>",
employeePassword: "<redacted>",
};
}
function deployment(
target: TestTargetSpec,
context: PikaoaTestRenderContext,
component: "api" | "worker" | "web",
image: string,
selector: Record<string, string>,
port: number,
podLabels: Record<string, string>,
annotations: Record<string, string>,
env: Array<Record<string, unknown>>,
livenessPath: string,
readinessPath: string,
): Record<string, unknown> {
const labels = ownershipLabels(target, context);
return {
apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } },
spec: {
replicas: 1, selector: { matchLabels: selector },
template: {
metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations },
spec: {
...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}),
containers: [{
name: component, image, imagePullPolicy: target.source.pullPolicy, env,
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
volumeMounts: [
...(component === "web" ? [] : [{ 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: [
...(component === "web" ? [] : [{ 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 } }] : []),
],
},
},
},
};
}
function runtimeSecretData(target: TestTargetSpec, context: PikaoaTestRenderContext, secrets: PikaoaTestSecretMaterial): Record<string, string> {
const databaseURL = renderDatabaseURL(target, secrets.databaseURL);
return {
[target.database.connection.targetKey]: databaseURL,
[target.runtime.sessionSecret.targetKey]: secrets.sessionSecret,
[target.runtime.administrator.password.targetKey]: secrets.administratorPassword,
[target.runtime.employee.password.targetKey]: secrets.employeePassword,
"pikaoa.yaml": [
"api:",
` listen: 0.0.0.0:${target.runtime.apiPort}`,
"worker:",
` interval: ${target.runtime.workerInterval}`,
` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`,
" outbox:",
` batchSize: ${target.runtime.outboxBatchSize}`,
` maxAttempts: ${target.runtime.outboxMaxAttempts}`,
` retryDelay: ${target.runtime.outboxRetryDelay}`,
"database:",
` 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)}`,
`shutdownTimeout: ${target.runtime.shutdownTimeout}`,
"",
].join("\n"),
};
}
function optionalRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
+27 -27
View File
@@ -74,11 +74,11 @@ if printf '%s' "$args" | grep -q ' rollout status '; then
[ ! -f "$root/slow" ] || sleep 0.4
exit 0
fi
if printf '%s' "$args" | grep -q ' get job/pikaoa-migrate '; then
if [ -f "$root/fail-migration-query" ]; then printf '%s\n' 'job query failed' >&2; exit 1; fi
if [ -f "$root/failure-target-migration" ]; then
if printf '%s' "$args" | grep -q ' get job/pikaoa-init '; then
if [ -f "$root/fail-initializer-query" ]; then printf '%s\n' 'job query failed' >&2; exit 1; fi
if [ -f "$root/failure-target-initializer" ]; then
printf '%s\n' '{"status":{"conditions":[{"type":"FailureTarget","status":"True","reason":"BackoffLimitExceeded"}]}}'
elif [ -f "$root/fail-migration" ]; then
elif [ -f "$root/fail-initializer" ]; then
printf '%s\n' '{"status":{"conditions":[{"type":"Failed","status":"True","reason":"BackoffLimitExceeded"}]}}'
else
printf '%s\n' '{"status":{"conditions":[{"type":"Complete","status":"True"}]}}'
@@ -273,33 +273,33 @@ exit 64
rmSync(join(fakeRoot, "slow"));
const workerMissingSucceeded = await waitFor("runtime", "start-succeeded", "api");
writeFileSync(join(fakeRoot, "fail-migration"), "1\n");
await projection("start", "runtime", true, "migration");
const failed = await waitFor("runtime", "start-failed", "migration");
writeFileSync(join(fakeRoot, "fail-initializer"), "1\n");
await projection("start", "runtime", true, "init");
const failed = await waitFor("runtime", "start-failed", "init");
const failedTask = ((failed.status as Record<string, unknown>).task as Record<string, unknown>);
assert.equal(failedTask.code, "migration-job-failed");
assert.equal(failedTask.code, "initializer-job-failed");
assert.equal(failedTask.exitCode, 46);
rmSync(join(fakeRoot, "fail-migration"));
rmSync(join(fakeRoot, "fail-initializer"));
writeFileSync(join(fakeRoot, "failure-target-migration"), "1\n");
await projectionWithCommit("start", "runtime", concurrentCommit, "migration");
const failureTarget = await waitFor("runtime", "start-failed", "migration");
writeFileSync(join(fakeRoot, "failure-target-initializer"), "1\n");
await projectionWithCommit("start", "runtime", concurrentCommit, "init");
const failureTarget = await waitFor("runtime", "start-failed", "init");
const failureTargetTask = ((failureTarget.status as Record<string, unknown>).task as Record<string, unknown>);
assert.equal(failureTargetTask.code, "migration-job-failure-target");
assert.equal(failureTargetTask.code, "initializer-job-failure-target");
assert.equal(failureTargetTask.exitCode, 46);
rmSync(join(fakeRoot, "failure-target-migration"));
rmSync(join(fakeRoot, "failure-target-initializer"));
writeFileSync(join(fakeRoot, "fail-migration-query"), "1\n");
await projectionWithCommit("start", "runtime", "abcdef0123456789", "migration");
const migrationQueryFailed = await waitFor("runtime", "start-failed", "migration");
const migrationQueryFailedTask = ((migrationQueryFailed.status as Record<string, unknown>).task as Record<string, unknown>);
assert.equal(migrationQueryFailedTask.code, "migration-job-query-failed");
assert.equal(migrationQueryFailedTask.exitCode, 46);
rmSync(join(fakeRoot, "fail-migration-query"));
writeFileSync(join(fakeRoot, "fail-initializer-query"), "1\n");
await projectionWithCommit("start", "runtime", "abcdef0123456789", "init");
const initializerQueryFailed = await waitFor("runtime", "start-failed", "init");
const initializerQueryFailedTask = ((initializerQueryFailed.status as Record<string, unknown>).task as Record<string, unknown>);
assert.equal(initializerQueryFailedTask.code, "initializer-job-query-failed");
assert.equal(initializerQueryFailedTask.exitCode, 46);
rmSync(join(fakeRoot, "fail-initializer-query"));
await projectionWithCommit("start", "runtime", "1234567890abcdef", "migration");
const migrationSucceeded = await waitFor("runtime", "start-succeeded", "migration");
assert.equal((((migrationSucceeded.status as Record<string, unknown>).task as Record<string, unknown>).exitCode), 0);
await projectionWithCommit("start", "runtime", "1234567890abcdef", "init");
const initializerSucceeded = await waitFor("runtime", "start-succeeded", "init");
assert.equal((((initializerSucceeded.status as Record<string, unknown>).task as Record<string, unknown>).exitCode), 0);
writeFileSync(join(fakeRoot, "slow"), "1\n");
await projection("start", "runtime", true, "web");
@@ -344,8 +344,8 @@ exit 64
workerMissingSucceeded,
failed,
failureTarget,
migrationQueryFailed,
migrationSucceeded,
initializerQueryFailed,
initializerSucceeded,
stopped,
foundationSubmitted,
foundationSucceeded,
@@ -397,7 +397,7 @@ test("validation-only fixture never invokes remote capture", async () => {
secretName: "pikaoa-test-runtime",
key: "pikaoa.yaml",
mountPath: "/etc/pikaoa/pikaoa.yaml",
consumers: ["migration", "api", "worker"],
consumers: ["initializer", "api", "worker"],
valuesPrinted: false,
});
assert.equal(planPayload.valuesPrinted, false);
+168 -239
View File
@@ -10,9 +10,18 @@ import type { RenderedCliResult } from "./output";
import { CliInputError } from "./output";
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
import { resolveConfigRef } from "./ops/config-refs";
import {
pikaoaManifestForStep,
placeholderPikaoaSecrets,
renderDatabaseURL,
renderPikaoaTestRuntimeManifest,
type PikaoaTestRenderContext,
type PikaoaTestSecretMaterial,
type PikaoaTestTargetStep,
} from "./pikaoa-test-runtime-manifest";
type TestTargetAction = "plan" | "status" | "start" | "stop";
type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web";
type TestTargetStep = PikaoaTestTargetStep;
interface TestTargetOptions {
action: TestTargetAction;
@@ -41,7 +50,7 @@ interface PrometheusSelectorSpec {
valuesPrinted: false;
}
interface TestTargetSpec {
export interface TestTargetSpec {
id: string;
enabled: boolean;
validationOnly: boolean;
@@ -64,12 +73,43 @@ interface TestTargetSpec {
mode: string;
repository: string;
commitPolicy: string;
branch: string;
worktreeRemote: string;
readUrl: string;
snapshotPrefix: string;
apiImage: string;
workerImage: string;
webImage: string;
migrationImage: string;
initializerImage: string;
pullPolicy: string;
};
ci: {
lane: string;
namespace: string;
pipeline: string;
pipelineRunPrefix: string;
serviceAccountName: string;
workspaceSize: string;
pipelineTimeout: string;
toolsImage: string;
buildkitImage: string;
};
build: {
dockerfiles: { api: string; worker: string; web: string };
images: { api: string; worker: string; web: string };
networkMode: string;
};
gitops: {
readUrl: string;
writeUrl: string;
branch: string;
manifestPath: string;
credentialSecretName: string;
credentialTokenKey: string;
credentialUsername: string;
authorName: string;
authorEmail: string;
};
database: {
configRef: string;
name: string;
@@ -103,7 +143,8 @@ interface TestTargetSpec {
employee: { username: string; password: SecretSourceSpec };
sessionSecret: SecretSourceSpec;
};
migration: {
initializer: {
mode: "fresh-database-only";
jobName: string;
backoffLimit: number;
command: string[];
@@ -155,24 +196,8 @@ interface Blocker {
message: string;
}
interface RenderContext {
instanceId: string;
commit: string;
step: TestTargetStep;
namespace: string;
expiresAt: string;
apiImage: string;
workerImage: string;
webImage: string;
migrationImage: string;
}
interface SecretMaterial {
databaseURL: string;
sessionSecret: string;
administratorPassword: string;
employeePassword: string;
}
type RenderContext = PikaoaTestRenderContext;
type SecretMaterial = PikaoaTestSecretMaterial;
type RemoteCapture = typeof capture;
@@ -194,14 +219,14 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
usage: [
"bun scripts/cli.ts pikaoa test-target plan [--config path] [--target id] [--instance id] [--commit sha] [--output json]",
"bun scripts/cli.ts pikaoa test-target status [--config path] [--target id] [--instance id] [--output json]",
"bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|migration|api|worker|web|all] --confirm",
"bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|init|api|worker|web|all] --confirm",
"bun scripts/cli.ts pikaoa test-target stop --target id --instance id --confirm",
],
source: "config/pikaoa.yaml#testRuntime",
guarantees: [
"未声明测试 target 时 plan/status 返回 configured=falsestart/stop 在远端调用前失败。",
"validationOnly target 只用于本地渲染,不连接 route。",
"start 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
"start 可按 foundation、init、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
"正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。",
"Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。",
],
@@ -240,7 +265,7 @@ function parseOptions(args: string[]): TestTargetOptions {
else if (arg === "--target") targetId = simpleId(value, arg);
else if (arg === "--instance") instanceId = kubernetesName(value, arg, 30);
else if (arg === "--commit") commit = sourceCommit(value);
else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "migration", "api", "worker", "web"]);
else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "init", "api", "worker", "web"]);
else {
if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]);
output = value;
@@ -298,6 +323,13 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
simpleId(id, "target id");
const source = record(root.source, `${path}.source`);
const images = record(source.images, `${path}.source.images`);
const ci = record(root.ci, `${path}.ci`);
const build = record(root.build, `${path}.build`);
const dockerfiles = record(build.dockerfiles, `${path}.build.dockerfiles`);
const buildImages = record(build.images, `${path}.build.images`);
const gitops = record(root.gitops, `${path}.gitops`);
const gitopsCredential = record(gitops.credential, `${path}.gitops.credential`);
const gitopsAuthor = record(gitops.author, `${path}.gitops.author`);
const database = record(root.database, `${path}.database`);
const runtime = record(root.runtime, `${path}.runtime`);
const attachment = record(runtime.attachment, `${path}.runtime.attachment`);
@@ -313,7 +345,7 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
const workerProbes = record(probes.worker, `${path}.probes.worker`);
const webProbes = record(probes.web, `${path}.probes.web`);
const cleanup = record(root.cleanup, `${path}.cleanup`);
const migration = record(root.migration, `${path}.migration`);
const initializer = record(root.initializer, `${path}.initializer`);
const taskState = record(root.taskState, `${path}.taskState`);
if (exporterFailure.blocking !== false) throw new Error(`${path}.observability.exporterFailure.blocking 必须为 false`);
if (exporterFailure.warning !== true) throw new Error(`${path}.observability.exporterFailure.warning 必须为 true`);
@@ -341,12 +373,51 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
mode: exactString(source.mode, `${path}.source.mode`, "prebuilt-images"),
repository: nonEmpty(source.repository, `${path}.source.repository`),
commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"),
branch: nonEmpty(source.branch, `${path}.source.branch`),
worktreeRemote: nonEmpty(source.worktreeRemote, `${path}.source.worktreeRemote`),
readUrl: nonEmpty(source.readUrl, `${path}.source.readUrl`),
snapshotPrefix: nonEmpty(source.snapshotPrefix, `${path}.source.snapshotPrefix`),
apiImage: imageReference(images.api, `${path}.source.images.api`),
workerImage: imageReference(images.worker, `${path}.source.images.worker`),
webImage: imageReference(images.web, `${path}.source.images.web`),
migrationImage: imageReference(images.migration, `${path}.source.images.migration`),
initializerImage: imageReference(images.initializer, `${path}.source.images.initializer`),
pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]),
},
ci: {
lane: simpleId(nonEmpty(ci.lane, `${path}.ci.lane`), `${path}.ci.lane`),
namespace: kubernetesName(nonEmpty(ci.namespace, `${path}.ci.namespace`), `${path}.ci.namespace`, 63),
pipeline: kubernetesName(nonEmpty(ci.pipeline, `${path}.ci.pipeline`), `${path}.ci.pipeline`, 63),
pipelineRunPrefix: kubernetesName(nonEmpty(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`), `${path}.ci.pipelineRunPrefix`, 63),
serviceAccountName: kubernetesName(nonEmpty(ci.serviceAccountName, `${path}.ci.serviceAccountName`), `${path}.ci.serviceAccountName`, 63),
workspaceSize: nonEmpty(ci.workspaceSize, `${path}.ci.workspaceSize`),
pipelineTimeout: nonEmpty(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`),
toolsImage: imageReference(ci.toolsImage, `${path}.ci.toolsImage`),
buildkitImage: imageReference(ci.buildkitImage, `${path}.ci.buildkitImage`),
},
build: {
dockerfiles: {
api: relativeSourcePath(dockerfiles.api, `${path}.build.dockerfiles.api`),
worker: relativeSourcePath(dockerfiles.worker, `${path}.build.dockerfiles.worker`),
web: relativeSourcePath(dockerfiles.web, `${path}.build.dockerfiles.web`),
},
images: {
api: imageRepository(buildImages.api, `${path}.build.images.api`),
worker: imageRepository(buildImages.worker, `${path}.build.images.worker`),
web: imageRepository(buildImages.web, `${path}.build.images.web`),
},
networkMode: exactString(build.networkMode, `${path}.build.networkMode`, "host"),
},
gitops: {
readUrl: nonEmpty(gitops.readUrl, `${path}.gitops.readUrl`),
writeUrl: nonEmpty(gitops.writeUrl, `${path}.gitops.writeUrl`),
branch: nonEmpty(gitops.branch, `${path}.gitops.branch`),
manifestPath: relativeSourcePath(gitops.manifestPath, `${path}.gitops.manifestPath`),
credentialSecretName: kubernetesName(nonEmpty(gitopsCredential.secretName, `${path}.gitops.credential.secretName`), `${path}.gitops.credential.secretName`, 63),
credentialTokenKey: nonEmpty(gitopsCredential.tokenKey, `${path}.gitops.credential.tokenKey`),
credentialUsername: nonEmpty(gitopsCredential.username, `${path}.gitops.credential.username`),
authorName: nonEmpty(gitopsAuthor.name, `${path}.gitops.author.name`),
authorEmail: nonEmpty(gitopsAuthor.email, `${path}.gitops.author.email`),
},
database: {
configRef: nonEmpty(database.configRef, `${path}.database.configRef`),
name: postgresIdentifier(database.name, `${path}.database.name`),
@@ -386,10 +457,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`),
initializer: {
mode: exactString(initializer.mode, `${path}.initializer.mode`, "fresh-database-only") as "fresh-database-only",
jobName: kubernetesName(nonEmpty(initializer.jobName, `${path}.initializer.jobName`), `${path}.initializer.jobName`, 63),
backoffLimit: nonNegativeInteger(initializer.backoffLimit, `${path}.initializer.backoffLimit`),
command: nonEmptyStringList(initializer.command, `${path}.initializer.command`),
},
exposure: {
serviceName: kubernetesName(nonEmpty(exposure.serviceName, `${path}.exposure.serviceName`), `${path}.exposure.serviceName`, 63),
@@ -422,6 +494,20 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
return target;
}
export function pikaoaTestRuntimeConfigRef(targetId: string): string {
simpleId(targetId, "PikaOA test runtime target id");
return `config/pikaoa.yaml#testRuntime.targets.${targetId}`;
}
export function resolvePikaoaTestRuntimeTargetByConfigRef(configRef: string): TestTargetSpec {
const resolved = resolveConfigRef(configRef, "PikaOA test runtime configRef");
const match = /^testRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment);
if (resolved.file !== "config/pikaoa.yaml" || match === null) {
throw new Error("PikaOA test runtime configRef must select config/pikaoa.yaml#testRuntime.targets.<id>");
}
return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file);
}
function planPayload(options: TestTargetOptions, selection: Selection): Record<string, unknown> {
const base = basePayload(options, selection);
if (selection.target === null) return base;
@@ -486,7 +572,10 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
worker: componentSelector("worker", context.instanceId),
web: componentSelector("web", context.instanceId),
};
const structuralManifest = manifestForStep(buildManifest(target, context, placeholderSecrets()), context.step);
const structuralManifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(target, context, {
secretMode: "materialized",
secrets: placeholderPikaoaSecrets(),
}), context.step);
return {
namespace: {
name: context.namespace,
@@ -508,7 +597,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
secretName: target.runtime.secretName,
key: "pikaoa.yaml",
mountPath: "/etc/pikaoa/pikaoa.yaml",
consumers: ["migration", "api", "worker"],
consumers: ["initializer", "api", "worker"],
valuesPrinted: false,
},
probes: {
@@ -531,10 +620,11 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
fsGroup: target.runtime.attachment.fsGroup,
accessModes: target.runtime.attachment.accessModes,
},
migration: {
jobName: target.migration.jobName,
image: context.migrationImage,
command: target.migration.command,
initializer: {
mode: target.initializer.mode,
jobName: target.initializer.jobName,
image: context.initializerImage,
command: target.initializer.command,
applyPhase: "before-workloads",
},
step: context.step,
@@ -582,14 +672,17 @@ async function startResult(config: UniDeskConfig, options: TestTargetOptions, se
}
const material = options.step === "foundation" ? {
ok: true,
values: placeholderSecrets(),
values: placeholderPikaoaSecrets(),
sources: secretSources(selection.target).map((source) => secretSourceSummary(selection.configPath, source)),
blockers: [] as Blocker[],
} : readSecretMaterial(selection, selection.target);
if (!material.ok) {
return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options);
}
const manifest = manifestForStep(buildManifest(selection.target, context, material.values), options.step);
const manifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(selection.target, context, {
secretMode: "materialized",
secrets: material.values,
}), options.step);
const remote = await remoteCapture(config, selection.target.route, ["sh"], startScript(selection.target, context, manifest));
const parsed = parseJsonOutput(remote.stdout);
const mutation = parsed?.mutation === true;
@@ -647,7 +740,7 @@ function renderContext(options: TestTargetOptions, target: TestTargetSpec, requi
apiImage: renderImage(target.source.apiImage, commit),
workerImage: renderImage(target.source.workerImage, commit),
webImage: renderImage(target.source.webImage, commit),
migrationImage: renderImage(target.source.migrationImage, commit),
initializerImage: renderImage(target.source.initializerImage, commit),
};
}
@@ -674,9 +767,9 @@ 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, target.source.workerImage, target.source.webImage, target.source.migrationImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。"));
if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.migrationImage].every((image) => image.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
if (!target.initializer.command.includes("migrate") || !target.initializer.command.includes("up")) warnings.push(warning("initializer-command-unrecognized", "空库 initializer 的当前产品命令未显示 migrate up;配置一致性只报告 warning。"));
if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.initializerImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或 initializer 镜像模板未包含 ${commit};版本一致性只报告 warning。"));
if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.initializerImage].every((image) => image.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;
@@ -717,178 +810,6 @@ function prometheusRuntimeStatus(items: Record<string, unknown>[], selector: Pro
};
}
function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, unknown>[] {
const labels = ownershipLabels(target, context);
const secretData = runtimeSecretData(target, context, secrets);
const apiSelector = componentSelector("api", context.instanceId);
const workerSelector = componentSelector("worker", context.instanceId);
const webSelector = componentSelector("web", context.instanceId);
const migrationSelector = componentSelector("migration", context.instanceId);
const prometheusPodLabels = target.observability.prometheusScrape ? {
[target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value,
} : {};
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
"prometheus.io/scrape": "true",
"prometheus.io/path": path,
"prometheus.io/port": String(port),
} : {};
return [
{
apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) },
},
{
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: target.runtime.apiServiceName, namespace: context.namespace, labels: { ...labels, ...apiSelector } },
spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] },
},
{
apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } },
spec: {
type: target.exposure.serviceType,
selector: webSelector,
ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }],
},
},
{
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, prometheusPodLabels, 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 } } },
{ 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 } } },
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username },
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } },
], 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" },
], 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}:${target.runtime.apiPort}` },
], target.probes.webLivenessPath, target.probes.webReadinessPath),
];
}
function manifestForStep(manifest: Record<string, unknown>[], step: TestTargetStep): Record<string, unknown>[] {
if (step === "all") return manifest;
return manifest.filter((object) => {
if (object.kind === "Namespace" || object.kind === "PersistentVolumeClaim") return true;
if (step === "foundation") return false;
if (object.kind === "Secret") return true;
if (step === "migration") return object.kind === "Job";
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {};
return labels["app.kubernetes.io/component"] === step;
});
}
function deployment(
target: TestTargetSpec,
context: RenderContext,
component: "api" | "worker" | "web",
image: string,
selector: Record<string, string>,
port: number,
podLabels: Record<string, string>,
annotations: Record<string, string>,
env: Array<Record<string, unknown>>,
livenessPath: string,
readinessPath: string,
): Record<string, unknown> {
const labels = ownershipLabels(target, context);
return {
apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } },
spec: {
replicas: 1, selector: { matchLabels: selector },
template: {
metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations },
spec: {
...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}),
containers: [{
name: component, image, imagePullPolicy: target.source.pullPolicy, env,
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
volumeMounts: [
...(component === "web" ? [] : [{ 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: [
...(component === "web" ? [] : [{ 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 } }] : []),
],
},
},
},
};
}
function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, string> {
const databaseURL = renderDatabaseURL(target, secrets.databaseURL);
return {
[target.database.connection.targetKey]: databaseURL,
[target.runtime.sessionSecret.targetKey]: secrets.sessionSecret,
[target.runtime.administrator.password.targetKey]: secrets.administratorPassword,
[target.runtime.employee.password.targetKey]: secrets.employeePassword,
"pikaoa.yaml": [
"api:",
` listen: 0.0.0.0:${target.runtime.apiPort}`,
"worker:",
` interval: ${target.runtime.workerInterval}`,
` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`,
" outbox:",
` batchSize: ${target.runtime.outboxBatchSize}`,
` maxAttempts: ${target.runtime.outboxMaxAttempts}`,
` retryDelay: ${target.runtime.outboxRetryDelay}`,
"database:",
` 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)}`,
`shutdownTimeout: ${target.runtime.shutdownTimeout}`,
"",
].join("\n"),
};
}
function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string {
const databaseURL = new URL(sourceURL);
databaseURL.searchParams.set("search_path", target.database.schema);
return databaseURL.toString();
}
function databasePlanSummary(target: TestTargetSpec): Record<string, unknown> {
const databaseURL = new URL(renderDatabaseURL(target, "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require"));
return {
@@ -1134,7 +1055,7 @@ printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"pr
}
function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[], job: AsyncJobIdentity): string {
const migration = manifest.filter((object) => object.kind === "Job");
const initializer = manifest.filter((object) => object.kind === "Job");
const workload = (name: string): Record<string, unknown>[] => manifest.filter((object) => {
if (object.kind !== "Deployment" && object.kind !== "Service") return false;
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
@@ -1146,7 +1067,7 @@ function startRunnerScript(target: TestTargetSpec, context: RenderContext, manif
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 runtimeEncoded = encode(runtime);
const migrationEncoded = encode(migration);
const initializerEncoded = encode(initializer);
const apiEncoded = encode(workload("api"));
const workerEncoded = encode(workload("worker"));
const webEncoded = encode(workload("web"));
@@ -1228,7 +1149,7 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu
fi
printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml"
printf '%s' ${shQuote(runtimeEncoded)} | base64 -d >"$tmp/runtime.yaml"
printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml"
printf '%s' ${shQuote(initializerEncoded)} | base64 -d >"$tmp/initializer.yaml"
printf '%s' ${shQuote(apiEncoded)} | base64 -d >"$tmp/api.yaml"
printf '%s' ${shQuote(workerEncoded)} | base64 -d >"$tmp/worker.yaml"
printf '%s' ${shQuote(webEncoded)} | base64 -d >"$tmp/web.yaml"
@@ -1249,22 +1170,22 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar
failure_code=runtime-secret-apply-failed
exit 44
fi
if [ "$step" = all ] || [ "$step" = migration ]; then
current_phase=migration-apply
if [ "$step" = all ] || [ "$step" = init ]; then
current_phase=initializer-apply
write_status running "$current_phase" start-running null
check_canceled
kubectl -n "$namespace" delete job/${target.migration.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true
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 failure_code=migration-apply-failed; exit 45; fi
current_phase=migration-wait
kubectl -n "$namespace" delete job/${target.initializer.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/initializer.yaml" >"$tmp/initializer-apply.out" 2>"$tmp/initializer-apply.err"; then failure_code=initializer-apply-failed; exit 45; fi
current_phase=initializer-wait
write_status running "$current_phase" start-running null
migration_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} ))
initializer_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} ))
while :; do
check_canceled
if ! kubectl -n "$namespace" get job/${target.migration.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/migration-job.json" 2>"$tmp/migration-wait.err"; then
failure_code=migration-job-query-failed
if ! kubectl -n "$namespace" get job/${target.initializer.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/initializer-job.json" 2>"$tmp/initializer-wait.err"; then
failure_code=initializer-job-query-failed
exit 46
fi
if ! migration_condition="$(python3 - "$tmp/migration-job.json" <<'PY'
if ! initializer_condition="$(python3 - "$tmp/initializer-job.json" <<'PY'
import json, pathlib, sys
payload = json.loads(pathlib.Path(sys.argv[1]).read_text())
conditions = {
@@ -1282,16 +1203,16 @@ else:
print("running")
PY
)"; then
failure_code=migration-job-condition-invalid
failure_code=initializer-job-condition-invalid
exit 46
fi
case "$migration_condition" in
case "$initializer_condition" in
complete) break ;;
failure-target) failure_code=migration-job-failure-target; exit 46 ;;
failed) failure_code=migration-job-failed; exit 46 ;;
failure-target) failure_code=initializer-job-failure-target; exit 46 ;;
failed) failure_code=initializer-job-failed; exit 46 ;;
esac
if [ "$(date +%s)" -ge "$migration_deadline" ]; then
failure_code=migration-job-timeout
if [ "$(date +%s)" -ge "$initializer_deadline" ]; then
failure_code=initializer-job-timeout
exit 46
fi
sleep 1
@@ -1567,8 +1488,8 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?:
commitPolicy: target.source.commitPolicy,
commit,
images: context === undefined
? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, migrationTemplate: target.source.migrationImage }
: { api: context.apiImage, worker: context.workerImage, web: context.webImage, migration: context.migrationImage },
? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, initializerTemplate: target.source.initializerImage }
: { api: context.apiImage, worker: context.workerImage, web: context.webImage, initializer: context.initializerImage },
};
}
@@ -1662,10 +1583,6 @@ function manifestFingerprint(manifest: Record<string, unknown>[], stableRequest:
return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`;
}
function placeholderSecrets(): SecretMaterial {
return { databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", sessionSecret: "<redacted>", administratorPassword: "<redacted>", employeePassword: "<redacted>" };
}
function secretSources(target: TestTargetSpec): SecretSourceSpec[] {
return [target.database.connection, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password];
}
@@ -1760,6 +1677,18 @@ function imageReference(value: unknown, path: string): string {
return image;
}
function imageRepository(value: unknown, path: string): string {
const image = nonEmpty(value, path);
if (/\s/u.test(image) || image.includes("@") || /:[^/]+$/u.test(image)) throw new Error(`${path} tag/digest `);
return image;
}
function relativeSourcePath(value: unknown, path: string): string {
const sourcePath = nonEmpty(value, path);
if (sourcePath.startsWith("/") || sourcePath.split(/[\\/]/u).includes("..")) throw new Error(`${path} .. `);
return sourcePath;
}
function otlpGrpcEndpoint(value: unknown, path: string): string {
const endpoint = nonEmpty(value, path);
if (endpoint.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(endpoint)) throw new Error(`${path} scheme OTLP gRPC host:port`);
@@ -120,19 +120,19 @@ test("unselected parse and repository errors remain warnings while selected obje
expect(() => parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-production-nc01" })).toThrow("namespace");
const invalidRepositoryDocument = pacConfigDocument();
const invalidRepository = (invalidRepositoryDocument.repositories as Array<Record<string, unknown>>).find((item) => item.id === "pikaoa-nc01");
const invalidRepository = (invalidRepositoryDocument.repositories as Array<Record<string, unknown>>).find((item) => item.id === "pikaoa-test-nc01");
if (invalidRepository === undefined) throw new Error("pikaoa repository fixture is required");
invalidRepository.cloneUrl = "https://gitea.example.invalid/mirrors/pikaoa.git";
const isolated = parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "selfmedia-nc01" });
expect(isolated.validationWarnings).toContainEqual({
code: "pac-unselected-repository-invalid",
object: { kind: "repository", id: "pikaoa-nc01" },
object: { kind: "repository", id: "pikaoa-test-nc01" },
consumer: null,
configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-nc01",
configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-test-nc01",
blocking: false,
evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u),
});
expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-nc01")).toBe(false);
expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-test-nc01")).toBe(false);
expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl");
const duplicateSelectedRepository = pacConfigDocument();
@@ -17,6 +17,9 @@ import {
canonicalizePacPipelineSpec,
firstPacSourceArtifactDrift,
pacSourceArtifactSafeError,
pacSourceArtifactProvenanceFromManifest,
pipelineRunLabels,
pipelineRunWorkspaces,
pacValueEvidence,
pacSourceArtifactYaml,
optionalPipelineRunWorkspaces,
@@ -96,6 +99,46 @@ function runtimeInput(commit: string | null = sourceCommit): Record<string, unkn
}
describe("PaC source artifact CLI contract", () => {
test("accepts PikaOA test runtime provenance", () => {
const expected = {
configRef: "config/pikaoa.yaml#testRuntime.targets.NC01",
effectiveConfigSha256: `sha256:${"a".repeat(64)}`,
renderer: "pikaoa-test-runtime",
mode: "embedded-pipeline-spec",
} as const;
const manifest = {
metadata: { annotations: pipelineProvenanceAnnotations(expected) },
};
expect(pacSourceArtifactProvenanceFromManifest(manifest)).toEqual(expected);
});
test("uses PikaOA-specific PipelineRun labels", () => {
const binding = { consumer: { id: "pikaoa-test-nc01", node: "NC01" } } as Parameters<typeof pipelineRunLabels>[0];
expect(pipelineRunLabels(binding, "pikaoa")).toEqual({
"app.kubernetes.io/name": "pikaoa-test-nc01-pac",
"app.kubernetes.io/part-of": "pikaoa",
"unidesk.ai/source-commit": "{{ revision }}",
"pikaoa.unidesk.io/source-commit": "{{ revision }}",
"pikaoa.unidesk.io/trigger": "pipelines-as-code",
});
});
test("maps the PikaOA shared workspace to the declared RWO PVC size", () => {
const binding = {
consumer: { sourceArtifact: { renderer: "pikaoa-test-runtime" } },
repository: { params: { workspace_pvc_size: "8Gi" } },
} as Parameters<typeof pipelineRunWorkspaces>[0];
expect(pipelineRunWorkspaces(binding, { workspaces: [{ name: "workspace" }] })).toEqual([{
name: "workspace",
volumeClaimTemplate: {
spec: {
accessModes: ["ReadWriteOnce"],
resources: { requests: { storage: "8Gi" } },
},
},
}]);
});
test("omits empty PipelineRun workspaces and preserves declared bindings", () => {
expect(optionalPipelineRunWorkspaces([])).toEqual({});
const workspaces = [{ name: "source", emptyDir: {} }];
@@ -19,9 +19,10 @@ import { stableJsonSha256, stableJsonValue } from "./stable-json";
import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance";
import { renderSub2RankDesiredPipeline, sub2RankOwningSourceRemote } from "./platform-infra-sub2rank-pipeline";
import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer";
import { renderPikaoaTestDesiredPipeline, pikaoaTestSourceWorktreeRemote } from "./pikaoa-test-delivery-renderer";
export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation";
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime";
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-test-runtime";
export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime";
export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable";
@@ -499,11 +500,13 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree
? renderHwlabDesiredPipeline(binding, sourceWorktree)
: sourceArtifact.renderer === "sub2rank-platform-service"
? renderSub2RankDesiredPipeline(binding)
: renderSelfMediaPipeline(binding, sourceWorktree);
: sourceArtifact.renderer === "selfmedia-runtime"
? renderSelfMediaPipeline(binding, sourceWorktree)
: renderPikaoaTestPipeline(binding, sourceWorktree);
const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane"
? rendered.pipeline
: withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance);
if (!provenanceEquals(provenanceFromManifest(pipeline), rendered.provenance)) {
if (!provenanceEquals(pacSourceArtifactProvenanceFromManifest(pipeline), rendered.provenance)) {
throw new Error(`${sourceArtifact.renderer} did not render the declared Pipeline provenance contract`);
}
const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec");
@@ -583,6 +586,19 @@ function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktr
};
}
function renderPikaoaTestPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record<string, unknown>; provenance: PacSourceArtifactProvenance } {
const rendered = renderPikaoaTestDesiredPipeline(binding, sourceWorktree);
return {
pipeline: rendered.pipeline,
provenance: {
configRef: rendered.configRef,
effectiveConfigSha256: rendered.effectiveConfigSha256,
renderer: "pikaoa-test-runtime",
mode: "embedded-pipeline-spec",
},
};
}
function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record<string, unknown> {
const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-"));
const temporarySource = resolve(temporaryRoot, "source");
@@ -659,7 +675,9 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec
? "sub2rank"
: provenance.renderer === "selfmedia-runtime"
? "selfmedia"
: "agentrun",
: provenance.renderer === "pikaoa-test-runtime"
? "pikaoa"
: "agentrun",
),
},
spec: {
@@ -732,7 +750,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P
};
}
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia"): Record<string, string> {
export function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia" | "pikaoa"): Record<string, string> {
if (partOf === "agentrun") {
return {
"app.kubernetes.io/part-of": "agentrun",
@@ -751,6 +769,15 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun"
"selfmedia.pikapython.com/trigger": "pipelines-as-code",
};
}
if (partOf === "pikaoa") {
return {
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
"app.kubernetes.io/part-of": "pikaoa",
"unidesk.ai/source-commit": "{{ revision }}",
"pikaoa.unidesk.io/source-commit": "{{ revision }}",
"pikaoa.unidesk.io/trigger": "pipelines-as-code",
};
}
if (partOf === "sub2rank") {
return {
"app.kubernetes.io/name": "sub2rank",
@@ -825,7 +852,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor
if (name === "source-stage-ref") return { name, value: "{{ source_snapshot_prefix }}/{{ revision }}" };
if (name === "git-url") return { name, value: "{{ repo_url }}" };
const repositoryParam = name.replaceAll("-", "_");
if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" && Object.prototype.hasOwnProperty.call(param, "default")) {
if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) {
return { name, value: param.default };
}
if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) {
@@ -836,11 +863,11 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor
});
}
function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
export function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
if (desiredSpec.workspaces === undefined) return [];
return arrayRecords(desiredSpec.workspaces, "Pipeline.spec.workspaces").map((workspace) => {
const name = requiredString(workspace.name, "Pipeline.spec.workspaces[].name");
if (name === "source") {
if (name === "source" || (name === "workspace" && binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime")) {
return {
name,
volumeClaimTemplate: {
@@ -875,9 +902,9 @@ function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifac
const pipelineDrift = firstPacSourceArtifactDrift(desired.pipeline, actualPipeline);
const pipelineRunDrift = firstPacSourceArtifactDrift(desired.pipelineRun, actualPipelineRun);
const actualProvenance = sourceArtifact.mode === "remote-pipeline-annotation"
? provenanceFromManifest(actualPipeline)
: provenanceFromManifest(actualPipelineRun);
const wrapperProvenance = provenanceFromManifest(actualPipelineRun);
? pacSourceArtifactProvenanceFromManifest(actualPipeline)
: pacSourceArtifactProvenanceFromManifest(actualPipelineRun);
const wrapperProvenance = pacSourceArtifactProvenanceFromManifest(actualPipelineRun);
const provenanceAligned = provenanceEquals(actualProvenance, desired.provenance) && provenanceEquals(wrapperProvenance, desired.provenance);
const aligned = pipelineDrift === null && pipelineRunDrift === null && provenanceAligned;
return {
@@ -918,6 +945,7 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string {
}
if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") return sub2RankOwningSourceRemote();
if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node);
if (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") return pikaoaTestSourceWorktreeRemote(binding.consumer.node);
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved");
return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl;
}
@@ -1030,13 +1058,14 @@ function assertPipelineIdentity(pipeline: Record<string, unknown>, binding: PacS
if (metadata.namespace !== binding.consumer.namespace) throw new Error(`rendered Pipeline namespace ${String(metadata.namespace)} does not match consumer ${binding.consumer.namespace}`);
}
function provenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
export function pacSourceArtifactProvenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
const provenance = pipelineProvenanceFromManifest(manifest);
if (provenance === null) return null;
if (provenance.renderer !== "agentrun-control-plane"
&& provenance.renderer !== "hwlab-runtime-lane"
&& provenance.renderer !== "sub2rank-platform-service"
&& provenance.renderer !== "selfmedia-runtime") return null;
&& provenance.renderer !== "selfmedia-runtime"
&& provenance.renderer !== "pikaoa-test-runtime") return null;
if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null;
return provenance as PacSourceArtifactProvenance;
}
@@ -801,7 +801,7 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
function parseSourceArtifact(value: Record<string, unknown>, path: string): PacSourceArtifactSpec {
const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const);
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime"] as const);
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime"] as const);
const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`);
const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`);
const taskRunTemplate = y.objectField(value, "taskRunTemplate", path);
@@ -811,6 +811,7 @@ function parseSourceArtifact(value: Record<string, unknown>, path: string): PacS
if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`);
if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`);
if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`);
if (renderer === "pikaoa-test-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-test-runtime requires embedded-pipeline-spec`);
return {
mode,
renderer,
@@ -1103,10 +1104,10 @@ function validateConsumerConfig(
if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) {
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`);
}
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") {
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`);
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime" || consumer.sourceArtifact?.renderer === "pikaoa-test-runtime") {
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for private GitOps runtime renderers`);
if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) {
throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`);
throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private repository`);
}
if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`);
if (params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.consumers.${consumer.id}.effective params.gitops_secret_name must match repository secretName`);