diff --git a/config/platform-db/postgres-nc01.yaml b/config/platform-db/postgres-nc01.yaml index 0294ea2d..29ba644d 100644 --- a/config/platform-db/postgres-nc01.yaml +++ b/config/platform-db/postgres-nc01.yaml @@ -213,6 +213,13 @@ objects: locale: C.UTF-8 extensions: [] +managedOperations: + monitorReset: + database: web_probe_monitor + owner: web_probe_monitor + schema: monitor + secretRef: platform-db/web-probe-monitor-nc01-db.env + exports: connectionStrings: - name: agentrun-nc01-v02-database-url diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index df2919b0..600e6bee 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -354,6 +354,26 @@ sourceAuthority: readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.git.readUrl disposition: replaced-by-gitea + - key: selfmedia-nc01 + targetId: NC01 + upstream: + repository: pikainc/selfmedia + cloneUrl: https://github.com/pikainc/selfmedia.git + branch: master + visibility: private + gitea: + owner: mirrors + name: pikainc-selfmedia + mirrorMode: controlled-push + publicRead: false + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + gitops: + branch: nc01-selfmedia-gitops + flushDisposition: gitea-writeback + snapshot: + naming: gitea-actions-immutable-source + prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 + legacyGitMirror: null targets: - id: JD01 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index c9ef5b00..9f00c1f0 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -162,6 +162,41 @@ repositories: variables: NODE: NC01 LANE: v03 + - id: selfmedia-nc01 + name: selfmedia-nc01 + namespace: selfmedia-ci + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikainc-selfmedia + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + owner: mirrors + repo: pikainc-selfmedia + secretName: pac-gitea-selfmedia-nc01 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + source_branch: master + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 + node: NC01 + pipeline_name: selfmedia-nc01-pac + pipeline_run_prefix: selfmedia-nc01 + service_account: selfmedia-nc01-tekton-runner + pipeline_timeout: 2h0m0s + image_repository: 127.0.0.1:5000/selfmedia/newsroom + registry_probe_base: http://127.0.0.1:5000 + gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + gitops_branch: nc01-selfmedia-gitops + gitops_username: unidesk-admin + gitops_secret_name: pac-gitea-selfmedia-nc01 + gitops_manifest_path: deploy/gitops/nc01/resources.yaml + runtime_namespace: selfmedia + runtime_deployment: selfmedia + runtime_service: selfmedia + runtime_service_port: "4317" + health_path: /healthz + health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -246,10 +281,13 @@ consumers: required: true markerValue: admission-pac-v2:platform-infra-sub2rank-nc01 executionServiceAccountName: sub2rank-nc01-tekton-runner - manageExecutionServiceAccount: true gitOps: repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git targetRevision: unidesk-host-gitops + runnerServiceAccount: + name: sub2rank-nc01-tekton-runner + automountServiceAccountToken: false + roleBindingName: sub2rank-nc01-tekton-runner sourceArtifact: mode: embedded-pipeline-spec renderer: sub2rank-platform-service @@ -279,6 +317,47 @@ consumers: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 + - id: selfmedia-nc01 + repositoryRef: selfmedia-nc01 + node: NC01 + lane: selfmedia + namespace: selfmedia-ci + pipeline: selfmedia-nc01-pac + pipelineRunPrefix: selfmedia-nc01 + argoNamespace: argocd + argoApplication: selfmedia-nc01 + closeoutGitOpsMirrorFlush: false + argoBootstrap: + project: default + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + targetRevision: nc01-selfmedia-gitops + path: deploy/gitops/nc01 + destinationNamespace: selfmedia + automated: true + repositoryCredential: + secretName: argocd-repo-selfmedia-nc01 + username: unidesk-admin + deliveryProvenance: + required: true + markerValue: admission-pac-v2:selfmedia-nc01 + executionServiceAccountName: selfmedia-nc01-tekton-runner + gitOps: + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + targetRevision: nc01-selfmedia-gitops + runnerServiceAccount: + name: selfmedia-nc01-tekton-runner + automountServiceAccountToken: false + roleBindingName: selfmedia-nc01-tekton-runner + sourceArtifact: + mode: embedded-pipeline-spec + renderer: selfmedia-runtime + configRef: config/selfmedia.yaml#delivery.targets.NC01 + pipelineRunPath: .tekton/selfmedia-nc01-pac.yaml + maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 templates: repositories: agentrunV02: diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 5c5c8143..763323c0 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -119,6 +119,25 @@ sources: - DATABASE_URL createIfMissing: enabled: false + externalFiles: + - sourceRef: ~/.env/TOKEN + type: raw-file + required: true + createIfMissing: + enabled: true + randomBase64Url: + bytes: 32 + prefix: smt_ + - sourceRef: ~/.codex/config.toml.pika + type: raw-file + required: true + createIfMissing: + enabled: false + - sourceRef: ~/.codex/auth.json.pika + type: raw-file + required: true + createIfMissing: + enabled: false targets: - id: platform-infra-g14 @@ -141,8 +160,32 @@ targets: namespace: hwlab-v03 scope: hwlab enabled: true + - id: selfmedia-nc01 + route: NC01:k3s + namespace: selfmedia + scope: selfmedia + enabled: true kubernetesSecrets: + - name: selfmedia-runtime + targetId: selfmedia-nc01 + secretName: selfmedia-runtime + type: Opaque + data: + - sourceRef: ~/.env/TOKEN + sourceKey: contents + targetKey: TOKEN + - name: selfmedia-codex + targetId: selfmedia-nc01 + secretName: selfmedia-codex + type: Opaque + data: + - sourceRef: ~/.codex/config.toml.pika + sourceKey: contents + targetKey: config.toml + - sourceRef: ~/.codex/auth.json.pika + sourceKey: contents + targetKey: auth.json - name: sub2rank-runtime targetId: sub2rank-nc01 secretName: sub2rank-secrets diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml new file mode 100644 index 00000000..82dacec9 --- /dev/null +++ b/config/selfmedia.yaml @@ -0,0 +1,262 @@ +version: 1 +kind: selfmedia-platform-delivery +metadata: + id: selfmedia-factory + owner: unidesk + repository: pikainc/selfmedia + description: 自媒体工厂在 NC01 的构建、GitOps、运行时与一次性切换真相。 +defaults: + targetId: NC01 +delivery: + targets: + NC01: + node: NC01 + lane: selfmedia + route: NC01:k3s + ci: + namespace: selfmedia-ci + pipeline: selfmedia-nc01-pac + pipelineRunPrefix: selfmedia-nc01 + serviceAccountName: selfmedia-nc01-tekton-runner + serviceAccountAutomount: false + roleBindingName: selfmedia-nc01-tekton-runner + workspaceSize: 16Gi + pipelineTimeout: 2h0m0s + toolImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + source: + repository: pikainc/selfmedia + worktreeRemote: https://github.com/pikainc/selfmedia.git + branch: master + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 + build: + dockerfile: deploy/Dockerfile + imageRepository: 127.0.0.1:5000/selfmedia/newsroom + networkMode: host + proxy: + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 + noProxy: + - localhost + - 127.0.0.1 + - "::1" + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - hyueapi.com + - .hyueapi.com + gitops: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git + branch: nc01-selfmedia-gitops + manifestPath: deploy/gitops/nc01/resources.yaml + releaseStatePath: deploy/gitops-state/nc01/release.json + credentialSecretName: pac-gitea-selfmedia-nc01 + credentialTokenKey: token + credentialUsername: unidesk-admin + author: + name: SelfMedia NC01 CI + email: selfmedia-nc01-ci@unidesk.local + deployment: + version: selfmedia.pikapython.com/v1 + kind: SelfMediaDeployment + metadata: + name: selfmedia-nc01 + owner: unidesk + target: + id: NC01 + route: NC01:k3s + namespace: selfmedia + publicHost: 152.53.229.148 + runtime: + replicas: 1 + serviceAccountName: selfmedia + imagePullPolicy: IfNotPresent + command: + - /usr/bin/tini + - -- + - bun + - scripts/selfmedia-cli.ts + - server + - foreground + service: + name: selfmedia + type: ClusterIP + port: 4317 + targetPort: 4317 + health: + path: /healthz + startupFailureThreshold: 30 + startupPeriodSeconds: 5 + readinessPeriodSeconds: 10 + livenessPeriodSeconds: 20 + resources: + requests: + cpu: 250m + memory: 768Mi + limits: + cpu: "4" + memory: 6Gi + securityContext: + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + runAsNonRoot: true + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + seccompProfile: RuntimeDefault + dropCapabilities: + - ALL + writablePaths: + tmp: + mountPath: /tmp + sizeLimit: 2Gi + codexHome: + mountPath: /home/codex/.codex + sizeLimit: 2Gi + cache: + mountPath: /home/codex/.cache + sizeLimit: 2Gi + toolCache: + mountPath: /app/.tools/cache + sizeLimit: 1Gi + publicExposure: + enabled: true + mode: hostPort-http-edge + address: http://152.53.229.148:4317 + hostPort: 4317 + edge: + name: selfmedia-public-edge + image: caddy:2.10.2-alpine + containerPort: 8080 + upstream: selfmedia.selfmedia.svc.cluster.local:4317 + resources: + requests: + cpu: 20m + memory: 32Mi + limits: + cpu: 200m + memory: 128Mi + storage: + media: + claimName: selfmedia-data + storageClassName: local-path + accessModes: + - ReadWriteOnce + size: 40Gi + mounts: + data: + mountPath: /app/data + subPath: data + state: + mountPath: /app/.state + subPath: state + logs: + mountPath: /app/logs + subPath: logs + codexSessions: + claimName: selfmedia-codex-sessions + storageClassName: local-path + accessModes: + - ReadWriteOnce + size: 5Gi + mounts: + sessions: + mountPath: /home/codex/.codex/sessions + subPath: sessions + state: + mountPath: /home/codex/.codex/state + subPath: state + secrets: + runtime: + sourceRef: ~/.env/TOKEN + target: + secretName: selfmedia-runtime + targetKey: TOKEN + mountPath: /home/codex/.env/TOKEN + codex: + sources: + - sourceRef: ~/.codex/config.toml.pika + targetKey: config.toml + - sourceRef: ~/.codex/auth.json.pika + targetKey: auth.json + target: + secretName: selfmedia-codex + files: + - targetKey: config.toml + mountPath: /home/codex/.codex/config.toml.pika + - targetKey: auth.json + mountPath: /home/codex/.codex/auth.json.pika + networkPolicy: + enabled: true + dnsNamespace: kube-system + dnsPort: 53 + blockedCidrs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + - 169.254.169.254/32 + - 152.53.229.148/32 + codex: + package: "@openai/codex" + version: 0.144.1 + executable: codex + home: /home/codex + codexHome: /home/codex/.codex + sessionsPath: /home/codex/.codex/sessions + supervisorStatePath: /home/codex/.codex/state/selfmedia-supervisor.json + resumeArgs: + - resume + - --last + sourcePath: /app + skillsPath: /home/codex/.codex/skills +cutover: + targets: + NC01: + mode: host-process-to-kubernetes + source: + workspace: /root/selfmedia + publicAddress: http://152.53.229.148:4317 + healthUrl: http://127.0.0.1:4317/healthz + stopCommand: + - bun + - scripts/selfmedia-cli.ts + - server + - stop + startCommand: + - bun + - scripts/selfmedia-cli.ts + - server + - start + dataPaths: + - data + - .state + - logs + excludes: + - .state/server.json + destination: + route: NC01:k3s + namespace: selfmedia + application: selfmedia-nc01 + publicAddress: http://152.53.229.148:4317 + healthUrl: http://152.53.229.148:4317/healthz + dataClaim: selfmedia-data + codexClaim: selfmedia-codex-sessions + prepare: + requiresFinalConfirmation: true + phases: + - 校验源服务健康、Secret presence/fingerprint 与目标 PVC + - 在线种子复制并记录文件数和字节数 + - 停止旧服务、执行最终增量并确认宿主 4317 已释放 + - 允许 Argo 首次同步并验证公网健康 + rollback: + phases: + - 暂停 selfmedia-nc01 Argo 自动同步 + - 缩容 selfmedia 与 selfmedia-public-edge + - 确认宿主 4317 已释放 + - 启动旧服务并验证原公网地址 + dataPolicy: 回滚默认不反向覆盖旧数据,需单独的数据恢复决策。 diff --git a/docs/MDTODO/cli-output-progressive-disclosure.md b/docs/MDTODO/cli-output-progressive-disclosure.md index 02a344e2..eda09bcf 100644 --- a/docs/MDTODO/cli-output-progressive-disclosure.md +++ b/docs/MDTODO/cli-output-progressive-disclosure.md @@ -130,3 +130,11 @@ ### R8.4 [completed] 补齐合并后验收发现的 [UniDesk #1890](https://github.com/pikasTech/unidesk/issues/1890) 残余:gh issue 默认 Next、readCommands、help、skill 与 reference 必须把 trans gh:/owner/repo/issue/ cat 作为人工正文读取首选,结构化 JSON/full/raw 仅作为显式机器披露,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R8.4_Task_Report.md)。 + +## R9 + +解决 [UniDesk #1905](https://github.com/pikasTech/unidesk/issues/1905):收敛 Artificer 一次性 GitHub 正文输入,直接使用 quoted heredoc stdin,不再被旧参考误导为先写临时正文文件;保持既有可复用 --body-file 能力,不扩大到 Monitor 主线,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R9_Task_Report.md)。 + +### R9.1 + +按 [UniDesk #1905](https://github.com/pikasTech/unidesk/issues/1905) 清理冲突的 reference/skill/Next,并在 Artificer YAML prompt/resource bundle 内明确支持 stdin 时禁止一次性 /tmp 正文;补最小 render/行为测试后独立交付,当前只登记不执行,完成任务后将详细报告写入[任务报告](./details/cli-output-progressive-disclosure/R9.1_Task_Report.md)。 diff --git a/docs/MDTODO/details/web-sentinel-runtime-reliability/R2.5.1_Task_Report.md b/docs/MDTODO/details/web-sentinel-runtime-reliability/R2.5.1_Task_Report.md new file mode 100644 index 00000000..d8bf9860 --- /dev/null +++ b/docs/MDTODO/details/web-sentinel-runtime-reliability/R2.5.1_Task_Report.md @@ -0,0 +1,11 @@ +# R2.5.1 任务报告 + +## 结论 + +- 已按 [#1873 最新决策](https://github.com/pikasTech/unidesk/issues/1873#issuecomment-4954659225) 冻结为空库切换:旧 SQLite 历史允许全部丢弃,不执行 export/import/verify,不把 legacy gap 或 payload conflict 作为门禁。 +- #1873 正文与 MDTODO 已同步为“从新数据开始”,后续 Artificer 不再收到迁移旧数据的冲突要求。 +- 切换只允许处理 Monitor 专属 `web_probe_monitor` database/schema;AgentRun、Decision Center 等 Host PG sibling database 不在 mutation 范围。 + +## 边界 + +不双写、不回读 SQLite、不补洞,也不新增迁移兼容层、合同、租约、安全机制或围栏。R2.5.2 仅负责受控空库与 schema 就绪证据。 diff --git a/docs/MDTODO/details/web-sentinel-runtime-reliability/R2.5.2_Task_Report.md b/docs/MDTODO/details/web-sentinel-runtime-reliability/R2.5.2_Task_Report.md new file mode 100644 index 00000000..20c17069 --- /dev/null +++ b/docs/MDTODO/details/web-sentinel-runtime-reliability/R2.5.2_Task_Report.md @@ -0,0 +1,18 @@ +# R2.5.2 任务报告 + +## 结论 + +- [PR #1903](https://github.com/pikasTech/unidesk/pull/1903) 已合并,merge commit 为 `0213fe0196515e99ceba2b2f84adfad90acbe7c2`,提供 YAML-first 的 Monitor 专属 Host PG `plan/status/reset --confirm` 受控入口。 +- owning YAML 精确限制 database=`web_probe_monitor`、owner=`web_probe_monitor`、schema=`monitor`;CLI 不接受任意 database 或 SQL 参数。 +- 空库操作已将 Monitor 业务行从 938 清为 0;未导入或读取 SQLite 历史。 + +## 验证 + +- 持久化 CLI 实测:`databaseExists=true`、`schemaReady=true`、`empty=true`、`ready=true`、`businessRows=0`,schema version=`2026-07-12-p17-3`。 +- sibling OID 摘要保持为 `agentrun_v02:59744,decision_center:119248`,证明未重建这两个数据库。 +- 独立定向测试 `8 pass / 0 fail`;`check --syntax-only` 为 `11/11`;`git diff --check` 通过。 +- 审查发现并修复 `status` 假绿:`ready=false` 现在同时让远端 shell、JSON `ok` 和上层命令退出失败。 + +## 后续 + +只允许 R2.5.3 / R2.6.3 从该空库实施 Release B,开始接收新数据;禁止再次清库或恢复 SQLite 历史。 diff --git a/docs/MDTODO/web-sentinel-runtime-reliability.md b/docs/MDTODO/web-sentinel-runtime-reliability.md index 7a6e8f1b..0a1a2632 100644 --- a/docs/MDTODO/web-sentinel-runtime-reliability.md +++ b/docs/MDTODO/web-sentinel-runtime-reliability.md @@ -51,15 +51,15 @@ PR 合并后只等待自动发布链,使用受控 Web sentinel 原入口产生 按 [#1873 用户最新决策](https://github.com/pikasTech/unidesk/issues/1873#issuecomment-4954659225) 放弃全部 SQLite 历史数据迁移,以空 Monitor Host PG 从新数据开始;先清空并验证 Monitor 专属数据库/schema,再完成 Release B 原子切换,SQLite、legacy gap 与 migration 不进入运行路径,不双写、不回读、不补洞,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2.5_Task_Report.md)。 -#### R2.5.1 [in_progress] +#### R2.5.1 [completed] 按 [#1873 最新决策](https://github.com/pikasTech/unidesk/issues/1873#issuecomment-4954659225) 冻结空库切换边界:取消 legacy gap、SQLite export/import/verify 和 payload conflict 作为门禁,确认只清空 Monitor 自有 Host PG database/schema,不影响共享 Host PG 其他服务,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2.5.1_Task_Report.md)。 -#### R2.5.2 +#### R2.5.2 [completed] 通过 YAML-first 受控入口按 [#1873 最新决策](https://github.com/pikasTech/unidesk/issues/1873#issuecomment-4954659225) 清空或重建 Monitor 专属 Host PG database/schema,验收 schema ready、业务数据 0 行且不读取 SQLite,再允许 Release B,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2.5.2_Task_Report.md)。 -#### R2.5.3 +#### R2.5.3 [in_progress] 空 Host PG 验证通过后实施 [#1873](https://github.com/pikasTech/unidesk/issues/1873) Release B,并与 R2.6 / [#1887](https://github.com/pikasTech/unidesk/issues/1887) 的 Monitor 前端重写同步收口:中心 service 成为唯一 writer/query authority,新数据从切换后开始;删除或屏蔽旧 SQLite/query/dashboard/fallback 与旧页面,只观察自动 CI/CD 并完成 CLI、Monitor Web 原入口验收,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2.5.3_Task_Report.md)。 @@ -75,6 +75,6 @@ PR 合并后只等待自动发布链,使用受控 Web sentinel 原入口产生 由 Artificer 按 [#1887](https://github.com/pikasTech/unidesk/issues/1887) 在独立 PR 实现全新 Monitor 页面组件、状态模型和样式,只消费 central Monitor API,覆盖筛选、timeline、详情、loading/empty/error/freshness 与刷新恢复,并运行最小前端定向测试,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2.6.2_Task_Report.md)。 -#### R2.6.3 +#### R2.6.3 [in_progress] 与 R2.5.3 的 Release B 协同合并 [#1887](https://github.com/pikasTech/unidesk/issues/1887):删除旧页面组件、旧 query/dashboard/fallback 和废弃 CSS,只观察自动 CI/CD,先 internal 后 public 原入口做 1920x1080/960x600 web-probe、深链刷新和截图验收,完成任务后将详细报告写入[任务报告](./details/web-sentinel-runtime-reliability/R2.6.3_Task_Report.md)。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 7a120349..32c9102c 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -444,6 +444,15 @@ async function main(): Promise { return; } + if (top === "selfmedia") { + const { runSelfMediaCommand } = await import("./src/selfmedia"); + const result = await runSelfMediaCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + if (top === "health") { const result = runHealthCommand(args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index 123a4811..f14ffb46 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -112,8 +112,6 @@ function evaluatePacAdmissionState(inputValue) { const role = record(input.role); const roleBinding = record(input.roleBinding); const expected = record(input.expected); - const executionServiceAccounts = Array.isArray(input.executionServiceAccounts) ? input.executionServiceAccounts.map(record) : []; - const expectedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts.map(record) : []; const namespace = stringOrNull(input.namespace); const policyMetadata = record(policy.metadata); const bindingMetadata = record(binding.metadata); @@ -169,31 +167,6 @@ function evaluatePacAdmissionState(inputValue) { if (namespace === null || subjects.length !== 1 || subjects[0].kind !== "ServiceAccount" || subjects[0].name !== "default" || subjects[0].namespace !== namespace) reasons.push("default-rolebinding-subject-mismatch"); if (typeof input.defaultCanMutate !== "boolean") reasons.push("default-serviceaccount-mutation-probe-unavailable"); if (input.defaultCanMutate === true) reasons.push("default-serviceaccount-can-mutate-tekton-runs"); - const executionServiceAccountState = expectedExecutionServiceAccounts.map((expectedServiceAccount) => { - const expectedNamespace = stringOrNull(expectedServiceAccount.namespace); - const expectedName = stringOrNull(expectedServiceAccount.name); - const expectedConsumerId = stringOrNull(expectedServiceAccount.consumerId); - const live = executionServiceAccounts.find((item) => { - const metadata = record(item.metadata); - return metadata.namespace === expectedNamespace && metadata.name === expectedName; - }); - const metadata = record(live?.metadata); - const annotations = record(metadata.annotations); - const labels = record(metadata.labels); - const identity = expectedConsumerId || expectedName || "unknown"; - if (live === undefined) reasons.push(`execution-serviceaccount-missing:${identity}`); - if (live !== undefined && live.automountServiceAccountToken !== false) reasons.push(`execution-serviceaccount-automount-not-false:${identity}`); - if (live !== undefined && annotations["unidesk.ai/effective-config-sha256"] !== expected.rbacConfigSha256) reasons.push(`execution-serviceaccount-config-sha256-mismatch:${identity}`); - if (live !== undefined && labels["unidesk.ai/pac-consumer"] !== expectedConsumerId) reasons.push(`execution-serviceaccount-consumer-mismatch:${identity}`); - return { - consumerId: expectedConsumerId, - namespace: expectedNamespace, - name: expectedName, - present: live !== undefined, - automountServiceAccountToken: live === undefined ? null : live.automountServiceAccountToken === false ? false : true, - configSha256: stringOrNull(annotations["unidesk.ai/effective-config-sha256"]), - }; - }); const timestamps = [policyMetadata.creationTimestamp, bindingMetadata.creationTimestamp] .filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value))) .sort((left, right) => Date.parse(left) - Date.parse(right)); @@ -216,7 +189,6 @@ function evaluatePacAdmissionState(inputValue) { observedGeneration: Number.isInteger(policyStatus.observedGeneration) ? policyStatus.observedGeneration : null, rbacConfigSha256: stringOrNull(roleAnnotations["unidesk.ai/effective-config-sha256"]), defaultCanMutate: typeof input.defaultCanMutate === "boolean" ? input.defaultCanMutate : null, - executionServiceAccounts: executionServiceAccountState, activeAfter, reasons, valuesPrinted: false, diff --git a/scripts/src/agentrun-managed-repository-reconciler.test.ts b/scripts/src/agentrun-managed-repository-reconciler.test.ts index f5ecf449..96283194 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.test.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.test.ts @@ -91,7 +91,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { "RoleBinding", "Role", "RoleBinding", - "ServiceAccount", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", diff --git a/scripts/src/cicd-delivery-authority.test.ts b/scripts/src/cicd-delivery-authority.test.ts index 262f91ae..fc9a06c8 100644 --- a/scripts/src/cicd-delivery-authority.test.ts +++ b/scripts/src/cicd-delivery-authority.test.ts @@ -51,9 +51,9 @@ const pacEvaluator = createRequire(import.meta.url)("../native/cicd/pac-status-e describe("YAML 组合的 CI/CD delivery authority", () => { test("组合 PaC 与 Gitea YAML,覆盖所有实际 consumer 且 id 唯一", () => { const catalog = readCicdDeliveryAuthorityCatalog(); - expect(catalog.consumers).toHaveLength(9); - expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(9); - for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01", "platform-infra-sub2rank-nc01"]) { + expect(catalog.consumers).toHaveLength(10); + expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(10); + for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]) { const authority = resolveCicdDeliveryAuthority({ consumerId }); expect(authority.kind).toBe("pac-pr-merge"); expect(authority.mutationHintsAllowed).toBe(false); diff --git a/scripts/src/platform-db-monitor-reset.test.ts b/scripts/src/platform-db-monitor-reset.test.ts new file mode 100644 index 00000000..c0e5504d --- /dev/null +++ b/scripts/src/platform-db-monitor-reset.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { rootPath } from "./config"; +import { readYamlRecord } from "./platform-infra-ops-library"; +import { monitorResetScript, monitorStatusScript, monitorStatusSucceeded, runPlatformDbCommand } from "./platform-db"; + +const configPath = "config/platform-db/postgres-nc01.yaml"; +const parsed = readYamlRecord>(rootPath(configPath), "postgres-host-cluster"); +const pg = { objects: parsed.objects }; +const target = parsed.managedOperations.monitorReset; + +describe("platform-db Monitor 专属空库入口", () => { + test("plan 精确显示 YAML 目标且默认零写入", async () => { + const result = await runPlatformDbCommand({} as never, ["postgres", "monitor", "plan", "--config", configPath]); + + expect(result).toMatchObject({ + ok: true, + target: "web-probe-monitor", + config: configPath, + database: "web_probe_monitor", + owner: "web_probe_monitor", + schema: "monitor", + secretRef: "platform-db/web-probe-monitor-nc01-db.env", + mutation: false, + valuesPrinted: false, + }); + expect(JSON.stringify(result)).not.toContain("PASSWORD"); + expect(JSON.stringify(result)).not.toContain("DATABASE_URL"); + }); + + test("reset 未确认时只返回计划", async () => { + const result = await runPlatformDbCommand({} as never, ["postgres", "monitor", "reset", "--config", configPath]); + + expect(result).toMatchObject({ ok: true, mutation: false, database: "web_probe_monitor" }); + expect(result).not.toHaveProperty("confirmed"); + }); + + test("确认脚本只重建 Monitor database 并应用 central schema", () => { + const script = monitorResetScript(pg as never, target); + + expect(script).toContain("database='web_probe_monitor'"); + expect(script).toContain("owner='web_probe_monitor'"); + expect(script).toContain('drop database if exists "web_probe_monitor";'); + expect(script).toContain("create schema if not exists monitor;"); + expect(script).toContain('set role "web_probe_monitor";'); + expect(script).not.toContain("drop database agentrun_v02"); + expect(script).not.toContain("drop database decision_center"); + }); + + test("status/reset 验证 schema ready、业务 0 行与其他数据库 OID 不变", () => { + const status = monitorStatusScript(pg as never, target); + const reset = monitorResetScript(pg as never, target); + + expect(status).toContain('\"schemaReady\":%s'); + expect(status).toContain('\"businessRows\":%s'); + expect(status).toContain('\"siblingDatabaseOids\":\"%s\"'); + expect(reset).toContain('[ "$before" != "$after" ]'); + expect(reset).toContain('[ "$rows" != "0" ]'); + expect(reset).toContain('\"siblingDatabasesUnchanged\":true'); + }); + + test("status ready=true 且远端退出 0 时成功", () => { + expect(monitorStatusSucceeded(0, { ok: true, ready: true, schemaReady: true, empty: true, businessRows: 0 })).toBe(true); + }); + + test("status ready=false 时失败,即使远端误返回退出 0", () => { + expect(monitorStatusSucceeded(0, { ok: false, ready: false, schemaReady: true, empty: false, businessRows: 1 })).toBe(false); + expect(monitorStatusSucceeded(0, { ok: true, ready: false, schemaReady: false, empty: false, businessRows: null })).toBe(false); + expect(monitorStatusSucceeded(1, { ok: false, ready: false })).toBe(false); + }); +}); diff --git a/scripts/src/platform-db-nc01-monitor-config.test.ts b/scripts/src/platform-db-nc01-monitor-config.test.ts index 4da08779..8df91c41 100644 --- a/scripts/src/platform-db-nc01-monitor-config.test.ts +++ b/scripts/src/platform-db-nc01-monitor-config.test.ts @@ -35,6 +35,7 @@ describe("NC01 Host PG Monitor declaration", () => { consumers: [{ scope: "hwlab-web-probe-monitor", secret: "hwlab-web-probe-monitor-db", key: "DATABASE_URL" }], }); expect(monitorExport.render.format).not.toContain("web_probe_monitor:"); + expect(config.managedOperations.monitorReset).toEqual({ database: "web_probe_monitor", owner: "web_probe_monitor", schema: "monitor", secretRef: "platform-db/web-probe-monitor-nc01-db.env" }); }); test("既有应用 role 保持原有独立 sourceRef", () => { diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index c261b541..96141acd 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -5,6 +5,7 @@ import { dirname, isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { repoRoot, rootPath } from "./config"; import { startJob } from "./jobs"; +import { MONITOR_CENTRAL_MIGRATIONS, MONITOR_CENTRAL_SCHEMA_VERSION } from "./monitor-central-persistence"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, parseJsonOutput } from "./platform-infra-ops-library"; @@ -21,6 +22,13 @@ interface PlatformDbOptions { raw: boolean; } +interface MonitorResetConfig { + database: string; + owner: string; + schema: string; + secretRef: string; +} + interface PostgresHostConfig { configPath: string; version: number; @@ -139,6 +147,7 @@ interface PostgresHostConfig { encryption: { enabled: boolean }; }; }; + managedOperations?: { monitorReset: MonitorResetConfig }; } interface PgHbaRule { @@ -300,6 +309,9 @@ export function platformDbHelp(): unknown { `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`, + "bun scripts/cli.ts platform-db postgres monitor plan --config config/platform-db/postgres-nc01.yaml", + "bun scripts/cli.ts platform-db postgres monitor status --config config/platform-db/postgres-nc01.yaml", + "bun scripts/cli.ts platform-db postgres monitor reset --config config/platform-db/postgres-nc01.yaml --confirm", ], description: "Manage YAML-declared host-native PostgreSQL for UniDesk platform state. The PK01 declaration uses PostgreSQL 16 for Sub2API and does not deploy k3s.", safety: { @@ -307,6 +319,7 @@ export function platformDbHelp(): unknown { defaultMutation: false, exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.", apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.", + monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.", redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.", noK3s: "PK01 PostgreSQL is host-systemd, not Docker or k3s.", }, @@ -316,6 +329,11 @@ export function platformDbHelp(): unknown { export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]): Promise> { const [target, action = "plan"] = args; if (target !== "postgres") return unsupported(args); + if (action === "monitor") { + const monitorAction = args[2] ?? "plan"; + const options = parseOptions(args.slice(3)); + return await monitorCommand(config, monitorAction, options); + } const options = parseOptions(args.slice(2)); if (action === "plan") return await plan(config, options); if (action === "status") return await status(config, options); @@ -324,6 +342,53 @@ export async function runPlatformDbCommand(config: UniDeskConfig, args: string[] return unsupported(args); } +async function monitorCommand(config: UniDeskConfig, action: string, options: PlatformDbOptions): Promise> { + if (!(["plan", "status", "reset"] as const).includes(action as "plan" | "status" | "reset")) return unsupported(["postgres", "monitor", action]); + const pg = readPostgresHostConfig(options.configPath); + const target = requireMonitorResetConfig(pg); + const base = { + action: `platform-db-postgres-monitor-${action}`, + target: "web-probe-monitor", + config: pg.configPath, + database: target.database, + owner: target.owner, + schema: target.schema, + schemaVersion: MONITOR_CENTRAL_SCHEMA_VERSION, + secretRef: target.secretRef, + valuesPrinted: false, + }; + if (action === "plan" || (action === "reset" && !options.confirm)) { + return { + ok: true, + ...base, + mutation: false, + plan: monitorResetPlan(target), + next: { + status: `bun scripts/cli.ts platform-db postgres monitor status --config ${pg.configPath}`, + confirm: `bun scripts/cli.ts platform-db postgres monitor reset --config ${pg.configPath} --confirm`, + }, + }; + } + const script = action === "status" ? monitorStatusScript(pg, target) : monitorResetScript(pg, target); + const capture = await runSshCommandCapture(config, pg.node.route, ["sh"], script); + const parsed = parseJsonOutput(capture.stdout); + const commandOk = action === "status" + ? monitorStatusSucceeded(capture.exitCode, parsed) + : capture.exitCode === 0 && parsed?.ok === true; + return { + ok: commandOk, + ...base, + mutation: action === "reset", + ...(action === "reset" ? { confirmed: true } : {}), + state: parsed, + remote: compactCapture(capture, { full: options.full || capture.exitCode !== 0 }), + }; +} + +export function monitorStatusSucceeded(exitCode: number, parsed: Record | null): boolean { + return exitCode === 0 && parsed?.ok === true && parsed.ready === true; +} + function unsupported(args: string[]): Record { return { ok: false, @@ -589,6 +654,7 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { const objects = objectField(parsed, "objects", configPath); const exportsConfig = objectField(parsed, "exports", configPath); const backup = objectField(parsed, "backup", configPath); + const managedOperations = parsed.managedOperations === undefined ? null : objectField(parsed, "managedOperations", configPath); const logicalDump = objectField(backup, "logicalDump", `${configPath}.backup`); const destination = objectField(logicalDump, "destination", `${configPath}.backup.logicalDump`); const encryption = objectField(logicalDump, "encryption", `${configPath}.backup.logicalDump`); @@ -618,6 +684,13 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { databaseNames.add(database.name); } const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`)); + const monitorReset = managedOperations === null ? null : monitorResetConfig(objectField(managedOperations, "monitorReset", `${configPath}.managedOperations`), `${configPath}.managedOperations.monitorReset`); + if (monitorReset !== null) { + const database = databases.find((item) => item.name === monitorReset.database); + if (monitorReset.database !== "web_probe_monitor" || monitorReset.owner !== "web_probe_monitor" || monitorReset.schema !== "monitor" || database?.owner !== monitorReset.owner) { + throw new Error(`${configPath}.managedOperations.monitorReset is restricted to the dedicated web_probe_monitor database/schema`); + } + } return { configPath: relativeConfigPath(configPath), version, @@ -729,6 +802,16 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { }, }, }, + ...(monitorReset === null ? {} : { managedOperations: { monitorReset } }), + }; +} + +function monitorResetConfig(item: Record, path: string): MonitorResetConfig { + return { + database: pgIdentifier(stringField(item, "database", path), `${path}.database`), + owner: pgIdentifier(stringField(item, "owner", path), `${path}.owner`), + schema: pgIdentifier(stringField(item, "schema", path), `${path}.schema`), + secretRef: stringField(item, "secretRef", path), }; } @@ -988,6 +1071,91 @@ function configSummary(pg: PostgresHostConfig): Record { }; } +function requireMonitorResetConfig(pg: PostgresHostConfig): MonitorResetConfig { + const target = pg.managedOperations?.monitorReset; + if (target === undefined) throw new Error(`${pg.configPath}.managedOperations.monitorReset is required`); + return target; +} + +function monitorResetPlan(target: MonitorResetConfig): string[] { + return [ + `terminate connections only to ${target.database}`, + `drop and recreate only ${target.database} owned by ${target.owner}`, + `apply central Monitor schema ${MONITOR_CENTRAL_SCHEMA_VERSION} as ${target.owner}`, + "verify schema ready and Monitor business row count is 0", + "verify sibling YAML-declared database OIDs are unchanged", + ]; +} + +export function monitorStatusScript(pg: PostgresHostConfig, target: MonitorResetConfig): string { + const siblings = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => database.name).join(","); + const siblingArray = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => sqlLiteral(database.name)).join(", "); + return `${monitorScriptPrelude()} +database=${shellQuote(target.database)} +schema_version=${shellQuote(MONITOR_CENTRAL_SCHEMA_VERSION)} +siblings=${shellQuote(siblings)} +database_oid=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select oid from pg_database where datname = ${sqlLiteral(target.database)};") +sibling_oids=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});") +schema_ready=false +schema_version_observed="" +business_rows="" +if [ -n "$database_oid" ]; then + migration_table=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select coalesce(to_regclass('monitor.schema_migrations')::text, '');") + if [ -n "$migration_table" ]; then + schema_version_observed=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select coalesce((select version from monitor.schema_migrations order by applied_at desc limit 1), '');") + fi + if [ "$schema_version_observed" = "$schema_version" ]; then + schema_ready=true + business_rows=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select (select count(*) from monitor.runners) + (select count(*) from monitor.runs) + (select count(*) from monitor.run_payloads) + (select count(*) from monitor.findings) + (select count(*) from monitor.artifact_locators) + (select count(*) from monitor.timeline_events) + (select count(*) from monitor.runtime_metadata) + (select count(*) from monitor.import_manifests);") + fi +fi +empty=false +ready=false +if [ "$schema_ready" = true ] && [ "$business_rows" = "0" ]; then empty=true; ready=true; fi +printf '{"ok":%s,"databaseExists":%s,"schemaReady":%s,"empty":%s,"ready":%s,"databaseOid":"%s","schemaVersion":"%s","businessRows":%s,"siblingDatabaseOids":"%s"}\n' "$ready" "$([ -n "$database_oid" ] && printf true || printf false)" "$schema_ready" "$empty" "$ready" "$database_oid" "$schema_version_observed" "$([ -n "$business_rows" ] && printf '%s' "$business_rows" || printf null)" "$sibling_oids" +if [ "$ready" != true ]; then exit 1; fi +`; +} + +export function monitorResetScript(pg: PostgresHostConfig, target: MonitorResetConfig): string { + const siblings = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => database.name).join(","); + const siblingArray = pg.objects.databases.filter((database) => database.name !== target.database).map((database) => sqlLiteral(database.name)).join(", "); + const migrations = MONITOR_CENTRAL_MIGRATIONS.map((statement) => `${statement};`).join("\n"); + return `${monitorScriptPrelude()} +database=${shellQuote(target.database)} +owner=${shellQuote(target.owner)} +siblings=${shellQuote(siblings)} +before=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});") +sudo -n runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d postgres <<'SQL' +select pg_terminate_backend(pid) from pg_stat_activity where datname = ${sqlLiteral(target.database)} and pid <> pg_backend_pid(); +drop database if exists "${target.database}"; +create database "${target.database}" owner "${target.owner}" encoding 'UTF8' locale 'C.UTF-8' template template0; +SQL +sudo -n runuser -u postgres -- psql -v ON_ERROR_STOP=1 -d "$database" <<'SQL' +set role "${target.owner}"; +${migrations} +insert into monitor.schema_migrations (version, applied_at) values (${sqlLiteral(MONITOR_CENTRAL_SCHEMA_VERSION)}, now()) on conflict (version) do nothing; +SQL +after=$(sudo -n runuser -u postgres -- psql -At -d postgres -c "select coalesce(string_agg(datname || ':' || oid, ',' order by datname), '') from pg_database where datname in (${siblingArray});") +rows=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select (select count(*) from monitor.runners) + (select count(*) from monitor.runs) + (select count(*) from monitor.run_payloads) + (select count(*) from monitor.findings) + (select count(*) from monitor.artifact_locators) + (select count(*) from monitor.timeline_events) + (select count(*) from monitor.runtime_metadata) + (select count(*) from monitor.import_manifests);") +version=$(sudo -n runuser -u postgres -- psql -At -d "$database" -c "select version from monitor.schema_migrations order by applied_at desc limit 1;") +if [ "$before" != "$after" ] || [ "$rows" != "0" ] || [ "$version" != ${shellQuote(MONITOR_CENTRAL_SCHEMA_VERSION)} ]; then exit 1; fi +printf '{"ok":true,"database":"%s","schema":"monitor","schemaVersion":"%s","businessRows":0,"siblingDatabasesUnchanged":true}\n' "$database" "$version" +`; +} + +function monitorScriptPrelude(): string { + return `set -eu +if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then + printf '{"ok":false,"error":"sudo-noninteractive-unavailable"}\n' + exit 1 +fi`; +} + +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + function inspectSecrets(pg: PostgresHostConfig, materialize: boolean): SecretInspection { const root = secretRoot(pg); const materials = new Map(); diff --git a/scripts/src/platform-infra-gitea-config.ts b/scripts/src/platform-infra-gitea-config.ts index c3e6436a..dcdaebb3 100644 --- a/scripts/src/platform-infra-gitea-config.ts +++ b/scripts/src/platform-infra-gitea-config.ts @@ -277,6 +277,7 @@ export interface GiteaMirrorRepository { repository: string; cloneUrl: string; branch: string; + visibility: "public" | "private"; }; gitea: { owner: string; @@ -296,7 +297,7 @@ export interface GiteaMirrorRepository { readUrl: string; configRef: string; disposition: "replaced-by-gitea" | "retained-for-gitops-flush" | "migration-readonly"; - }; + } | null; } export function readGiteaConfig(): GiteaConfig { @@ -653,7 +654,9 @@ function parseMirrorRepository(record: Record, index: number): const gitea = y.objectField(record, "gitea", path); const gitops = y.objectField(record, "gitops", path); const snapshot = y.objectField(record, "snapshot", path); - const legacyGitMirror = y.objectField(record, "legacyGitMirror", path); + const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined + ? null + : y.objectField(record, "legacyGitMirror", path); return { key: y.stringField(record, "key", path), targetId: y.stringField(record, "targetId", path), @@ -661,6 +664,7 @@ function parseMirrorRepository(record: Record, index: number): repository: repositoryField(upstream, "repository", `${path}.upstream`), cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`), branch: y.stringField(upstream, "branch", `${path}.upstream`), + visibility: upstream.visibility === undefined ? "public" : y.enumField(upstream, "visibility", `${path}.upstream`, ["public", "private"] as const), }, gitea: { owner: y.stringField(gitea, "owner", `${path}.gitea`), @@ -676,7 +680,7 @@ function parseMirrorRepository(record: Record, index: number): snapshot: { prefix: refPrefixField(snapshot, "prefix", `${path}.snapshot`), }, - legacyGitMirror: { + legacyGitMirror: legacyGitMirror === null ? null : { readUrl: urlField(legacyGitMirror, "readUrl", `${path}.legacyGitMirror`), configRef: y.stringField(legacyGitMirror, "configRef", `${path}.legacyGitMirror`), disposition: y.enumField(legacyGitMirror, "disposition", `${path}.legacyGitMirror`, ["replaced-by-gitea", "retained-for-gitops-flush", "migration-readonly"] as const), @@ -816,6 +820,7 @@ function validateConfig(gitea: GiteaConfig): void { const readUrl = new URL(repo.gitea.readUrl); if (readUrl.hostname !== `${gitea.app.serviceName}.${resolveTarget(gitea, repo.targetId).namespace}.svc.cluster.local`) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must use the internal k3s Gitea Service DNS`); if (readUrl.hostname === new URL(gitea.app.publicExposure.publicBaseUrl).hostname) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.readUrl must not use the public Web UI hostname`); + if (repo.upstream.visibility === "private" && repo.gitea.publicRead) throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.gitea.publicRead must be false for a private upstream`); } } diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index c0bc2342..011bc60e 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -80,7 +80,8 @@ test("owning YAML renders one child Application and the complete durable bridge "RoleBinding", "Role", "RoleBinding", - "ServiceAccount", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", @@ -91,7 +92,7 @@ test("owning YAML renders one child Application and the complete durable bridge .filter((item) => item.kind === "Role" && item.metadata?.name === "unidesk-pac-consumer-runner") .map((item) => item.metadata.namespace) .sort(); - expect(pacReadOnlyNamespaces).toEqual(["agentrun-ci", "devops-infra"]); + expect(pacReadOnlyNamespaces).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]); const candidate = documents.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "gitea-github-sync-candidate"); const activeConfig = documents.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "gitea-github-sync-config"); const bridge = documents.find((item) => item.kind === "Deployment" && item.metadata?.name === "gitea-github-sync"); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index b5ba9dec..075a81f6 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -515,7 +515,8 @@ for i, repo in enumerate(repos): key = repo["key"] source_branch = repo["upstream"]["branch"] gitops_branch = repo["gitops"]["branch"] - branches = [source_branch] + ([] if gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch]) + sync_gitops_from_upstream = repo["gitops"].get("flushDisposition") != "gitea-writeback" + branches = [source_branch] + ([] if not sync_gitops_from_upstream or gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch]) work = f"$tmp/repo-{i}.git" gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path script += [ @@ -525,13 +526,14 @@ for i, repo in enumerate(repos): f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc", f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc", ] - if gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch": + if sync_gitops_from_upstream and gitops_branch != source_branch and gitops_branch != "not-a-gitops-branch": script.append(f"if [ \"$fetch_rc\" -eq 0 ]; then gitops_sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(gitops_branch)} 2>/dev/null || true); else gitops_sha=''; fi") script.append(f"if [ -n \"$gitops_sha\" ] && [ \"$push_rc\" -eq 0 ]; then git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $gitops_sha:refs/heads/{shlex.quote(gitops_branch)} >>$tmp/{key}.push.out 2>>$tmp/{key}.push.err; gitops_push_rc=$?; else gitops_push_rc=0; fi; printf '%s' \"$gitops_push_rc\" >$tmp/{key}.gitops-push.rc") else: script.append(f"printf '%s' '0' >$tmp/{key}.gitops-push.rc") script.append(f"if [ \"$push_rc\" -eq 0 ]; then printf '%s %s\\n' \"$sha\" \"$snapshot_ref\" >$tmp/{key}.bundle; fi") - meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"]}) + legacy = repo.get("legacyGitMirror") or {} + meta.append({"key": key, "sourceBranch": source_branch, "gitopsBranch": gitops_branch, "gitopsUpstreamSync": sync_gitops_from_upstream, "readUrl": repo["gitea"]["readUrl"], "writeUrlHost": urllib.parse.urlparse(gitea_write_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": legacy.get("readUrl"), "legacyDisposition": legacy.get("disposition", "native-gitea-only")}) open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2) PY @@ -621,7 +623,8 @@ for repo in repos: gitops = repo["gitops"]["branch"] gitea_probe_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path script.append(f"git -c http.extraHeader=\"$GITEA_AUTH_HEADER\" ls-remote {shlex.quote(gitea_probe_url)} refs/heads/{shlex.quote(branch)} '{shlex.quote(repo['snapshot']['prefix'])}/*' refs/heads/{shlex.quote(gitops)} >$tmp/{key}.ls.out 2>$tmp/{key}.ls.err") - meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": repo["legacyGitMirror"]["readUrl"], "legacyDisposition": repo["legacyGitMirror"]["disposition"]}) + legacy = repo.get("legacyGitMirror") or {} + meta.append({"key": key, "branch": branch, "gitopsBranch": gitops, "readUrl": repo["gitea"]["readUrl"], "probeUrlHost": urllib.parse.urlparse(gitea_probe_url).netloc, "snapshotPrefix": repo["snapshot"]["prefix"], "legacyReadUrl": legacy.get("readUrl"), "legacyDisposition": legacy.get("disposition", "native-gitea-only")}) open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n") json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False) PY diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index ab5468ca..20b7c759 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -1483,13 +1483,14 @@ function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Rec key: repo.key, upstreamRepository: repo.upstream.repository, upstreamBranch: repo.upstream.branch, + upstreamVisibility: repo.upstream.visibility, giteaOwner: repo.gitea.owner, giteaRepo: repo.gitea.name, mirrorMode: repo.gitea.mirrorMode, readUrl: repo.gitea.readUrl, snapshotPrefix: repo.snapshot.prefix, - legacyReadUrl: repo.legacyGitMirror.readUrl, - legacyDisposition: repo.legacyGitMirror.disposition, + legacyReadUrl: repo.legacyGitMirror?.readUrl ?? null, + legacyDisposition: repo.legacyGitMirror?.disposition ?? "native-gitea-only", sourceAuthority: "gitea", rootUrlMatches: repo.gitea.readUrl.startsWith(gitea.app.server.rootUrl), }; diff --git a/scripts/src/platform-infra-pac-consumer-rbac.ts b/scripts/src/platform-infra-pac-consumer-rbac.ts index 12963abc..58ee9a87 100644 --- a/scripts/src/platform-infra-pac-consumer-rbac.ts +++ b/scripts/src/platform-infra-pac-consumer-rbac.ts @@ -5,12 +5,6 @@ import { readPacAdmissionContract } from "./platform-infra-pac-provenance"; export interface PacConsumerRbacDesiredIdentity { readonly configSha256: string; readonly namespaces: readonly string[]; - readonly executionServiceAccounts: readonly { - readonly consumerId: string; - readonly namespace: string; - readonly name: string; - readonly automountServiceAccountToken: false; - }[]; } const pacConsumerReadOnlyRules = [ @@ -25,7 +19,7 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string { const namespaces = [...new Set(consumers.map((consumer) => consumer.namespace))]; if (namespaces.length === 0) return ""; const configSha256 = pacConsumerRbacDesiredIdentity(targetId).configSha256; - const objects: Record[] = namespaces.flatMap((namespace) => { + const objects = namespaces.flatMap((namespace) => { const labels = { "app.kubernetes.io/managed-by": "unidesk", "app.kubernetes.io/part-of": "platform-infra", @@ -51,25 +45,6 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string { }, ]; }); - objects.push(...consumers.filter((consumer) => consumer.manageExecutionServiceAccount).map((consumer) => ({ - apiVersion: "v1", - kind: "ServiceAccount", - metadata: { - name: consumer.executionServiceAccountName, - namespace: consumer.namespace, - labels: { - "app.kubernetes.io/managed-by": "unidesk", - "app.kubernetes.io/part-of": "platform-infra", - "unidesk.ai/pac-rbac-contract": "admission-provenance-required", - "unidesk.ai/pac-consumer": consumer.id, - }, - annotations: { - "argocd.argoproj.io/sync-wave": "-2", - "unidesk.ai/effective-config-sha256": configSha256, - }, - }, - automountServiceAccountToken: false, - }))); return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; } @@ -85,18 +60,8 @@ export function pacConsumerRbacDesiredIdentity(targetId: string): PacConsumerRba roleRef: pacConsumerDefaultRoleRef, }, })); - const executionServiceAccounts = consumers - .filter((consumer) => consumer.manageExecutionServiceAccount) - .map((consumer) => ({ - consumerId: consumer.id, - namespace: consumer.namespace, - name: consumer.executionServiceAccountName, - automountServiceAccountToken: false as const, - })) - .sort((left, right) => `${left.namespace}/${left.name}`.localeCompare(`${right.namespace}/${right.name}`)); return { - configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, executionServiceAccounts, contract: "admission-provenance-required-v2" })).digest("hex")}`, + configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, contract: "admission-provenance-required-v1" })).digest("hex")}`, namespaces, - executionServiceAccounts, }; } diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 26c3f28f..ab7c46c1 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -68,7 +68,7 @@ test("versioned admission desired state protects controller proof, candidate ide toState: "started", }]); expect(contract.child.enabled).toBe(false); - expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01"]); + expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); const policy = items[0]; @@ -90,6 +90,7 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain("agentrun-nc01-v02-tekton-runner"); expect(expressions).toContain("sub2rank-nc01-tekton-runner"); expect(expressions).not.toContain('serviceAccountName == "default"'); + expect(expressions).toContain("selfmedia-nc01-tekton-runner"); expect(messages).toContain("execution ServiceAccount"); expect(expressions).toContain(contract.child.parentUidAnnotation); expect(expressions).toContain("oldObject"); @@ -125,33 +126,29 @@ test("versioned admission desired state protects controller proof, candidate ide expect(queueGuard.expression.match(/pipelines-as-code-watcher/gu)).toHaveLength(1); expect(queueGuard.expression.match(/agentrun-nc01-v02-tekton-runner/gu)).toHaveLength(2); expect(queueGuard.expression.match(/sub2rank-nc01-tekton-runner/gu)).toHaveLength(2); + expect(queueGuard.expression.match(/selfmedia-nc01-tekton-runner/gu)).toHaveLength(2); expect(queueGuard.expression).not.toContain("admission-pac-v1:"); expect(queueGuard.expression).not.toContain('== "Cancelled"'); }); -test("required consumers have one automatic desired owner per namespace and no Tekton mutation verbs", () => { +test("required consumer default RBAC has one automatic desired owner per namespace and no Tekton mutation verbs", () => { const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "ServiceAccount"]); - expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra", "devops-infra"]); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]); + expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]); for (const role of rbac.filter((item) => item.kind === "Role")) { expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); const verbs = role.rules.flatMap((rule: any) => rule.verbs); for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); } - const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount"); - expect(executionServiceAccount).toMatchObject({ - metadata: { name: "sub2rank-nc01-tekton-runner", namespace: "devops-infra" }, - automountServiceAccountToken: false, - }); - expect(executionServiceAccount.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-2"); const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind); expect(desiredKinds).toEqual([ "Role", "RoleBinding", "Role", "RoleBinding", - "ServiceAccount", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", @@ -164,7 +161,6 @@ test("live admission evaluator requires exact desired hashes, observed generatio const contract = readPacAdmissionContract(); const admission = documents(renderPacAdmissionDesiredFragment("NC01")); const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount"); const policy = { ...admission[0], metadata: { ...admission[0].metadata, uid: "policy-uid", generation: 2, creationTimestamp: "2026-01-01T00:00:00Z" }, status: { observedGeneration: 2, typeChecking: { expressionWarnings: [] } } }; const binding = { ...admission[1], metadata: { ...admission[1].metadata, uid: "binding-uid", creationTimestamp: "2026-01-01T00:00:01Z" } }; const expected = { @@ -175,14 +171,9 @@ test("live admission evaluator requires exact desired hashes, observed generatio controllerIdentity: contract.controllerUsername, configSha256: policy.metadata.annotations["unidesk.ai/effective-config-sha256"], rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"], - managedExecutionServiceAccounts: pacConsumerRbacDesiredIdentity("NC01").executionServiceAccounts, }; - const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, executionServiceAccounts: [executionServiceAccount], expected }; + const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected }; expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false }); - expect(evaluator.evaluatePacAdmissionState({ ...input, executionServiceAccounts: [] })).toMatchObject({ - ready: false, - reasons: expect.arrayContaining(["execution-serviceaccount-missing:platform-infra-sub2rank-nc01"]), - }); expect(evaluator.evaluatePacAdmissionState({ ...input, policy: { @@ -377,6 +368,15 @@ test("history evaluates admission and default RBAC in each consumer namespace", expect(remote).toContain("'delivery plan source commit does not match the selected PipelineRun'"); expect(remote).not.toContain("'g14-ci-plan structured evidence is missing'"); expect(remote).not.toContain("for (const consumer of consumers) consumer.admissionState = admissionState"); + expect(remote).toContain("consumer_bootstrap_state()"); + expect(remote).toContain('get serviceaccount "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME"'); + expect(remote).toContain('get rolebinding "$UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME"'); + expect(remote).toContain('get secret "$UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME"'); + expect(remote).toContain("sa.automountServiceAccountToken === false"); + expect(remote).toContain("const expectedKeys = ['password', 'url', 'username']"); + expect(remote).toContain("passwordDecoded: false"); + expect(remote).not.toContain("Buffer.from(argoSecret.data.password"); + expect(remote).not.toContain("--from-literal=password="); }); test("id-specific PaC debug keeps fixture failures but does not dump every passing check", () => { diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index 951cc337..c95f517d 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -14,7 +14,6 @@ export interface PacAdmissionConsumerContract { readonly pipelineRunPrefix: string; readonly markerValue: string; readonly executionServiceAccountName: string; - readonly manageExecutionServiceAccount: boolean; readonly gitOps: { readonly repoUrl: string; readonly targetRevision: string }; } @@ -99,7 +98,6 @@ export function parsePacAdmissionContract(source: Record): PacA pipelineRunPrefix: required(consumer.pipelineRunPrefix, `consumers[${index}].pipelineRunPrefix`), markerValue: required(value.markerValue, `consumers[${index}].deliveryProvenance.markerValue`), executionServiceAccountName: required(value.executionServiceAccountName, `consumers[${index}].deliveryProvenance.executionServiceAccountName`), - manageExecutionServiceAccount: optionalBoolean(value.manageExecutionServiceAccount, `consumers[${index}].deliveryProvenance.manageExecutionServiceAccount`, false), gitOps: { repoUrl: required(gitOps.repoUrl, `consumers[${index}].deliveryProvenance.gitOps.repoUrl`), targetRevision: required(gitOps.targetRevision, `consumers[${index}].deliveryProvenance.gitOps.targetRevision`), @@ -400,9 +398,3 @@ function literal(value: unknown, path: string, expec if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; } - -function optionalBoolean(value: unknown, path: string, fallback: boolean): boolean { - if (value === undefined) return fallback; - if (typeof value !== "boolean") throw new Error(`${path} must be boolean`); - return value; -} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 61b9a669..d9818316 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -202,6 +202,50 @@ roleRef: EOF } +pac_repository_secret_manifest() { + node -e ' +const fs = require("node:fs"); +const input = fs.readFileSync(0); +const split = input.indexOf(0); +if (split < 0) process.exit(2); +const token = input.subarray(0, split); +const webhook = input.subarray(split + 1); +const data = {}; +data[process.env.UNIDESK_PAC_TOKEN_KEY] = token.toString("base64"); +data[process.env.UNIDESK_PAC_WEBHOOK_SECRET_KEY] = webhook.toString("base64"); +process.stdout.write(JSON.stringify({ + apiVersion: "v1", + kind: "Secret", + metadata: { name: process.env.UNIDESK_PAC_SECRET_NAME, namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE }, + type: "Opaque", + data, +})); +' +} + +argo_repository_secret_manifest() { + node -e ' +const fs = require("node:fs"); +const token = fs.readFileSync(0); +const encoded = (value) => Buffer.from(value, "utf8").toString("base64"); +process.stdout.write(JSON.stringify({ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: process.env.UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME, + namespace: process.env.UNIDESK_PAC_ARGO_NAMESPACE, + labels: { "argocd.argoproj.io/secret-type": "repository" }, + }, + type: "Opaque", + data: { + url: encoded(process.env.UNIDESK_PAC_ARGO_REPOSITORY_URL || ""), + username: encoded(process.env.UNIDESK_PAC_ARGO_REPOSITORY_USERNAME || ""), + password: token.toString("base64"), + }, +})); +' +} + apply_release() { tmp=$(mktemp) printf '%s' "$UNIDESK_PAC_RELEASE_MANIFEST_B64" | base64 -d > "$tmp" @@ -238,15 +282,25 @@ apply_action() { else consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null fi + if [ -n "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64:-}" ]; then + runner_tmp=$(mktemp) + printf '%s' "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64" | base64 -d >"$runner_tmp" + kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-runner-bootstrap" -f "$runner_tmp" >/dev/null + rm -f "$runner_tmp" + fi token=$(ensure_token) test -n "$token" - kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \ - --from-literal="$UNIDESK_PAC_TOKEN_KEY=$token" \ - --from-literal="$UNIDESK_PAC_WEBHOOK_SECRET_KEY=$UNIDESK_PAC_WEBHOOK_SECRET" \ - --dry-run=client -o yaml | kubectl apply -f - >/dev/null + { printf '%s' "$token"; printf '\0'; printf '%s' "$UNIDESK_PAC_WEBHOOK_SECRET"; } \ + | pac_repository_secret_manifest \ + | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-repository-secret" -f - >/dev/null repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null argo_bootstrap=false if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then + if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then + printf '%s' "$token" \ + | argo_repository_secret_manifest \ + | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-argocd-repository" -f - >/dev/null + fi argo_tmp=$(mktemp) printf '%s' "$UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64" | base64 -d >"$argo_tmp" kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f "$argo_tmp" >/dev/null @@ -291,28 +345,10 @@ admission_provenance_state() { export UNIDESK_PAC_DEFAULT_CAN_MUTATE="$default_can_mutate" node - "$policy_file" "$binding_file" "$role_file" "$role_binding_file" <<'NODE' const fs = require('node:fs'); -const cp = require('node:child_process'); const { evaluatePacAdmissionState } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); function read(path) { try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; } } -let managedExecutionServiceAccounts = []; -try { - const parsed = JSON.parse(process.env.UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON || '[]'); - if (Array.isArray(parsed)) managedExecutionServiceAccounts = parsed; -} catch {} -const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10); -const kubectlTimeoutMs = Number.isInteger(waitTimeoutSeconds) && waitTimeoutSeconds > 0 ? waitTimeoutSeconds * 1000 : null; -const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => { - if (kubectlTimeoutMs === null || !item || typeof item.namespace !== 'string' || typeof item.name !== 'string') return {}; - const result = cp.spawnSync('kubectl', ['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'], { - encoding: 'utf8', - timeout: kubectlTimeoutMs, - maxBuffer: 1024 * 1024, - }); - if (result.error || result.status !== 0) return {}; - try { return JSON.parse(result.stdout || '{}'); } catch { return {}; } -}); process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ policy: read(process.argv[2]), binding: read(process.argv[3]), @@ -320,7 +356,6 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ roleBinding: read(process.argv[5]), namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE || null, defaultCanMutate: process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'unknown' ? null : process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'true', - executionServiceAccounts, expected: { targetId: process.env.UNIDESK_PAC_TARGET_ID || '', policyName: process.env.UNIDESK_PAC_ADMISSION_POLICY_NAME || '', @@ -329,7 +364,6 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ controllerIdentity: process.env.UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY || '', configSha256: process.env.UNIDESK_PAC_ADMISSION_CONFIG_SHA256 || '', rbacConfigSha256: process.env.UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256 || '', - managedExecutionServiceAccounts, }, }))); NODE @@ -490,7 +524,6 @@ function kubectlJson(args, fallback, context) { let admissionPolicy = null; let admissionBinding = null; const admissionRbac = new Map(); -const admissionExecutionServiceAccounts = new Map(); function defaultCanMutate(namespace) { const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10); @@ -528,18 +561,6 @@ function admissionStateForConsumer(consumer) { }); } const rbac = admissionRbac.get(consumer.namespace); - const managedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts : []; - const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => { - const key = `${item.namespace || ''}/${item.name || ''}`; - if (!admissionExecutionServiceAccounts.has(key)) { - admissionExecutionServiceAccounts.set(key, kubectlJson( - ['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'], - {}, - `${consumer.id}:execution-serviceaccount:${key}`, - )); - } - return admissionExecutionServiceAccounts.get(key); - }); return evaluatePacAdmissionState({ policy: admissionPolicy, binding: admissionBinding, @@ -547,7 +568,6 @@ function admissionStateForConsumer(consumer) { roleBinding: rbac.roleBinding, namespace: consumer.namespace, defaultCanMutate: rbac.defaultCanMutate, - executionServiceAccounts, expected: { targetId: expected.targetId, policyName: expected.policyName, @@ -556,7 +576,6 @@ function admissionStateForConsumer(consumer) { controllerIdentity: expected.controllerIdentity, configSha256: expected.configSha256, rbacConfigSha256: expected.rbacConfigSha256, - managedExecutionServiceAccounts, }, }); } @@ -1322,11 +1341,121 @@ process.stdout.write(JSON.stringify(rows)); NODE } +consumer_bootstrap_state() { + if [ -z "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME:-}" ] && [ -z "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then + printf '{"configured":false,"ready":null,"reasons":["consumer-bootstrap-not-declared"],"valuesPrinted":false}' + return + fi + sa_file=$(mktemp) + role_binding_file=$(mktemp) + argo_secret_file=$(mktemp) + if [ -n "${UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME:-}" ]; then + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get serviceaccount "$UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME" -o json >"$sa_file" 2>/dev/null || printf '{}' >"$sa_file" + kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get rolebinding "$UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME" -o json >"$role_binding_file" 2>/dev/null || printf '{}' >"$role_binding_file" + else + printf '{}' >"$sa_file" + printf '{}' >"$role_binding_file" + fi + if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then + kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get secret "$UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME" -o json >"$argo_secret_file" 2>/dev/null || printf '{}' >"$argo_secret_file" + else + printf '{}' >"$argo_secret_file" + fi + node - "$sa_file" "$role_binding_file" "$argo_secret_file" <<'NODE' +const crypto = require('node:crypto'); +const fs = require('node:fs'); +function read(path) { + try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; } +} +function fingerprint(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} +const sa = read(process.argv[2]); +const roleBinding = read(process.argv[3]); +const argoSecret = read(process.argv[4]); +const namespace = process.env.UNIDESK_PAC_TARGET_NAMESPACE || ''; +const runnerName = process.env.UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME || ''; +const roleBindingName = process.env.UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME || ''; +const argoNamespace = process.env.UNIDESK_PAC_ARGO_NAMESPACE || ''; +const argoSecretName = process.env.UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME || ''; +const argoUrl = process.env.UNIDESK_PAC_ARGO_REPOSITORY_URL || ''; +const reasons = []; +const runnerConfigured = runnerName.length > 0; +const saExists = !runnerConfigured || sa.metadata?.name === runnerName; +const automountDisabled = !runnerConfigured || sa.automountServiceAccountToken === false; +const subjects = Array.isArray(roleBinding.subjects) ? roleBinding.subjects : []; +const roleBindingExact = !runnerConfigured || ( + roleBinding.metadata?.name === roleBindingName + && roleBinding.metadata?.namespace === namespace + && subjects.length === 1 + && subjects[0]?.kind === 'ServiceAccount' + && subjects[0]?.name === runnerName + && subjects[0]?.namespace === namespace + && roleBinding.roleRef?.apiGroup === 'rbac.authorization.k8s.io' + && roleBinding.roleRef?.kind === 'Role' + && roleBinding.roleRef?.name === 'unidesk-pac-consumer-runner' +); +if (!saExists) reasons.push('runner-service-account-missing'); +if (!automountDisabled) reasons.push('runner-service-account-automount-not-false'); +if (!roleBindingExact) reasons.push('runner-rolebinding-drifted-or-missing'); +const argoConfigured = argoSecretName.length > 0; +const argoExists = !argoConfigured || (argoSecret.metadata?.name === argoSecretName && argoSecret.metadata?.namespace === argoNamespace); +const argoLabelReady = !argoConfigured || argoSecret.metadata?.labels?.['argocd.argoproj.io/secret-type'] === 'repository'; +const keys = Object.keys(argoSecret.data || {}).sort(); +const expectedKeys = ['password', 'url', 'username']; +const keysReady = !argoConfigured || JSON.stringify(keys) === JSON.stringify(expectedKeys); +let observedUrlFingerprint = null; +if (argoConfigured && typeof argoSecret.data?.url === 'string') { + try { observedUrlFingerprint = fingerprint(Buffer.from(argoSecret.data.url, 'base64').toString('utf8')); } catch {} +} +const expectedUrlFingerprint = argoConfigured ? fingerprint(argoUrl) : null; +const urlReady = !argoConfigured || observedUrlFingerprint === expectedUrlFingerprint; +const passwordPresent = !argoConfigured || (typeof argoSecret.data?.password === 'string' && argoSecret.data.password.length > 0); +if (!argoExists) reasons.push('argocd-repository-secret-missing'); +if (!argoLabelReady) reasons.push('argocd-repository-secret-label-drifted'); +if (!keysReady) reasons.push('argocd-repository-secret-keys-drifted'); +if (!urlReady) reasons.push('argocd-repository-secret-url-drifted'); +if (!passwordPresent) reasons.push('argocd-repository-secret-password-missing'); +process.stdout.write(JSON.stringify({ + configured: runnerConfigured || argoConfigured, + ready: reasons.length === 0, + runner: { + configured: runnerConfigured, + serviceAccount: runnerName || null, + exists: saExists, + automountServiceAccountToken: sa.metadata?.name === runnerName ? sa.automountServiceAccountToken ?? null : null, + automountDisabled, + roleBinding: roleBindingName || null, + roleBindingExact, + }, + argoRepository: { + configured: argoConfigured, + secretName: argoSecretName || null, + exists: argoExists, + labelReady: argoLabelReady, + expectedKeys, + observedKeys: keys, + keysReady, + expectedUrlFingerprint, + observedUrlFingerprint, + urlReady, + passwordPresent, + passwordDecoded: false, + }, + reasons, + valuesPrinted: false, +})); +NODE + rm -f "$sa_file" "$role_binding_file" "$argo_secret_file" +} + status_action() { crd=$(kubectl get crd repositories.pipelinesascode.tekton.dev -o name 2>/dev/null || true) controller_ready=$(kubectl -n "$UNIDESK_PAC_RELEASE_NAMESPACE" get deploy "$UNIDESK_PAC_CONTROLLER_SERVICE_NAME" -o jsonpath='{.status.readyReplicas}/{.spec.replicas}' 2>/dev/null || echo "0/0") repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME") prepare_admission_provenance_state + consumer_bootstrap=$(consumer_bootstrap_state) + bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")') pipelines=$(pipeline_rows) latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")') export UNIDESK_PAC_TARGET_PIPELINERUN="$latest" @@ -1351,9 +1480,10 @@ NODE runtime=$(json_normalize "$(runtime_summary)") diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime") admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")') - diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node <<'NODE' + diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node <<'NODE' const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}'); const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}'); +const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || '{}'); if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) { process.stdout.write(JSON.stringify({ ...diagnostics, @@ -1364,16 +1494,28 @@ if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionPro admissionProvenance, valuesPrinted: false, })); +} else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) { + process.stdout.write(JSON.stringify({ + ...diagnostics, + ok: false, + code: 'pac-consumer-bootstrap-not-ready', + phase: 'consumer-bootstrap-not-ready', + hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted', + admissionProvenance, + consumerBootstrap, + valuesPrinted: false, + })); } else { - process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, valuesPrinted: false })); + process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false })); } NODE ) - printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ - "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ + printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \ + "$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \ "$( [ -n "$crd" ] && echo true || echo false )" \ "$(json_string "$controller_ready")" \ "$UNIDESK_PAC_ADMISSION_STATE_JSON" \ + "$consumer_bootstrap" \ "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" \ "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" \ "$(json_string "$UNIDESK_PAC_GITEA_REPO")" \ 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 20544ec8..a2d5558f 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 @@ -198,6 +198,10 @@ describe("PaC source artifact CLI contract", () => { const sourceArtifact = consumers.find((item) => item.extends === template && item.variables?.NODE === "NC01")?.sourceArtifact; expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 }); } + for (const id of ["platform-infra-sub2rank-nc01", "selfmedia-nc01"]) { + const sourceArtifact = consumers.find((item) => item.id === id)?.sourceArtifact; + expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 }); + } }); test("undeclared JD01 source artifact owner fails closed", () => { 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 bc1da2af..afbde7b0 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -18,9 +18,10 @@ import { renderedCliResult } from "./agentrun/render"; 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"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; -export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service"; +export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; @@ -454,7 +455,9 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree ? renderAgentRunDesiredPipeline(binding) : sourceArtifact.renderer === "hwlab-runtime-lane" ? renderHwlabDesiredPipeline(binding, sourceWorktree) - : renderSub2RankDesiredPipeline(binding); + : sourceArtifact.renderer === "sub2rank-platform-service" + ? renderSub2RankDesiredPipeline(binding) + : renderSelfMediaPipeline(binding, sourceWorktree); const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane" ? rendered.pipeline : withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance); @@ -525,6 +528,19 @@ function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWor }; } +function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { + const rendered = renderSelfMediaDesiredPipeline(binding, sourceWorktree); + return { + pipeline: rendered.pipeline, + provenance: { + configRef: rendered.configRef, + effectiveConfigSha256: rendered.effectiveConfigSha256, + renderer: "selfmedia-runtime", + mode: "embedded-pipeline-spec", + }, + }; +} + function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record { const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-")); const temporarySource = resolve(temporaryRoot, "source"); @@ -594,10 +610,17 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`, namespace: binding.consumer.namespace, annotations: pipelineRunAnnotations(binding, provenance), - labels: pipelineRunLabels(binding, provenance.renderer === "sub2rank-platform-service" ? "sub2rank" : "agentrun"), + labels: pipelineRunLabels( + binding, + provenance.renderer === "sub2rank-platform-service" + ? "sub2rank" + : provenance.renderer === "selfmedia-runtime" + ? "selfmedia" + : "agentrun", + ), }, spec: { - timeouts: { pipeline: binding.repository.params.pipeline_timeout }, + ...(binding.repository.params.pipeline_timeout === undefined ? {} : { timeouts: { pipeline: binding.repository.params.pipeline_timeout } }), pipelineSpec: desiredSpec, taskRunTemplate: taskRunTemplate(binding), params: pipelineRunParams(binding, desiredSpec), @@ -661,29 +684,42 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P }; } -function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank"): Record { - return partOf === "agentrun" - ? { +function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia"): Record { + if (partOf === "agentrun") { + return { "app.kubernetes.io/part-of": "agentrun", "agentrun.pikastech.local/lane": "v0.2", "agentrun.pikastech.local/node": binding.consumer.node, "agentrun.pikastech.local/source-commit": "{{ revision }}", "agentrun.pikastech.local/trigger": "pipelines-as-code", - } - : partOf === "hwlab" ? { + }; + } + if (partOf === "selfmedia") { + return { "app.kubernetes.io/name": `${binding.consumer.id}-pac`, - "app.kubernetes.io/part-of": "hwlab", + "app.kubernetes.io/part-of": "selfmedia-factory", "unidesk.ai/source-commit": "{{ revision }}", - "hwlab.pikastech.local/gitops-target": binding.consumer.lane, - "hwlab.pikastech.local/source-commit": "{{ revision }}", - "hwlab.pikastech.local/trigger": "pipelines-as-code", - } : { + "selfmedia.pikapython.com/source-commit": "{{ revision }}", + "selfmedia.pikapython.com/trigger": "pipelines-as-code", + }; + } + if (partOf === "sub2rank") { + return { "app.kubernetes.io/name": "sub2rank", "app.kubernetes.io/part-of": "platform-infra", "unidesk.ai/node": binding.consumer.node, "unidesk.ai/source-commit": "{{ revision }}", "unidesk.ai/trigger": "pipelines-as-code", }; + } + return { + "app.kubernetes.io/name": `${binding.consumer.id}-pac`, + "app.kubernetes.io/part-of": "hwlab", + "unidesk.ai/source-commit": "{{ revision }}", + "hwlab.pikastech.local/gitops-target": binding.consumer.lane, + "hwlab.pikastech.local/source-commit": "{{ revision }}", + "hwlab.pikastech.local/trigger": "pipelines-as-code", + }; } function taskRunTemplate(binding: PacSourceArtifactBinding): Record { @@ -828,9 +864,8 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string { if (binding.consumer.sourceArtifact.renderer === "agentrun-control-plane") { return resolveAgentRunLaneTarget({ node: binding.consumer.node, lane: binding.consumer.lane }).spec.source.worktreeRemote; } - if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") { - return sub2RankOwningSourceRemote(); - } + if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") return sub2RankOwningSourceRemote(); + if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node); if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved"); return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl; } @@ -946,7 +981,10 @@ function assertPipelineIdentity(pipeline: Record, binding: PacS function provenanceFromManifest(manifest: Record): PacSourceArtifactProvenance | null { const provenance = pipelineProvenanceFromManifest(manifest); if (provenance === null) return null; - if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane" && provenance.renderer !== "sub2rank-platform-service") return null; + if (provenance.renderer !== "agentrun-control-plane" + && provenance.renderer !== "hwlab-runtime-lane" + && provenance.renderer !== "sub2rank-platform-service" + && provenance.renderer !== "selfmedia-runtime") return null; if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null; return provenance as PacSourceArtifactProvenance; } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index c8c595d4..b041a07b 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -154,18 +154,26 @@ interface PacConsumer { path: string; destinationNamespace: string; automated: boolean; + repositoryCredential: { + secretName: string; + username: string; + } | null; } | null; deliveryProvenance: { required: true; markerValue: string; executionServiceAccountName: string; - manageExecutionServiceAccount: boolean; gitOps: { repoUrl: string; targetRevision: string }; } | null; repositoryRef: string; closeoutGitOpsMirrorFlush: boolean; closeoutGitOpsMirrorLane: "v02" | "v03" | null; sourceArtifact: PacSourceArtifactSpec | null; + runnerServiceAccount: { + name: string; + automountServiceAccountToken: false; + roleBindingName: string; + } | null; } interface CommonOptions { @@ -499,6 +507,7 @@ function parseConsumer(consumer: Record, path: string, defaultR const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`; const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path); const deliveryProvenance = consumer.deliveryProvenance === undefined ? null : parseConsumerDeliveryProvenance(y.objectField(consumer, "deliveryProvenance", path), `${path}.deliveryProvenance`); + const runnerServiceAccount = consumer.runnerServiceAccount === undefined ? null : y.objectField(consumer, "runnerServiceAccount", path); return { id, node: y.stringField(consumer, "node", path), @@ -515,15 +524,32 @@ function parseConsumer(consumer: Record, path: string, defaultR path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`), destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`), automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`), + repositoryCredential: argoBootstrap.repositoryCredential === undefined ? null : (() => { + const credential = y.objectField(argoBootstrap, "repositoryCredential", `${path}.argoBootstrap`); + return { + secretName: y.kubernetesNameField(credential, "secretName", `${path}.argoBootstrap.repositoryCredential`), + username: y.stringField(credential, "username", `${path}.argoBootstrap.repositoryCredential`), + }; + })(), }, deliveryProvenance, repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path), closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const), sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`), + runnerServiceAccount: runnerServiceAccount === null ? null : { + name: y.kubernetesNameField(runnerServiceAccount, "name", `${path}.runnerServiceAccount`), + automountServiceAccountToken: parseFalse(runnerServiceAccount, "automountServiceAccountToken", `${path}.runnerServiceAccount`), + roleBindingName: y.kubernetesNameField(runnerServiceAccount, "roleBindingName", `${path}.runnerServiceAccount`), + }, }; } +function parseFalse(record: Record, key: string, path: string): false { + if (y.booleanField(record, key, path) !== false) throw new Error(`${path}.${key} must be false`); + return false; +} + function parseConsumerDeliveryProvenance(value: Record, path: string): PacConsumer["deliveryProvenance"] { const required = y.booleanField(value, "required", path); if (!required) return null; @@ -532,9 +558,6 @@ function parseConsumerDeliveryProvenance(value: Record, path: s required: true, markerValue: y.stringField(value, "markerValue", path), executionServiceAccountName: y.kubernetesNameField(value, "executionServiceAccountName", path), - manageExecutionServiceAccount: value.manageExecutionServiceAccount === undefined - ? false - : y.booleanField(value, "manageExecutionServiceAccount", path), gitOps: { repoUrl: urlField(gitOps, "repoUrl", `${path}.gitOps`), targetRevision: y.stringField(gitOps, "targetRevision", `${path}.gitOps`), @@ -544,7 +567,7 @@ function parseConsumerDeliveryProvenance(value: 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"] as const); + const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-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); @@ -553,6 +576,7 @@ function parseSourceArtifact(value: Record, path: string): PacS if (renderer === "agentrun-control-plane" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer agentrun-control-plane requires embedded-pipeline-spec`); 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`); return { mode, renderer, @@ -789,6 +813,22 @@ function validateConfig(config: PacConfig): void { || consumer.argoBootstrap.targetRevision !== consumer.deliveryProvenance.gitOps.targetRevision)) { throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.gitOps must match argoBootstrap source identity`); } + if (consumer.runnerServiceAccount !== null && consumer.runnerServiceAccount.name !== consumer.deliveryProvenance.executionServiceAccountName) { + throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount.name must match deliveryProvenance.executionServiceAccountName`); + } + } + if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) { + throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`); + } + if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") { + if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`); + if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) { + throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`); + } + if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`); + if (repository.params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.repositories.${repository.id}.params.gitops_secret_name must match secretName`); + if (repository.params.gitops_username !== consumer.argoBootstrap.repositoryCredential.username) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo and Tekton Gitea usernames must match`); + if (repository.params.gitops_read_url !== consumer.argoBootstrap.repoUrl) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo repoUrl must match params.gitops_read_url`); } } if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); @@ -1244,8 +1284,10 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"), UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0", UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"), + UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64: Buffer.from(runnerServiceAccountManifest(consumer), "utf8").toString("base64"), + UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME: consumer.runnerServiceAccount?.name ?? "", + UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME: consumer.runnerServiceAccount?.roleBindingName ?? "", UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256: rbacIdentity.configSha256, - UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON: JSON.stringify(rbacIdentity.executionServiceAccounts), UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName, UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName, UNIDESK_PAC_ADMISSION_VERSION: admissionIdentity.version, @@ -1260,6 +1302,9 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace, UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication, UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"), + UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME: consumer.argoBootstrap?.repositoryCredential?.secretName ?? "", + UNIDESK_PAC_ARGO_REPOSITORY_USERNAME: consumer.argoBootstrap?.repositoryCredential?.username ?? "", + UNIDESK_PAC_ARGO_REPOSITORY_URL: consumer.argoBootstrap?.repoUrl ?? "", UNIDESK_PAC_PART_OF: pacPartOf(consumer.id), UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec), }; @@ -1289,7 +1334,6 @@ function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; +} + function argoBootstrapManifest(consumer: PacConsumer): string { const bootstrap = consumer.argoBootstrap; if (bootstrap === null) return ""; @@ -1430,6 +1501,7 @@ function statusSummary(payload: Record): Record): Record): Record 0, + }, valuesPrinted: false, }; } @@ -1685,6 +1763,8 @@ function compactPlanJson(result: Record): Record): Record): RenderedCliResult { const argo = record(summary.argo); const diagnostics = record(summary.diagnostics); const admissionProvenance = record(summary.admissionProvenance); + const consumerBootstrap = record(summary.consumerBootstrap); + const bootstrapRunner = record(consumerBootstrap.runner); + const bootstrapArgo = record(consumerBootstrap.argoRepository); const sourceObservation = record(summary.sourceObservation); const deliveryPlan = record(sourceObservation.plan); const repository = record(summary.repository); @@ -1989,6 +2073,13 @@ function renderStatus(result: Record): RenderedCliResult { ` admission-provenance: ready=${boolText(admissionProvenance.ready)} policy=${stringValue(admissionProvenance.policyName)} binding=${stringValue(admissionProvenance.bindingName)} active-after=${stringValue(admissionProvenance.activeAfter)} default-can-mutate=${boolText(admissionProvenance.defaultCanMutate)}`, ` admission-reasons: ${Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons.join(",") || "-" : "-"}`, ]), + ...(consumerBootstrap.configured !== true + ? [" consumer-bootstrap: not-declared"] + : [ + ` consumer-bootstrap: ready=${boolText(consumerBootstrap.ready)} runner=${stringValue(bootstrapRunner.serviceAccount)} exists=${boolText(bootstrapRunner.exists)} automount-disabled=${boolText(bootstrapRunner.automountDisabled)} rolebinding-exact=${boolText(bootstrapRunner.roleBindingExact)}`, + ` argocd-repository-secret: name=${stringValue(bootstrapArgo.secretName)} exists=${boolText(bootstrapArgo.exists)} label=${boolText(bootstrapArgo.labelReady)} keys=${boolText(bootstrapArgo.keysReady)} url=${boolText(bootstrapArgo.urlReady)} password-present=${boolText(bootstrapArgo.passwordPresent)} password-decoded=${boolText(bootstrapArgo.passwordDecoded)}`, + ` consumer-bootstrap-reasons: ${Array.isArray(consumerBootstrap.reasons) ? consumerBootstrap.reasons.join(",") || "-" : "-"}`, + ]), "", "GITEA HOOKS", ...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))), diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 6c689084..3ae1a934 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -1,5 +1,6 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; @@ -39,23 +40,36 @@ interface SecretDistributionConfig { version: number; kind: "unidesk-secret-distribution"; metadata: { id: string; owner: string; relatedIssues: number[] }; - sources: { root: string; files: SourceFileConfig[] }; + sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] }; targets: DistributionTarget[]; kubernetesSecrets: KubernetesSecretConfig[]; } -interface SourceFileConfig { +interface SourceCreateIfMissingConfig { + enabled: boolean; + values: Record; + randomHex: Record; + randomBase64Url: Record; +} + +interface EnvSourceFileConfig { sourceRef: string; type: "env"; requiredKeys: string[]; - createIfMissing: { - enabled: boolean; - values: Record; - randomHex: Record; - randomBase64Url: Record; - }; + required: true; + createIfMissing: SourceCreateIfMissingConfig; } +interface RawSourceFileConfig { + sourceRef: string; + type: "raw-file"; + requiredKeys: ["contents"]; + required: boolean; + createIfMissing: SourceCreateIfMissingConfig; +} + +type SourceFileConfig = EnvSourceFileConfig | RawSourceFileConfig; + interface DistributionTarget { id: string; route: string; @@ -80,8 +94,10 @@ interface SecretDataMapping { interface SourceMaterial { sourceRef: string; + type: SourceFileConfig["type"]; sourcePath: string; exists: boolean; + required: boolean; requiredKeys: string[]; presentKeys: string[]; missingKeys: string[]; @@ -190,7 +206,7 @@ function parseOptions(args: string[]): SecretsOptions { function plan(options: SecretsOptions): Record { const distribution = readSecretDistributionConfig(options.configPath); assertSelectedTargets(distribution, options); - const sources = inspectSources(distribution, false); + const sources = inspectSources(distribution, false, selectedSourceRefs(distribution, options)); const desired = desiredSecrets(distribution, options, sources); return { ok: sources.ok && desired.every((secret) => secret.missingKeys.length === 0), @@ -200,7 +216,7 @@ function plan(options: SecretsOptions): Record { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary), policy: { - sourceAuthority: "local YAML-declared sourceRef files under sources.root", + sourceAuthority: "local YAML-declared managed and external sourceRef files", runtimeReverseEngineering: false, valuesPrinted: false, }, @@ -233,7 +249,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise secret.missingKeys); @@ -257,7 +273,7 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise> { const distribution = readSecretDistributionConfig(options.configPath); assertSelectedTargets(distribution, options); - const sources = inspectSources(distribution, false); + const sources = inspectSources(distribution, false, selectedSourceRefs(distribution, options)); const desired = desiredSecrets(distribution, options, sources); const perTarget = await Promise.all(groupDesiredSecretsByTarget(desired).map(async (group) => await statusTargetSecrets(config, group.target, group.secrets, options))); return { @@ -293,6 +309,9 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig sources: { root: stringField(sources, "root", `${label}.sources`), files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)), + externalFiles: sources.externalFiles === undefined + ? [] + : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)), }, targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)), kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)), @@ -301,7 +320,7 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig return config; } -function parseSourceFile(record: Record, path: string): SourceFileConfig { +function parseSourceFile(record: Record, path: string): EnvSourceFileConfig { const type = stringField(record, "type", path); if (type !== "env") throw new Error(`${path}.type must be env`); const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path); @@ -310,6 +329,7 @@ function parseSourceFile(record: Record, path: string): SourceF sourceRef: sourceRefField(record, "sourceRef", path), type, requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)), + required: true, createIfMissing: { enabled: createRaw.enabled === undefined ? false : booleanField(createRaw, "enabled", `${path}.createIfMissing`), values: createRaw.values === undefined ? {} : stringMapField(createRaw, "values", `${path}.createIfMissing`), @@ -319,6 +339,35 @@ function parseSourceFile(record: Record, path: string): SourceF }; } +function parseRawSourceFile(record: Record, path: string): RawSourceFileConfig { + const type = stringField(record, "type", path); + if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`); + const createRaw = objectField(record, "createIfMissing", path); + const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`); + const randomHex = createRaw.randomHex === undefined + ? {} + : { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) }; + const randomBase64Url = createRaw.randomBase64Url === undefined + ? {} + : { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) }; + if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`); + const generatorCount = Object.keys(randomHex).length + Object.keys(randomBase64Url).length; + if (enabled && generatorCount !== 1) throw new Error(`${path}.createIfMissing must declare exactly one of randomHex or randomBase64Url when enabled`); + if (!enabled && generatorCount !== 0) throw new Error(`${path}.createIfMissing generators require enabled: true`); + return { + sourceRef: externalSourceRefField(record, "sourceRef", path), + type, + requiredKeys: ["contents"], + required: booleanField(record, "required", path), + createIfMissing: { + enabled, + values: {}, + randomHex, + randomBase64Url, + }, + }; +} + function parseTarget(record: Record, path: string): DistributionTarget { return { id: simpleId(stringField(record, "id", path), `${path}.id`), @@ -338,17 +387,22 @@ function parseKubernetesSecret(record: Record, path: string): K secretName: kubernetesNameField(record, "secretName", path), type, data: arrayOfRecords(record.data, `${path}.data`).map((item, index) => ({ - sourceRef: sourceRefField(item, "sourceRef", `${path}.data[${index}]`), - sourceKey: envKeyField(item, "sourceKey", `${path}.data[${index}]`), + sourceRef: declaredSourceRefField(item, "sourceRef", `${path}.data[${index}]`), + sourceKey: sourceDataKeyField(item, "sourceKey", `${path}.data[${index}]`), targetKey: kubernetesSecretKeyField(item, "targetKey", `${path}.data[${index}]`), })), }; } function validateDistributionConfig(config: SecretDistributionConfig): void { - if (config.sources.files.length === 0) throw new Error(`${config.configPath}.sources.files must not be empty`); + const sourceFiles = allSourceFiles(config); + if (sourceFiles.length === 0) throw new Error(`${config.configPath}.sources must declare files or externalFiles`); if (config.targets.length === 0) throw new Error(`${config.configPath}.targets must not be empty`); - const sources = new Map(config.sources.files.map((item) => [item.sourceRef, item])); + const sources = new Map(); + for (const source of sourceFiles) { + if (sources.has(source.sourceRef)) throw new Error(`${config.configPath}.sources declares duplicate sourceRef ${source.sourceRef}`); + sources.set(source.sourceRef, source); + } const targets = new Set(config.targets.map((item) => item.id)); const targetSecrets = new Set(); for (const secret of config.kubernetesSecrets) { @@ -360,20 +414,25 @@ function validateDistributionConfig(config: SecretDistributionConfig): void { for (const item of secret.data) { const source = sources.get(item.sourceRef); if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`); - if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps ${item.sourceRef}.${item.sourceKey}, but that key is not listed in sources.files.requiredKeys`); + if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`); targetKeys.add(item.targetKey); } } } -function inspectSources(config: SecretDistributionConfig, materialize: boolean): SourceInspection { +function allSourceFiles(config: SecretDistributionConfig): SourceFileConfig[] { + return [...config.sources.files, ...config.sources.externalFiles]; +} + +function inspectSources(config: SecretDistributionConfig, materialize: boolean, selectedRefs: Set): SourceInspection { const root = secretRoot(config); const materials = new Map(); - const entries = config.sources.files.map((source) => { - const sourcePath = join(root, source.sourceRef); + const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => { + const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef); const exists = existsSync(sourcePath); - const existing = exists ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {}; + const existingText = exists ? readFileSync(sourcePath, "utf8") : ""; + const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {}; const next = { ...existing }; const generatedKeys: string[] = []; const unmaterializedGeneratedKeys: string[] = []; @@ -402,11 +461,30 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean): const missingBefore = source.requiredKeys.filter((key) => existing[key] === undefined || existing[key].length === 0); const missingKeys = source.requiredKeys.filter((key) => next[key] === undefined || next[key].length === 0); const action = missingKeys.length > 0 ? "blocked" : !exists ? "create" : missingBefore.length > 0 ? "update" : "none"; - if (materialize && action !== "blocked" && (action === "create" || action === "update")) writeEnvFile(sourcePath, next); + if (materialize && action !== "blocked" && (action === "create" || action === "update")) { + if (source.type === "env") writeEnvFile(sourcePath, next); + else writeRawFile(sourcePath, next.contents); + } + const materialized = materialize && action !== "blocked" && (action === "create" || action === "update"); + const presence = materialized || (exists && existingText.length > 0); + const bytes = materialized + ? source.type === "env" + ? Buffer.byteLength(readFileSync(sourcePath, "utf8"), "utf8") + : Buffer.byteLength(next.contents, "utf8") + : exists + ? Buffer.byteLength(existingText, "utf8") + : null; + const fingerprint = missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0 + ? source.type === "raw-file" + ? fingerprintRawFileContent(next.contents) + : fingerprintValues(next, source.requiredKeys) + : null; const material: SourceMaterial = { sourceRef: source.sourceRef, + type: source.type, sourcePath, exists, + required: source.required, requiredKeys: source.requiredKeys, presentKeys: source.requiredKeys.filter((key) => next[key] !== undefined && next[key].length > 0), missingKeys, @@ -414,11 +492,22 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean): generatedKeys, unmaterializedGeneratedKeys, values: next, - fingerprint: missingKeys.length === 0 && unmaterializedGeneratedKeys.length === 0 ? fingerprintValues(next, source.requiredKeys) : null, + fingerprint, }; materials.set(source.sourceRef, material); + if (source.type === "raw-file") { + return { + sourceRef: source.sourceRef, + type: source.type, + presence, + bytes, + fingerprint, + valuesPrinted: false, + }; + } return { sourceRef: source.sourceRef, + type: source.type, sourcePath: redactRepoPath(sourcePath), exists, requiredKeys: source.requiredKeys, @@ -432,7 +521,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean): }; }); return { - ok: entries.every((entry) => (entry.missingKeys as string[]).length === 0), + ok: Array.from(materials.values()).every((material) => !material.required || material.missingKeys.length === 0), root: redactRepoPath(root), entries, materials, @@ -489,6 +578,13 @@ function selectedTargets(config: SecretDistributionConfig, options: SecretsOptio .filter((target) => options.targetId === null || target.id === options.targetId); } +function selectedSourceRefs(config: SecretDistributionConfig, options: SecretsOptions): Set { + const targetIds = new Set(selectedTargets(config, options).map((target) => target.id)); + return new Set(config.kubernetesSecrets + .filter((secret) => targetIds.has(secret.targetId)) + .flatMap((secret) => secret.data.map((item) => item.sourceRef))); +} + async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTarget, secrets: DesiredSecret[], options: SecretsOptions): Promise> { if (secrets.length === 0) return { ok: true, target: targetSummary(target), mode: "skipped-no-secrets" }; const yaml = renderSecretManifest(target, secrets); @@ -561,21 +657,21 @@ else apply_rc=1 fi python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY' -import base64, json, sys +import base64, json, os, sys ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2]) -def text(path, limit=5000): +def byte_count(path): try: - return open(path, encoding="utf-8", errors="replace").read()[-limit:] + return os.path.getsize(path) except FileNotFoundError: - return "" + return 0 payload = { "ok": ns_rc == 0 and apply_rc == 0, "namespace": "${target.namespace}", "secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")), "valuesPrinted": False, "steps": { - "namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[3]), "stderr": text(sys.argv[4])}, - "apply": {"exitCode": apply_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])}, + "namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True}, + "apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) @@ -645,13 +741,17 @@ function groupDesiredSecretsByTarget(secrets: DesiredSecret[]): Array<{ target: } function configSummary(config: SecretDistributionConfig, options: SecretsOptions): Record { + const sourceRefs = selectedSourceRefs(config, options); return { path: config.configPath, metadata: config.metadata, root: redactRepoPath(secretRoot(config)), scope: options.scope, targetId: options.targetId, - sources: config.sources.files.map((item) => ({ sourceRef: item.sourceRef, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })), + sources: [ + ...config.sources.files.filter((item) => sourceRefs.has(item.sourceRef)).map((item) => ({ sourceRef: item.sourceRef, type: item.type, requiredKeys: item.requiredKeys, createIfMissing: item.createIfMissing.enabled })), + ...config.sources.externalFiles.filter((item) => sourceRefs.has(item.sourceRef)).map((item) => ({ sourceRef: item.sourceRef, type: item.type, required: item.required, createIfMissing: item.createIfMissing.enabled })), + ], targets: config.targets.filter((target) => (options.scope === null || target.scope === options.scope) && (options.targetId === null || target.id === options.targetId)).map(targetSummary), valuesPrinted: false, }; @@ -748,6 +848,16 @@ function writeEnvFile(path: string, values: Record): void { chmodSync(path, 0o600); } +function writeRawFile(path: string, value: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, value, { encoding: "utf8", mode: 0o600 }); + chmodSync(path, 0o600); +} + +function fingerprintRawFileContent(value: string): string { + return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`; +} + function quoteEnv(value: string): string { if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value; return `'${value.replaceAll("'", "'\"'\"'")}'`; @@ -837,8 +947,30 @@ function sourceRefField(obj: Record, key: string, path: string) return value; } -function envKeyField(obj: Record, key: string, path: string): string { - return envKeyValue(stringField(obj, key, path), `${path}.${key}`); +function externalSourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (value.includes("\0") || value.split("/").includes("..")) throw new Error(`${path}.${key} must not contain NUL or parent traversal`); + if (value.startsWith("~/") && value.length > 2) return value; + if (isAbsolute(value)) return value; + throw new Error(`${path}.${key} must be an absolute path or a ~/ home path`); +} + +function declaredSourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (value.startsWith("~/") || isAbsolute(value)) return externalSourceRefField(obj, key, path); + return sourceRefField(obj, key, path); +} + +function externalSourcePath(sourceRef: string): string { + if (sourceRef.startsWith("~/")) return join(homedir(), sourceRef.slice(2)); + if (isAbsolute(sourceRef)) return sourceRef; + throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`); +} + +function sourceDataKeyField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`); + return value; } function envKeyValue(value: string, path: string): string { diff --git a/scripts/src/selfmedia-config.ts b/scripts/src/selfmedia-config.ts new file mode 100644 index 00000000..4f4dde63 --- /dev/null +++ b/scripts/src/selfmedia-config.ts @@ -0,0 +1,299 @@ +import { readFileSync } from "node:fs"; + +import { rootPath } from "./config"; + +export const selfMediaConfigPath = rootPath("config", "selfmedia.yaml"); +export const selfMediaConfigRef = "config/selfmedia.yaml"; + +export interface SelfMediaDeliveryTarget { + readonly id: string; + readonly node: string; + readonly lane: string; + readonly route: string; + readonly ci: { + readonly namespace: string; + readonly pipeline: string; + readonly pipelineRunPrefix: string; + readonly serviceAccountName: string; + readonly serviceAccountAutomount: false; + readonly roleBindingName: string; + readonly workspaceSize: string; + readonly pipelineTimeout: string; + readonly toolImage: string; + readonly buildkitImage: string; + }; + readonly source: { + readonly repository: string; + readonly worktreeRemote: string; + readonly branch: string; + readonly readUrl: string; + readonly snapshotPrefix: string; + }; + readonly build: Record & { + readonly dockerfile: string; + readonly imageRepository: string; + readonly networkMode: string; + readonly proxy: Record; + }; + readonly gitops: Record & { + readonly readUrl: string; + readonly writeUrl: string; + readonly branch: string; + readonly manifestPath: string; + readonly releaseStatePath: string; + readonly credentialSecretName: string; + readonly credentialTokenKey: string; + readonly credentialUsername: string; + }; + readonly deployment: Record; +} + +export interface SelfMediaCutoverTarget { + readonly id: string; + readonly mode: "host-process-to-kubernetes"; + readonly source: { + readonly workspace: string; + readonly publicAddress: string; + readonly healthUrl: string; + readonly stopCommand: readonly string[]; + readonly startCommand: readonly string[]; + readonly dataPaths: readonly string[]; + readonly excludes: readonly string[]; + }; + readonly destination: { + readonly route: string; + readonly namespace: string; + readonly application: string; + readonly publicAddress: string; + readonly healthUrl: string; + readonly dataClaim: string; + readonly codexClaim: string; + }; + readonly prepare: { + readonly requiresFinalConfirmation: true; + readonly phases: readonly string[]; + }; + readonly rollback: { + readonly phases: readonly string[]; + readonly dataPolicy: string; + }; +} + +interface SelfMediaConfig { + readonly defaultTargetId: string; + readonly deliveryTargets: Readonly>; + readonly cutoverTargets: Readonly>; +} + +export function readSelfMediaConfig(): SelfMediaConfig { + const root = record(Bun.YAML.parse(readFileSync(selfMediaConfigPath, "utf8")), selfMediaConfigRef); + if (integer(root.version, "version") !== 1) throw new Error(`${selfMediaConfigRef}.version must be 1`); + if (text(root.kind, "kind") !== "selfmedia-platform-delivery") throw new Error(`${selfMediaConfigRef}.kind must be selfmedia-platform-delivery`); + const defaults = record(root.defaults, "defaults"); + const delivery = record(root.delivery, "delivery"); + const cutover = record(root.cutover, "cutover"); + const deliveryRecords = record(delivery.targets, "delivery.targets"); + const cutoverRecords = record(cutover.targets, "cutover.targets"); + const deliveryTargets = Object.fromEntries(Object.entries(deliveryRecords).map(([id, value]) => [id, parseDeliveryTarget(id, record(value, `delivery.targets.${id}`))])); + const cutoverTargets = Object.fromEntries(Object.entries(cutoverRecords).map(([id, value]) => [id, parseCutoverTarget(id, record(value, `cutover.targets.${id}`))])); + const defaultTargetId = text(defaults.targetId, "defaults.targetId"); + if (deliveryTargets[defaultTargetId] === undefined || cutoverTargets[defaultTargetId] === undefined) { + throw new Error(`${selfMediaConfigRef}.defaults.targetId must resolve in delivery.targets and cutover.targets`); + } + return { defaultTargetId, deliveryTargets, cutoverTargets }; +} + +export function resolveSelfMediaDeliveryTarget(targetId?: string | null): SelfMediaDeliveryTarget { + const config = readSelfMediaConfig(); + const id = targetId ?? config.defaultTargetId; + const target = config.deliveryTargets[id]; + if (target === undefined) throw new Error(`unknown selfmedia delivery target ${id}; known targets: ${Object.keys(config.deliveryTargets).join(", ")}`); + return target; +} + +export function resolveSelfMediaCutoverTarget(targetId?: string | null): SelfMediaCutoverTarget { + const config = readSelfMediaConfig(); + const id = targetId ?? config.defaultTargetId; + const target = config.cutoverTargets[id]; + if (target === undefined) throw new Error(`unknown selfmedia cutover target ${id}; known targets: ${Object.keys(config.cutoverTargets).join(", ")}`); + return target; +} + +export function selfMediaDeliveryConfigRef(targetId: string): string { + return `${selfMediaConfigRef}#delivery.targets.${targetId}`; +} + +export function selfMediaEffectiveDeployment(target: SelfMediaDeliveryTarget): Record { + return { + ...structuredClone(target.deployment), + delivery: { + enabled: true, + source: { + branch: target.source.branch, + snapshotPrefix: target.source.snapshotPrefix, + readUrl: target.source.readUrl, + }, + build: structuredClone(target.build), + gitops: structuredClone(target.gitops), + }, + }; +} + +function parseDeliveryTarget(id: string, value: Record): SelfMediaDeliveryTarget { + const path = `delivery.targets.${id}`; + const ci = record(value.ci, `${path}.ci`); + const source = record(value.source, `${path}.source`); + const build = record(value.build, `${path}.build`); + const proxy = record(build.proxy, `${path}.build.proxy`); + const gitops = record(value.gitops, `${path}.gitops`); + const deployment = record(value.deployment, `${path}.deployment`); + const target = record(deployment.target, `${path}.deployment.target`); + const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`); + const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`); + if (!noProxy.includes("hyueapi.com") || !noProxy.includes(".hyueapi.com")) throw new Error(`${path}.build.proxy.noProxy must preserve hyueapi.com and .hyueapi.com`); + if (text(value.node, `${path}.node`) !== id) throw new Error(`${path}.node must match target id ${id}`); + if (text(target.id, `${path}.deployment.target.id`) !== id) throw new Error(`${path}.deployment.target.id must match ${id}`); + if (text(source.repository, `${path}.source.repository`) !== "pikainc/selfmedia") throw new Error(`${path}.source.repository must be pikainc/selfmedia`); + if (!text(source.snapshotPrefix, `${path}.source.snapshotPrefix`).startsWith("refs/unidesk/snapshots/")) throw new Error(`${path}.source.snapshotPrefix must be immutable`); + if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`); + if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`); + if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`); + const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`); + if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`); + return { + id, + node: id, + lane: text(value.lane, `${path}.lane`), + route: text(value.route, `${path}.route`), + ci: { + 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`), + serviceAccountAutomount: false, + roleBindingName: kubernetesName(ci.roleBindingName, `${path}.ci.roleBindingName`), + workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`), + pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`), + toolImage: text(ci.toolImage, `${path}.ci.toolImage`), + buildkitImage: text(ci.buildkitImage, `${path}.ci.buildkitImage`), + }, + source: { + repository: "pikainc/selfmedia", + worktreeRemote: url(source.worktreeRemote, `${path}.source.worktreeRemote`), + branch: text(source.branch, `${path}.source.branch`), + readUrl: url(source.readUrl, `${path}.source.readUrl`), + snapshotPrefix: text(source.snapshotPrefix, `${path}.source.snapshotPrefix`), + }, + build: { + ...structuredClone(build), + dockerfile: relativePath(build.dockerfile, `${path}.build.dockerfile`), + imageRepository: text(build.imageRepository, `${path}.build.imageRepository`), + networkMode: text(build.networkMode, `${path}.build.networkMode`), + proxy: structuredClone(proxy), + }, + gitops: { + ...structuredClone(gitops), + readUrl: url(gitops.readUrl, `${path}.gitops.readUrl`), + writeUrl: url(gitops.writeUrl, `${path}.gitops.writeUrl`), + branch: text(gitops.branch, `${path}.gitops.branch`), + manifestPath: relativePath(gitops.manifestPath, `${path}.gitops.manifestPath`), + releaseStatePath: relativePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`), + credentialSecretName: kubernetesName(gitops.credentialSecretName, `${path}.gitops.credentialSecretName`), + credentialTokenKey: text(gitops.credentialTokenKey, `${path}.gitops.credentialTokenKey`), + credentialUsername: text(gitops.credentialUsername, `${path}.gitops.credentialUsername`), + }, + deployment: structuredClone(deployment), + }; +} + +function parseCutoverTarget(id: string, value: Record): SelfMediaCutoverTarget { + const path = `cutover.targets.${id}`; + const source = record(value.source, `${path}.source`); + const destination = record(value.destination, `${path}.destination`); + const prepare = record(value.prepare, `${path}.prepare`); + const rollback = record(value.rollback, `${path}.rollback`); + const mode = text(value.mode, `${path}.mode`); + if (mode !== "host-process-to-kubernetes") throw new Error(`${path}.mode must be host-process-to-kubernetes`); + if (boolean(prepare.requiresFinalConfirmation, `${path}.prepare.requiresFinalConfirmation`) !== true) throw new Error(`${path}.prepare.requiresFinalConfirmation must be true`); + return { + id, + mode, + source: { + workspace: absolutePath(source.workspace, `${path}.source.workspace`), + publicAddress: url(source.publicAddress, `${path}.source.publicAddress`), + healthUrl: url(source.healthUrl, `${path}.source.healthUrl`), + stopCommand: stringArray(source.stopCommand, `${path}.source.stopCommand`), + startCommand: stringArray(source.startCommand, `${path}.source.startCommand`), + dataPaths: stringArray(source.dataPaths, `${path}.source.dataPaths`).map((item, index) => relativePath(item, `${path}.source.dataPaths[${index}]`)), + excludes: stringArray(source.excludes, `${path}.source.excludes`).map((item, index) => relativePath(item, `${path}.source.excludes[${index}]`)), + }, + destination: { + route: text(destination.route, `${path}.destination.route`), + namespace: kubernetesName(destination.namespace, `${path}.destination.namespace`), + application: kubernetesName(destination.application, `${path}.destination.application`), + publicAddress: url(destination.publicAddress, `${path}.destination.publicAddress`), + healthUrl: url(destination.healthUrl, `${path}.destination.healthUrl`), + dataClaim: kubernetesName(destination.dataClaim, `${path}.destination.dataClaim`), + codexClaim: kubernetesName(destination.codexClaim, `${path}.destination.codexClaim`), + }, + prepare: { + requiresFinalConfirmation: true, + phases: stringArray(prepare.phases, `${path}.prepare.phases`), + }, + rollback: { + phases: stringArray(rollback.phases, `${path}.rollback.phases`), + dataPolicy: text(rollback.dataPolicy, `${path}.rollback.dataPolicy`), + }, + }; +} + +function record(value: unknown, path: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an object`); + return value as Record; +} + +function text(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0 || value.includes("\n")) throw new Error(`${selfMediaConfigRef}.${path} must be a non-empty single-line string`); + return value; +} + +function integer(value: unknown, path: string): number { + if (!Number.isInteger(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an integer`); + return value as number; +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${selfMediaConfigRef}.${path} must be a boolean`); + return value; +} + +function stringArray(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\n"))) throw new Error(`${selfMediaConfigRef}.${path} must be an array of non-empty single-line strings`); + return [...value] as string[]; +} + +function kubernetesName(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/u.test(result) || result.length > 63) throw new Error(`${selfMediaConfigRef}.${path} must be a Kubernetes name`); + return result; +} + +function absolutePath(value: unknown, path: string): string { + const result = text(value, path); + if (!result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be an absolute path without ..`); + return result; +} + +function relativePath(value: unknown, path: string): string { + const result = text(value, path); + if (result.startsWith("/") || result.split("/").includes("..")) throw new Error(`${selfMediaConfigRef}.${path} must be a relative path without ..`); + return result; +} + +function url(value: unknown, path: string): string { + const result = text(value, path); + const parsed = new URL(result); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${selfMediaConfigRef}.${path} must use http or https`); + if (parsed.username.length > 0 || parsed.password.length > 0) throw new Error(`${selfMediaConfigRef}.${path} must not contain credentials`); + return result; +} diff --git a/scripts/src/selfmedia-delivery-renderer.ts b/scripts/src/selfmedia-delivery-renderer.ts new file mode 100644 index 00000000..acffbfe9 --- /dev/null +++ b/scripts/src/selfmedia-delivery-renderer.ts @@ -0,0 +1,356 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { + resolveSelfMediaDeliveryTarget, + selfMediaDeliveryConfigRef, + selfMediaEffectiveDeployment, + type SelfMediaDeliveryTarget, +} from "./selfmedia-config"; +import { stableJsonSha256 } from "./stable-json"; + +export interface SelfMediaRendererBinding { + readonly consumer: { + readonly id: string; + readonly node: string; + readonly lane: string; + readonly namespace: string; + readonly pipeline: string; + readonly pipelineRunPrefix: string; + readonly sourceArtifact: { + readonly configRef: string; + readonly mode: string; + readonly renderer: string; + }; + }; + readonly repository: { + readonly id: string; + readonly params: Readonly>; + }; +} + +export interface SelfMediaRenderedPipeline { + readonly pipeline: Record; + readonly configRef: string; + readonly effectiveConfigSha256: string; + readonly sourceWorktreeRemote: string; +} + +export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline { + const target = resolveSelfMediaDeliveryTarget(binding.consumer.node); + const configRef = selfMediaDeliveryConfigRef(target.id); + if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") { + throw new Error("selfmedia-runtime requires embedded-pipeline-spec"); + } + if (binding.consumer.sourceArtifact.configRef !== configRef) { + throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}; observed ${binding.consumer.sourceArtifact.configRef}`); + } + assertBinding(target, binding); + assertServiceRepositoryContract(sourceWorktree); + const effectiveDeployment = selfMediaEffectiveDeployment(target); + validateRuntimeRender(sourceWorktree, effectiveDeployment); + const effectiveDeploymentB64 = Buffer.from(`${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8").toString("base64"); + return { + pipeline: pipeline(target, effectiveDeploymentB64), + configRef, + effectiveConfigSha256: stableJsonSha256(target), + sourceWorktreeRemote: target.source.worktreeRemote, + }; +} + +export function selfMediaSourceWorktreeRemote(targetId: string): string { + return resolveSelfMediaDeliveryTarget(targetId).source.worktreeRemote; +} + +function pipeline(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record { + const params = [ + "revision", + "source-snapshot-prefix", + "git-read-url", + "gitops-read-url", + "gitops-write-url", + "gitops-username", + "gitops-secret-name", + ]; + const taskParams = params.map((name) => ({ name, type: "string" })); + const taskParamBindings = params.map((name) => ({ name, value: `$(params.${name})` })); + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { + name: target.ci.pipeline, + namespace: target.ci.namespace, + labels: { + "app.kubernetes.io/name": target.ci.pipeline, + "app.kubernetes.io/part-of": "selfmedia-factory", + "app.kubernetes.io/managed-by": "unidesk", + }, + }, + spec: { + params: taskParams, + workspaces: [], + tasks: [{ + name: "build-and-publish", + params: taskParamBindings, + taskSpec: { + params: taskParams, + volumes: [ + { name: "workspace", emptyDir: { sizeLimit: target.ci.workspaceSize } }, + { name: "buildkit-state", emptyDir: { sizeLimit: "12Gi" } }, + { name: "tmp", emptyDir: { sizeLimit: "4Gi" } }, + { + name: "gitops-token", + secret: { + secretName: "$(params.gitops-secret-name)", + defaultMode: 288, + items: [{ key: target.gitops.credentialTokenKey, path: "token" }], + }, + }, + ], + steps: [ + sourceStep(target, effectiveDeploymentB64), + prepareBuildkitStep(target), + imageBuildStep(target), + gitOpsPublishStep(target), + ], + }, + }], + }, + }; +} + +function sourceStep(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record { + const proxy = target.build.proxy; + return { + name: "immutable-source", + image: target.ci.toolImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace", + env: [ + { name: "HTTP_PROXY", value: requiredString(proxy.http, "build.proxy.http") }, + { name: "HTTPS_PROXY", value: requiredString(proxy.https, "build.proxy.https") }, + { name: "ALL_PROXY", value: requiredString(proxy.all, "build.proxy.all") }, + { name: "NO_PROXY", value: requiredStringArray(proxy.noProxy, "build.proxy.noProxy").join(",") }, + ], + script: `#!/bin/sh +set -eu +source_commit="$(params.revision)" +snapshot_prefix="$(params.source-snapshot-prefix)" +rm -rf /workspace/source /workspace/release +askpass=/tmp/selfmedia-source-git-askpass +cat >"$askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;; + *Password*) cat /var/run/selfmedia-gitops/token ;; + *) exit 1 ;; +esac +ASKPASS +chmod 700 "$askpass" +export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)" +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +git clone --filter=blob:none --no-checkout "$(params.git-read-url)" /workspace/source +cd /workspace/source +git fetch --depth=1 --filter=blob:none origin "+$snapshot_prefix/$source_commit:refs/remotes/origin/selfmedia-source-snapshot" +git checkout --detach "$source_commit" +test "$(git rev-parse HEAD)" = "$source_commit" +rm -f "$askpass" +printf '%s' '${effectiveDeploymentB64}' | base64 -d > /workspace/effective-deployment.yaml +bun install --frozen-lockfile +bun deploy/scripts/prepare-build.ts \\ + --config /workspace/effective-deployment.yaml \\ + --source-root /workspace/source \\ + --source-commit "$source_commit" \\ + --source-snapshot-prefix "$snapshot_prefix" \\ + --output-dir /workspace/release +`, + securityContext: nonRootSecurity(), + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "tmp", mountPath: "/tmp" }, + { name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true }, + ], + }; +} + +function prepareBuildkitStep(target: SelfMediaDeliveryTarget): Record { + return { + name: "prepare-buildkit-state", + image: target.ci.toolImage, + imagePullPolicy: "IfNotPresent", + script: "#!/bin/sh\nset -eu\nmkdir -p /home/user/.local/share/buildkit\nchown -R 1000:1000 /home/user/.local/share/buildkit\n", + securityContext: { runAsUser: 0, runAsGroup: 0 }, + volumeMounts: [{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" }], + }; +} + +function imageBuildStep(target: SelfMediaDeliveryTarget): Record { + return { + name: "image-build", + image: target.ci.buildkitImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace", + env: [ + { name: "SOURCE_COMMIT", value: "$(params.revision)" }, + { name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }, + ], + script: "#!/bin/sh\nset -eu\nexec /bin/sh /workspace/source/deploy/scripts/build-image.sh\n", + securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" }, + { name: "tmp", mountPath: "/tmp" }, + ], + }; +} + +function gitOpsPublishStep(target: SelfMediaDeliveryTarget): Record { + return { + name: "gitops-publish", + image: target.ci.toolImage, + imagePullPolicy: "IfNotPresent", + workingDir: "/workspace", + env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }], + script: `#!/bin/sh +set -eu +askpass=/tmp/selfmedia-git-askpass +cat >"$askpass" <<'ASKPASS' +#!/bin/sh +case "$1" in + *Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;; + *Password*) cat /var/run/selfmedia-gitops/token ;; + *) exit 1 ;; +esac +ASKPASS +chmod 700 "$askpass" +export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)" +export GIT_ASKPASS="$askpass" +export GIT_TERMINAL_PROMPT=0 +bun /workspace/source/deploy/scripts/publish-gitops.ts \\ + --config /workspace/effective-deployment.yaml \\ + --source-root /workspace/source \\ + --metadata /workspace/build-metadata.json \\ + --source-commit "$SOURCE_COMMIT" \\ + --worktree /workspace/selfmedia-gitops +rm -f "$askpass" +`, + securityContext: nonRootSecurity(), + volumeMounts: [ + { name: "workspace", mountPath: "/workspace" }, + { name: "tmp", mountPath: "/tmp" }, + { name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true }, + ], + }; +} + +function assertBinding(target: SelfMediaDeliveryTarget, binding: SelfMediaRendererBinding): void { + const expected: Record = { + node: target.node, + lane: target.lane, + namespace: target.ci.namespace, + pipeline: target.ci.pipeline, + pipelineRunPrefix: target.ci.pipelineRunPrefix, + }; + const observed: Record = { + node: binding.consumer.node, + lane: binding.consumer.lane, + namespace: binding.consumer.namespace, + pipeline: binding.consumer.pipeline, + pipelineRunPrefix: binding.consumer.pipelineRunPrefix, + }; + for (const [key, value] of Object.entries(expected)) { + if (observed[key] !== value) throw new Error(`PaC ${binding.consumer.id} ${key}=${observed[key]} does not match selfmedia owner ${value}`); + } + const requiredParams: Readonly> = { + node: target.node, + source_branch: target.source.branch, + source_snapshot_prefix: target.source.snapshotPrefix, + git_read_url: target.source.readUrl, + gitops_read_url: target.gitops.readUrl, + gitops_write_url: target.gitops.writeUrl, + gitops_branch: target.gitops.branch, + gitops_username: target.gitops.credentialUsername, + gitops_secret_name: target.gitops.credentialSecretName, + pipeline_name: target.ci.pipeline, + pipeline_run_prefix: target.ci.pipelineRunPrefix, + service_account: target.ci.serviceAccountName, + image_repository: target.build.imageRepository, + pipeline_timeout: target.ci.pipelineTimeout, + }; + for (const [key, value] of Object.entries(requiredParams)) { + if (binding.repository.params[key] !== value) throw new Error(`PaC ${binding.consumer.id} repository params.${key} must match selfmedia owner`); + } +} + +function assertServiceRepositoryContract(sourceWorktree: string): void { + const required = [ + "deploy/Dockerfile", + "deploy/scripts/prepare-build.ts", + "deploy/scripts/build-image.sh", + "deploy/scripts/publish-gitops.ts", + "deploy/scripts/render-runtime.ts", + ]; + for (const file of required) { + if (!existsSync(resolve(sourceWorktree, file))) throw new Error(`selfmedia source repository is missing renderer contract file ${file}`); + } +} + +function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Record): void { + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-selfmedia-render-")); + const configPath = resolve(temporary, "effective-deployment.yaml"); + try { + writeFileSync(configPath, `${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8"); + const result = spawnSync("bun", [ + "deploy/scripts/render-runtime.ts", + "--config", configPath, + "--image", `127.0.0.1:5000/selfmedia/newsroom@sha256:${"a".repeat(64)}`, + "--source-commit", "b".repeat(40), + ], { + cwd: sourceWorktree, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + timeout: 60_000, + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + const evidence = `${result.stderr || result.stdout || "render-runtime failed"}`.trim().slice(-2000); + throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`); + } + const output = result.stdout; + const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"]; + for (const marker of required) { + if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`); + } + const forbidden = ["kind: Secret", "kind: Role\n", "kind: RoleBinding", "kind: ClusterRole", "kind: ClusterRoleBinding"]; + for (const marker of forbidden) { + if (output.includes(marker)) throw new Error(`selfmedia service renderer output contains forbidden ${marker.trim()}`); + } + if ((output.match(/kind: PersistentVolumeClaim/gu) ?? []).length !== 2) throw new Error("selfmedia service renderer must produce exactly two PVCs"); + if ((output.match(/kind: NetworkPolicy/gu) ?? []).length < 3) throw new Error("selfmedia service renderer must produce at least three NetworkPolicies"); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +function nonRootSecurity(): Record { + return { + runAsUser: 1000, + runAsGroup: 1000, + runAsNonRoot: true, + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + }; +} + +function requiredString(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`); + return value; +} + +function requiredStringArray(value: unknown, path: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`); + return value as string[]; +} diff --git a/scripts/src/selfmedia.ts b/scripts/src/selfmedia.ts new file mode 100644 index 00000000..148a91fb --- /dev/null +++ b/scripts/src/selfmedia.ts @@ -0,0 +1,253 @@ +import { createHash } from "node:crypto"; +import { createReadStream, existsSync, lstatSync, readdirSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; + +import { resolveSelfMediaCutoverTarget, resolveSelfMediaDeliveryTarget, selfMediaConfigRef, type SelfMediaCutoverTarget } from "./selfmedia-config"; + +type CutoverAction = "plan" | "prepare" | "status" | "rollback"; + +interface CutoverOptions { + readonly targetId: string | null; + readonly dryRun: boolean; + readonly confirm: boolean; +} + +interface SourceDataSummary { + readonly available: boolean; + readonly files: number; + readonly bytes: number; + readonly digest: string | null; + readonly digestBasis: "relative-path,size,sha256-content"; + readonly missingPaths: readonly string[]; + readonly excludedPaths: readonly string[]; + readonly valuesPrinted: false; +} + +export async function runSelfMediaCommand(args: string[]): Promise> { + if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return help(); + if (args[0] !== "cutover") return { ok: false, error: "unsupported-selfmedia-command", args, help: help() }; + const action = args[1]; + if (action !== "plan" && action !== "prepare" && action !== "status" && action !== "rollback") { + return { ok: false, error: "unsupported-selfmedia-cutover-command", args, help: help() }; + } + const options = parseOptions(args.slice(2)); + const target = resolveSelfMediaCutoverTarget(options.targetId); + const delivery = resolveSelfMediaDeliveryTarget(target.id); + if (action === "plan") return cutoverPlan(target, delivery.route); + if (action === "status") return await cutoverStatus(target, delivery.route); + if (action === "prepare") return await cutoverPrepare(target, delivery.route, options); + return cutoverRollback(target, delivery.route, options); +} + +function help(): Record { + return { + ok: true, + command: "selfmedia cutover plan|prepare|status|rollback", + configTruth: selfMediaConfigRef, + usage: [ + "bun scripts/cli.ts selfmedia cutover plan --target NC01", + "bun scripts/cli.ts selfmedia cutover prepare --target NC01 --dry-run", + "bun scripts/cli.ts selfmedia cutover status --target NC01", + "bun scripts/cli.ts selfmedia cutover rollback --target NC01 --dry-run", + ], + boundary: "当前入口只做配置校验、源数据摘要和切换计划;不停止服务、不复制数据、不访问 k8s、不切换端口。", + valuesPrinted: false, + }; +} + +function parseOptions(args: string[]): CutoverOptions { + let targetId: string | null = null; + let dryRun = false; + let confirm = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--target") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value"); + targetId = value; + index += 1; + continue; + } + if (token === "--dry-run") { + dryRun = true; + continue; + } + if (token === "--confirm") { + confirm = true; + continue; + } + if (token === "--json") continue; + throw new Error(`unsupported selfmedia cutover option ${token}`); + } + if (dryRun && confirm) throw new Error("--dry-run and --confirm are mutually exclusive"); + return { targetId, dryRun, confirm }; +} + +function cutoverPlan(target: SelfMediaCutoverTarget, deliveryRoute: string): Record { + return { + ok: true, + action: "selfmedia-cutover-plan", + target: target.id, + mode: target.mode, + mutation: false, + configTruth: `${selfMediaConfigRef}#cutover.targets.${target.id}`, + routeAligned: target.destination.route === deliveryRoute, + source: { + workspace: target.source.workspace, + healthUrl: target.source.healthUrl, + dataPaths: target.source.dataPaths, + excludes: target.source.excludes, + }, + destination: target.destination, + phases: target.prepare.phases, + rollback: { + phases: target.rollback.phases, + dataPolicy: target.rollback.dataPolicy, + }, + safety: { + requiresFinalConfirmation: target.prepare.requiresFinalConfirmation, + oldLifecycleStateExcluded: target.source.excludes.includes(".state/server.json"), + runtimeExecutorAvailable: false, + }, + next: `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run`, + valuesPrinted: false, + }; +} + +async function cutoverStatus(target: SelfMediaCutoverTarget, deliveryRoute: string): Promise> { + const sourceData = await summarizeSourceData(target); + return { + ok: sourceData.available, + action: "selfmedia-cutover-status", + target: target.id, + mutation: false, + configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"), + sourceWorkspacePresent: existsSync(target.source.workspace), + sourceData, + runtimeObservation: { + status: "not-requested", + reason: "read-only CLI does not contact the host service, k8s, Argo, or public endpoint", + }, + next: sourceData.available + ? `bun scripts/cli.ts selfmedia cutover prepare --target ${target.id} --dry-run` + : "Restore or select the YAML-declared source workspace before preparing cutover.", + valuesPrinted: false, + }; +} + +async function cutoverPrepare(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Promise> { + const sourceData = await summarizeSourceData(target); + const confirmedButUnsupported = options.confirm; + return { + ok: sourceData.available && !confirmedButUnsupported, + action: "selfmedia-cutover-prepare", + target: target.id, + mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed", + mutation: false, + configReady: target.destination.route === deliveryRoute && target.source.excludes.includes(".state/server.json"), + sourceData, + proposedPhases: target.prepare.phases, + portHandoff: { + port: 4317, + oldServiceStopCommand: target.source.stopCommand, + destinationHealthUrl: target.destination.healthUrl, + executed: false, + }, + blocked: confirmedButUnsupported ? { + code: "selfmedia-cutover-runtime-executor-not-implemented", + reason: "本次交付只建立 YAML 所有权和只读/干运行入口,禁止从此命令执行真实切换。", + } : null, + next: confirmedButUnsupported + ? "由后续受控执行器实现 PVC seed、最终增量、端口释放、Argo 同步和原入口验收后再开放 --confirm。" + : `bun scripts/cli.ts selfmedia cutover status --target ${target.id}`, + valuesPrinted: false, + }; +} + +function cutoverRollback(target: SelfMediaCutoverTarget, deliveryRoute: string, options: CutoverOptions): Record { + const confirmedButUnsupported = options.confirm; + return { + ok: !confirmedButUnsupported, + action: "selfmedia-cutover-rollback", + target: target.id, + mode: options.dryRun || !options.confirm ? "dry-run" : "blocked-confirmed", + mutation: false, + configReady: target.destination.route === deliveryRoute, + proposedPhases: target.rollback.phases, + dataPolicy: target.rollback.dataPolicy, + blocked: confirmedButUnsupported ? { + code: "selfmedia-cutover-runtime-executor-not-implemented", + reason: "回滚命令当前只输出计划,不操作 Argo、Deployment、宿主进程或数据。", + } : null, + valuesPrinted: false, + }; +} + +async function summarizeSourceData(target: SelfMediaCutoverTarget): Promise { + const root = resolve(target.source.workspace); + const missingPaths: string[] = []; + const files: { absolute: string; relative: string; size: number }[] = []; + for (const declared of target.source.dataPaths) { + const absolute = resolve(root, declared); + if (!inside(root, absolute) || !existsSync(absolute)) { + missingPaths.push(declared); + continue; + } + collectFiles(root, absolute, target.source.excludes, files); + } + files.sort((left, right) => left.relative.localeCompare(right.relative)); + const digest = createHash("sha256"); + let bytes = 0; + for (const file of files) { + const contentDigest = await hashFile(file.absolute); + bytes += file.size; + digest.update(file.relative); + digest.update("\0"); + digest.update(String(file.size)); + digest.update("\0"); + digest.update(contentDigest); + digest.update("\n"); + } + return { + available: missingPaths.length === 0, + files: files.length, + bytes, + digest: missingPaths.length === 0 ? `sha256:${digest.digest("hex")}` : null, + digestBasis: "relative-path,size,sha256-content", + missingPaths, + excludedPaths: target.source.excludes, + valuesPrinted: false, + }; +} + +function collectFiles(root: string, current: string, excludes: readonly string[], output: { absolute: string; relative: string; size: number }[]): void { + const currentRelative = relative(root, current).split(sep).join("/"); + if (isExcluded(currentRelative, excludes)) return; + const stat = lstatSync(current); + if (stat.isSymbolicLink()) return; + if (stat.isFile()) { + output.push({ absolute: current, relative: currentRelative, size: stat.size }); + return; + } + if (!stat.isDirectory()) return; + for (const entry of readdirSync(current).sort()) collectFiles(root, resolve(current, entry), excludes, output); +} + +function isExcluded(path: string, excludes: readonly string[]): boolean { + return excludes.some((excluded) => path === excluded || path.startsWith(`${excluded}/`)); +} + +function inside(root: string, candidate: string): boolean { + return candidate === root || candidate.startsWith(`${root}${sep}`); +} + +async function hashFile(path: string): Promise { + const digest = createHash("sha256"); + await new Promise((resolvePromise, reject) => { + const stream = createReadStream(path); + stream.on("data", (chunk) => digest.update(chunk)); + stream.on("error", reject); + stream.on("end", resolvePromise); + }); + return digest.digest("hex"); +}