From 902c619267b74f6025d997077e78711231227728 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 08:01:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=20PikaOA=20=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E5=BC=80=E5=8F=91=E8=87=AA=E5=8A=A8=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/fixtures/pikaoa-test-target.yaml | 175 -- config/pikaoa.yaml | 79 +- config/platform-db/postgres-nc01.yaml | 43 +- config/platform-infra/gitea.yaml | 5 +- config/platform-infra/pipelines-as-code.yaml | 61 +- config/platform-infra/public-edge.yaml | 3 +- config/secrets-distribution.yaml | 21 +- scripts/cli.ts | 13 +- scripts/src/help.ts | 6 +- ...st.ts => pikaoa-delivery-renderer.test.ts} | 36 +- ...enderer.ts => pikaoa-delivery-renderer.ts} | 64 +- ...manifest.ts => pikaoa-runtime-manifest.ts} | 148 +- scripts/src/pikaoa-runtime.ts | 505 +++++ scripts/src/pikaoa-test-target-async.test.ts | 487 ----- scripts/src/pikaoa-test-target.ts | 1883 ----------------- .../src/platform-infra-gitea-config.test.ts | 12 +- .../src/platform-infra-pac-provenance.test.ts | 10 +- ...-infra-pipelines-as-code-bootstrap.test.ts | 8 +- ...-pipelines-as-code-source-artifact.test.ts | 12 +- ...infra-pipelines-as-code-source-artifact.ts | 21 +- .../src/platform-infra-pipelines-as-code.ts | 7 +- 21 files changed, 756 insertions(+), 2843 deletions(-) delete mode 100644 config/fixtures/pikaoa-test-target.yaml rename scripts/src/{pikaoa-test-delivery-renderer.test.ts => pikaoa-delivery-renderer.test.ts} (83%) rename scripts/src/{pikaoa-test-delivery-renderer.ts => pikaoa-delivery-renderer.ts} (87%) rename scripts/src/{pikaoa-test-runtime-manifest.ts => pikaoa-runtime-manifest.ts} (63%) create mode 100644 scripts/src/pikaoa-runtime.ts delete mode 100644 scripts/src/pikaoa-test-target-async.test.ts delete mode 100644 scripts/src/pikaoa-test-target.ts diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml deleted file mode 100644 index 0e0bd3d8..00000000 --- a/config/fixtures/pikaoa-test-target.yaml +++ /dev/null @@ -1,175 +0,0 @@ -version: 1 -kind: pikaoa-platform-delivery - -metadata: - id: pikaoa-enterprise-fixture - owner: unidesk - repository: pikainc/pikaoa - spec: PJ2026-03 - -testRuntime: - defaultTargetId: TEST01 - targets: - TEST01: - enabled: true - validationOnly: true - node: TEST01 - route: TEST01:k3s - namespace: pikaoa-test - ttlSeconds: 7200 - waitTimeoutSeconds: 180 - fieldManager: unidesk-pikaoa-test-target - taskState: - rootPath: /tmp/unidesk-pikaoa-test-target-fixture - statusRequestTimeoutSeconds: 3 - logTailLines: 8 - cleanup: - mode: delete-namespace - requireOwnershipLabels: true - source: - 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} - 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-nc01.yaml#exports.connectionStrings.pikaoa-test-database-url - name: pikaoa_test - username: pikaoa_test - schema: pikaoa - connection: - sourceRef: ./secrets/pikaoa-test.env - 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 - workerMetricsPort: 8081 - webPort: 8080 - webApiUpstreamEnv: PIKAOA_API_UPSTREAM - attachment: - storageRoot: /var/lib/pikaoa/attachments - maxBytes: 104857600 - claimName: pikaoa-attachments - storageRequest: 1Gi - storageClassName: null - fsGroup: 65532 - accessModes: - - ReadWriteOnce - workerInterval: 2s - outbox: - batchSize: 50 - maxAttempts: 4 - retryDelay: 2s - sessionTTL: 8h - shutdownTimeout: 10s - administrator: - username: admin - password: - sourceRef: ./secrets/pikaoa-test-admin-password.txt - targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD - employee: - username: employee - password: - sourceRef: ./secrets/pikaoa-test-employee-password.txt - targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD - sessionSecret: - sourceRef: ./secrets/pikaoa-test.env - sourceKey: PIKAOA_SESSION_SECRET - targetKey: PIKAOA_SESSION_SECRET - adminToken: - sourceRef: ./secrets/pikaoa-test.env - sourceKey: OA_ADMIN_TOKEN - targetKey: OA_ADMIN_TOKEN - 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: 192.0.2.10 - port: 32080 - observability: - otlpEndpoint: otel-collector.observability.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: - PROD: - namespace: pikaoa - ci: - namespace: pikaoa-ci diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index 5d7b23f6..284477ca 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -11,6 +11,7 @@ metadata: - 2110 - 2128 - 2263 + - 2274 defaults: targetId: NC01 @@ -29,45 +30,30 @@ modules: events: transactional-outbox workers: named-consumer -testRuntime: +developmentRuntime: defaultTargetId: NC01 targets: NC01: enabled: true - validationOnly: false node: NC01 route: NC01:k3s - namespace: pikaoa-test - ttlSeconds: 86400 - waitTimeoutSeconds: 300 - fieldManager: unidesk-pikaoa-test-target - taskState: - rootPath: /var/lib/unidesk/pikaoa-test-target - statusRequestTimeoutSeconds: 10 - logTailLines: 80 - cleanup: - mode: delete-namespace - requireOwnershipLabels: true + namespace: pikaoa-dev + fieldManager: unidesk-pikaoa-development-runtime source: - mode: prebuilt-images + mode: pac-source-build repository: pikainc/pikaoa - commitPolicy: cli-required + commitPolicy: pac-revision-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} - initializer: 127.0.0.1:5000/pikaoa/api:${commit} pullPolicy: IfNotPresent ci: - lane: pikaoa-test + lane: pikaoa-dev namespace: pikaoa-ci - pipeline: pikaoa-test-nc01-pac - pipelineRunPrefix: pikaoa-test-nc01 - serviceAccountName: pikaoa-test-nc01-tekton-runner + pipeline: pikaoa-dev-nc01-pac + pipelineRunPrefix: pikaoa-dev-nc01 + serviceAccountName: pikaoa-dev-nc01-tekton-runner workspaceSize: 8Gi pipelineTimeout: 20m0s toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 @@ -85,22 +71,22 @@ testRuntime: 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 + branch: nc01-pikaoa-dev-gitops + manifestPath: deploy/gitops/nc01-dev/resources.yaml credential: - secretName: pac-gitea-pikaoa-test-nc01 + secretName: pac-gitea-pikaoa-dev-nc01 tokenKey: token username: unidesk-admin author: - name: PikaOA Test NC01 CI - email: pikaoa-test-nc01-ci@unidesk.local + name: PikaOA Development NC01 CI + email: pikaoa-dev-nc01-ci@unidesk.local database: - configRef: config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-test-database-url - name: pikaoa_test - username: pikaoa_test + configRef: config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-dev-database-url + name: pikaoa_dev + username: pikaoa_dev schema: pikaoa connection: - sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-dev.env sourceKey: DATABASE_URL targetKey: DATABASE_URL runtime: @@ -113,7 +99,8 @@ testRuntime: 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 + secretName: pikaoa-dev-runtime + configMapName: pikaoa-runtime-config apiServiceName: pikaoa-api apiPort: 8080 workerMetricsPort: 8081 @@ -146,11 +133,11 @@ testRuntime: sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD sessionSecret: - sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-dev.env sourceKey: PIKAOA_SESSION_SECRET targetKey: PIKAOA_SESSION_SECRET adminToken: - sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-dev.env sourceKey: OA_ADMIN_TOKEN targetKey: OA_ADMIN_TOKEN initializer: @@ -197,33 +184,18 @@ releaseRuntime: 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 + mode: pac-source-build repository: pikainc/pikaoa - commitPolicy: cli-required + commitPolicy: pac-revision-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 @@ -277,6 +249,7 @@ releaseRuntime: 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 + configMapName: pikaoa-runtime-config apiServiceName: pikaoa-api apiPort: 8080 workerMetricsPort: 8081 diff --git a/config/platform-db/postgres-nc01.yaml b/config/platform-db/postgres-nc01.yaml index 38d5085e..5e270c20 100644 --- a/config/platform-db/postgres-nc01.yaml +++ b/config/platform-db/postgres-nc01.yaml @@ -8,6 +8,7 @@ metadata: relatedIssues: - 1875 - 2250 + - 2274 cluster: role: primary @@ -126,8 +127,8 @@ postgres: address: 10.42.0.0/16 method: scram-sha-256 - type: hostssl - database: pikaoa_test - user: pikaoa_test + database: pikaoa_dev + user: pikaoa_dev address: 10.42.0.0/16 method: scram-sha-256 @@ -191,20 +192,20 @@ secrets: PIKAOA_DB_NAME: pikaoa randomHex: PIKAOA_DB_PASSWORD: 32 - - name: pikaoa-test-nc01-db-credentials - sourceRef: platform-db/pikaoa-test-db.env + - name: pikaoa-dev-nc01-db-credentials + sourceRef: platform-db/pikaoa-dev-db.env type: env requiredKeys: - - PIKAOA_TEST_DB_USER - - PIKAOA_TEST_DB_PASSWORD - - PIKAOA_TEST_DB_NAME + - PIKAOA_DEV_DB_USER + - PIKAOA_DEV_DB_PASSWORD + - PIKAOA_DEV_DB_NAME createIfMissing: enabled: true values: - PIKAOA_TEST_DB_USER: pikaoa_test - PIKAOA_TEST_DB_NAME: pikaoa_test + PIKAOA_DEV_DB_USER: pikaoa_dev + PIKAOA_DEV_DB_NAME: pikaoa_dev randomHex: - PIKAOA_TEST_DB_PASSWORD: 32 + PIKAOA_DEV_DB_PASSWORD: 32 objects: roles: @@ -244,10 +245,10 @@ objects: createdb: false createrole: false superuser: false - - name: pikaoa_test + - name: pikaoa_dev passwordRef: - sourceRef: platform-db/pikaoa-test-db.env - key: PIKAOA_TEST_DB_PASSWORD + sourceRef: platform-db/pikaoa-dev-db.env + key: PIKAOA_DEV_DB_PASSWORD login: true attributes: createdb: false @@ -274,8 +275,8 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] - - name: pikaoa_test - owner: pikaoa_test + - name: pikaoa_dev + owner: pikaoa_dev encoding: UTF8 locale: C.UTF-8 extensions: [] @@ -349,20 +350,20 @@ exports: - scope: pikaoa secret: pikaoa-runtime key: DATABASE_URL - - name: pikaoa-test-database-url - sourceSecretRef: platform-db/pikaoa-test-db.env + - name: pikaoa-dev-database-url + sourceSecretRef: platform-db/pikaoa-dev-db.env render: envKey: DATABASE_URL - format: postgresql://$(PIKAOA_TEST_DB_USER):$(PIKAOA_TEST_DB_PASSWORD)@$(PGHOST):5432/$(PIKAOA_TEST_DB_NAME)?sslmode=require + format: postgresql://$(PIKAOA_DEV_DB_USER):$(PIKAOA_DEV_DB_PASSWORD)@$(PGHOST):5432/$(PIKAOA_DEV_DB_NAME)?sslmode=require variables: PGHOST: 10.42.0.1 writeToSecretSource: - sourceRef: platform-infra/pikaoa-test.env + sourceRef: platform-infra/pikaoa-dev.env key: DATABASE_URL mode: update-or-insert consumers: - - scope: pikaoa-test - secret: pikaoa-test-runtime + - scope: pikaoa-dev + secret: pikaoa-dev-runtime key: DATABASE_URL backup: diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 9f0eaa78..e1be1292 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -13,6 +13,7 @@ metadata: - 1560 - 1760 - 1768 + - 2274 defaults: targetId: NC01 @@ -489,7 +490,7 @@ sourceAuthority: naming: gitea-actions-immutable-source prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01 legacyGitMirror: null - - key: pikaoa-test-nc01 + - key: pikaoa-dev-nc01 targetId: NC01 credentialOverride: github: @@ -529,7 +530,7 @@ sourceAuthority: publicRead: false readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git gitops: - branch: nc01-pikaoa-test-gitops + branch: nc01-pikaoa-dev-gitops flushDisposition: gitea-writeback snapshot: naming: gitea-actions-immutable-source diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 92e30436..32d53457 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -14,6 +14,7 @@ metadata: - 1769 - 1802 - 1811 + - 2274 defaults: targetId: NC01 consumerId: hwlab-nc01-v03 @@ -247,15 +248,15 @@ repositories: runtime_service_port: "4317" health_path: /healthz health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz - - id: pikaoa-test-nc01 - name: pikaoa-test-nc01 + - id: pikaoa-dev-nc01 + name: pikaoa-dev-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-test-nc01 + secretName: pac-gitea-pikaoa-dev-nc01 tokenKey: token webhookSecretKey: webhook.secret concurrencyLimit: 1 @@ -264,9 +265,9 @@ repositories: source_branch: master source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 node: NC01 - pipeline_name: pikaoa-test-nc01-pac - pipeline_run_prefix: pikaoa-test-nc01 - service_account: pikaoa-test-nc01-tekton-runner + pipeline_name: pikaoa-dev-nc01-pac + pipeline_run_prefix: pikaoa-dev-nc01 + service_account: pikaoa-dev-nc01-tekton-runner pipeline_timeout: 20m0s workspace_pvc_size: 8Gi image_repository: 127.0.0.1:5000/pikaoa/api @@ -276,16 +277,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-test-gitops + gitops_branch: nc01-pikaoa-dev-gitops gitops_username: unidesk-admin - gitops_secret_name: pac-gitea-pikaoa-test-nc01 - gitops_manifest_path: deploy/gitops/nc01-test/resources.yaml - runtime_namespace: pikaoa-test + gitops_secret_name: pac-gitea-pikaoa-dev-nc01 + gitops_manifest_path: deploy/gitops/nc01-dev/resources.yaml + runtime_namespace: pikaoa-dev runtime_deployment: pikaoa-api runtime_service: pikaoa-web runtime_service_port: "8080" health_path: /healthz - health_url: http://pikaoa-web.pikaoa-test.svc.cluster.local:8080/healthz + health_url: http://pikaoa-web.pikaoa-dev.svc.cluster.local:8080/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -560,49 +561,49 @@ consumers: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 - - id: pikaoa-test-nc01 - repositoryRef: pikaoa-test-nc01 + - id: pikaoa-dev-nc01 + repositoryRef: pikaoa-dev-nc01 node: NC01 - lane: pikaoa-test + lane: pikaoa-dev namespace: pikaoa-ci - pipeline: pikaoa-test-nc01-pac - pipelineRunPrefix: pikaoa-test-nc01 + pipeline: pikaoa-dev-nc01-pac + pipelineRunPrefix: pikaoa-dev-nc01 argoNamespace: argocd - argoApplication: pikaoa-test-nc01 + argoApplication: pikaoa-dev-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 + targetRevision: nc01-pikaoa-dev-gitops + path: deploy/gitops/nc01-dev + destinationNamespace: pikaoa-dev automated: true repositoryCredential: - secretName: argocd-repo-pikaoa-test-nc01 + secretName: argocd-repo-pikaoa-dev-nc01 username: unidesk-admin deliveryProvenance: required: true - markerValue: admission-pac-v2:pikaoa-test-nc01 - executionServiceAccountName: pikaoa-test-nc01-tekton-runner + markerValue: admission-pac-v2:pikaoa-dev-nc01 + executionServiceAccountName: pikaoa-dev-nc01-tekton-runner gitOps: repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git - targetRevision: nc01-pikaoa-test-gitops + targetRevision: nc01-pikaoa-dev-gitops runnerServiceAccount: - name: pikaoa-test-nc01-tekton-runner + name: pikaoa-dev-nc01-tekton-runner automountServiceAccountToken: false - roleBindingName: pikaoa-test-nc01-tekton-runner + roleBindingName: pikaoa-dev-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 + renderer: pikaoa-development-runtime + configRef: config/pikaoa.yaml#developmentRuntime.targets.NC01 + pipelineRunPath: .tekton/pikaoa-dev-nc01-pac.yaml maxKeepRuns: 8 taskRunTemplate: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 - id: pikaoa-nc01 - repositoryRef: pikaoa-test-nc01 + repositoryRef: pikaoa-dev-nc01 node: NC01 lane: pikaoa namespace: pikaoa-ci diff --git a/config/platform-infra/public-edge.yaml b/config/platform-infra/public-edge.yaml index 098ce9e8..66d43f17 100644 --- a/config/platform-infra/public-edge.yaml +++ b/config/platform-infra/public-edge.yaml @@ -6,6 +6,7 @@ metadata: owner: platform-infra relatedIssues: - 2181 + - 2274 defaults: targetId: NC01 @@ -37,7 +38,7 @@ targets: sites: - id: pikaoa-development configRef: config/pikaoa.yaml - path: testRuntime.targets.NC01 + path: developmentRuntime.targets.NC01 - id: pikaoa-production configRef: config/pikaoa.yaml path: releaseRuntime.targets.NC01 diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index bb095321..839a0700 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -13,6 +13,7 @@ metadata: - 2110 - 2128 - 2263 + - 2274 sources: root: /root/.unidesk/.state/secrets @@ -116,7 +117,7 @@ sources: RESTIC_PASSWORD: bytes: 32 prefix: poa_restic_ - - sourceRef: platform-infra/pikaoa-test.env + - sourceRef: platform-infra/pikaoa-dev.env type: env requiredKeys: - DATABASE_URL @@ -266,10 +267,10 @@ targets: namespace: pikaoa scope: pikaoa enabled: true - - id: pikaoa-test-nc01 + - id: pikaoa-dev-nc01 route: NC01:k3s - namespace: pikaoa-test - scope: pikaoa-test + namespace: pikaoa-dev + scope: pikaoa-dev enabled: true kubernetesSecrets: @@ -301,18 +302,18 @@ kubernetesSecrets: - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt sourceKey: contents targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD - - name: pikaoa-test-runtime - targetId: pikaoa-test-nc01 - secretName: pikaoa-test-runtime + - name: pikaoa-dev-runtime + targetId: pikaoa-dev-nc01 + secretName: pikaoa-dev-runtime type: Opaque data: - - sourceRef: platform-infra/pikaoa-test.env + - sourceRef: platform-infra/pikaoa-dev.env sourceKey: DATABASE_URL targetKey: DATABASE_URL - - sourceRef: platform-infra/pikaoa-test.env + - sourceRef: platform-infra/pikaoa-dev.env sourceKey: PIKAOA_SESSION_SECRET targetKey: PIKAOA_SESSION_SECRET - - sourceRef: platform-infra/pikaoa-test.env + - sourceRef: platform-infra/pikaoa-dev.env sourceKey: OA_ADMIN_TOKEN targetKey: OA_ADMIN_TOKEN - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt diff --git a/scripts/cli.ts b/scripts/cli.ts index 1a3b8229..54bc22c7 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -1,5 +1,6 @@ // SPEC: PJ2026-01060509 出站诊断 draft-2026-06-26-p8-egress-job-friction. // SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. +// SPEC: PJ2026-03 PikaOA v0.8. // UniDesk CLI dispatcher with bounded server lifecycle and job drill-down output. import { readConfig } from "./src/config"; import { debugDispatch, debugEgressProxy, debugHealth, debugProviderRootfs, debugSshPool, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug"; @@ -429,8 +430,16 @@ async function main(): Promise { } if (top === "pikaoa") { - const { runPikaoaCommand } = await import("./src/pikaoa-test-target"); - const result = await runPikaoaCommand(readConfig(), args.slice(1)); + if (args[1] !== "attachments-backup") { + throw new CliInputError("pikaoa 只支持 attachments-backup;运行面由 PaC 自动交付", { + code: "unsupported-pikaoa-command", + argument: args[1] ?? "", + supported: ["attachments-backup"], + usage: "bun scripts/cli.ts pikaoa attachments-backup --help", + }); + } + const { runPikaoaAttachmentsBackupCommand } = await import("./src/pikaoa-attachments-backup"); + const result = await runPikaoaAttachmentsBackupCommand(readConfig(), args.slice(2)); if (isRenderedCliResult(result)) { emitText(result.renderedText, result.command || commandName); if (!result.ok) process.exitCode = 1; diff --git a/scripts/src/help.ts b/scripts/src/help.ts index a6357545..7951b41d 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,5 +1,6 @@ // SPEC: PJ2026-01060509 出站诊断 draft-2026-06-26-p8-egress-job-friction. // SPEC: PJ2026-01060703 CI/CD branch follower draft-2026-07-03-p0-branch-follower. +// SPEC: PJ2026-03 PikaOA v0.8. // Static CLI help for job aliases and server lifecycle progressive disclosure. import { join } from "node:path"; import { ghHelp, ghScopedHelp } from "./gh"; @@ -75,7 +76,7 @@ export function rootHelp(): unknown { { command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node NC01 --lane v03", description: "Operate the NC01 HWLAB v0.3 development and deployment lane from YAML source truth." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, { command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot, n8n and Web Terminal, manage YAML-controlled public Caddy/FRP exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, - { command: "pikaoa test-target ... | pikaoa attachments-backup ...", description: "管理 PikaOA YAML-first 测试运行面和生产附件离机备份控制面。" }, + { command: "pikaoa attachments-backup ...", description: "管理 PikaOA 生产附件离机备份控制面;开发与生产运行面由 PaC 自动交付。" }, { command: "secrets plan|sync|status", description: "Plan, push and inspect YAML-declared local secret source keys to Kubernetes Secrets without printing or reverse-engineering values." }, { command: "platform-db postgres plan|status|export-secrets|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." }, { command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Archived HWLAB DEV CD diagnostics only; current HWLAB rollout uses NC01 GitOps." }, @@ -1065,9 +1066,8 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary()); - if (top === "pikaoa" && sub === "test-target") return loadHelp(async () => (await import("./pikaoa-test-target")).pikaoaTestTargetHelp(), { command: "pikaoa test-target", source: "config/pikaoa.yaml#testRuntime" }); if (top === "pikaoa" && sub === "attachments-backup") return loadHelp(async () => (await import("./pikaoa-attachments-backup")).pikaoaAttachmentsBackupHelp(), { command: "pikaoa attachments-backup", source: "config/pikaoa.yaml#delivery.targets.*.attachmentsBackup" }); - if (top === "pikaoa") return loadHelp(async () => (await import("./pikaoa-test-target")).pikaoaHelp(), { command: "pikaoa", commands: ["test-target", "attachments-backup"] }); + if (top === "pikaoa" && (sub === undefined || isHelpToken(sub))) return loadHelp(async () => ({ ok: true, commands: [(await import("./pikaoa-attachments-backup")).pikaoaAttachmentsBackupHelp()] }), { command: "pikaoa", commands: ["attachments-backup"] }); if (top === "platform-db") return platformDbHelp(); if (top === "secrets") return secretsHelp(); if (top === "web-probe") return loadHelp(async () => (await import("./web-probe")).webProbeHelp(), webProbeHelpSummary()); diff --git a/scripts/src/pikaoa-test-delivery-renderer.test.ts b/scripts/src/pikaoa-delivery-renderer.test.ts similarity index 83% rename from scripts/src/pikaoa-test-delivery-renderer.test.ts rename to scripts/src/pikaoa-delivery-renderer.test.ts index c1a437ca..3eae33df 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.test.ts +++ b/scripts/src/pikaoa-delivery-renderer.test.ts @@ -3,13 +3,13 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { renderPikaoaTestDesiredPipeline } from "./pikaoa-test-delivery-renderer"; +import { renderPikaoaDesiredPipeline } from "./pikaoa-delivery-renderer"; function pacDocument(): Record { return Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record; } -function rendererBinding(id = "pikaoa-test-nc01"): Parameters[0] { +function rendererBinding(id = "pikaoa-dev-nc01"): Parameters[0] { const document = pacDocument(); const repositories = document.repositories as Array>; const consumers = document.consumers as Array>; @@ -25,31 +25,31 @@ function rendererBinding(id = "pikaoa-test-nc01"): Parameters | undefined) ?? {}), }, }, - } as Parameters[0]; + } as Parameters[0]; } function sourceFixture(): string { - const root = mkdtempSync(resolve(tmpdir(), "pikaoa-test-renderer-")); + const root = mkdtempSync(resolve(tmpdir(), "pikaoa-development-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", () => { +describe("PikaOA development PaC renderer", () => { test("shares one PaC repository while isolating master and release consumers", () => { const document = pacDocument(); const repositories = document.repositories as Array>; const consumers = document.consumers as Array>; - const repository = repositories.find((item) => item.id === "pikaoa-test-nc01"); - const testConsumer = consumers.find((item) => item.id === "pikaoa-test-nc01"); + const repository = repositories.find((item) => item.id === "pikaoa-dev-nc01"); + const developmentConsumer = consumers.find((item) => item.id === "pikaoa-dev-nc01"); const productionConsumer = consumers.find((item) => item.id === "pikaoa-nc01"); const duplicateProductionRepository = repositories.find((item) => item.id === "pikaoa-nc01"); expect((repository?.params as Record).source_branch).toBe("master"); - expect((testConsumer?.sourceArtifact as Record).pipelineRunPath).toBe(".tekton/pikaoa-test-nc01-pac.yaml"); - expect((testConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-test-runtime"); + expect((developmentConsumer?.sourceArtifact as Record).pipelineRunPath).toBe(".tekton/pikaoa-dev-nc01-pac.yaml"); + expect((developmentConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-development-runtime"); expect((productionConsumer?.params as Record).source_branch).toBe("release"); expect((productionConsumer?.params as Record).source_snapshot_prefix).toBe("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01"); - expect(productionConsumer?.repositoryRef).toBe("pikaoa-test-nc01"); + expect(productionConsumer?.repositoryRef).toBe("pikaoa-dev-nc01"); expect(duplicateProductionRepository).toBeUndefined(); expect((productionConsumer?.sourceArtifact as Record).renderer).toBe("pikaoa-release-runtime"); expect((productionConsumer?.sourceArtifact as Record).configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01"); @@ -58,13 +58,13 @@ describe("PikaOA test PaC renderer", () => { test("renders release runtime from its isolated owning selector", () => { const source = sourceFixture(); try { - const rendered = renderPikaoaTestDesiredPipeline(rendererBinding("pikaoa-nc01"), source); + const rendered = renderPikaoaDesiredPipeline(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"); + expect(serialized).not.toContain("pikaoa-dev-nc01-pac"); } finally { rmSync(source, { recursive: true, force: true }); } @@ -73,7 +73,7 @@ describe("PikaOA test PaC renderer", () => { test("renders parallel digest builds and a PreSync fresh database initializer", () => { const source = sourceFixture(); try { - const rendered = renderPikaoaTestDesiredPipeline(rendererBinding(), source); + const rendered = renderPikaoaDesiredPipeline(rendererBinding(), source); const spec = rendered.pipeline.spec as Record; const tasks = spec.tasks as Array>; for (const task of tasks) { @@ -97,6 +97,8 @@ describe("PikaOA test PaC renderer", () => { expect(buildScript).not.toContain("\u0001"); expect(buildScript).toContain("Docker-Content-Digest:"); expect(buildScript).toContain("build_status=$?"); + expect(buildScript).toContain('if [ -f "$metadata" ]'); + expect(buildScript).toContain("grep -Eq '^sha256:[0-9a-f]{64}$'"); expect(buildScript).toContain('"code":"buildctl-nonzero-artifact-verified"'); expect(buildScript).toContain('"artifactVerified":true'); } @@ -137,11 +139,17 @@ describe("PikaOA test PaC renderer", () => { 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("kind: ConfigMap"); + expect(manifest).toContain("name: pikaoa-runtime-config"); + expect(manifest).toContain("configMap:"); + expect(manifest).toContain("secretKeyRef:"); + expect(manifest.match(/name: pikaoa-dev-runtime/gu)).toHaveLength(7); + expect(manifest).not.toContain("secretName:"); expect(manifest.match(/name: DATABASE_URL/gu)).toHaveLength(3); expect(manifest.match(/key: DATABASE_URL/gu)).toHaveLength(3); expect(manifest.match(/name: OA_ADMIN_TOKEN/gu)).toHaveLength(1); expect(manifest.match(/key: OA_ADMIN_TOKEN/gu)).toHaveLength(1); + expect(script).toContain("grep -Eq '@sha256:[0-9a-f]{64}$'"); expect(manifest).toContain("image: __PIKAOA_API_IMAGE__"); expect(manifest).toContain("pikaoa.unidesk.io/source-commit: __PIKAOA_SOURCE_COMMIT__"); expect(script).toContain('s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g'); diff --git a/scripts/src/pikaoa-test-delivery-renderer.ts b/scripts/src/pikaoa-delivery-renderer.ts similarity index 87% rename from scripts/src/pikaoa-test-delivery-renderer.ts rename to scripts/src/pikaoa-delivery-renderer.ts index b3d760f7..98ee080b 100644 --- a/scripts/src/pikaoa-test-delivery-renderer.ts +++ b/scripts/src/pikaoa-delivery-renderer.ts @@ -1,17 +1,18 @@ +// SPEC: PJ2026-03 PikaOA v0.8. import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { - pikaoaTestRuntimeConfigRef, + pikaoaDevelopmentRuntimeConfigRef, pikaoaReleaseRuntimeConfigRef, resolvePikaoaReleaseRuntimeTargetByConfigRef, - resolvePikaoaTestRuntimeTargetByConfigRef, - type TestTargetSpec, -} from "./pikaoa-test-target"; -import { renderPikaoaTestRuntimeManifest, type PikaoaTestRenderContext } from "./pikaoa-test-runtime-manifest"; + resolvePikaoaDevelopmentRuntimeTargetByConfigRef, + type PikaoaRuntimeSpec, +} from "./pikaoa-runtime"; +import { renderPikaoaRuntimeManifest, type PikaoaRuntimeRenderContext } from "./pikaoa-runtime-manifest"; import { stableJsonSha256 } from "./stable-json"; -export interface PikaoaTestRendererBinding { +export interface PikaoaRendererBinding { readonly consumer: { readonly id: string; readonly node: string; @@ -31,20 +32,20 @@ export interface PikaoaTestRendererBinding { }; } -export interface PikaoaTestRenderedPipeline { +export interface PikaoaRenderedPipeline { readonly pipeline: Record; readonly configRef: string; readonly effectiveConfigSha256: string; readonly sourceWorktreeRemote: string; } -export function renderPikaoaTestDesiredPipeline(binding: PikaoaTestRendererBinding, sourceWorktree: string): PikaoaTestRenderedPipeline { +export function renderPikaoaDesiredPipeline(binding: PikaoaRendererBinding, sourceWorktree: string): PikaoaRenderedPipeline { 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") { + : resolvePikaoaDevelopmentRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef); + const configRef = release ? pikaoaReleaseRuntimeConfigRef(target.id) : pikaoaDevelopmentRuntimeConfigRef(target.id); + if ((!release && binding.consumer.sourceArtifact.renderer !== "pikaoa-development-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) { @@ -60,15 +61,15 @@ export function renderPikaoaTestDesiredPipeline(binding: PikaoaTestRendererBindi }; } -export function pikaoaTestSourceWorktreeRemote(targetId: string): string { - return resolvePikaoaTestRuntimeTargetByConfigRef(pikaoaTestRuntimeConfigRef(targetId)).source.worktreeRemote; +export function pikaoaDevelopmentSourceWorktreeRemote(targetId: string): string { + return resolvePikaoaDevelopmentRuntimeTargetByConfigRef(pikaoaDevelopmentRuntimeConfigRef(targetId)).source.worktreeRemote; } export function pikaoaReleaseSourceWorktreeRemote(targetId: string): string { return resolvePikaoaReleaseRuntimeTargetByConfigRef(pikaoaReleaseRuntimeConfigRef(targetId)).source.worktreeRemote; } -function pipeline(target: TestTargetSpec): Record { +function pipeline(target: PikaoaRuntimeSpec): Record { const runtimeManifest = renderGitOpsManifest(target); return { apiVersion: "tekton.dev/v1", @@ -96,7 +97,7 @@ function pipeline(target: TestTargetSpec): Record { }; } -function sourceTask(target: TestTargetSpec): Record { +function sourceTask(target: PikaoaRuntimeSpec): Record { return { name: "source", taskSpec: { @@ -157,7 +158,7 @@ printf '{"ok":true,"phase":"source","sourceCommit":"%s","valuesPrinted":false}\n }; } -function imageBuildTask(target: TestTargetSpec, component: "api" | "worker" | "web"): Record { +function imageBuildTask(target: PikaoaRuntimeSpec, component: "api" | "worker" | "web"): Record { const taskName = `build-${component}`; const imageRepository = target.build.images[component]; const dockerfile = target.build.dockerfiles[component]; @@ -190,7 +191,10 @@ buildctl-daemonless.sh build \\ --metadata-file "$metadata" build_status=$? set -e -metadata_compact=$(tr -d '\\n\\r' < "$metadata") +metadata_compact= +if [ -f "$metadata" ]; then + metadata_compact=$(tr -d '\\n\\r' < "$metadata") +fi digest=$(printf '%s' "$metadata_compact" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)".*/\\1/p' | head -n1) if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then registry_host=${shellSingleQuote(imageRepository.split("/")[0] ?? "")} @@ -198,7 +202,10 @@ if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then headers=$(wget --server-response --spider --header='Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' "http://$registry_host/v2/$registry_path/manifests/$revision" 2>&1 || true) digest=$(printf '%s\\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*Docker-Content-Digest:/ {gsub(/\\r/, "", $2); print $2; exit}') fi -case "$digest" in sha256:[0-9a-f][0-9a-f]*) test "\${#digest}" -eq 71 ;; *) echo 'build digest missing from metadata and registry manifest' >&2; exit 3;; esac +if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then + echo 'build digest missing from metadata and registry manifest' >&2 + exit 3 +fi if [ "$build_status" -ne 0 ]; then printf '{"level":"warning","code":"buildctl-nonzero-artifact-verified","component":"${component}","buildctlExitCode":%s,"artifactVerified":true,"valuesPrinted":false}\\n' "$build_status" >&2 fi @@ -214,7 +221,7 @@ printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","di }; } -function gitOpsTask(target: TestTargetSpec, runtimeManifest: string): Record { +function gitOpsTask(target: PikaoaRuntimeSpec, runtimeManifest: string): Record { const manifestB64 = Buffer.from(runtimeManifest, "utf8").toString("base64"); return { name: "gitops-publish", @@ -257,7 +264,10 @@ 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 + if ! printf '%s' "$image" | grep -Eq '@sha256:[0-9a-f]{64}$'; then + echo 'required digestRef missing' >&2 + exit 4 + fi done rm -rf /workspace/gitops mkdir -p /workspace/gitops @@ -320,25 +330,22 @@ printf '{"ok":true,"status":"published","phase":"gitops-publish","sourceCommitId }; } -function renderGitOpsManifest(target: TestTargetSpec): string { - const context: PikaoaTestRenderContext = { +function renderGitOpsManifest(target: PikaoaRuntimeSpec): string { + const context: PikaoaRuntimeRenderContext = { 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" }); + const manifest = renderPikaoaRuntimeManifest(target, context, { 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 { +function assertBinding(target: PikaoaRuntimeSpec, binding: PikaoaRendererBinding): void { const expected: Readonly> = { - id: target.source.branch === "release" ? "pikaoa-nc01" : "pikaoa-test-nc01", node: target.node, lane: target.ci.lane, namespace: target.ci.namespace, @@ -349,7 +356,6 @@ function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBindin serviceAccount: target.ci.serviceAccountName, }; const actual: Readonly> = { - id: binding.consumer.id, node: binding.consumer.node, lane: binding.consumer.lane, namespace: binding.consumer.namespace, @@ -364,7 +370,7 @@ function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBindin } } -function assertSourceRepositoryContract(target: TestTargetSpec, sourceWorktree: string): void { +function assertSourceRepositoryContract(target: PikaoaRuntimeSpec, 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}`); } diff --git a/scripts/src/pikaoa-test-runtime-manifest.ts b/scripts/src/pikaoa-runtime-manifest.ts similarity index 63% rename from scripts/src/pikaoa-test-runtime-manifest.ts rename to scripts/src/pikaoa-runtime-manifest.ts index bc4de215..8fc740c6 100644 --- a/scripts/src/pikaoa-test-runtime-manifest.ts +++ b/scripts/src/pikaoa-runtime-manifest.ts @@ -1,44 +1,28 @@ -import type { TestTargetSpec } from "./pikaoa-test-target"; +// SPEC: PJ2026-03 PikaOA v0.8. +import type { PikaoaRuntimeSpec } from "./pikaoa-runtime"; -export type PikaoaTestTargetStep = "all" | "foundation" | "init" | "api" | "worker" | "web"; - -export interface PikaoaTestRenderContext { +export interface PikaoaRuntimeRenderContext { 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; - adminToken: string; - administratorPassword: string; - employeePassword: string; -} - -export interface PikaoaTestManifestOptions { - secretMode: "materialized" | "existing"; - secrets?: PikaoaTestSecretMaterial; +export interface PikaoaRuntimeManifestOptions { initializerHook?: "argo-presync"; } const TARGET_LABEL = "pikaoa.unidesk.io/target"; const INSTANCE_LABEL = "pikaoa.unidesk.io/instance"; -export function renderPikaoaTestRuntimeManifest( - target: TestTargetSpec, - context: PikaoaTestRenderContext, - options: PikaoaTestManifestOptions, +export function renderPikaoaRuntimeManifest( + target: PikaoaRuntimeSpec, + context: PikaoaRuntimeRenderContext, + options: PikaoaRuntimeManifestOptions, ): Record[] { - 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); @@ -60,10 +44,10 @@ export function renderPikaoaTestRuntimeManifest( { 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: "ConfigMap", metadata: { name: target.runtime.configMapName, namespace: context.namespace, labels }, + data: { "pikaoa.yaml": runtimeConfigData(target, context) }, + }, { apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels }, spec: { @@ -103,7 +87,7 @@ export function renderPikaoaTestRuntimeManifest( ], volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], }], - volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], + volumes: [{ name: "runtime", configMap: { name: target.runtime.configMapName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }], }, }, }, @@ -128,26 +112,13 @@ export function renderPikaoaTestRuntimeManifest( ]; } -export function pikaoaManifestForStep(manifest: Record[], step: PikaoaTestTargetStep): Record[] { - 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 { +export function ownershipLabels(target: PikaoaRuntimeSpec, context: PikaoaRuntimeRenderContext): Record { const release = target.source.branch === "release"; return { "app.kubernetes.io/name": "pikaoa", - "app.kubernetes.io/part-of": release ? "pikaoa-release-runtime" : "pikaoa-test-runtime", + "app.kubernetes.io/part-of": release ? "pikaoa-release-runtime" : "pikaoa-development-runtime", "app.kubernetes.io/managed-by": target.fieldManager, - [release ? "pikaoa.unidesk.io/release-runtime" : "pikaoa.unidesk.io/test-runtime"]: "true", + [release ? "pikaoa.unidesk.io/release-runtime" : "pikaoa.unidesk.io/development-runtime"]: "true", [TARGET_LABEL]: target.id, [INSTANCE_LABEL]: context.instanceId, }; @@ -157,34 +128,17 @@ export function componentSelector(component: string, instanceId: string): Record return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId }; } -export function namespaceAnnotations(target: TestTargetSpec, context: PikaoaTestRenderContext): Record { +export function namespaceAnnotations(target: PikaoaRuntimeSpec, context: PikaoaRuntimeRenderContext): Record { 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: "", - adminToken: "", - administratorPassword: "", - employeePassword: "", + "pikaoa.unidesk.io/delivery-managed": "true", }; } function deployment( - target: TestTargetSpec, - context: PikaoaTestRenderContext, + target: PikaoaRuntimeSpec, + context: PikaoaRuntimeRenderContext, component: "api" | "worker" | "web", image: string, selector: Record, @@ -215,7 +169,7 @@ function deployment( 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 === "web" ? [] : [{ name: "runtime", configMap: { name: target.runtime.configMapName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }]), ...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []), ], }, @@ -224,40 +178,28 @@ function deployment( }; } -function runtimeSecretData(target: TestTargetSpec, context: PikaoaTestRenderContext, secrets: PikaoaTestSecretMaterial): Record { - const databaseURL = renderDatabaseURL(target, secrets.databaseURL); - return { - [target.database.connection.targetKey]: databaseURL, - [target.runtime.sessionSecret.targetKey]: secrets.sessionSecret, - [target.runtime.adminToken.targetKey]: secrets.adminToken, - [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: ${target.source.branch === "release" ? "pikaoa" : `pikaoa-test-${context.instanceId}`}`, - ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, - `shutdownTimeout: ${target.runtime.shutdownTimeout}`, - "", - ].join("\n"), - }; -} - -function optionalRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +function runtimeConfigData(target: PikaoaRuntimeSpec, context: PikaoaRuntimeRenderContext): string { + return [ + "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: ""', + "identity:", + ` sessionTTL: ${target.runtime.sessionTTL}`, + "attachment:", + ` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`, + ` maxBytes: ${target.runtime.attachment.maxBytes}`, + "observability:", + ` serviceName: ${target.source.branch === "release" ? "pikaoa" : `pikaoa-dev-${context.instanceId}`}`, + ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, + `shutdownTimeout: ${target.runtime.shutdownTimeout}`, + "", + ].join("\n"); } diff --git a/scripts/src/pikaoa-runtime.ts b/scripts/src/pikaoa-runtime.ts new file mode 100644 index 00000000..63aa9db4 --- /dev/null +++ b/scripts/src/pikaoa-runtime.ts @@ -0,0 +1,505 @@ +// SPEC: PJ2026-03 PikaOA v0.8. +import { resolveConfigRef } from "./ops/config-refs"; + +interface SecretSourceSpec { + purpose: string; + sourceRef: string; + sourceKey: string | null; + targetKey: string; +} + +interface PrometheusSelectorSpec { + sourceRef: string; + key: string; + value: string; + presence: true; + fingerprint: string; + valuesPrinted: false; +} + +export interface PikaoaRuntimeSpec { + id: string; + enabled: boolean; + node: string; + route: string; + namespace: string; + fieldManager: string; + source: { + mode: string; + repository: string; + commitPolicy: string; + branch: string; + worktreeRemote: string; + readUrl: string; + snapshotPrefix: 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; + username: string; + schema: string; + connection: SecretSourceSpec; + }; + runtime: { + replicas: { api: number; worker: number; web: number }; + resources: Record<"api" | "worker" | "web" | "initializer", Record>; + secretName: string; + configMapName: string; + apiServiceName: string; + apiPort: number; + workerMetricsPort: number; + webPort: number; + webApiUpstreamEnv: string; + attachment: { + storageRoot: string; + maxBytes: number; + claimName: string; + storageRequest: string; + storageClassName: string | null; + fsGroup: number; + accessModes: string[]; + }; + workerInterval: string; + outboxBatchSize: number; + outboxMaxAttempts: number; + outboxRetryDelay: string; + sessionTTL: string; + shutdownTimeout: string; + administrator: { username: string; password: SecretSourceSpec }; + employee: { username: string; password: SecretSourceSpec }; + sessionSecret: SecretSourceSpec; + adminToken: SecretSourceSpec; + }; + initializer: { + mode: "fresh-database-only"; + jobName: string; + backoffLimit: number; + command: string[]; + }; + exposure: { + serviceName: string; + serviceType: "NodePort"; + hostIP: string; + port: number; + publicBaseUrl: string; + expectedA: string; + }; + observability: { + otlpEndpoint: string; + prometheusScrape: boolean; + prometheusSelector: PrometheusSelectorSpec; + apiMetricsPath: string; + workerMetricsPath: string; + exporterWarning: boolean; + exporterBlocking: false; + }; + probes: { + apiLivenessPath: string; + apiReadinessPath: string; + workerLivenessPath: string; + workerReadinessPath: string; + webLivenessPath: string; + webReadinessPath: string; + }; +} + +export function pikaoaDevelopmentRuntimeConfigRef(targetId: string): string { + simpleId(targetId, "PikaOA development runtime target id"); + return `config/pikaoa.yaml#developmentRuntime.targets.${targetId}`; +} + +export function resolvePikaoaDevelopmentRuntimeTargetByConfigRef(configRef: string): PikaoaRuntimeSpec { + return resolvePikaoaRuntimeTarget(configRef, "developmentRuntime"); +} + +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): PikaoaRuntimeSpec { + return resolvePikaoaRuntimeTarget(configRef, "releaseRuntime"); +} + +function resolvePikaoaRuntimeTarget(configRef: string, runtimeSection: "developmentRuntime" | "releaseRuntime"): PikaoaRuntimeSpec { + const resolved = resolveConfigRef(configRef, `PikaOA ${runtimeSection} configRef`); + const match = new RegExp(`^${runtimeSection}\\.targets\\.([A-Za-z0-9._-]+)$`, "u").exec(resolved.fragment); + if (resolved.file !== "config/pikaoa.yaml" || match === null) { + throw new Error(`PikaOA ${runtimeSection} configRef must select config/pikaoa.yaml#${runtimeSection}.targets.`); + } + return parseRuntime(match[1]!, record(resolved.value, configRef), resolved.file, runtimeSection); +} + +function parseRuntime(id: string, root: Record, configLabel: string, runtimeSection: string): PikaoaRuntimeSpec { + const path = `${configLabel}.${runtimeSection}.targets.${id}`; + simpleId(id, "target id"); + const source = record(root.source, `${path}.source`); + 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 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`); + const employee = record(runtime.employee, `${path}.runtime.employee`); + const initializer = record(root.initializer, `${path}.initializer`); + const exposure = record(root.exposure, `${path}.exposure`); + const observability = record(root.observability, `${path}.observability`); + const prometheus = record(observability.prometheus, `${path}.observability.prometheus`); + const exporterFailure = record(observability.exporterFailure, `${path}.observability.exporterFailure`); + const probes = record(root.probes, `${path}.probes`); + const apiProbes = record(probes.api, `${path}.probes.api`); + const workerProbes = record(probes.worker, `${path}.probes.worker`); + const webProbes = record(probes.web, `${path}.probes.web`); + if (exporterFailure.blocking !== false || exporterFailure.warning !== true) { + throw new Error(`${path}.observability.exporterFailure 必须为 warning=true、blocking=false`); + } + const target: PikaoaRuntimeSpec = { + id, + enabled: bool(root.enabled, `${path}.enabled`), + node: text(root.node, `${path}.node`), + route: text(root.route, `${path}.route`), + namespace: kubernetesName(root.namespace, `${path}.namespace`), + fieldManager: kubernetesName(root.fieldManager, `${path}.fieldManager`), + source: { + mode: exact(source.mode, `${path}.source.mode`, "pac-source-build"), + repository: text(source.repository, `${path}.source.repository`), + commitPolicy: exact(source.commitPolicy, `${path}.source.commitPolicy`, "pac-revision-required"), + branch: text(source.branch, `${path}.source.branch`), + worktreeRemote: text(source.worktreeRemote, `${path}.source.worktreeRemote`), + readUrl: text(source.readUrl, `${path}.source.readUrl`), + snapshotPrefix: text(source.snapshotPrefix, `${path}.source.snapshotPrefix`), + pullPolicy: oneOf(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]), + }, + ci: { + lane: simpleId(text(ci.lane, `${path}.ci.lane`), `${path}.ci.lane`), + namespace: kubernetesName(ci.namespace, `${path}.ci.namespace`), + pipeline: kubernetesName(ci.pipeline, `${path}.ci.pipeline`), + pipelineRunPrefix: kubernetesName(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`), + serviceAccountName: kubernetesName(ci.serviceAccountName, `${path}.ci.serviceAccountName`), + workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`), + pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`), + toolsImage: imageReference(ci.toolsImage, `${path}.ci.toolsImage`), + buildkitImage: imageReference(ci.buildkitImage, `${path}.ci.buildkitImage`), + }, + build: { + dockerfiles: { + api: relativePath(dockerfiles.api, `${path}.build.dockerfiles.api`), + worker: relativePath(dockerfiles.worker, `${path}.build.dockerfiles.worker`), + web: relativePath(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: exact(build.networkMode, `${path}.build.networkMode`, "host"), + }, + gitops: { + readUrl: text(gitops.readUrl, `${path}.gitops.readUrl`), + writeUrl: text(gitops.writeUrl, `${path}.gitops.writeUrl`), + branch: text(gitops.branch, `${path}.gitops.branch`), + manifestPath: relativePath(gitops.manifestPath, `${path}.gitops.manifestPath`), + credentialSecretName: kubernetesName(gitopsCredential.secretName, `${path}.gitops.credential.secretName`), + credentialTokenKey: text(gitopsCredential.tokenKey, `${path}.gitops.credential.tokenKey`), + credentialUsername: text(gitopsCredential.username, `${path}.gitops.credential.username`), + authorName: text(gitopsAuthor.name, `${path}.gitops.author.name`), + authorEmail: text(gitopsAuthor.email, `${path}.gitops.author.email`), + }, + database: { + configRef: text(database.configRef, `${path}.database.configRef`), + name: postgresIdentifier(database.name, `${path}.database.name`), + username: postgresIdentifier(database.username, `${path}.database.username`), + schema: postgresIdentifier(database.schema, `${path}.database.schema`), + connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true), + }, + runtime: { + replicas: { + api: positiveInteger(replicas.api, `${path}.runtime.replicas.api`, Number.MAX_SAFE_INTEGER), + worker: positiveInteger(replicas.worker, `${path}.runtime.replicas.worker`, Number.MAX_SAFE_INTEGER), + web: positiveInteger(replicas.web, `${path}.runtime.replicas.web`, Number.MAX_SAFE_INTEGER), + }, + 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(runtime.secretName, `${path}.runtime.secretName`), + configMapName: kubernetesName(runtime.configMapName, `${path}.runtime.configMapName`), + apiServiceName: kubernetesName(runtime.apiServiceName, `${path}.runtime.apiServiceName`), + apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535), + workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535), + webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535), + webApiUpstreamEnv: envName(runtime.webApiUpstreamEnv, `${path}.runtime.webApiUpstreamEnv`), + attachment: { + storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`), + maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER), + claimName: kubernetesName(attachment.claimName, `${path}.runtime.attachment.claimName`), + storageRequest: text(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`), + storageClassName: nullableText(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`), + fsGroup: positiveInteger(attachment.fsGroup, `${path}.runtime.attachment.fsGroup`, 2_147_483_647), + accessModes: stringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]), + }, + workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`), + outboxBatchSize: positiveInteger(outbox.batchSize, `${path}.runtime.outbox.batchSize`, Number.MAX_SAFE_INTEGER), + outboxMaxAttempts: positiveInteger(outbox.maxAttempts, `${path}.runtime.outbox.maxAttempts`, Number.MAX_SAFE_INTEGER), + outboxRetryDelay: duration(outbox.retryDelay, `${path}.runtime.outbox.retryDelay`), + sessionTTL: duration(runtime.sessionTTL, `${path}.runtime.sessionTTL`), + shutdownTimeout: duration(runtime.shutdownTimeout, `${path}.runtime.shutdownTimeout`), + administrator: { + username: text(administrator.username, `${path}.runtime.administrator.username`), + password: secretSource(administrator.password, "runtime.administrator.password", `${path}.runtime.administrator.password`), + }, + employee: { + username: text(employee.username, `${path}.runtime.employee.username`), + password: secretSource(employee.password, "runtime.employee.password", `${path}.runtime.employee.password`), + }, + sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`), + adminToken: secretSource(runtime.adminToken, "runtime.adminToken", `${path}.runtime.adminToken`, true), + }, + initializer: { + mode: exact(initializer.mode, `${path}.initializer.mode`, "fresh-database-only") as "fresh-database-only", + jobName: kubernetesName(initializer.jobName, `${path}.initializer.jobName`), + backoffLimit: nonNegativeInteger(initializer.backoffLimit, `${path}.initializer.backoffLimit`), + command: nonEmptyStringList(initializer.command, `${path}.initializer.command`), + }, + exposure: { + serviceName: kubernetesName(exposure.serviceName, `${path}.exposure.serviceName`), + serviceType: exact(exposure.serviceType, `${path}.exposure.serviceType`, "NodePort") as "NodePort", + hostIP: ipv4Address(exposure.hostIP, `${path}.exposure.hostIP`), + port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535), + publicBaseUrl: httpsUrl(exposure.publicBaseUrl, `${path}.exposure.publicBaseUrl`), + expectedA: ipv4Address(exposure.expectedA, `${path}.exposure.expectedA`), + }, + observability: { + otlpEndpoint: otlpGrpcEndpoint(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`), + prometheusScrape: bool(prometheus.scrape, `${path}.observability.prometheus.scrape`), + prometheusSelector: prometheusSelector(prometheus.configRef, `${path}.observability.prometheus`), + apiMetricsPath: httpPath(prometheus.apiPath, `${path}.observability.prometheus.apiPath`), + workerMetricsPath: httpPath(prometheus.workerPath, `${path}.observability.prometheus.workerPath`), + exporterWarning: true, + exporterBlocking: false, + }, + probes: { + apiLivenessPath: httpPath(apiProbes.livenessPath, `${path}.probes.api.livenessPath`), + apiReadinessPath: httpPath(apiProbes.readinessPath, `${path}.probes.api.readinessPath`), + workerLivenessPath: httpPath(workerProbes.livenessPath, `${path}.probes.worker.livenessPath`), + workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`), + webLivenessPath: httpPath(webProbes.livenessPath, `${path}.probes.web.livenessPath`), + webReadinessPath: httpPath(webProbes.readinessPath, `${path}.probes.web.readinessPath`), + }, + }; + const secretKeys = [target.database.connection, target.runtime.sessionSecret, target.runtime.adminToken, target.runtime.administrator.password, target.runtime.employee.password] + .map((item) => item.targetKey); + if (new Set(secretKeys).size !== secretKeys.length || secretKeys.includes("pikaoa.yaml")) { + throw new Error(`${path} 的 Secret targetKey 必须唯一且不能使用 pikaoa.yaml`); + } + return target; +} + +function secretSource(value: unknown, purpose: string, path: string, requireSourceKey = false): SecretSourceSpec { + const source = record(value, path); + const targetKey = envName(source.targetKey, `${path}.targetKey`); + const sourceKey = nullableText(source.sourceKey, `${path}.sourceKey`); + if (requireSourceKey && sourceKey === null) throw new Error(`${path}.sourceKey 必须显式声明`); + if (sourceKey !== null) envName(sourceKey, `${path}.sourceKey`); + return { purpose, sourceRef: text(source.sourceRef, `${path}.sourceRef`), sourceKey, targetKey }; +} + +function prometheusSelector(value: unknown, path: string): PrometheusSelectorSpec { + const sourceRef = text(value, `${path}.configRef`); + const resolved = resolveConfigRef(sourceRef, `${path}.configRef`); + const selector = record(resolved.value, sourceRef); + return { + sourceRef, + key: kubernetesLabelKey(selector.key, `${path}.configRef.key`), + value: kubernetesLabelValue(selector.value, `${path}.configRef.value`), + presence: true, + fingerprint: `sha256:${resolved.sha256}`, + valuesPrinted: false, + }; +} + +function record(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是 YAML object`); + return value as Record; +} + +function text(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`); + return value.trim(); +} + +function nullableText(value: unknown, path: string): string | null { + return value === undefined || value === null ? null : text(value, path); +} + +function bool(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${path} 必须是 boolean`); + return value; +} + +function positiveInteger(value: unknown, path: string, max: number): number { + if (!Number.isInteger(value) || Number(value) <= 0 || Number(value) > max) throw new Error(`${path} 必须是 1-${max} 的整数`); + return Number(value); +} + +function nonNegativeInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${path} 必须是非负安全整数`); + return Number(value); +} + +function simpleId(value: string, path: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path} 必须是简单 ID`); + return value; +} + +function kubernetesName(value: unknown, path: string): string { + const result = text(value, path); + if (result.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(result)) throw new Error(`${path} 必须是 Kubernetes DNS label`); + return result; +} + +function postgresIdentifier(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(result)) throw new Error(`${path} 必须是 PostgreSQL identifier`); + return result; +} + +function imageReference(value: unknown, path: string): string { + const result = text(value, path); + if (/\s/u.test(result) || !result.includes(":")) throw new Error(`${path} 必须是带 tag/template 的镜像引用`); + return result; +} + +function imageRepository(value: unknown, path: string): string { + const result = text(value, path); + if (/\s/u.test(result) || result.includes("@") || /:[^/]+$/u.test(result)) throw new Error(`${path} 必须是不带 tag/digest 的镜像仓库`); + return result; +} + +function relativePath(value: unknown, path: string): string { + const result = text(value, path); + if (result.startsWith("/") || result.split(/[\\/]/u).includes("..")) throw new Error(`${path} 必须是仓库相对路径`); + return result; +} + +function absolutePath(value: unknown, path: string): string { + const result = text(value, path); + if (!result.startsWith("/") || result.includes("\0")) throw new Error(`${path} 必须是绝对路径`); + return result; +} + +function envName(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${path} 必须是合法环境变量名`); + return result; +} + +function duration(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[1-9][0-9]*(?:ms|s|m|h)$/u.test(result)) throw new Error(`${path} 必须是正 duration`); + return result; +} + +function httpPath(value: unknown, path: string): string { + const result = text(value, path); + if (!result.startsWith("/") || /[\s?#]/u.test(result)) throw new Error(`${path} 必须是绝对 HTTP path`); + return result; +} + +function httpsUrl(value: unknown, path: string): string { + const result = text(value, path); + const parsed = new URL(result); + if (parsed.protocol !== "https:") throw new Error(`${path} 必须是 HTTPS URL`); + return result; +} + +function ipv4Address(value: unknown, path: string): string { + const result = text(value, path); + const octets = result.split("."); + if (octets.length !== 4 || octets.some((item) => !/^(?:0|[1-9][0-9]{0,2})$/u.test(item) || Number(item) > 255)) throw new Error(`${path} 必须是 IPv4 地址`); + return result; +} + +function otlpGrpcEndpoint(value: unknown, path: string): string { + const result = text(value, path); + if (result.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(result)) throw new Error(`${path} 必须是 OTLP gRPC host:port`); + const port = Number(result.slice(result.lastIndexOf(":") + 1)); + if (port > 65_535) throw new Error(`${path} 端口必须是 1-65535`); + return result; +} + +function kubernetesLabelKey(value: unknown, path: string): string { + const result = text(value, path); + const parts = result.split("/"); + const name = parts.pop() ?? ""; + const prefix = parts.length === 1 ? parts[0] : null; + const namePattern = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u; + const prefixPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/u; + if (parts.length > 1 || !namePattern.test(name) || (prefix !== null && !prefixPattern.test(prefix))) throw new Error(`${path} 必须是 Kubernetes label key`); + return result; +} + +function kubernetesLabelValue(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(result)) throw new Error(`${path} 必须是 Kubernetes label value`); + return result; +} + +function nonEmptyStringList(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`); + return value.map((item, index) => text(item, `${path}[${index}]`)); +} + +function stringList(value: unknown, path: string, allowed: readonly T[]): T[] { + const items = nonEmptyStringList(value, path).map((item) => oneOf(item, path, allowed)); + if (new Set(items).size !== items.length) throw new Error(`${path} 不能包含重复值`); + return items; +} + +function oneOf(value: unknown, path: string, allowed: readonly T[]): T { + if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} 必须是 ${allowed.join("、")}`); + return value as T; +} + +function exact(value: unknown, path: string, expected: string): string { + if (value !== expected) throw new Error(`${path} 必须是 ${expected}`); + return expected; +} diff --git a/scripts/src/pikaoa-test-target-async.test.ts b/scripts/src/pikaoa-test-target-async.test.ts deleted file mode 100644 index c2e5390e..00000000 --- a/scripts/src/pikaoa-test-target-async.test.ts +++ /dev/null @@ -1,487 +0,0 @@ -import assert from "node:assert/strict"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "bun:test"; - -import type { UniDeskConfig } from "./config"; -import { rootPath } from "./config"; -import { runPikaoaCommand } from "./pikaoa-test-target"; -import type { SshCaptureResult } from "./ssh"; - -test("PikaOA test target submits once and exposes bounded typed status", async () => { - const root = mkdtempSync(join(tmpdir(), "pikaoa-test-target-")); - const stateRoot = join(root, "state"); - const fakeRoot = join(root, "fake-k8s"); - const binDir = join(root, "bin"); - const secretsDir = join(root, "secrets"); - mkdirSync(binDir, { recursive: true }); - mkdirSync(secretsDir, { recursive: true }); - mkdirSync(join(fakeRoot, "namespaces"), { recursive: true }); - - const secretValues = ["fixture-db-password", "fixture-admin-password", "fixture-employee-password", "fixture-session-secret", "fixture-oa-admin-token"]; - for (const [name, value] of [ - ["pikaoa-test.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require\nPIKAOA_SESSION_SECRET=${secretValues[3]}\nOA_ADMIN_TOKEN=${secretValues[4]}`], - ["pikaoa-test-admin-password.txt", secretValues[1]], - ["pikaoa-test-employee-password.txt", secretValues[2]], - ]) writeFileSync(join(secretsDir, name), `${value}\n`, { mode: 0o600 }); - - const fakeKubectl = join(binDir, "kubectl"); - writeFileSync(fakeKubectl, `#!/bin/sh -set -u -root="$FAKE_K8S_ROOT" -args="$*" -namespace="" -previous="" -for argument in "$@"; do - if [ "$previous" = "-n" ]; then namespace="$argument"; fi - previous="$argument" -done -namespace_after_resource() { - previous="" - for argument in "$@"; do - if [ "$previous" = "namespace" ]; then printf '%s' "$argument"; return; fi - previous="$argument" - done -} -if printf '%s' "$args" | grep -Eq '(^| )apply( |$)'; then - file="" - previous="" - for argument in "$@"; do - if [ "$previous" = "-f" ]; then file="$argument"; fi - previous="$argument" - done - if grep -q '"kind":"Namespace"' "$file"; then - applied_identity="$(python3 - "$file" <<'PY' -import json, pathlib, sys -for line in pathlib.Path(sys.argv[1]).read_text().splitlines(): - if not line or line == "---": - continue - item = json.loads(line) - if item.get("kind") == "Namespace": - print(item["metadata"]["name"] + "|" + item["metadata"]["labels"]["pikaoa.unidesk.io/instance"]) - break -PY -)" - applied_namespace="\${applied_identity%%|*}" - applied_instance="\${applied_identity##*|}" - printf '%s' "$applied_instance" >"$root/namespaces/$applied_namespace" - fi - [ ! -f "$root/slow" ] || sleep 0.2 - exit 0 -fi -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-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-initializer" ]; then - printf '%s\n' '{"status":{"conditions":[{"type":"Failed","status":"True","reason":"BackoffLimitExceeded"}]}}' - else - printf '%s\n' '{"status":{"conditions":[{"type":"Complete","status":"True"}]}}' - fi - exit 0 -fi -if printf '%s' "$args" | grep -Eq '(^| )get namespace '; then - name="$(namespace_after_resource "$@")" - if [ -f "$root/fail-runtime-query" ]; then - printf '%s\n' 'unable to connect to the server' >&2 - exit 1 - fi - if [ ! -f "$root/namespaces/$name" ]; then - printf 'Error from server (NotFound): namespaces "%s" not found\n' "$name" >&2 - exit 1 - fi - instance="$(cat "$root/namespaces/$name")" - if printf '%s' "$args" | grep -q 'managed-by'; then printf '%s' 'unidesk-pikaoa-test-target'; exit 0; fi - if printf '%s' "$args" | grep -q 'unidesk.*target'; then printf '%s' 'TEST01'; exit 0; fi - if printf '%s' "$args" | grep -q 'unidesk.*instance'; then printf '%s' "$instance"; exit 0; fi - printf '{"metadata":{"name":"%s","labels":{"app.kubernetes.io/managed-by":"unidesk-pikaoa-test-target","pikaoa.unidesk.io/target":"TEST01","pikaoa.unidesk.io/instance":"%s"}}}\n' "$name" "$instance" - exit 0 -fi -if printf '%s' "$args" | grep -q ' get deployment,job,service,persistentvolumeclaim,pod '; then - if [ -f "$root/fail-runtime-resource-query" ]; then - printf '%s\n' 'resource query failed' >&2 - exit 1 - fi - printf '%s\n' '{"items":[]}' - exit 0 -fi -if printf '%s' "$args" | grep -Eq '(^| )delete namespace '; then - name="$(namespace_after_resource "$@")" - rm -f "$root/namespaces/$name" - exit 0 -fi -printf 'unsupported fake kubectl call: %s\n' "$args" >&2 -exit 64 -`, { mode: 0o700 }); - chmodSync(fakeKubectl, 0o700); - - const fixturePath = join(root, "pikaoa.yaml"); - const fixture = readFileSync(rootPath("config", "fixtures", "pikaoa-test-target.yaml"), "utf8") - .replace("validationOnly: true", "validationOnly: false") - .replace("statusRequestTimeoutSeconds: 3", "statusRequestTimeoutSeconds: 31") - .replace("logTailLines: 8", "logTailLines: 101") - .replace("/tmp/unidesk-pikaoa-test-target-fixture", stateRoot); - writeFileSync(fixturePath, fixture); - - const config = {} as UniDeskConfig; - let lastCapture: SshCaptureResult | null = null; - const localCapture = async (_config: UniDeskConfig, _route: string, args: string[], stdin: string): Promise => { - assert.deepEqual(args, ["sh"]); - const child = Bun.spawn(["sh"], { - env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}`, FAKE_K8S_ROOT: fakeRoot }, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); - child.stdin.write(stdin); - child.stdin.end(); - const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); - lastCapture = { stdout, stderr, exitCode }; - return lastCapture; - }; - const args = (action: string, instance: string, withCommit = false, step = "all"): string[] => [ - "test-target", action, "--config", fixturePath, "--target", "TEST01", "--instance", instance, - ...(withCommit ? ["--commit", "0123456789abcdef"] : []), - "--step", step, - ...((action === "start" || action === "stop") ? ["--confirm"] : []), - "--output", "json", - ]; - const projection = async (action: string, instance: string, withCommit = false, step = "all"): Promise> => { - const result = await runPikaoaCommand(config, args(action, instance, withCommit, step), localCapture); - return JSON.parse(result.renderedText) as Record; - }; - const projectionWithCommit = async (action: string, instance: string, commit: string, step = "all"): Promise> => { - const commandArgs = args(action, instance, true, step); - commandArgs[commandArgs.indexOf("--commit") + 1] = commit; - const result = await runPikaoaCommand(config, commandArgs, localCapture); - return JSON.parse(result.renderedText) as Record; - }; - const waitFor = async (instance: string, expectedCode: string, step = "all"): Promise> => { - let lastStatus: Record | null = null; - for (let attempt = 0; attempt < 80; attempt += 1) { - const value = await projection("status", instance, false, step); - const status = value.status as Record; - if (status.code === expectedCode) return value; - lastStatus = status; - await Bun.sleep(25); - } - assert.fail(`status did not reach ${expectedCode}: ${JSON.stringify(lastStatus)}`); - }; - - try { - const planned = await projection("plan", "runtime", true); - const plan = planned.plan as Record; - const secret = plan.secret as Record; - const sources = secret.sources as Array>; - assert.equal(sources.find((source) => source.purpose === "database.connection")?.presence, true); - assert.equal(sources.find((source) => source.purpose === "runtime.adminToken")?.targetKey, "OA_ADMIN_TOKEN"); - assert.equal(sources.find((source) => source.purpose === "runtime.adminToken")?.valuesPrinted, false); - const objects = plan.objects as Array>; - const observability = plan.observability as Record; - const prometheus = observability.prometheus as Record; - const selector = prometheus.selector as Record; - assert.equal(objects.some((object) => object.kind === "StatefulSet" || object.name === "pikaoa-postgres"), false); - assert.equal(objects.some((object) => object.kind === "Deployment" && object.name === "pikaoa-web"), true); - assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === "pikaoa-api")?.podFsGroup, 65532); - assert.equal(observability.otlpEndpoint, "otel-collector.observability.svc.cluster.local:4317"); - assert.equal(selector.sourceRef, "config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector"); - assert.equal(selector.presence, true); - assert.match(String(selector.fingerprint), /^sha256:[0-9a-f]{64}$/u); - assert.equal(selector.valuesPrinted, false); - for (const component of ["pikaoa-api", "pikaoa-worker"]) { - assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === component)?.prometheusSelectorLabel, selector.value); - } - assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === "pikaoa-web")?.prometheusSelectorLabel, undefined); - assert.deepEqual(plan.exposure, { serviceType: "NodePort", serviceName: "pikaoa-web", hostIP: "192.0.2.10", port: 32080 }); - assert.deepEqual(plan.webApiUpstream, { envName: "PIKAOA_API_UPSTREAM", value: "http://pikaoa-api:8080", scope: "same-namespace" }); - - const missing = await projection("status", "runtime", false, "api"); - assert.equal((missing.status as Record).code, "start-not-found"); - - writeFileSync(join(fakeRoot, "slow"), "1\n"); - const submitted = await projection("start", "runtime", true); - assert.equal(submitted.code, "start-submitted", JSON.stringify(lastCapture)); - assert.equal(submitted.mutation, true); - assert.deepEqual((submitted.target as Record).taskState, { - rootPath: stateRoot, - statusRequestTimeoutSeconds: 31, - logTailLines: 101, - }); - const running = await projection("status", "runtime"); - assert.equal((running.status as Record).code, "start-running"); - const concurrentCommit = "fedcba9876543210"; - const concurrent = await projectionWithCommit("start", "runtime", concurrentCommit); - assert.equal(concurrent.code, "start-instance-conflict"); - assert.equal(concurrent.mutation, false); - assert.equal(concurrent.previousTaskReset, false); - rmSync(join(fakeRoot, "slow")); - const succeeded = await waitFor("runtime", "start-succeeded"); - const succeededStatus = succeeded.status as Record; - assert.equal((succeededStatus.task as Record).exitCode, 0); - assert.ok((succeededStatus.logTail as unknown[]).length <= 101); - - writeFileSync(join(fakeRoot, "fail-runtime-query"), "1\n"); - const runtimeQueryFailed = await projection("status", "runtime"); - const runtimeQueryFailedStatus = runtimeQueryFailed.status as Record; - assert.equal(runtimeQueryFailed.ok, false); - assert.equal(runtimeQueryFailedStatus.code, "runtime-query-failed"); - assert.equal(runtimeQueryFailedStatus.taskCode, "start-succeeded"); - assert.equal((runtimeQueryFailedStatus.runtime as Record).code, "runtime-query-failed"); - const runtimeQueryFailedText = await runPikaoaCommand(config, args("status", "runtime").slice(0, -2), localCapture); - assert.match(runtimeQueryFailedText.renderedText, /status=runtime-query-failed/); - rmSync(join(fakeRoot, "fail-runtime-query")); - - writeFileSync(join(fakeRoot, "fail-runtime-resource-query"), "1\n"); - const runtimeResourceQueryFailed = await projection("status", "runtime"); - const runtimeResourceQueryFailedStatus = runtimeResourceQueryFailed.status as Record; - assert.equal(runtimeResourceQueryFailed.ok, false); - assert.equal(runtimeResourceQueryFailedStatus.code, "runtime-resource-query-failed"); - assert.equal(runtimeResourceQueryFailedStatus.taskCode, "start-succeeded"); - assert.equal((runtimeResourceQueryFailedStatus.runtime as Record).code, "runtime-resource-query-failed"); - rmSync(join(fakeRoot, "fail-runtime-resource-query")); - - const repeated = await projection("start", "runtime", true); - assert.equal(repeated.code, "start-already-succeeded"); - assert.equal(repeated.mutation, false); - const rerunSubmitted = await projectionWithCommit("start", "runtime", concurrentCommit); - assert.equal(rerunSubmitted.code, "start-submitted"); - assert.equal(rerunSubmitted.previousTaskReset, true); - const rerunSucceeded = await waitFor("runtime", "start-succeeded"); - assert.equal(((rerunSucceeded.status as Record).task as Record).sourceCommit, concurrentCommit); - writeFileSync(fixturePath, readFileSync(fixturePath, "utf8").replace(" livenessPath: /healthz\n", " livenessPath: /healthz-v2\n")); - const manifestRerunSubmitted = await projectionWithCommit("start", "runtime", concurrentCommit); - assert.equal(manifestRerunSubmitted.code, "start-submitted"); - assert.equal(manifestRerunSubmitted.previousTaskReset, true); - const manifestRerunSucceeded = await waitFor("runtime", "start-succeeded"); - await Bun.sleep(5); - const sameRequestAfterRenderTimeChanged = await projectionWithCommit("start", "runtime", concurrentCommit); - assert.equal(sameRequestAfterRenderTimeChanged.code, "start-already-succeeded"); - assert.equal(sameRequestAfterRenderTimeChanged.previousTaskReset, false); - - writeFileSync(join(fakeRoot, "slow"), "1\n"); - const workerMissingSubmitted = await projection("start", "runtime", true, "api"); - const workerPid = Number((workerMissingSubmitted.remote as Record).pid); - assert.equal(Number.isSafeInteger(workerPid) && workerPid > 0, true); - process.kill(workerPid, "SIGKILL"); - await Bun.sleep(25); - const workerMissingReset = await projectionWithCommit("start", "runtime", concurrentCommit, "api"); - assert.equal(workerMissingReset.code, "start-submitted"); - assert.equal(workerMissingReset.previousTaskReset, true); - rmSync(join(fakeRoot, "slow")); - const workerMissingSucceeded = await waitFor("runtime", "start-succeeded", "api"); - - 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).task as Record); - assert.equal(failedTask.code, "initializer-job-failed"); - assert.equal(failedTask.exitCode, 46); - rmSync(join(fakeRoot, "fail-initializer")); - - 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).task as Record); - assert.equal(failureTargetTask.code, "initializer-job-failure-target"); - assert.equal(failureTargetTask.exitCode, 46); - rmSync(join(fakeRoot, "failure-target-initializer")); - - 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).task as Record); - assert.equal(initializerQueryFailedTask.code, "initializer-job-query-failed"); - assert.equal(initializerQueryFailedTask.exitCode, 46); - rmSync(join(fakeRoot, "fail-initializer-query")); - - await projectionWithCommit("start", "runtime", "1234567890abcdef", "init"); - const initializerSucceeded = await waitFor("runtime", "start-succeeded", "init"); - assert.equal((((initializerSucceeded.status as Record).task as Record).exitCode), 0); - - writeFileSync(join(fakeRoot, "slow"), "1\n"); - await projection("start", "runtime", true, "web"); - const stopped = await projection("stop", "runtime", false, "web"); - assert.equal(stopped.startTaskDisposition, "cancel-requested"); - assert.equal(stopped.mutation, true); - await waitFor("runtime", "start-canceled", "web"); - - for (const name of [ - "pikaoa-test.env", - "pikaoa-test-admin-password.txt", - "pikaoa-test-employee-password.txt", - ]) rmSync(join(secretsDir, name)); - const foundationPlan = await projection("plan", "foundation", false, "foundation"); - assert.equal(foundationPlan.readyForMutation, true); - assert.deepEqual(foundationPlan.missingFields, []); - assert.deepEqual((foundationPlan.blockers as unknown[]), []); - const foundationObjects = ((foundationPlan.plan as Record).objects as Array>); - assert.deepEqual(foundationObjects.map((object) => object.kind), ["Namespace", "PersistentVolumeClaim"]); - assert.equal(((foundationPlan.plan as Record).secret as Record).requiredForStep, false); - const foundationSubmitted = await projection("start", "foundation", false, "foundation"); - assert.equal(foundationSubmitted.code, "start-submitted", JSON.stringify(lastCapture)); - const foundationSucceeded = await waitFor("foundation", "start-succeeded", "foundation"); - const foundationRepeated = await projection("start", "foundation", false, "foundation"); - assert.equal(foundationRepeated.code, "start-already-succeeded"); - const foundationRerun = await projectionWithCommit("start", "foundation", concurrentCommit, "foundation"); - assert.equal(foundationRerun.code, "start-submitted"); - assert.equal(foundationRerun.previousTaskReset, true); - const foundationRerunSucceeded = await waitFor("foundation", "start-succeeded", "foundation"); - - const serialized = JSON.stringify({ - submitted, - succeeded, - concurrent, - rerunSubmitted, - rerunSucceeded, - manifestRerunSubmitted, - manifestRerunSucceeded, - sameRequestAfterRenderTimeChanged, - workerMissingSubmitted, - workerMissingReset, - workerMissingSucceeded, - failed, - failureTarget, - initializerQueryFailed, - initializerSucceeded, - stopped, - foundationSubmitted, - foundationSucceeded, - foundationRepeated, - foundationRerun, - foundationRerunSucceeded, - }); - for (const value of secretValues) assert.equal(serialized.includes(value), false); - assert.equal(serialized.includes("postgresql://pikaoa_test:"), false); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}, 30_000); - -test("validation-only fixture never invokes remote capture", async () => { - let calls = 0; - const forbiddenCapture = async (): Promise => { - calls += 1; - throw new Error("validation-only fixture attempted remote capture"); - }; - const result = await runPikaoaCommand({} as UniDeskConfig, [ - "test-target", "status", "--config", "config/fixtures/pikaoa-test-target.yaml", "--target", "TEST01", "--instance", "validation-only", "--output", "json", - ], forbiddenCapture); - assert.equal(calls, 0); - const payload = JSON.parse(result.renderedText) as Record; - assert.equal(payload.remoteQueried, false); - assert.equal((payload.status as Record).remoteQueried, false); - - const planResult = await runPikaoaCommand({} as UniDeskConfig, [ - "test-target", "plan", "--config", "config/fixtures/pikaoa-test-target.yaml", "--target", "TEST01", "--instance", "validation-only", "--commit", "0123456789abcdef", "--output", "json", - ], forbiddenCapture); - const planPayload = JSON.parse(planResult.renderedText) as Record; - const plan = planPayload.plan as Record; - assert.deepEqual(plan.database, { - configRef: "config/platform-db/postgres-nc01.yaml#exports.connectionStrings.pikaoa-test-database-url", - name: "pikaoa_test", - username: "pikaoa_test", - schema: "pikaoa", - connection: { - sourceRef: "./secrets/pikaoa-test.env", - sourceKey: "DATABASE_URL", - targetKey: "DATABASE_URL", - valuesPrinted: false, - }, - dsnParameterPresence: { sslmode: true, search_path: true }, - valuesPrinted: false, - }); - assert.deepEqual(plan.runtimeConfig, { - secretName: "pikaoa-test-runtime", - key: "pikaoa.yaml", - mountPath: "/etc/pikaoa/pikaoa.yaml", - consumers: ["initializer", "api", "worker"], - valuesPrinted: false, - }); - assert.equal(planPayload.valuesPrinted, false); - assert.equal(((planPayload.observability as Record).prometheus as Record).selector !== undefined, true); - assert.equal(JSON.stringify(planPayload).includes("fixture-db-password"), false); - assert.equal(JSON.stringify(planPayload).includes("postgres://"), false); - - const missingSchemaRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-missing-schema-")); - const missingSchemaPath = join(missingSchemaRoot, "pikaoa.yaml"); - const missingSchemaFixture = readFileSync(rootPath("config", "fixtures", "pikaoa-test-target.yaml"), "utf8") - .replace(" schema: pikaoa\n", ""); - writeFileSync(missingSchemaPath, missingSchemaFixture); - try { - await assert.rejects( - runPikaoaCommand({} as UniDeskConfig, [ - "test-target", "status", "--config", missingSchemaPath, "--target", "TEST01", "--instance", "missing-schema", "--output", "json", - ], forbiddenCapture), - /testRuntime\.targets\.TEST01\.database\.schema 必须是非空字符串/u, - ); - assert.equal(calls, 0); - } finally { - rmSync(missingSchemaRoot, { recursive: true, force: true }); - } - - const missingSelectorRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-missing-selector-")); - const missingSelectorPath = join(missingSelectorRoot, "pikaoa.yaml"); - const fixture = readFileSync(rootPath("config", "fixtures", "pikaoa-test-target.yaml"), "utf8"); - writeFileSync(missingSelectorPath, fixture.replace(" configRef: config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector\n", "")); - try { - await assert.rejects( - runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", missingSelectorPath, "--target", "TEST01", "--instance", "missing-selector", "--output", "json"], forbiddenCapture), - /observability\.prometheus\.configRef 必须是非空字符串/u, - ); - } finally { - rmSync(missingSelectorRoot, { recursive: true, force: true }); - } - - const invalidSelectorRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-invalid-selector-")); - const invalidSelectorPath = join(invalidSelectorRoot, "pikaoa.yaml"); - writeFileSync(invalidSelectorPath, fixture.replace( - "config/platform-infra/observability.yaml#metricsBackend.discovery.labelSelector", - "config/fixtures/pikaoa-observability-invalid-label.yaml#metricsBackend.discovery.labelSelector", - )); - try { - await assert.rejects( - runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", invalidSelectorPath, "--target", "TEST01", "--instance", "invalid-selector", "--output", "json"], forbiddenCapture), - /observability\.prometheus\.configRef\.key 必须是合法 Kubernetes label key/u, - ); - } finally { - rmSync(invalidSelectorRoot, { recursive: true, force: true }); - } - - const missingSelectorPathRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-missing-selector-path-")); - const missingSelectorConfigPath = join(missingSelectorPathRoot, "pikaoa.yaml"); - writeFileSync(missingSelectorConfigPath, fixture.replace("metricsBackend.discovery.labelSelector", "metricsBackend.discovery.missingSelector")); - try { - await assert.rejects( - runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", missingSelectorConfigPath, "--target", "TEST01", "--instance", "missing-selector-path", "--output", "json"], forbiddenCapture), - /observability\.prometheus\.configRef 解析失败.*missingSelector/u, - ); - } finally { - rmSync(missingSelectorPathRoot, { recursive: true, force: true }); - } - - const owningConfig = Bun.YAML.parse(readFileSync(rootPath("config", "pikaoa.yaml"), "utf8")) as Record; - const testTarget = (((owningConfig.testRuntime as Record).targets as Record).NC01 as Record); - assert.equal((testTarget.observability as Record).otlpEndpoint, "otel-collector.platform-infra.svc.cluster.local:4317"); - const production = (((owningConfig.delivery as Record).targets as Record).NC01 as Record); - const productionObservability = (production.runtime as Record).observability as Record; - assert.equal(productionObservability.otlpEndpoint, "otel-collector.platform-infra.svc.cluster.local:4317"); - assert.equal("otlpHttpEndpoint" in productionObservability, false); - - const unconfiguredRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-unconfigured-")); - const unconfiguredPath = join(unconfiguredRoot, "pikaoa.yaml"); - writeFileSync(unconfiguredPath, "version: 1\nkind: pikaoa-platform-delivery\ntestRuntime:\n defaultTargetId: null\n targets: {}\n"); - try { - const unconfigured = await runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", unconfiguredPath, "--output", "json"], forbiddenCapture); - const unconfiguredPayload = JSON.parse(unconfigured.renderedText) as Record; - assert.equal(calls, 0); - assert.equal(unconfiguredPayload.configured, false); - assert.equal(unconfiguredPayload.remoteQueried, false); - } finally { - rmSync(unconfiguredRoot, { recursive: true, force: true }); - } -}); diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts deleted file mode 100644 index 36b033b1..00000000 --- a/scripts/src/pikaoa-test-target.ts +++ /dev/null @@ -1,1883 +0,0 @@ -// SPEC: pikasTech/unidesk#2079 PikaOA YAML-first NC01 test runtime. -import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; -import type { UniDeskConfig } from "./config"; -import { repoRoot, rootPath } from "./config"; -import { renderMachine } from "./cicd-render"; -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 = PikaoaTestTargetStep; - -interface TestTargetOptions { - action: TestTargetAction; - configPath: string; - targetId: string | null; - instanceId: string | null; - commit: string | null; - step: TestTargetStep; - confirm: boolean; - output: "text" | "json"; -} - -interface SecretSourceSpec { - purpose: string; - sourceRef: string; - sourceKey: string | null; - targetKey: string; -} - -interface PrometheusSelectorSpec { - sourceRef: string; - key: string; - value: string; - presence: true; - fingerprint: string; - valuesPrinted: false; -} - -export interface TestTargetSpec { - id: string; - enabled: boolean; - validationOnly: boolean; - node: string; - route: string; - namespace: string; - ttlSeconds: number; - waitTimeoutSeconds: number; - fieldManager: string; - taskState: { - rootPath: string; - statusRequestTimeoutSeconds: number; - logTailLines: number; - }; - cleanup: { - mode: "delete-namespace"; - requireOwnershipLabels: true; - }; - source: { - mode: string; - repository: string; - commitPolicy: string; - branch: string; - worktreeRemote: string; - readUrl: string; - snapshotPrefix: string; - apiImage: string; - workerImage: string; - webImage: 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; - username: string; - schema: string; - connection: SecretSourceSpec; - }; - runtime: { - replicas: { api: number; worker: number; web: number }; - resources: Record<"api" | "worker" | "web" | "initializer", Record>; - secretName: string; - apiServiceName: string; - apiPort: number; - workerMetricsPort: number; - webPort: number; - webApiUpstreamEnv: string; - attachment: { - storageRoot: string; - maxBytes: number; - claimName: string; - storageRequest: string; - storageClassName: string | null; - fsGroup: number; - accessModes: string[]; - }; - workerInterval: string; - outboxBatchSize: number; - outboxMaxAttempts: number; - outboxRetryDelay: string; - sessionTTL: string; - shutdownTimeout: string; - administrator: { username: string; password: SecretSourceSpec }; - employee: { username: string; password: SecretSourceSpec }; - sessionSecret: SecretSourceSpec; - adminToken: SecretSourceSpec; - }; - initializer: { - mode: "fresh-database-only"; - jobName: string; - backoffLimit: number; - command: string[]; - }; - exposure: { - serviceName: string; - serviceType: "NodePort"; - hostIP: string; - port: number; - }; - observability: { - otlpEndpoint: string; - prometheusScrape: boolean; - prometheusSelector: PrometheusSelectorSpec; - apiMetricsPath: string; - workerMetricsPath: string; - exporterWarning: boolean; - exporterBlocking: false; - }; - probes: { - apiLivenessPath: string; - apiReadinessPath: string; - workerLivenessPath: string; - workerReadinessPath: string; - webLivenessPath: string; - webReadinessPath: string; - }; -} - -interface Selection { - configPath: string; - configLabel: string; - target: TestTargetSpec | null; - candidateTargetIds: string[]; - protectedNamespaces: string[]; - missingFields: string[]; - warnings: Warning[]; -} - -interface Warning { - code: string; - message: string; - blocking: false; -} - -interface Blocker { - code: string; - field: string; - message: string; -} - -type RenderContext = PikaoaTestRenderContext; -type SecretMaterial = PikaoaTestSecretMaterial; - -type RemoteCapture = typeof capture; - -interface AsyncJobIdentity { - id: string; - stateDir: string; -} - -const DEFAULT_CONFIG_PATH = rootPath("config", "pikaoa.yaml"); -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 pikaoaTestTargetHelp(): Record { - return { - command: "pikaoa test-target", - description: "通过 YAML-first target 管理 PikaOA 固定 namespace 测试运行面,不经过正式 CI/CD。", - 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|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=false,start/stop 在远端调用前失败。", - "validationOnly target 只用于本地渲染,不连接 route。", - "start 可按 foundation、init、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。", - "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", - "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", - ], - }; -} - -export async function pikaoaHelp(): Promise> { - const { pikaoaAttachmentsBackupHelp } = await import("./pikaoa-attachments-backup"); - return { ok: true, commands: [pikaoaTestTargetHelp(), pikaoaAttachmentsBackupHelp()] }; -} - -export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise { - if (args[0] === "attachments-backup") { - const { runPikaoaAttachmentsBackupCommand } = await import("./pikaoa-attachments-backup"); - return await runPikaoaAttachmentsBackupCommand(config, args.slice(1)); - } - if (args.length === 0 || (args[0] !== "test-target" && args.some(isHelpToken))) { - return renderMachine("pikaoa", await pikaoaHelp(), "json"); - } - if (args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json"); - if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target 或 attachments-backup", "unsupported-pikaoa-command", "pikaoa", ["test-target", "attachments-backup"]); - const options = parseOptions(args.slice(1)); - const selection = readSelection(options); - if (options.action === "plan") return renderResult(planPayload(options, selection), options); - if (options.action === "status") return await statusResult(config, options, selection, remoteCapture); - if (options.action === "start") return await startResult(config, options, selection, remoteCapture); - return await stopResult(config, options, selection, remoteCapture); -} - -function parseOptions(args: string[]): TestTargetOptions { - const action = args[0]; - if (action !== "plan" && action !== "status" && action !== "start" && action !== "stop") { - throw inputError("test-target action 必须是 plan、status、start 或 stop", "invalid-action", action ?? "", ["plan", "status", "start", "stop"]); - } - let configPath = DEFAULT_CONFIG_PATH; - let targetId: string | null = null; - let instanceId: string | null = null; - let commit: string | null = null; - let step: TestTargetStep = "all"; - let confirm = false; - let output: "text" | "json" = "text"; - for (let index = 1; index < args.length; index += 1) { - const arg = args[index]!; - if (arg === "--config" || arg === "--target" || arg === "--instance" || arg === "--commit" || arg === "--step" || arg === "--output" || arg === "-o") { - const value = args[index + 1]; - if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg); - if (arg === "--config") configPath = resolveConfigPath(value); - 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", "init", "api", "worker", "web"]); - else { - if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]); - output = value; - } - index += 1; - } else if (arg === "--json") { - output = "json"; - } else if (arg === "--confirm") { - confirm = true; - } else if (arg === "--dry-run") { - throw inputError("test-target 不支持 --dry-run;使用 plan 完成本地只读渲染", "dry-run-unsupported", arg); - } else { - throw inputError(`不支持的 test-target 参数:${arg}`, "unsupported-option", arg); - } - } - if ((action === "plan" || action === "status") && confirm) throw inputError(`${action} 是只读操作,不接受 --confirm`, "confirm-not-allowed", "--confirm"); - return { action, configPath, targetId, instanceId, commit, step, confirm, output }; -} - -function readSelection(options: TestTargetOptions): Selection { - if (!existsSync(options.configPath)) throw inputError(`找不到 PikaOA owning YAML:${displayPath(options.configPath)}`, "config-not-found", "--config"); - const root = readYamlRecord(options.configPath, "pikaoa-platform-delivery"); - const configLabel = displayPath(options.configPath); - const protectedNamespaces = deliveryNamespaces(root); - const testRuntime = optionalRecord(root.testRuntime, `${configLabel}.testRuntime`); - const targets = testRuntime === null ? {} : optionalRecord(testRuntime.targets, `${configLabel}.testRuntime.targets`) ?? {}; - const targetRecords = Object.entries(targets).filter(([, value]) => isRecord(value)); - const candidateTargetIds = targetRecords.filter(([, value]) => (value as Record).enabled === true).map(([id]) => id).sort(); - const defaultTargetId = testRuntime === null ? null : nullableString(testRuntime.defaultTargetId, `${configLabel}.testRuntime.defaultTargetId`); - const selectedId = options.targetId ?? defaultTargetId ?? (candidateTargetIds.length === 1 ? candidateTargetIds[0]! : null); - const missingFields: string[] = []; - const warnings: Warning[] = []; - if (selectedId === null) { - if (candidateTargetIds.length > 1) missingFields.push("--target"); - return { configPath: options.configPath, configLabel, target: null, candidateTargetIds, protectedNamespaces, missingFields, warnings }; - } - const selected = targets[selectedId]; - if (!isRecord(selected) || selected.enabled !== true) { - warnings.push(warning("target-not-enabled", `testRuntime.targets.${selectedId} 不存在或未启用。`)); - return { configPath: options.configPath, configLabel, target: null, candidateTargetIds, protectedNamespaces, missingFields, warnings }; - } - return { - configPath: options.configPath, - configLabel, - target: parseTarget(selectedId, selected, configLabel), - candidateTargetIds, - protectedNamespaces, - missingFields, - warnings, - }; -} - -function parseTarget(id: string, root: Record, 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`); - 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 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`); - const employee = record(runtime.employee, `${path}.runtime.employee`); - const observability = record(root.observability, `${path}.observability`); - const exposure = record(root.exposure, `${path}.exposure`); - const prometheus = record(observability.prometheus, `${path}.observability.prometheus`); - const exporterFailure = record(observability.exporterFailure, `${path}.observability.exporterFailure`); - const probes = record(root.probes, `${path}.probes`); - const apiProbes = record(probes.api, `${path}.probes.api`); - const workerProbes = record(probes.worker, `${path}.probes.worker`); - const webProbes = record(probes.web, `${path}.probes.web`); - const cleanup = record(root.cleanup, `${path}.cleanup`); - 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`); - if (cleanup.requireOwnershipLabels !== true) throw new Error(`${path}.cleanup.requireOwnershipLabels 必须为 true`); - const target: TestTargetSpec = { - id, - enabled: boolean(root.enabled, `${path}.enabled`), - validationOnly: boolean(root.validationOnly, `${path}.validationOnly`), - node: nonEmpty(root.node, `${path}.node`), - route: nonEmpty(root.route, `${path}.route`), - namespace: kubernetesName(nonEmpty(root.namespace, `${path}.namespace`), `${path}.namespace`, 63), - ttlSeconds: positiveInteger(root.ttlSeconds, `${path}.ttlSeconds`, 86_400), - waitTimeoutSeconds: positiveInteger(root.waitTimeoutSeconds, `${path}.waitTimeoutSeconds`, 900), - fieldManager: kubernetesName(nonEmpty(root.fieldManager, `${path}.fieldManager`), `${path}.fieldManager`, 63), - taskState: { - rootPath: taskStateRoot(taskState.rootPath, `${path}.taskState.rootPath`), - statusRequestTimeoutSeconds: positiveSafeInteger(taskState.statusRequestTimeoutSeconds, `${path}.taskState.statusRequestTimeoutSeconds`), - logTailLines: positiveSafeInteger(taskState.logTailLines, `${path}.taskState.logTailLines`), - }, - cleanup: { - mode: exactString(cleanup.mode, `${path}.cleanup.mode`, "delete-namespace") as "delete-namespace", - requireOwnershipLabels: true, - }, - source: { - 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`), - 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`), - username: postgresIdentifier(database.username, `${path}.database.username`), - schema: postgresIdentifier(database.schema, `${path}.database.schema`), - 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), - workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535), - webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535), - webApiUpstreamEnv: envName(runtime.webApiUpstreamEnv, `${path}.runtime.webApiUpstreamEnv`), - attachment: { - storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`), - maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER), - claimName: kubernetesName(nonEmpty(attachment.claimName, `${path}.runtime.attachment.claimName`), `${path}.runtime.attachment.claimName`, 63), - storageRequest: nonEmpty(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`), - storageClassName: nullableString(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`), - fsGroup: positiveInteger(attachment.fsGroup, `${path}.runtime.attachment.fsGroup`, 2_147_483_647), - accessModes: enumStringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]), - }, - workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`), - outboxBatchSize: positiveInteger(outbox.batchSize, `${path}.runtime.outbox.batchSize`, 1000), - outboxMaxAttempts: positiveInteger(outbox.maxAttempts, `${path}.runtime.outbox.maxAttempts`, 100), - outboxRetryDelay: duration(outbox.retryDelay, `${path}.runtime.outbox.retryDelay`), - sessionTTL: duration(runtime.sessionTTL, `${path}.runtime.sessionTTL`), - shutdownTimeout: duration(runtime.shutdownTimeout, `${path}.runtime.shutdownTimeout`), - administrator: { - username: nonEmpty(administrator.username, `${path}.runtime.administrator.username`), - password: secretSource(administrator.password, "runtime.administrator.password", `${path}.runtime.administrator.password`), - }, - employee: { - username: nonEmpty(employee.username, `${path}.runtime.employee.username`), - password: secretSource(employee.password, "runtime.employee.password", `${path}.runtime.employee.password`), - }, - sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`), - adminToken: secretSource(runtime.adminToken, "runtime.adminToken", `${path}.runtime.adminToken`, true), - }, - 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), - serviceType: exactString(exposure.serviceType, `${path}.exposure.serviceType`, "NodePort") as "NodePort", - hostIP: ipv4Address(exposure.hostIP, `${path}.exposure.hostIP`), - port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535), - }, - observability: { - otlpEndpoint: otlpGrpcEndpoint(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`), - prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`), - prometheusSelector: prometheusSelector(prometheus.configRef, `${path}.observability.prometheus`), - apiMetricsPath: httpPath(prometheus.apiPath, `${path}.observability.prometheus.apiPath`), - workerMetricsPath: httpPath(prometheus.workerPath, `${path}.observability.prometheus.workerPath`), - exporterWarning: boolean(exporterFailure.warning, `${path}.observability.exporterFailure.warning`), - exporterBlocking: false, - }, - probes: { - apiLivenessPath: httpPath(apiProbes.livenessPath, `${path}.probes.api.livenessPath`), - apiReadinessPath: httpPath(apiProbes.readinessPath, `${path}.probes.api.readinessPath`), - workerLivenessPath: httpPath(workerProbes.livenessPath, `${path}.probes.worker.livenessPath`), - workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`), - webLivenessPath: httpPath(webProbes.livenessPath, `${path}.probes.web.livenessPath`), - webReadinessPath: httpPath(webProbes.readinessPath, `${path}.probes.web.readinessPath`), - }, - }; - const secretKeys = secretSources(target).map((item) => item.targetKey); - if (new Set(secretKeys).size !== secretKeys.length || secretKeys.includes("pikaoa.yaml")) { - throw new Error(`${path} 的 Secret targetKey 必须唯一且不能使用 pikaoa.yaml`); - } - 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."); - } - 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."); - } - return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "releaseRuntime"); -} - -function planPayload(options: TestTargetOptions, selection: Selection): Record { - const base = basePayload(options, selection); - if (selection.target === null) return base; - const requireCommit = (options.action === "plan" || options.action === "start") && options.step !== "foundation"; - const context = renderContext(options, selection.target, requireCommit); - const blockers = safetyBlockers(options, selection, context, requireCommit); - if ((options.action === "plan" || options.action === "start") && options.step !== "foundation") { - for (const source of secretSources(selection.target)) { - const summary = secretSourceSummary(selection.configPath, source); - if (summary.presence !== true) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef/sourceKey 不存在或为空。` }); - } - } - const warnings = [...selection.warnings, ...consistencyWarnings(selection.target, context)]; - const renderWorkloads = context !== null && (options.commit !== null || options.step === "foundation") && (options.action === "plan" || options.action === "start"); - return { - ...base, - target: targetSummary(selection.target), - instance: context === null ? null : { - id: context.instanceId, - namespace: context.namespace, - ...((options.action === "plan" || options.action === "start") ? { expiresAt: context.expiresAt } : {}), - }, - source: renderWorkloads && context !== null && options.step !== "foundation" - ? sourceSummary(selection.target, context.commit, context) - : sourceSummary(selection.target, options.commit), - readyForMutation: blockers.length === 0 && !selection.target.validationOnly, - missingFields: missingMutationFields(options, selection), - blockers, - warnings, - observability: observabilitySummary(selection.target), - plan: renderWorkloads && context !== null ? renderedPlan(selection, context) : null, - next: nextCommands(options, selection.target), - }; -} - -function basePayload(options: TestTargetOptions, selection: Selection): Record { - return { - ok: true, - action: `pikaoa test-target ${options.action}`, - configured: selection.target !== null, - mutation: false, - step: options.step, - configPath: selection.configLabel, - target: null, - candidateTargetIds: selection.candidateTargetIds, - protectedNamespaces: selection.protectedNamespaces, - missingFields: selection.missingFields, - warnings: selection.warnings, - next: selection.target === null ? { - configure: `${selection.configLabel}#testRuntime.targets`, - plan: "bun scripts/cli.ts pikaoa test-target plan --target --commit ", - } : nextCommands(options, selection.target), - valuesPrinted: false, - }; -} - -function renderedPlan(selection: Selection, context: RenderContext): Record { - const target = selection.target!; - const labels = ownershipLabels(target, context); - const selectors = { - api: componentSelector("api", context.instanceId), - worker: componentSelector("worker", context.instanceId), - web: componentSelector("web", context.instanceId), - }; - const structuralManifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(target, context, { - secretMode: "materialized", - secrets: placeholderPikaoaSecrets(), - }), context.step); - return { - namespace: { - name: context.namespace, - labels, - annotations: namespaceAnnotations(target, context), - ttlSeconds: target.ttlSeconds, - cleanup: target.cleanup, - }, - objects: structuralManifest.map((object) => objectSummary(object, target.observability.prometheusSelector.key)), - selectors, - secret: { - name: target.runtime.secretName, - requiredForStep: context.step !== "foundation", - valuesPrinted: false, - sources: secretSources(target).map((source) => secretSourceSummary(selection.configPath, source)), - }, - database: databasePlanSummary(target), - runtimeConfig: { - secretName: target.runtime.secretName, - key: "pikaoa.yaml", - mountPath: "/etc/pikaoa/pikaoa.yaml", - consumers: ["initializer", "api", "worker"], - valuesPrinted: false, - }, - probes: { - api: { liveness: target.probes.apiLivenessPath, readiness: target.probes.apiReadinessPath, metrics: target.observability.apiMetricsPath }, - worker: { liveness: target.probes.workerLivenessPath, readiness: target.probes.workerReadinessPath, metrics: target.observability.workerMetricsPath }, - web: { liveness: target.probes.webLivenessPath, readiness: target.probes.webReadinessPath }, - }, - observability: { - ...observabilitySummary(target), - exporterFailure: { warning: target.observability.exporterWarning, blocking: false }, - }, - exposure: { serviceType: target.exposure.serviceType, serviceName: target.exposure.serviceName, hostIP: target.exposure.hostIP, port: target.exposure.port }, - webApiUpstream: { envName: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}:${target.runtime.apiPort}`, scope: "same-namespace" }, - attachment: { - storageRoot: target.runtime.attachment.storageRoot, - maxBytes: target.runtime.attachment.maxBytes, - claimName: target.runtime.attachment.claimName, - storageRequest: target.runtime.attachment.storageRequest, - storageClassName: target.runtime.attachment.storageClassName, - fsGroup: target.runtime.attachment.fsGroup, - accessModes: target.runtime.attachment.accessModes, - }, - initializer: { - mode: target.initializer.mode, - jobName: target.initializer.jobName, - image: context.initializerImage, - command: target.initializer.command, - applyPhase: "before-workloads", - }, - step: context.step, - taskState: { - jobId: asyncJobIdentity(target, context).id, - rootPath: target.taskState.rootPath, - statusRequestTimeoutSeconds: target.taskState.statusRequestTimeoutSeconds, - logTailLines: target.taskState.logTailLines, - mutation: false, - }, - manifestFingerprint: structuralFingerprint(structuralManifest), - mutation: false, - }; -} - -async function statusResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise { - const planned = planPayload(options, selection); - if (selection.target === null) { - return renderResult({ ...planned, remoteQueried: false, status: { remoteQueried: false, reason: "test-target-not-configured" } }, options); - } - if (selection.target.validationOnly) { - return renderResult({ ...planned, remoteQueried: false, status: { remoteQueried: false, reason: "validation-only-target" } }, options); - } - const context = renderContext(options, selection.target, false); - if (context === null) return renderResult({ ...planned, ok: false, status: { remoteQueried: false, reason: "instance-required" } }, options); - const blockers = safetyBlockers(options, selection, context, false); - if (blockers.length > 0) { - return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers, status: { remoteQueried: false, reason: "preflight-blocked" } }, options); - } - const remote = await remoteCapture(config, selection.target.route, ["sh"], statusScript(selection.target, context)); - const parsed = parseJsonOutput(remote.stdout); - const status = parsed === null ? { ok: false, remote: compactCapture(remote, { full: true }) } : summarizeRemoteStatus(parsed, selection.target, context, selection.configPath); - return renderResult({ ...planned, ok: remote.exitCode === 0 && status.ok !== false, remoteQueried: true, status: { remoteQueried: true, ...status } }, options); -} - -async function startResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise { - const planned = planPayload(options, selection); - const requireCommit = options.step !== "foundation"; - const context = selection.target === null ? null : renderContext(options, selection.target, requireCommit); - const blockers = safetyBlockers(options, selection, context, requireCommit); - if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "start 需要显式 --confirm。" }); - if (selection.target?.validationOnly === true) blockers.push({ code: "validation-only-target", field: "testRuntime.targets.*.validationOnly", message: "validationOnly target 禁止远端操作。" }); - if (selection.target === null || context === null || blockers.length > 0) { - return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: uniqueBlockers(blockers), failure: "preflight-blocked" }, options); - } - const material = options.step === "foundation" ? { - ok: true, - 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 = 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; - return renderResult({ - ...planned, - ok: remote.exitCode === 0 && parsed?.ok === true, - mutation, - remoteQueried: true, - mode: mutation ? "submitted" : parsed === null ? "submit-failed" : "existing-task", - code: parsed?.code ?? "start-submit-unreadable", - jobId: parsed?.jobId ?? asyncJobIdentity(selection.target, context).id, - previousTaskReset: parsed?.previousTaskReset === true, - task: parsed?.task ?? null, - secret: { sources: material.sources, valuesPrinted: false }, - remote: parsed ?? compactCapture(remote, { full: true }), - manifestFingerprint: structuralFingerprint(manifest), - }, options); -} - -async function stopResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise { - const planned = planPayload(options, selection); - const context = selection.target === null ? null : renderContext(options, selection.target, false); - const blockers = safetyBlockers(options, selection, context, false); - if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "stop 需要显式 --confirm。" }); - if (selection.target?.validationOnly === true) blockers.push({ code: "validation-only-target", field: "testRuntime.targets.*.validationOnly", message: "validationOnly target 禁止远端操作。" }); - if (selection.target === null || context === null || blockers.length > 0) { - return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: uniqueBlockers(blockers), failure: "preflight-blocked" }, options); - } - const remote = await remoteCapture(config, selection.target.route, ["sh"], stopScript(selection.target, context)); - const parsed = parseJsonOutput(remote.stdout); - return renderResult({ - ...planned, - ok: remote.exitCode === 0 && parsed?.ok === true, - mutation: parsed?.mutation === true, - remoteQueried: true, - mode: parsed?.mutation === true ? "confirmed" : "no-change", - code: parsed?.code ?? "stop-result-unreadable", - jobId: parsed?.jobId ?? asyncJobIdentity(selection.target, context).id, - startTaskDisposition: parsed?.startTaskDisposition ?? "unknown", - remote: parsed ?? compactCapture(remote, { full: true }), - }, options); -} - -function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null { - if (requireCommit && options.commit === null) return null; - const instanceId = options.instanceId ?? "default"; - const commit = options.commit ?? (options.step === "foundation" ? "foundation" : "not-required-for-stop"); - const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString(); - return { - instanceId, - commit, - step: options.step, - namespace: target.namespace, - expiresAt, - apiImage: renderImage(target.source.apiImage, commit), - workerImage: renderImage(target.source.workerImage, commit), - webImage: renderImage(target.source.webImage, commit), - initializerImage: renderImage(target.source.initializerImage, commit), - }; -} - -function asyncJobIdentity(target: TestTargetSpec, context: RenderContext): AsyncJobIdentity { - const digest = createHash("sha256").update(target.id).update("\0").update(context.instanceId).update("\0").update(context.step).digest("hex").slice(0, 20); - const id = `pikaoa-${digest}`; - return { id, stateDir: `${target.taskState.rootPath}/${id}` }; -} - -function safetyBlockers(options: TestTargetOptions, selection: Selection, context: RenderContext | null, requireCommit = true): Blocker[] { - const blockers: Blocker[] = []; - const target = selection.target; - if (target === null) { - blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的测试 target。" }); - return blockers; - } - if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "当前 step 必须选择源码 commit。" }); - if (context !== null) { - if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespace", message: `YAML namespace 不合法:${context.namespace}` }); - if (selection.protectedNamespaces.includes(context.namespace)) blockers.push({ code: "production-namespace-protected", field: "namespace", message: `${context.namespace} 属于 delivery 正式 namespace。` }); - } - return blockers; -} - -function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] { - const warnings: Warning[] = []; - 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; -} - -function observabilitySummary(target: TestTargetSpec): Record { - return { - otlpEndpoint: target.observability.otlpEndpoint, - prometheus: { - scrape: target.observability.prometheusScrape, - selector: target.observability.prometheusSelector, - }, - valuesPrinted: false, - }; -} - -function prometheusRuntimeStatus(items: Record[], selector: PrometheusSelectorSpec, runtimePresent: boolean, step: TestTargetStep): Record { - const components = step === "all" ? ["api", "worker"] : step === "api" || step === "worker" ? [step] : []; - const workloads = components.map((component) => { - const deployment = items.find((item) => item.kind === "Deployment" && optionalRecord(item.metadata, "resource.metadata")?.name === `pikaoa-${component}`); - const spec = deployment === undefined ? null : optionalRecord(deployment.spec, "resource.spec"); - const template = spec === null ? null : optionalRecord(spec.template, "resource.spec.template"); - const metadata = template === null ? null : optionalRecord(template.metadata, "resource.spec.template.metadata"); - const labels = metadata === null ? null : optionalRecord(metadata.labels, "resource.spec.template.metadata.labels"); - const actual = labels?.[selector.key]; - return { component, present: deployment !== undefined, actual: typeof actual === "string" ? actual : null, matches: actual === selector.value }; - }); - const checked = runtimePresent && components.length > 0; - const drift = checked && workloads.some((workload) => !workload.matches); - return { - selector, - checked, - workloads, - drift, - warning: drift ? warning("prometheus-selector-label-drift", "API/Worker Pod template 的 Prometheus selector label 与 owning configRef 不一致;该漂移不阻塞业务运行。") : null, - blocking: false, - valuesPrinted: false, - }; -} - -function databasePlanSummary(target: TestTargetSpec): Record { - const databaseURL = new URL(renderDatabaseURL(target, "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require")); - return { - configRef: target.database.configRef, - name: target.database.name, - username: target.database.username, - schema: target.database.schema, - connection: { sourceRef: target.database.connection.sourceRef, sourceKey: target.database.connection.sourceKey, targetKey: target.database.connection.targetKey, valuesPrinted: false }, - dsnParameterPresence: { - sslmode: databaseURL.searchParams.has("sslmode"), - search_path: databaseURL.searchParams.has("search_path"), - }, - valuesPrinted: false, - }; -} - -function readSecretMaterial(selection: Selection, target: TestTargetSpec): { - ok: boolean; - values: SecretMaterial; - sources: Array>; - blockers: Blocker[]; -} { - const sources = secretSources(target); - const values = new Map(); - const summaries: Array> = []; - const blockers: Blocker[] = []; - for (const source of sources) { - const path = resolveSourceRef(selection.configPath, source.sourceRef); - const present = existsSync(path); - const raw = present ? readFileSync(path, "utf8") : ""; - const value = source.sourceKey === null ? raw.trim() : envValue(raw, source.sourceKey); - const valid = present && value.length > 0; - if (!valid) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef 不存在或为空。` }); - if (valid) values.set(source.purpose, value); - summaries.push({ - purpose: source.purpose, - sourceRef: source.sourceRef, - sourceKey: source.sourceKey, - targetKey: source.targetKey, - presence: valid, - fingerprint: valid ? sha256Fingerprint(value) : null, - valuesPrinted: false, - }); - } - const databaseURL = values.get("database.connection"); - if (databaseURL !== undefined) { - try { - const parsed = new URL(databaseURL); - const databaseName = parsed.pathname.replace(/^\/+/, ""); - if (parsed.username !== target.database.username || databaseName !== target.database.name) { - blockers.push({ code: "database-identity-mismatch", field: target.database.connection.sourceRef, message: "外部 DSN 必须匹配 YAML 声明的独立测试 database 和 role。" }); - } - } catch { - blockers.push({ code: "database-url-invalid", field: target.database.connection.sourceRef, message: "外部测试数据库 sourceKey 不是合法 PostgreSQL URL。" }); - } - } - return { - ok: blockers.length === 0, - values: { - databaseURL: values.get("database.connection") ?? "", - sessionSecret: values.get("runtime.sessionSecret") ?? "", - adminToken: values.get("runtime.adminToken") ?? "", - administratorPassword: values.get("runtime.administrator.password") ?? "", - employeePassword: values.get("runtime.employee.password") ?? "", - }, - sources: summaries, - blockers, - }; -} - -function secretSourceSummary(configPath: string, source: SecretSourceSpec): Record { - const path = resolveSourceRef(configPath, source.sourceRef); - const present = existsSync(path); - const raw = present ? readFileSync(path, "utf8") : ""; - const value = source.sourceKey === null ? raw.trim() : envValue(raw, source.sourceKey); - const available = value.length > 0; - return { - purpose: source.purpose, - sourceRef: source.sourceRef, - sourceKey: source.sourceKey, - targetKey: source.targetKey, - presence: available, - fingerprint: available ? sha256Fingerprint(value) : null, - valuesPrinted: false, - }; -} - -function envValue(raw: string, key: string): string { - for (const line of raw.split(/\r?\n/u)) { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith("#")) continue; - const separator = trimmed.indexOf("="); - if (separator < 1 || trimmed.slice(0, separator).trim() !== key) continue; - const value = trimmed.slice(separator + 1).trim(); - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1); - return value; - } - return ""; -} - -function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record[]): string { - const job = asyncJobIdentity(target, context); - const manifestFingerprint = requestStructuralFingerprint(manifest); - const requestId = createHash("sha256") - .update(target.id) - .update("\0") - .update(context.instanceId) - .update("\0") - .update(context.commit) - .update("\0") - .update(context.step) - .update("\0") - .update(manifestFingerprint) - .digest("hex"); - const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64"); - return ` -set -u -umask 077 -state_root=${shQuote(target.taskState.rootPath)} -state_dir=${shQuote(job.stateDir)} -status_file="$state_dir/status.json" -request_file="$state_dir/request-id" -pid_file="$state_dir/pid" -job_id=${shQuote(job.id)} -request_id=${shQuote(requestId)} -reset_previous=false -inspect_existing() { - python3 - "$request_file" "$status_file" "$pid_file" "$job_id" "$request_id" <<'PY' -import json, os, pathlib, sys -request_path, status_path, pid_path = map(pathlib.Path, sys.argv[1:4]) -job_id, expected_request = sys.argv[4:6] -actual_request = request_path.read_text(errors="replace").strip() if request_path.exists() else "" -try: - task = json.loads(status_path.read_text()) -except Exception: - print(json.dumps({"ok": False, "mutation": False, "code": "start-state-unreadable", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False})) - raise SystemExit(10) -state = str(task.get("state") or "unknown") -runner_alive = False -pid = task.get("pid") -if not isinstance(pid, int) or pid <= 0: - try: - pid = int(pid_path.read_text().strip()) - except Exception: - pid = None -if isinstance(pid, int) and pid > 0: - try: - os.kill(pid, 0) - runner_alive = True - except OSError: - pass -worker_missing = state in ("queued", "running") and not runner_alive -if actual_request != expected_request: - if state in ("succeeded", "failed", "canceled") or worker_missing: - raise SystemExit(20) - print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False})) - raise SystemExit(10) -code_by_state = { - "queued": "start-already-running", - "running": "start-already-running", - "succeeded": "start-already-succeeded", - "failed": "start-already-failed", - "canceled": "start-already-canceled", -} -code = "start-worker-missing" if worker_missing else code_by_state.get(state, "start-state-unknown") -ok = state not in ("failed", "canceled", "unknown") and code != "start-worker-missing" -print(json.dumps({"ok": ok, "mutation": False, "code": code, "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False})) -raise SystemExit(10) -PY -} -if ! mkdir -p "$state_root"; then - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-root-create-failed","valuesPrinted":false}' - exit 41 -fi -claim_attempt=0 -while ! mkdir "$state_dir" 2>/dev/null; do - if [ ! -d "$state_dir" ]; then - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}' - exit 42 - fi - inspect_existing - decision=$? - if [ "$decision" -eq 10 ]; then exit 0; fi - if [ "$decision" -ne 20 ]; then - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-inspect-failed","valuesPrinted":false}' - exit 42 - fi - retired_dir="$state_dir.reset.$request_id.$$" - if mv "$state_dir" "$retired_dir" 2>/dev/null; then - rm -rf "$retired_dir" - reset_previous=true - fi - claim_attempt=$((claim_attempt + 1)) - if [ "$claim_attempt" -ge 5 ]; then - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-reset-conflict","valuesPrinted":false}' - exit 42 - fi -done -started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -printf '%s\n' "$request_id" >"$request_file" -printf '%s\n' "$started_at" >"$state_dir/started-at" -if ! printf '%s' ${shQuote(runnerEncoded)} | base64 -d >"$state_dir/job.sh"; then - rm -rf "$state_dir" - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-runner-write-failed","valuesPrinted":false}' - exit 43 -fi -chmod 0700 "$state_dir/job.sh" -python3 - "$status_file" "$job_id" ${shQuote(target.id)} ${shQuote(context.instanceId)} ${shQuote(context.namespace)} ${shQuote(context.commit)} ${shQuote(context.step)} "$started_at" <<'PY' -import json, os, pathlib, sys -path = pathlib.Path(sys.argv[1]) -payload = { - "ok": True, "jobId": sys.argv[2], "targetId": sys.argv[3], "instanceId": sys.argv[4], - "namespace": sys.argv[5], "sourceCommit": sys.argv[6], "step": sys.argv[7], "state": "running", "phase": "submitted", - "code": "start-running", "startedAt": sys.argv[8], "updatedAt": sys.argv[8], "finishedAt": None, - "exitCode": None, "pid": None, "valuesPrinted": False, -} -tmp = path.with_name(path.name + ".tmp") -tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") -os.replace(tmp, path) -PY -if [ "$?" -ne 0 ]; then - rm -rf "$state_dir" - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-initialize-failed","valuesPrinted":false}' - exit 44 -fi -nohup sh "$state_dir/job.sh" >/dev/null 2>&1 "$pid_file" -python3 - "$status_file" "$pid" <<'PY' -import json, os, pathlib, sys -path = pathlib.Path(sys.argv[1]) -try: - payload = json.loads(path.read_text()) -except Exception: - raise SystemExit(0) -if payload.get("pid") is None and payload.get("phase") == "submitted": - payload["pid"] = int(sys.argv[2]) - tmp = path.with_name(path.name + ".tmp.submit") - tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") - os.replace(tmp, path) -PY -printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"previousTaskReset":%s,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$reset_previous" "$job_id" "$pid" -`; -} - -function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record[], job: AsyncJobIdentity): string { - const initializer = manifest.filter((object) => object.kind === "Job"); - const workload = (name: string): Record[] => manifest.filter((object) => { - if (object.kind !== "Deployment" && object.kind !== "Service") return false; - const metadata = optionalRecord(object.metadata, "metadata") ?? {}; - const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; - return labels["app.kubernetes.io/component"] === name; - }); - const foundation = manifest.filter((object) => object.kind === "Namespace" || object.kind === "PersistentVolumeClaim"); - const runtime = manifest.filter((object) => object.kind === "Secret"); - const encode = (objects: Record[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); - const foundationEncoded = encode(foundation); - const runtimeEncoded = encode(runtime); - const initializerEncoded = encode(initializer); - const apiEncoded = encode(workload("api")); - const workerEncoded = encode(workload("worker")); - const webEncoded = encode(workload("web")); - return `#!/bin/sh -set -eu -umask 077 -state_dir=${shQuote(job.stateDir)} -status_file="$state_dir/status.json" -events_file="$state_dir/events.ndjson" -started_at="$(cat "$state_dir/started-at")" -job_id=${shQuote(job.id)} -namespace=${shQuote(context.namespace)} -managed_by=${shQuote(MANAGED_BY)} -target_id=${shQuote(target.id)} -instance_id=${shQuote(context.instanceId)} -source_commit=${shQuote(context.commit)} -step=${shQuote(context.step)} -current_phase=runner-start -failure_code=start-runner-failed -tmp="" -write_status() { - state="$1"; phase="$2"; code="$3"; exit_code="$4" - now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - python3 - "$status_file" "$events_file" "$job_id" "$target_id" "$instance_id" "$namespace" "$source_commit" "$state" "$phase" "$code" "$started_at" "$now" "$exit_code" "$$" <<'PY' -import json, os, pathlib, sys -status_path = pathlib.Path(sys.argv[1]) -events_path = pathlib.Path(sys.argv[2]) -state, phase, code = sys.argv[8:11] -exit_code = None if sys.argv[13] == "null" else int(sys.argv[13]) -terminal = state in ("succeeded", "failed", "canceled") -payload = { - "ok": state in ("running", "succeeded"), "jobId": sys.argv[3], "targetId": sys.argv[4], - "instanceId": sys.argv[5], "namespace": sys.argv[6], "sourceCommit": sys.argv[7], "step": ${JSON.stringify(context.step)}, - "state": state, "phase": phase, "code": code, "startedAt": sys.argv[11], "updatedAt": sys.argv[12], - "finishedAt": sys.argv[12] if terminal else None, "exitCode": exit_code, "pid": int(sys.argv[14]), - "valuesPrinted": False, -} -tmp = status_path.with_name(status_path.name + ".tmp." + sys.argv[14]) -tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") -os.replace(tmp, status_path) -event = {"at": sys.argv[12], "state": state, "phase": phase, "code": code, "valuesPrinted": False} -with events_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(event, separators=(",", ":")) + "\\n") -PY -} -check_canceled() { - if [ -f "$state_dir/cancel-requested" ]; then failure_code=start-canceled; exit 130; fi -} -finish() { - rc=$? - trap - EXIT HUP INT TERM - if [ -f "$state_dir/cancel-requested" ]; then - write_status canceled canceled start-canceled "$rc" || true - elif [ "$rc" -eq 0 ]; then - write_status succeeded completed started 0 || true - else - write_status failed "$current_phase" "$failure_code" "$rc" || true - fi - [ -z "$tmp" ] || rm -rf "$tmp" - exit "$rc" -} -trap finish EXIT -trap 'failure_code=start-runner-hangup; exit 129' HUP -trap 'failure_code=start-runner-interrupted; exit 130' INT -trap 'failure_code=start-runner-terminated; exit 143' TERM -rm -f "$state_dir/job.sh" -tmp="$(mktemp -d)" -current_phase=ownership-check -write_status running "$current_phase" start-running null -check_canceled -if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s >/dev/null 2>&1; then - actual_managed="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.app\\.kubernetes\\.io/managed-by}' 2>/dev/null || true)" - actual_target="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/target}' 2>/dev/null || true)" - actual_instance="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/instance}' 2>/dev/null || true)" - if [ "$actual_managed" != "$managed_by" ] || [ "$actual_target" != "$target_id" ] || [ "$actual_instance" != "$instance_id" ]; then - failure_code=namespace-ownership-mismatch - exit 42 - fi -fi -printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml" -printf '%s' ${shQuote(runtimeEncoded)} | base64 -d >"$tmp/runtime.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" -current_phase=foundation-apply -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/foundation.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then - failure_code=apply-failed - exit 43 -fi -if [ "$step" = foundation ]; then - exit 0 -fi -current_phase=runtime-secret-apply -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/runtime.yaml" >"$tmp/runtime-apply.out" 2>"$tmp/runtime-apply.err"; then - failure_code=runtime-secret-apply-failed - exit 44 -fi -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.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 - initializer_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} )) - while :; do - check_canceled - 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 ! initializer_condition="$(python3 - "$tmp/initializer-job.json" <<'PY' -import json, pathlib, sys -payload = json.loads(pathlib.Path(sys.argv[1]).read_text()) -conditions = { - item.get("type"): item.get("status") - for item in payload.get("status", {}).get("conditions", []) - if isinstance(item, dict) -} -if conditions.get("Complete") == "True": - print("complete") -elif conditions.get("FailureTarget") == "True": - print("failure-target") -elif conditions.get("Failed") == "True": - print("failed") -else: - print("running") -PY -)"; then - failure_code=initializer-job-condition-invalid - exit 46 - fi - case "$initializer_condition" in - complete) break ;; - failure-target) failure_code=initializer-job-failure-target; exit 46 ;; - failed) failure_code=initializer-job-failed; exit 46 ;; - esac - if [ "$(date +%s)" -ge "$initializer_deadline" ]; then - failure_code=initializer-job-timeout - exit 46 - fi - sleep 1 - done -fi -for component in api worker web; do - if [ "$step" != all ] && [ "$step" != "$component" ]; then continue; fi - current_phase="$component-apply" - write_status running "$current_phase" start-running null - check_canceled - if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/$component.yaml" >"$tmp/$component-apply.out" 2>"$tmp/$component-apply.err"; then failure_code="$component-apply-failed"; exit 47; fi - current_phase="$component-rollout" - write_status running "$current_phase" start-running null - check_canceled - if ! kubectl -n "$namespace" rollout status "deployment/pikaoa-$component" --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/$component-wait.err"; then failure_code="$component-rollout-wait-failed"; exit 48; fi -done -`; -} - -function statusScript(target: TestTargetSpec, context: RenderContext): string { - const job = asyncJobIdentity(target, context); - return ` -set -u -umask 077 -state_dir=${shQuote(job.stateDir)} -job_id=${shQuote(job.id)} -namespace=${shQuote(context.namespace)} -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT -runtime_code=runtime-not-found -if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/namespace.json" 2>"$tmp/namespace.err"; then - runtime_code=runtime-present - if ! kubectl -n "$namespace" get deployment,job,service,persistentvolumeclaim,pod --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/resources.json" 2>"$tmp/resources.err"; then - runtime_code=runtime-resource-query-failed - fi -elif ! grep -Eiq 'not[[:space:]-]*found' "$tmp/namespace.err"; then - runtime_code=runtime-query-failed -fi -python3 - "$state_dir" "$job_id" "$runtime_code" "$tmp/namespace.json" "$tmp/resources.json" ${shQuote(String(target.taskState.logTailLines))} <<'PY' -import json, os, pathlib, sys -state_dir = pathlib.Path(sys.argv[1]) -job_id, runtime_code = sys.argv[2:4] -namespace_path, resources_path = map(pathlib.Path, sys.argv[4:6]) -tail_lines = int(sys.argv[6]) -status_path = state_dir / "status.json" -task = None -task_error = None -if status_path.exists(): - try: - task = json.loads(status_path.read_text()) - except Exception: - task_error = "start-state-unreadable" -pid = task.get("pid") if isinstance(task, dict) else None -runner_alive = False -if isinstance(pid, int) and pid > 0: - try: - os.kill(pid, 0) - runner_alive = True - except OSError: - pass -events = [] -events_path = state_dir / "events.ndjson" -if events_path.exists(): - for line in events_path.read_text(errors="replace").splitlines()[-tail_lines:]: - try: - item = json.loads(line) - except Exception: - continue - if isinstance(item, dict): - events.append({key: item.get(key) for key in ("at", "state", "phase", "code", "valuesPrinted")}) -if task_error is not None: - code, ok, exists = task_error, False, True -elif task is None: - code, ok, exists = "start-not-found", True, False -else: - state = str(task.get("state") or "unknown") - if state in ("queued", "running") and not runner_alive: - code, ok = "start-worker-missing", False - else: - code = {"queued": "start-running", "running": "start-running", "succeeded": "start-succeeded", "failed": "start-failed", "canceled": "start-canceled"}.get(state, "start-state-unknown") - ok = state not in ("failed", "canceled", "unknown") - exists = True -task_code = code -runtime = {"queried": True, "exists": runtime_code in ("runtime-present", "runtime-resource-query-failed"), "code": runtime_code} -runtime_paths = [] -if runtime_code in ("runtime-present", "runtime-resource-query-failed"): - runtime_paths.append(("namespace", namespace_path)) -if runtime_code == "runtime-present": - runtime_paths.append(("resources", resources_path)) -for key, path in runtime_paths: - if path.exists() and path.stat().st_size > 0: - try: - runtime[key] = json.loads(path.read_text()) - except Exception: - runtime["code"] = "runtime-response-unreadable" - else: - runtime["code"] = "runtime-response-unreadable" -if runtime["code"] in ("runtime-query-failed", "runtime-resource-query-failed", "runtime-response-unreadable"): - code, ok = runtime["code"], False -payload = {"ok": ok, "mutation": False, "code": code, "taskCode": task_code, "jobId": job_id, "taskExists": exists, "task": task, "runnerAlive": runner_alive, "logTail": events, "runtime": runtime, "valuesPrinted": False} -print(json.dumps(payload, separators=(",", ":"))) -PY -`; -} - -function stopScript(target: TestTargetSpec, context: RenderContext): string { - const job = asyncJobIdentity(target, context); - return ` -set -u -umask 077 -state_dir=${shQuote(job.stateDir)} -job_id=${shQuote(job.id)} -namespace=${shQuote(context.namespace)} -managed_by=${shQuote(MANAGED_BY)} -target_id=${shQuote(target.id)} -instance_id=${shQuote(context.instanceId)} -mark_cancel() { - python3 - "$state_dir" <<'PY' -import json, pathlib, sys -state_dir = pathlib.Path(sys.argv[1]) -status_path = state_dir / "status.json" -if not status_path.exists(): - print("not-found|false") - raise SystemExit(0) -try: - state = str(json.loads(status_path.read_text()).get("state") or "unknown") -except Exception: - print("state-unreadable|false") - raise SystemExit(0) -if state in ("queued", "running"): - cancel_path = state_dir / "cancel-requested" - if cancel_path.exists(): - print("cancel-already-requested|false") - else: - cancel_path.touch(mode=0o600, exist_ok=False) - print("cancel-requested|true") -else: - print("already-terminal|false") -PY -} -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT -if ! kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/namespace.json" 2>"$tmp/namespace.err"; then - if ! grep -Eiq 'not[[:space:]-]*found' "$tmp/namespace.err"; then - printf '{"ok":false,"mutation":false,"code":"runtime-query-failed","jobId":"%s","startTaskDisposition":"unknown","valuesPrinted":false}\n' "$job_id" - exit 45 - fi - cancel_result="$(mark_cancel)" - cancel_disposition="\${cancel_result%%|*}" - cancel_mutation="\${cancel_result##*|}" - printf '{"ok":true,"mutation":%s,"code":"already-absent","jobId":"%s","startTaskDisposition":"%s","valuesPrinted":false}\n' "$cancel_mutation" "$job_id" "$cancel_disposition" - exit 0 -fi -actual_managed="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.app\\.kubernetes\\.io/managed-by}' 2>/dev/null || true)" -actual_target="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/target}' 2>/dev/null || true)" -actual_instance="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/instance}' 2>/dev/null || true)" -if [ "$actual_managed" != "$managed_by" ] || [ "$actual_target" != "$target_id" ] || [ "$actual_instance" != "$instance_id" ]; then - printf '{"ok":false,"mutation":false,"code":"namespace-ownership-mismatch","jobId":"%s","startTaskDisposition":"not-touched","valuesPrinted":false}\n' "$job_id" - exit 42 -fi -cancel_result="$(mark_cancel)" -cancel_disposition="\${cancel_result%%|*}" -cancel_mutation="\${cancel_result##*|}" -if ! kubectl delete namespace "$namespace" --wait=false --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s >/dev/null 2>"$tmp/delete.err"; then - printf '{"ok":false,"mutation":%s,"code":"namespace-delete-failed","jobId":"%s","startTaskDisposition":"%s","valuesPrinted":false}\n' "$cancel_mutation" "$job_id" "$cancel_disposition" - exit 43 -fi -printf '{"ok":true,"mutation":true,"code":"stop-requested","jobId":"%s","startTaskDisposition":"%s","valuesPrinted":false}\n' "$job_id" "$cancel_disposition" -`; -} - -function summarizeRemoteStatus(parsed: Record, target: TestTargetSpec, context: RenderContext, configPath: string): Record { - const runtime = optionalRecord(parsed.runtime, "status.runtime") ?? {}; - const namespace = optionalRecord(runtime.namespace, "status.runtime.namespace") ?? {}; - const metadata = optionalRecord(namespace.metadata, "status.namespace.metadata") ?? {}; - const labels = optionalRecord(metadata.labels, "status.namespace.metadata.labels") ?? {}; - const resources = optionalRecord(runtime.resources, "status.runtime.resources") ?? {}; - const items = Array.isArray(resources.items) ? resources.items.filter(isRecord) : []; - const prometheus = prometheusRuntimeStatus(items, target.observability.prometheusSelector, runtime.exists === true, context.step); - return { - ok: parsed.ok === true, - code: parsed.code ?? "start-status-unknown", - taskCode: parsed.taskCode ?? "start-status-unknown", - jobId: parsed.jobId ?? asyncJobIdentity(target, context).id, - taskExists: parsed.taskExists === true, - task: optionalRecord(parsed.task, "status.task"), - runnerAlive: parsed.runnerAlive === true, - logTail: Array.isArray(parsed.logTail) ? parsed.logTail.filter(isRecord) : [], - mutation: false, - runtime: { - queried: runtime.queried === true, - exists: runtime.exists === true, - code: runtime.code ?? "runtime-status-unknown", - ownership: runtime.exists !== true ? null : { - valid: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TARGET_LABEL] === target.id && labels[INSTANCE_LABEL] === context.instanceId, - managedBy: labels["app.kubernetes.io/managed-by"] ?? null, - target: labels[TARGET_LABEL] ?? null, - instance: labels[INSTANCE_LABEL] ?? null, - }, - resources: items.map((item) => { - const itemMetadata = optionalRecord(item.metadata, "resource.metadata") ?? {}; - const status = optionalRecord(item.status, "resource.status") ?? {}; - const spec = optionalRecord(item.spec, "resource.spec") ?? {}; - return { - kind: item.kind ?? null, - name: itemMetadata.name ?? null, - desired: spec.replicas ?? null, - ready: status.readyReplicas ?? status.numberReady ?? null, - phase: status.phase ?? null, - }; - }), - database: { external: true, configRef: target.database.configRef, ...secretSourceSummary(configPath, target.database.connection) }, - exposure: { hostIP: target.exposure.hostIP, port: target.exposure.port, serviceName: target.exposure.serviceName }, - observability: { ...observabilitySummary(target), prometheus, blocking: false }, - }, - valuesPrinted: false, - }; -} - -function renderResult(payload: Record, options: TestTargetOptions): RenderedCliResult { - if (options.output === "json") return renderMachine(`pikaoa test-target ${options.action}`, payload, "json", payload.ok !== false); - return { - ok: payload.ok !== false, - command: `pikaoa test-target ${options.action}`, - contentType: "text/plain", - renderedText: renderText(payload), - projection: payload, - }; -} - -function renderText(payload: Record): string { - const target = optionalRecord(payload.target, "target"); - const instance = optionalRecord(payload.instance, "instance"); - const next = optionalRecord(payload.next, "next") ?? {}; - const warnings = Array.isArray(payload.warnings) ? payload.warnings.filter(isRecord) : []; - const blockers = Array.isArray(payload.blockers) ? payload.blockers.filter(isRecord) : []; - const status = optionalRecord(payload.status, "status"); - const task = status === null ? null : optionalRecord(status.task, "status.task"); - return [ - `PIKAOA TEST TARGET ${String(payload.action ?? "").split(" ").at(-1)?.toUpperCase() ?? ""} (${payload.ok === false ? "blocked" : "ok"})`, - `configured=${String(payload.configured === true)} mutation=${String(payload.mutation === true)} config=${String(payload.configPath ?? "-")}`, - `target=${String(target?.id ?? "-")} node=${String(target?.node ?? "-")} validationOnly=${String(target?.validationOnly ?? "-")}`, - `instance=${String(instance?.id ?? "-")} namespace=${String(instance?.namespace ?? "-")} step=${String(payload.step ?? "all")}`, - ...(payload.code === undefined ? [] : [`code=${String(payload.code)} jobId=${String(payload.jobId ?? "-")}`]), - ...(status === null ? [] : [`status=${status.remoteQueried === false ? String(status.reason ?? "local") : String(status.code ?? "unknown")} phase=${String(task?.phase ?? "-")}`]), - `warnings=${warnings.length} blockers=${blockers.length} valuesPrinted=false`, - ...warnings.map((item) => `WARNING ${String(item.code ?? "warning")} blocking=false ${String(item.message ?? "")}`), - ...blockers.map((item) => `BLOCKER ${String(item.code ?? "blocked")} field=${String(item.field ?? "-")} ${String(item.message ?? "")}`), - "NEXT", - ...Object.entries(next).map(([key, value]) => ` ${key}: ${String(value)}`), - "", - ].join("\n"); -} - -function targetSummary(target: TestTargetSpec): Record { - return { - id: target.id, - node: target.node, - route: target.route, - validationOnly: target.validationOnly, - namespace: target.namespace, - exposure: target.exposure, - ttlSeconds: target.ttlSeconds, - taskState: target.taskState, - cleanup: target.cleanup, - }; -} - -function sourceSummary(target: TestTargetSpec, commit: string | null, context?: RenderContext): Record { - return { - mode: target.source.mode, - repository: target.source.repository, - commitPolicy: target.source.commitPolicy, - commit, - images: context === undefined - ? { 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 }, - }; -} - -function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Record { - const configFlag = options.configPath === DEFAULT_CONFIG_PATH ? "" : ` --config ${displayPath(options.configPath)}`; - const targetFlag = `--target ${target.id}`; - const instance = options.instanceId ?? "default"; - const commit = options.commit ?? ""; - const step = options.step; - const commitFlag = step === "foundation" ? "" : ` --commit ${commit}`; - return { - plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step}`, - status: `bun scripts/cli.ts pikaoa test-target status${configFlag} ${targetFlag} --instance ${instance}`, - start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step} --confirm`, - stop: `bun scripts/cli.ts pikaoa test-target stop${configFlag} ${targetFlag} --instance ${instance} --confirm`, - }; -} - -function missingMutationFields(options: TestTargetOptions, selection: Selection): string[] { - return [ - ...(selection.target === null ? ["--target"] : []), - ...((options.action === "plan" || options.action === "start") && options.step !== "foundation" && options.commit === null ? ["--commit"] : []), - ]; -} - -function ownershipLabels(target: TestTargetSpec, context: RenderContext): Record { - 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, - }; -} - -function componentSelector(component: string, instanceId: string): Record { - return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId }; -} - -function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): Record { - 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), - }; -} - -function objectSummary(object: Record, prometheusSelectorKey: string): Record { - const metadata = optionalRecord(object.metadata, "metadata") ?? {}; - const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; - const spec = optionalRecord(object.spec, "spec") ?? {}; - const template = optionalRecord(spec.template, "spec.template") ?? {}; - const templateMetadata = optionalRecord(template.metadata, "spec.template.metadata") ?? {}; - const podLabels = optionalRecord(templateMetadata.labels, "spec.template.metadata.labels") ?? {}; - const podSpec = optionalRecord(template.spec, "spec.template.spec") ?? {}; - const securityContext = optionalRecord(podSpec.securityContext, "spec.template.spec.securityContext") ?? {}; - return { - kind: object.kind ?? null, - name: metadata.name ?? null, - owned: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string", - prometheusSelectorLabel: object.kind === "Deployment" && typeof podLabels[prometheusSelectorKey] === "string" ? podLabels[prometheusSelectorKey] : undefined, - podFsGroup: securityContext.fsGroup, - secretValuesRedacted: object.kind === "Secret" ? true : undefined, - }; -} - -function structuralFingerprint(manifest: Record[]): string { - return manifestFingerprint(manifest, false); -} - -function requestStructuralFingerprint(manifest: Record[]): string { - return manifestFingerprint(manifest, true); -} - -function manifestFingerprint(manifest: Record[], stableRequest: boolean): string { - const normalized = manifest.map((object) => { - let value = object.kind === "Secret" - ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, ""])) } - : object; - if (!stableRequest) return value; - const metadata = optionalRecord(value.metadata, "metadata"); - const annotations = metadata === null ? null : optionalRecord(metadata.annotations, "metadata.annotations"); - if (metadata === null || annotations === null || annotations["pikaoa.unidesk.io/expires-at"] === undefined) return value; - const stableAnnotations = { ...annotations }; - delete stableAnnotations["pikaoa.unidesk.io/expires-at"]; - value = { ...value, metadata: { ...metadata, annotations: stableAnnotations } }; - return value; - }); - return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`; -} - -function secretSources(target: TestTargetSpec): SecretSourceSpec[] { - return [target.database.connection, target.runtime.sessionSecret, target.runtime.adminToken, target.runtime.administrator.password, target.runtime.employee.password]; -} - -function secretSource(value: unknown, purpose: string, path: string, requireSourceKey = false): SecretSourceSpec { - const root = record(value, path); - const targetKey = nonEmpty(root.targetKey, `${path}.targetKey`); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(targetKey)) throw new Error(`${path}.targetKey 必须是合法 Secret key`); - const sourceKey = nullableString(root.sourceKey, `${path}.sourceKey`); - if (requireSourceKey && sourceKey === null) throw new Error(`${path}.sourceKey 必须显式声明`); - if (sourceKey !== null && !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(sourceKey)) throw new Error(`${path}.sourceKey 必须是合法 env key`); - return { purpose, sourceRef: nonEmpty(root.sourceRef, `${path}.sourceRef`), sourceKey, targetKey }; -} - -function deliveryNamespaces(root: Record): string[] { - const delivery = optionalRecord(root.delivery, "delivery"); - const targets = delivery === null ? null : optionalRecord(delivery.targets, "delivery.targets"); - if (targets === null) return []; - const namespaces = Object.values(targets).filter(isRecord).flatMap((target) => { - const ci = optionalRecord(target.ci, "delivery.targets.*.ci"); - return [target.namespace, ci?.namespace]; - }).filter((value): value is string => typeof value === "string" && value.length > 0); - return [...new Set(namespaces)].sort(); -} - -function resolveConfigPath(value: string): string { - if (value.startsWith("~/")) return resolve(homedir(), value.slice(2)); - return isAbsolute(value) ? resolve(value) : resolve(repoRoot, value); -} - -function resolveSourceRef(configPath: string, sourceRef: string): string { - if (sourceRef.startsWith("~/")) return resolve(homedir(), sourceRef.slice(2)); - if (isAbsolute(sourceRef)) return resolve(sourceRef); - return resolve(dirname(configPath), sourceRef); -} - -function displayPath(path: string): string { - const rel = relative(repoRoot, path); - return rel.length > 0 && !rel.startsWith("..") ? rel : path; -} - -function renderImage(template: string, commit: string): string { - return template.replaceAll("${commit}", commit); -} - -function warning(code: string, message: string): Warning { - return { code, message, blocking: false }; -} - -function uniqueBlockers(blockers: Blocker[]): Blocker[] { - const seen = new Set(); - return blockers.filter((blocker) => { - const key = `${blocker.code}\0${blocker.field}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function inputError(message: string, code: string, argument?: string, supported?: string[]): CliInputError { - return new CliInputError(message, { - code, - argument, - supported, - usage: "bun scripts/cli.ts pikaoa test-target plan|status|start|stop [options]", - hint: "运行 bun scripts/cli.ts pikaoa test-target --help 查看受控入口。", - }); -} - -function simpleId(value: string, field: string): string { - if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw inputError(`${field} 必须是简单 ID`, "invalid-simple-id", field); - return value; -} - -function kubernetesName(value: string, field: string, maxLength: number): string { - if (value.length > maxLength || !isKubernetesName(value)) throw inputError(`${field} 必须是长度不超过 ${maxLength} 的 Kubernetes DNS label`, "invalid-kubernetes-name", field); - return value; -} - -function isKubernetesName(value: string): boolean { - return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(value); -} - -function sourceCommit(value: string): string { - if (!/^[0-9a-f]{7,64}$/u.test(value)) throw inputError("--commit 必须是 7-64 位小写十六进制 commit", "invalid-commit", "--commit"); - return value; -} - -function imageReference(value: unknown, path: string): string { - const image = nonEmpty(value, path); - if (/\s/u.test(image) || !image.includes(":")) throw new Error(`${path} 必须是带 tag/template 的镜像引用`); - 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`); - const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1)); - if (port > 65_535) throw new Error(`${path} 端口必须是 1-65535`); - return endpoint; -} - -function prometheusSelector(value: unknown, path: string): PrometheusSelectorSpec { - const sourceRef = nonEmpty(value, `${path}.configRef`); - let resolved: ReturnType; - try { - resolved = resolveConfigRef(sourceRef, `${path}.configRef`); - } catch (error) { - throw new Error(`${path}.configRef 解析失败:${error instanceof Error ? error.message : String(error)}`); - } - const selector = record(resolved.value, `${path}.configRef (${sourceRef})`); - const key = kubernetesLabelKey(nonEmpty(selector.key, `${path}.configRef.key`), `${path}.configRef.key`); - const selectorValue = kubernetesLabelValue(nonEmpty(selector.value, `${path}.configRef.value`), `${path}.configRef.value`); - return { sourceRef, key, value: selectorValue, presence: true, fingerprint: `sha256:${resolved.sha256}`, valuesPrinted: false }; -} - -function kubernetesLabelKey(value: string, path: string): string { - const parts = value.split("/"); - const name = parts.pop() ?? ""; - const prefix = parts.length === 1 ? parts[0] : null; - const labelNamePattern = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u; - const dnsPrefixPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/u; - if (parts.length > 1 || !labelNamePattern.test(name) || (prefix !== null && !dnsPrefixPattern.test(prefix))) throw new Error(`${path} 必须是合法 Kubernetes label key`); - return value; -} - -function kubernetesLabelValue(value: string, path: string): string { - if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(value)) throw new Error(`${path} 必须是合法 Kubernetes label value`); - return value; -} - -function postgresIdentifier(value: unknown, path: string): string { - const name = nonEmpty(value, path); - if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(name)) throw new Error(`${path} 必须是简单 PostgreSQL identifier`); - return name; -} - -function httpPath(value: unknown, path: string): string { - const result = nonEmpty(value, path); - if (!result.startsWith("/") || /[\s?#]/u.test(result)) throw new Error(`${path} 必须是绝对 HTTP path`); - return result; -} - -function envName(value: unknown, path: string): string { - const result = nonEmpty(value, path); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${path} 必须是合法环境变量名`); - return result; -} - -function ipv4Address(value: unknown, path: string): string { - const result = nonEmpty(value, path); - const octets = result.split("."); - if (octets.length !== 4 || octets.some((octet) => !/^(?:0|[1-9][0-9]{0,2})$/u.test(octet) || Number(octet) > 255)) throw new Error(`${path} 必须是 IPv4 地址`); - return result; -} - -function duration(value: unknown, path: string): string { - const result = nonEmpty(value, path); - if (!/^[1-9][0-9]*(?:ms|s|m|h)$/u.test(result)) throw new Error(`${path} 必须是正 duration`); - return result; -} - -function record(value: unknown, path: string): Record { - if (!isRecord(value)) throw new Error(`${path} 必须是 YAML object`); - return value; -} - -function optionalRecord(value: unknown, path: string): Record | null { - if (value === undefined || value === null) return null; - return record(value, path); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function nonEmpty(value: unknown, path: string): string { - if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`); - return value.trim(); -} - -function nullableString(value: unknown, path: string): string | null { - if (value === undefined || value === null) return null; - return nonEmpty(value, path); -} - -function absolutePath(value: unknown, path: string): string { - const result = nonEmpty(value, path); - if (!result.startsWith("/") || result.includes("\0")) throw new Error(`${path} 必须是绝对路径`); - return result; -} - -function taskStateRoot(value: unknown, path: string): string { - const result = absolutePath(value, path).replace(/\/+$/u, ""); - if (result.length === 0) throw new Error(`${path} 不能是文件系统根目录`); - return result; -} - -function nonEmptyStringList(value: unknown, path: string): string[] { - if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`); - return value.map((item, index) => nonEmpty(item, `${path}[${index}]`)); -} - -function enumStringList(value: unknown, path: string, allowed: readonly T[]): T[] { - const items = nonEmptyStringList(value, path).map((item) => enumString(item, path, allowed)); - if (new Set(items).size !== items.length) throw new Error(`${path} 不能包含重复值`); - return items; -} - -function boolean(value: unknown, path: string): boolean { - if (typeof value !== "boolean") throw new Error(`${path} 必须是 boolean`); - return value; -} - -function positiveInteger(value: unknown, path: string, max: number): number { - if (!Number.isInteger(value) || Number(value) <= 0 || Number(value) > max) throw new Error(`${path} 必须是 1-${max} 的整数`); - return Number(value); -} - -function positiveSafeInteger(value: unknown, path: string): number { - if (!Number.isSafeInteger(value) || Number(value) <= 0) throw new Error(`${path} 必须是正安全整数`); - return Number(value); -} - -function nonNegativeInteger(value: unknown, path: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${path} 必须是非负安全整数`); - return Number(value); -} - -function enumString(value: unknown, path: string, allowed: readonly T[]): T { - if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} 必须是 ${allowed.join("、")}`); - return value as T; -} - -function exactString(value: unknown, path: string, expected: string): string { - if (value !== expected) throw new Error(`${path} 必须是 ${expected}`); - return expected; -} - -function isHelpToken(value: string | undefined): boolean { - return value === "help" || value === "--help" || value === "-h"; -} diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index 45c9fe65..920d2a6f 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -49,18 +49,18 @@ describe("platform-infra Gitea repository GitHub credentials", () => { test("keeps PikaOA master and release authorities on distinct Secret keys", () => { const config = readGiteaConfig(); - const testAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-test-nc01"); + const developmentAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-dev-nc01"); const productionAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-nc01"); - const testKey = testAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key; + const developmentKey = developmentAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key; const productionKey = productionAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key; - expect(testAuthority?.upstream.branch).toBe("master"); + expect(developmentAuthority?.upstream.branch).toBe("master"); expect(productionAuthority?.upstream.branch).toBe("release"); - expect(testKey).toBe("github-token-pikainc-pikaoa"); + expect(developmentKey).toBe("github-token-pikainc-pikaoa"); expect(productionKey).toBe("github-token-pikainc-pikaoa-release"); - expect(productionKey).not.toBe(testKey); + expect(productionKey).not.toBe(developmentKey); expect(githubTokenEnvNameForSecretKey(productionKey ?? "")) - .not.toBe(githubTokenEnvNameForSecretKey(testKey ?? "")); + .not.toBe(githubTokenEnvNameForSecretKey(developmentKey ?? "")); }); test("normalizes Secret keys to bounded environment variable names", () => { diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index d3e057c9..12510983 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -74,7 +74,15 @@ test("versioned admission desired state reports controller proof and candidate d toState: "started", }]); expect(contract.child.enabled).toBe(false); - expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01", "selfmedia-production-nc01", "pikaoa-test-nc01"]); + expect(contract.consumers.map((item) => item.id)).toEqual([ + "agentrun-nc01-v02", + "agentrun-nc01-release", + "platform-infra-sub2rank-nc01", + "selfmedia-nc01", + "selfmedia-production-nc01", + "pikaoa-dev-nc01", + "pikaoa-nc01", + ]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); const policy = items[0]; diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 4b15926f..816ed8f1 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -159,19 +159,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>).find((item) => item.id === "pikaoa-test-nc01"); + const invalidRepository = (invalidRepositoryDocument.repositories as Array>).find((item) => item.id === "pikaoa-dev-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-test-nc01" }, + object: { kind: "repository", id: "pikaoa-dev-nc01" }, consumer: null, - configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-test-nc01", + configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-dev-nc01", blocking: false, 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(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-dev-nc01")).toBe(false); expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl"); const duplicateSelectedRepository = pacConfigDocument(); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index 5e50f91c..7b85ab9b 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -99,11 +99,11 @@ function runtimeInput(commit: string | null = sourceCommit): Record { - test("accepts PikaOA test runtime provenance", () => { + test("accepts PikaOA development runtime provenance", () => { const expected = { - configRef: "config/pikaoa.yaml#testRuntime.targets.NC01", + configRef: "config/pikaoa.yaml#developmentRuntime.targets.NC01", effectiveConfigSha256: `sha256:${"a".repeat(64)}`, - renderer: "pikaoa-test-runtime", + renderer: "pikaoa-development-runtime", mode: "embedded-pipeline-spec", } as const; const manifest = { @@ -113,9 +113,9 @@ describe("PaC source artifact CLI contract", () => { }); test("uses PikaOA-specific PipelineRun labels", () => { - const binding = { consumer: { id: "pikaoa-test-nc01", node: "NC01" } } as Parameters[0]; + const binding = { consumer: { id: "pikaoa-dev-nc01", node: "NC01" } } as Parameters[0]; expect(pipelineRunLabels(binding, "pikaoa")).toEqual({ - "app.kubernetes.io/name": "pikaoa-test-nc01-pac", + "app.kubernetes.io/name": "pikaoa-dev-nc01-pac", "app.kubernetes.io/part-of": "pikaoa", "unidesk.ai/source-commit": "{{ revision }}", "pikaoa.unidesk.io/source-commit": "{{ revision }}", @@ -125,7 +125,7 @@ describe("PaC source artifact CLI contract", () => { test("maps the PikaOA shared workspace to the declared RWO PVC size", () => { const binding = { - consumer: { sourceArtifact: { renderer: "pikaoa-test-runtime" } }, + consumer: { sourceArtifact: { renderer: "pikaoa-development-runtime" } }, repository: { params: { workspace_pvc_size: "8Gi" } }, } as Parameters[0]; expect(pipelineRunWorkspaces(binding, { workspaces: [{ name: "workspace" }] })).toEqual([{ diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index c0eb03ea..44d1c296 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -1,3 +1,4 @@ +// SPEC: PJ2026-03 PikaOA v0.8. import { createHash } from "node:crypto"; import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -19,10 +20,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, pikaoaReleaseSourceWorktreeRemote, pikaoaTestSourceWorktreeRemote } from "./pikaoa-test-delivery-renderer"; +import { renderPikaoaDesiredPipeline, pikaoaDevelopmentSourceWorktreeRemote, pikaoaReleaseSourceWorktreeRemote } from "./pikaoa-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" | "pikaoa-release-runtime"; +export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-development-runtime" | "pikaoa-release-runtime"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; @@ -502,7 +503,7 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree ? renderSub2RankDesiredPipeline(binding) : sourceArtifact.renderer === "selfmedia-runtime" ? renderSelfMediaPipeline(binding, sourceWorktree) - : renderPikaoaTestPipeline(binding, sourceWorktree); + : renderPikaoaPipeline(binding, sourceWorktree); const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane" ? rendered.pipeline : withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance); @@ -586,8 +587,8 @@ function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktr }; } -function renderPikaoaTestPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { - const rendered = renderPikaoaTestDesiredPipeline(binding, sourceWorktree); +function renderPikaoaPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { + const rendered = renderPikaoaDesiredPipeline(binding, sourceWorktree); return { pipeline: rendered.pipeline, provenance: { @@ -675,7 +676,7 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec ? "sub2rank" : provenance.renderer === "selfmedia-runtime" ? "selfmedia" - : provenance.renderer === "pikaoa-test-runtime" + : provenance.renderer === "pikaoa-development-runtime" || provenance.renderer === "pikaoa-release-runtime" ? "pikaoa" : "agentrun", ), @@ -857,7 +858,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor } 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" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) { + if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-development-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)) { @@ -875,7 +876,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" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime"))) { + if (name === "source" || (name === "workspace" && (binding.consumer.sourceArtifact.renderer === "pikaoa-development-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime"))) { return { name, volumeClaimTemplate: { @@ -953,7 +954,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-development-runtime") return pikaoaDevelopmentSourceWorktreeRemote(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; @@ -1074,7 +1075,7 @@ export function pacSourceArtifactProvenanceFromManifest(manifest: Record, path: s function parseSourceArtifact(value: Record, 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", "pikaoa-release-runtime"] as const); + const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-development-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); @@ -879,7 +880,7 @@ function parseSourceArtifact(value: Record, 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`); + if (renderer === "pikaoa-development-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-development-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, @@ -1221,7 +1222,7 @@ 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" || consumer.sourceArtifact?.renderer === "pikaoa-test-runtime") { + if (consumer.sourceArtifact?.renderer === "selfmedia-runtime" || consumer.sourceArtifact?.renderer === "pikaoa-development-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 repository`);