Merge pull request #2137 from pikasTech/feat/pikaoa-production-release
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

feat(pikaoa): 建立生产 release 控制面
This commit is contained in:
Lyon
2026-07-15 14:48:30 +08:00
committed by GitHub
14 changed files with 418 additions and 69 deletions
+166
View File
@@ -101,6 +101,15 @@ testRuntime:
sourceKey: DATABASE_URL
targetKey: DATABASE_URL
runtime:
replicas:
api: 1
worker: 1
web: 1
resources:
api: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } }
worker: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } }
web: { requests: { cpu: 50m, memory: 64Mi }, limits: { cpu: 500m, memory: 256Mi } }
initializer: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 1, memory: 512Mi } }
secretName: pikaoa-test-runtime
apiServiceName: pikaoa-api
apiPort: 8080
@@ -174,6 +183,163 @@ testRuntime:
livenessPath: /healthz
readinessPath: /healthz
releaseRuntime:
defaultTargetId: NC01
targets:
NC01:
enabled: true
validationOnly: false
node: NC01
route: NC01:k3s
namespace: pikaoa
ttlSeconds: 86400
waitTimeoutSeconds: 300
fieldManager: unidesk-pikaoa-release-runtime
taskState:
rootPath: /var/lib/unidesk/pikaoa-release-runtime
statusRequestTimeoutSeconds: 10
logTailLines: 80
cleanup:
mode: delete-namespace
requireOwnershipLabels: true
source:
mode: prebuilt-images
repository: pikainc/pikaoa
commitPolicy: cli-required
branch: release
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-release-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}
initializer: 127.0.0.1:5000/pikaoa/api:${commit}
pullPolicy: IfNotPresent
ci:
lane: pikaoa
namespace: pikaoa-ci
pipeline: pikaoa-nc01-pac
pipelineRunPrefix: pikaoa-nc01
serviceAccountName: pikaoa-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-gitops
manifestPath: deploy/gitops/nc01/resources.yaml
credential:
secretName: pac-gitea-pikaoa-nc01
tokenKey: token
username: unidesk-admin
author:
name: PikaOA NC01 CI
email: pikaoa-nc01-ci@unidesk.local
database:
configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url
name: pikaoa
username: pikaoa
schema: pikaoa
connection:
sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa.env
sourceKey: DATABASE_URL
targetKey: DATABASE_URL
runtime:
replicas:
api: 1
worker: 1
web: 1
resources:
api: { requests: { cpu: 250m, memory: 256Mi }, limits: { cpu: 1, memory: 1Gi } }
worker: { requests: { cpu: 100m, memory: 256Mi }, limits: { cpu: 1, memory: 1Gi } }
web: { requests: { cpu: 100m, memory: 128Mi }, limits: { cpu: 500m, memory: 512Mi } }
initializer: { requests: { cpu: 250m, memory: 256Mi }, limits: { cpu: 1, memory: 1Gi } }
secretName: pikaoa-runtime
apiServiceName: pikaoa-api
apiPort: 8080
workerMetricsPort: 8081
webPort: 8080
webApiUpstreamEnv: PIKAOA_API_UPSTREAM
attachment:
storageRoot: /var/lib/pikaoa/attachments
maxBytes: 104857600
claimName: pikaoa-attachments
storageRequest: 20Gi
storageClassName: null
fsGroup: 65532
accessModes:
- ReadWriteOnce
workerInterval: 2s
outbox:
batchSize: 50
maxAttempts: 4
retryDelay: 2s
sessionTTL: 8h
shutdownTimeout: 10s
administrator:
username: admin
password:
sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt
targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD
employee:
username: employee
password:
sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt
targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD
sessionSecret:
sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa.env
sourceKey: PIKAOA_SESSION_SECRET
targetKey: PIKAOA_SESSION_SECRET
initializer:
mode: fresh-database-only
jobName: pikaoa-init
backoffLimit: 2
command:
- /usr/local/bin/pikaoa
- migrate
- up
- --config
- /etc/pikaoa/pikaoa.yaml
exposure:
serviceName: pikaoa-web
serviceType: NodePort
hostIP: 152.53.229.148
port: 32096
observability:
configRef: config/platform-infra/observability.yaml
otlpEndpoint: otel-collector.platform-infra.svc.cluster.local:4317
prometheus:
configRef: config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector
scrape: true
apiPath: /metrics
workerPath: /metrics
exporterFailure:
warning: true
blocking: false
probes:
api:
livenessPath: /healthz
readinessPath: /readyz
worker:
livenessPath: /healthz
readinessPath: /readyz
web:
livenessPath: /healthz
readinessPath: /healthz
delivery:
targets:
NC01:
+57 -1
View File
@@ -252,6 +252,45 @@ repositories:
runtime_service_port: "8080"
health_path: /healthz
health_url: http://pikaoa-web.pikaoa-test.svc.cluster.local:8080/healthz
- id: pikaoa-nc01
name: pikaoa-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
tokenKey: token
webhookSecretKey: webhook.secret
concurrencyLimit: 1
params:
git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
source_branch: release
source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01
node: NC01
pipeline_name: pikaoa-nc01-pac
pipeline_run_prefix: pikaoa-nc01
service_account: pikaoa-nc01-tekton-runner
pipeline_timeout: 20m0s
workspace_pvc_size: 8Gi
image_repository: 127.0.0.1:5000/pikaoa/api
api_image_repository: 127.0.0.1:5000/pikaoa/api
worker_image_repository: 127.0.0.1:5000/pikaoa/worker
web_image_repository: 127.0.0.1:5000/pikaoa/web
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_username: unidesk-admin
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
runtime_service_port: "8080"
health_path: /healthz
health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz
consumers:
- extends: templates.consumers.agentrunV02
variables:
@@ -509,7 +548,7 @@ consumers:
dnsPolicy: ClusterFirstWithHostNet
fsGroup: 1000
- id: pikaoa-nc01
repositoryRef: pikaoa-test-nc01
repositoryRef: pikaoa-nc01
node: NC01
lane: pikaoa
namespace: pikaoa-ci
@@ -541,10 +580,27 @@ consumers:
repositoryCredential:
secretName: argocd-repo-pikaoa-nc01
username: unidesk-admin
deliveryProvenance:
required: true
markerValue: admission-pac-v2:pikaoa-nc01
executionServiceAccountName: pikaoa-nc01-tekton-runner
gitOps:
repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git
targetRevision: nc01-pikaoa-gitops
runnerServiceAccount:
name: pikaoa-nc01-tekton-runner
automountServiceAccountToken: false
roleBindingName: pikaoa-nc01-tekton-runner
sourceArtifact:
mode: embedded-pipeline-spec
renderer: pikaoa-release-runtime
configRef: config/pikaoa.yaml#releaseRuntime.targets.NC01
pipelineRunPath: .tekton/pikaoa-nc01-pac.yaml
maxKeepRuns: 8
taskRunTemplate:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
fsGroup: 1000
templates:
repositories:
agentrunV02:
@@ -0,0 +1,53 @@
# R1.6.1.3 任务报告
## 任务回链
- MDTODO`R1.6.1.3`
- GitHub`pikasTech/unidesk#2129``pikasTech/unidesk#2110`
- Target`NC01`
- 交付边界:本任务只完成生产 release 控制面、只读计划与定向测试,不执行生产 mutation。
## 已完成
- `config/pikaoa.yaml#releaseRuntime.targets.NC01` 成为生产运行面唯一 owning selector
- namespace、单副本、resources、附件 PVC、Service、OTel、Prometheus、release source、fresh initializer policy 均由 YAML 声明;
- Secret 继续引用既有受控 distribution 与 PK01 PostgreSQL export,不打印值;
- 公网 hostname、FRP port、Caddy/TLS 事实保留在同一生产 delivery 声明。
- `config/platform-infra/pipelines-as-code.yaml` 新增独立 `pikaoa-nc01` Repository/consumer
- 只跟随 `release``refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01`
-`pikaoa-test-nc01/master` 的 Secret、PipelineRun、GitOps branch、Argo application 完全隔离;
- production sourceArtifact 使用 `pikaoa-release-runtime` renderer 生成 `.tekton/pikaoa-nc01-pac.yaml`
- test/release 共用 renderer 已修复真实 PaC 缺陷:
- TaskSpec workspace 声明 `mountPath: /workspace`step 不再手工挂载 workspacetoken Secret mounts 保留;
- BuildKit metadata 解析允许 JSON 冒号两侧空白,只接受 `containerimage.digest` 的完整 `sha256:<64hex>`
- metadata 不可读时使用本地 registry manifest `Docker-Content-Digest` 受控兜底。
- Gitea mirror webhook status 只解析选中 repository 的 credential 与 topology
- `--repo pikaoa-nc01` 不再读取未选 `pikaoa-test-nc01` token
- 现场状态显示 release commit `07a9a9db3cad` 已 committed。
## 验证证据
- `bun test scripts/src/pikaoa-test-delivery-renderer.test.ts scripts/src/platform-infra-gitea-config.test.ts scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts``24 pass / 0 fail`
- `platform-infra pipelines-as-code plan --target NC01 --consumer pikaoa-nc01`:识别独立 production Repository、consumer、Tekton 与 Argo 路径。
- `platform-infra gitea mirror webhook status --target NC01 --repo pikaoa-nc01``READY=true`release head、Gitea 与 snapshot 均为 `07a9a9db3cad`
- source-artifact `plan/check`desired hash 已生成;产品 `.tekton/pikaoa-nc01-pac.yaml` 尚未写入,符合本任务禁止直接编辑固定产品主 worktree 的边界。
## 主代理后续命令
`/root/pikaoa` clean worktree 审核并写入产品 release artifact
```bash
bun scripts/cli.ts platform-infra pipelines-as-code source-artifact plan --target NC01 --consumer pikaoa-nc01 --source-worktree /root/pikaoa
bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer pikaoa-nc01 --source-worktree /root/pikaoa --confirm
bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer pikaoa-nc01 --source-worktree /root/pikaoa
```
产品 artifact 提交、正常 release PR merge 后,由主代理执行只读 bootstrap 计划并按正常入口触发交付:
```bash
bun scripts/cli.ts platform-infra gitea mirror webhook status --target NC01 --repo pikaoa-nc01
bun scripts/cli.ts platform-infra pipelines-as-code plan --target NC01 --consumer pikaoa-nc01
bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer pikaoa-nc01
```
交付完成后再按原入口验收 `pikaoa` namespace、initializer、API/Worker/Web、附件 PVC、OTel/Prometheus 与 `https://oa.pikapython.com`;不得人工创建 PipelineRun、Argo refresh 或生产 namespace mutation。
@@ -145,6 +145,12 @@
##### R1.6.1.1 [completed]
将 PK01 PostgreSQL 逻辑备份正规化为 YAML-first 多数据库 logicalDumps,保留 Sub2API 并新增 PikaOA 独立 dump、timer、retention 与受控状态/可恢复性摘要(pikasTech/unidesk#2115),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.1_Task_Report.md)。
##### R1.6.1.2
为 NC01 pikaoa namespace 的生产附件 PVC 建立 YAML-first Restic 离机备份、受控状态与 restore smoke,复用 PK01 /var/backups/unidesk 且失败只告警不阻塞业务,执行记录见 [pikasTech/unidesk#2128](https://github.com/pikasTech/unidesk/issues/2128),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.2_Task_Report.md)。
##### R1.6.1.3 [completed]
建立仅跟随产品 release 的 PikaOA 生产 PaC/Tekton/GitOps/Argo lane,以 fresh initializer 在 NC01 pikaoa namespace 启动 API/Worker/Web/附件 PVC,接入 PK01 host PostgreSQL、OTel/Prometheus 和 oa.pikapython.com TLS 公网入口,执行记录见 [pikasTech/unidesk#2129](https://github.com/pikasTech/unidesk/issues/2129),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6.1.3_Task_Report.md)。
### R1.7
通过 CLI、OTel、Prometheus 和 https://oa.pikapython.com 桌面/移动原入口验证完整主路径,依赖 R1.6,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.7_Task_Report.md)。
@@ -9,13 +9,13 @@ 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] {
function rendererBinding(id = "pikaoa-test-nc01"): 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");
const consumer = consumers.find((item) => item.id === id);
const repository = repositories.find((item) => item.id === consumer?.repositoryRef);
if (repository === undefined || consumer === undefined) throw new Error(`PikaOA PaC fixture is required: ${id}`);
return { consumer, repository } as Parameters<typeof renderPikaoaTestDesiredPipeline>[0];
}
@@ -34,12 +34,31 @@ describe("PikaOA test PaC renderer", () => {
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");
const productionRepository = repositories.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();
expect(productionConsumer?.repositoryRef).toBe("pikaoa-nc01");
expect((productionRepository?.params as Record<string, unknown>).source_branch).toBe("release");
expect((productionConsumer?.sourceArtifact as Record<string, unknown>).renderer).toBe("pikaoa-release-runtime");
expect((productionConsumer?.sourceArtifact as Record<string, unknown>).configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01");
});
test("renders release runtime from its isolated owning selector", () => {
const source = sourceFixture();
try {
const rendered = renderPikaoaTestDesiredPipeline(rendererBinding("pikaoa-nc01"), source);
expect(rendered.configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01");
const serialized = JSON.stringify(rendered.pipeline);
expect(serialized).toContain("pikaoa-nc01-pac");
expect(serialized).toContain("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01");
expect(serialized).toContain("pac-gitea-pikaoa-nc01");
expect(serialized).not.toContain("pikaoa-test-nc01-pac");
} finally {
rmSync(source, { recursive: true, force: true });
}
});
test("renders parallel digest builds and a PreSync fresh database initializer", () => {
@@ -65,15 +84,30 @@ describe("PikaOA test PaC renderer", () => {
expect(JSON.stringify(task)).not.toContain("source-token");
const buildScript = (((task?.taskSpec as Record<string, unknown>).steps as Array<Record<string, unknown>>)[0]?.script) as string;
expect(buildScript).toContain('"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)"');
expect(buildScript).toContain(".*/\\1/p");
expect(buildScript).not.toContain("\u0001");
expect(buildScript).toContain("Docker-Content-Digest:");
expect(buildScript).toContain("build_status=$?");
expect(buildScript).toContain('"code":"buildctl-nonzero-artifact-verified"');
expect(buildScript).toContain('"artifactVerified":true');
}
const digest = "sha256:" + "a".repeat(64);
const metadata = JSON.stringify({ "containerimage.digest": digest }, null, 2);
const parsedDigest = /"containerimage\.digest"\s*:\s*"(sha256:[0-9a-f]{64})"/u.exec(metadata)?.[1];
expect(parsedDigest).toBe(digest);
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)");
for (const task of tasks) {
const taskSpec = task.taskSpec as Record<string, unknown> | undefined;
if (taskSpec === undefined) continue;
expect(taskSpec.workspaces).toEqual([{ name: "workspace", mountPath: "/workspace" }]);
const steps = taskSpec.steps as Array<Record<string, unknown>>;
for (const step of steps) {
expect((step.volumeMounts as Array<Record<string, unknown>> | undefined)?.some((mount) => mount.name === "workspace") ?? false).toBe(false);
}
}
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);
+16 -7
View File
@@ -3,6 +3,8 @@ import { resolve } from "node:path";
import {
pikaoaTestRuntimeConfigRef,
pikaoaReleaseRuntimeConfigRef,
resolvePikaoaReleaseRuntimeTargetByConfigRef,
resolvePikaoaTestRuntimeTargetByConfigRef,
type TestTargetSpec,
} from "./pikaoa-test-target";
@@ -37,10 +39,13 @@ export interface PikaoaTestRenderedPipeline {
}
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");
const release = binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime";
const target = release
? resolvePikaoaReleaseRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef)
: resolvePikaoaTestRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef);
const configRef = release ? pikaoaReleaseRuntimeConfigRef(target.id) : pikaoaTestRuntimeConfigRef(target.id);
if ((!release && binding.consumer.sourceArtifact.renderer !== "pikaoa-test-runtime") || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
throw new Error(`${binding.consumer.sourceArtifact.renderer} requires embedded-pipeline-spec`);
}
if (binding.consumer.sourceArtifact.configRef !== configRef) {
throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}`);
@@ -59,6 +64,10 @@ export function pikaoaTestSourceWorktreeRemote(targetId: string): string {
return resolvePikaoaTestRuntimeTargetByConfigRef(pikaoaTestRuntimeConfigRef(targetId)).source.worktreeRemote;
}
export function pikaoaReleaseSourceWorktreeRemote(targetId: string): string {
return resolvePikaoaReleaseRuntimeTargetByConfigRef(pikaoaReleaseRuntimeConfigRef(targetId)).source.worktreeRemote;
}
function pipeline(target: TestTargetSpec): Record<string, unknown> {
const runtimeManifest = renderGitOpsManifest(target);
return {
@@ -280,7 +289,7 @@ 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 commit -m "chore(gitops): deploy pikaoa $SOURCE_COMMIT"
git -C /workspace/gitops push "$(params.gitops-write-url)" HEAD:${shellSingleQuote(target.gitops.branch)}
changed=true
fi
@@ -329,7 +338,7 @@ function renderGitOpsManifest(target: TestTargetSpec): string {
function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBinding): void {
const expected: Readonly<Record<string, string>> = {
id: "pikaoa-test-nc01",
id: target.source.branch === "release" ? "pikaoa-nc01" : "pikaoa-test-nc01",
node: target.node,
lane: target.ci.lane,
namespace: target.ci.namespace,
@@ -351,7 +360,7 @@ function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBindin
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}`);
if (actual[key] !== value) throw new Error(`PikaOA runtime binding ${key} must equal owning YAML value ${value}`);
}
}
+8 -8
View File
@@ -27,8 +27,6 @@ export interface PikaoaTestManifestOptions {
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";
@@ -97,6 +95,7 @@ export function renderPikaoaTestRuntimeManifest(
containers: [{
name: "initializer", image: context.initializerImage, imagePullPolicy: target.source.pullPolicy,
command: target.initializer.command,
resources: target.runtime.resources.initializer,
env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }],
volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }],
}],
@@ -136,11 +135,12 @@ export function pikaoaManifestForStep(manifest: Record<string, unknown>[], step:
}
export function ownershipLabels(target: TestTargetSpec, context: PikaoaTestRenderContext): Record<string, string> {
const release = target.source.branch === "release";
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",
"app.kubernetes.io/part-of": release ? "pikaoa-release-runtime" : "pikaoa-test-runtime",
"app.kubernetes.io/managed-by": target.fieldManager,
[release ? "pikaoa.unidesk.io/release-runtime" : "pikaoa.unidesk.io/test-runtime"]: "true",
[TARGET_LABEL]: target.id,
[INSTANCE_LABEL]: context.instanceId,
};
@@ -191,13 +191,13 @@ function deployment(
return {
apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } },
spec: {
replicas: 1, selector: { matchLabels: selector },
replicas: target.runtime.replicas[component], 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,
name: component, image, imagePullPolicy: target.source.pullPolicy, env, resources: target.runtime.resources[component],
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
volumeMounts: [
...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]),
@@ -241,7 +241,7 @@ function runtimeSecretData(target: TestTargetSpec, context: PikaoaTestRenderCont
` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`,
` maxBytes: ${target.runtime.attachment.maxBytes}`,
"observability:",
` serviceName: pikaoa-test-${context.instanceId}`,
` serviceName: ${target.source.branch === "release" ? "pikaoa" : `pikaoa-test-${context.instanceId}`}`,
` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`,
`shutdownTimeout: ${target.runtime.shutdownTimeout}`,
"",
+32 -3
View File
@@ -118,6 +118,8 @@ export interface TestTargetSpec {
connection: SecretSourceSpec;
};
runtime: {
replicas: { api: number; worker: number; web: number };
resources: Record<"api" | "worker" | "web" | "initializer", Record<string, unknown>>;
secretName: string;
apiServiceName: string;
apiPort: number;
@@ -318,8 +320,8 @@ function readSelection(options: TestTargetOptions): Selection {
};
}
function parseTarget(id: string, root: Record<string, unknown>, configLabel: string): TestTargetSpec {
const path = `${configLabel}.testRuntime.targets.${id}`;
function parseTarget(id: string, root: Record<string, unknown>, configLabel: string, runtimeSection = "testRuntime"): TestTargetSpec {
const path = `${configLabel}.${runtimeSection}.targets.${id}`;
simpleId(id, "target id");
const source = record(root.source, `${path}.source`);
const images = record(source.images, `${path}.source.images`);
@@ -332,6 +334,8 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
const gitopsAuthor = record(gitops.author, `${path}.gitops.author`);
const database = record(root.database, `${path}.database`);
const runtime = record(root.runtime, `${path}.runtime`);
const replicas = record(runtime.replicas, `${path}.runtime.replicas`);
const resources = record(runtime.resources, `${path}.runtime.resources`);
const attachment = record(runtime.attachment, `${path}.runtime.attachment`);
const outbox = record(runtime.outbox, `${path}.runtime.outbox`);
const administrator = record(runtime.administrator, `${path}.runtime.administrator`);
@@ -426,6 +430,17 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true),
},
runtime: {
replicas: {
api: positiveInteger(replicas.api, `${path}.runtime.replicas.api`, 20),
worker: positiveInteger(replicas.worker, `${path}.runtime.replicas.worker`, 20),
web: positiveInteger(replicas.web, `${path}.runtime.replicas.web`, 20),
},
resources: {
api: record(resources.api, `${path}.runtime.resources.api`),
worker: record(resources.worker, `${path}.runtime.resources.worker`),
web: record(resources.web, `${path}.runtime.resources.web`),
initializer: record(resources.initializer, `${path}.runtime.resources.initializer`),
},
secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63),
apiServiceName: kubernetesName(nonEmpty(runtime.apiServiceName, `${path}.runtime.apiServiceName`), `${path}.runtime.apiServiceName`, 63),
apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535),
@@ -505,7 +520,21 @@ export function resolvePikaoaTestRuntimeTargetByConfigRef(configRef: string): Te
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);
return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "testRuntime");
}
export function pikaoaReleaseRuntimeConfigRef(targetId: string): string {
simpleId(targetId, "PikaOA release runtime target id");
return `config/pikaoa.yaml#releaseRuntime.targets.${targetId}`;
}
export function resolvePikaoaReleaseRuntimeTargetByConfigRef(configRef: string): TestTargetSpec {
const resolved = resolveConfigRef(configRef, "PikaOA release runtime configRef");
const match = /^releaseRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment);
if (resolved.file !== "config/pikaoa.yaml" || match === null) {
throw new Error("PikaOA release runtime configRef must select config/pikaoa.yaml#releaseRuntime.targets.<id>");
}
return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "releaseRuntime");
}
function planPayload(options: TestTargetOptions, selection: Selection): Record<string, unknown> {
@@ -103,6 +103,25 @@ describe("platform-infra Gitea repository GitHub credentials", () => {
expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.github.sourceRef);
});
test("production PikaOA status scope never reads the unselected master credential", () => {
const config = readGiteaConfig();
const production = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-nc01");
expect(production).toBeDefined();
const requested: string[] = [];
const result = buildGiteaMirrorBootstrapCredentialPreflight(config, [production!], (credential) => {
requested.push(credential.gitFetchCredential.secretRef.key);
return { [credential.sourceKey]: "fixture-release-token" };
});
expect(result.blockers).toEqual([]);
expect(requested).toEqual(["github-token-pikainc-pikaoa-release"]);
expect(result.credentials).toHaveLength(1);
expect(result.credentials[0]).toMatchObject({
id: "github-upstream:pikaoa-nc01",
valuesPrinted: false,
});
});
test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => {
const config = readGiteaConfig();
const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia");
+3 -1
View File
@@ -1074,7 +1074,9 @@ rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
topology_spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {})
token = os.environ[topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]]
credential_spec = repo.get("githubCredential") or {}
token_env = credential_spec.get("tokenEnv") or topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]
token = os.environ[token_env]
branch = repo["upstream"]["branch"]
result = request(f"/repos/{repository}/hooks")
hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body"))
+8 -36
View File
@@ -1182,6 +1182,9 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
const frpcExposure = targetFrpcExposure(gitea, target);
const sync = targetWebhookSync(gitea, target);
const selectedRepositories = params.repos ?? repositoriesForTarget(gitea, target);
const selectedCredentials = githubCredentialsForConsumers(gitea, target, selectedRepositories, false);
const defaultCredential = selectedCredentials[0] ?? gitea.sourceAuthority.credentials.github;
const env: Record<string, string> = {
UNIDESK_GITEA_ACTION: action,
UNIDESK_GITEA_TARGET_ID: target.id,
@@ -1201,9 +1204,9 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-prefligh
UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl,
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key,
UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key),
UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), true)
UNIDESK_GITEA_GITHUB_SECRET_KEY: defaultCredential.gitFetchCredential.secretRef.key,
UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(defaultCredential.gitFetchCredential.secretRef.key),
UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: selectedCredentials
.map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`)
.join(","),
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
@@ -1702,10 +1705,7 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<str
function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuthority.repositories): Array<Record<string, unknown>> {
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
const github = gitea.sourceAuthority.credentials.github;
const githubPath = credentialPath(gitea, github.sourceRef);
const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null };
const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {};
const summaries: Array<Record<string, unknown>> = [
{
id: "gitea-admin",
@@ -1716,36 +1716,8 @@ function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuth
permissionResult: { status: "not-applicable", error: null },
valuesPrinted: false,
},
{
id: "github-upstream",
sourceRef: github.sourceRef,
present: existsSync(githubPath),
requiredKeysPresent: Boolean(githubEnv[github.sourceKey]),
fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]),
permissionResult: existsSync(githubPath) && Boolean(githubEnv[github.sourceKey])
? { status: "not-probed", error: null }
: { status: "blocked-missing-credential", error: "credential material is absent" },
valuesPrinted: false,
},
];
for (const repo of repositories) {
const githubOverride = repo.credentialOverride?.github;
if (githubOverride === undefined) continue;
const sourcePath = credentialPath(gitea, githubOverride.sourceRef);
const values = existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, githubOverride) : {};
summaries.push({
id: `github-upstream:${repo.key}`,
repository: repo.upstream.repository,
sourceRef: githubOverride.sourceRef,
present: existsSync(sourcePath),
requiredKeysPresent: Boolean(values[githubOverride.sourceKey]),
fingerprint: fingerprintKeys(values, [githubOverride.sourceKey]),
permissionResult: existsSync(sourcePath) && Boolean(values[githubOverride.sourceKey])
? { status: "not-probed", permissions: githubOverride.permissions, error: null }
: { status: "blocked-missing-credential", permissions: githubOverride.permissions, error: "credential material is absent" },
valuesPrinted: false,
});
}
summaries.push(...buildGiteaMirrorBootstrapCredentialPreflight(gitea, repositories).credentials);
return summaries;
}
@@ -1803,7 +1775,7 @@ function githubCredentialsForConsumers(
repositories: GiteaMirrorRepository[],
includeHookOwnerCredentials: boolean,
): GiteaGithubCredential[] {
const credentials = [gitea.sourceAuthority.credentials.github, ...repositories.flatMap((repo) => repo.credentialOverride?.github ?? [])];
const credentials = repositories.map((repo) => githubCredentialForRepo(gitea, repo));
if (includeHookOwnerCredentials
&& gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase()) {
credentials.push(...gitea.sourceAuthority.repositories.flatMap((repo) => {
@@ -133,7 +133,7 @@ test("unselected parse and repository errors remain warnings while selected obje
evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u),
});
expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-test-nc01")).toBe(false);
expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl");
expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).not.toThrow();
const duplicateSelectedRepository = pacConfigDocument();
const repositories = duplicateSelectedRepository.repositories as Array<Record<string, unknown>>;
@@ -19,10 +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";
import { renderPikaoaTestDesiredPipeline, pikaoaReleaseSourceWorktreeRemote, 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" | "pikaoa-test-runtime";
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-test-runtime" | "pikaoa-release-runtime";
export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime";
export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable";
@@ -593,7 +593,7 @@ function renderPikaoaTestPipeline(binding: PacSourceArtifactBinding, sourceWorkt
provenance: {
configRef: rendered.configRef,
effectiveConfigSha256: rendered.effectiveConfigSha256,
renderer: "pikaoa-test-runtime",
renderer: binding.consumer.sourceArtifact.renderer,
mode: "embedded-pipeline-spec",
},
};
@@ -852,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" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) {
if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) {
return { name, value: param.default };
}
if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) {
@@ -867,7 +867,7 @@ export function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desired
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" || (name === "workspace" && binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime")) {
if (name === "source" || (name === "workspace" && (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime"))) {
return {
name,
volumeClaimTemplate: {
@@ -946,6 +946,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 (binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") return pikaoaReleaseSourceWorktreeRemote(binding.consumer.node);
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved");
return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl;
}
@@ -1065,7 +1066,8 @@ export function pacSourceArtifactProvenanceFromManifest(manifest: Record<string,
&& provenance.renderer !== "hwlab-runtime-lane"
&& provenance.renderer !== "sub2rank-platform-service"
&& provenance.renderer !== "selfmedia-runtime"
&& provenance.renderer !== "pikaoa-test-runtime") return null;
&& provenance.renderer !== "pikaoa-test-runtime"
&& provenance.renderer !== "pikaoa-release-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", "pikaoa-test-runtime"] as const);
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime", "pikaoa-release-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);
@@ -812,6 +812,7 @@ function parseSourceArtifact(value: Record<string, unknown>, path: string): PacS
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`);
if (renderer === "pikaoa-release-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-release-runtime requires embedded-pipeline-spec`);
return {
mode,
renderer,