diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 0a454dc2..e7a1548c 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -1,6 +1,6 @@ --- name: unidesk-cicd -description: UniDesk CI/CD 控制面 — `hwlab g14` 和 `agentrun` 子命令,覆盖 PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理和 AgentRun v0.1 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k3s 部署、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。 +description: UniDesk CI/CD 控制面 — `hwlab g14` 和 `agentrun` 子命令,覆盖 PR 监控自动合并、Tekton/Argo 控制面、git-mirror、Secret、observability、CI tools image、PipelineRun 清理、AgentRun v0.1 部署和 AgentRun YAML-only lane 部署。用户提到 CI/CD、deploy、rollout、PipelineRun、trigger、git-mirror、control-plane、k3s 部署、agentrun 部署、hwlab g14、monitor-prs、trigger-current 时使用。任何需要把代码变更推送部署到 G14 k3s 的操作都必须走本 skill。 --- # UniDesk HWLAB G14 CI/CD CLI @@ -206,7 +206,25 @@ bun scripts/cli.ts hwlab g14 record-rollout --pr --source-commit --- -## AgentRun v0.1 控制面 +## AgentRun 控制面 + +YAML-only lane 以 `config/agentrun.yaml` 为部署真相;node/lane、source workspace/branch、image build、GitOps branch/path、runtime namespace、Secret、外置数据库、manager env、git-mirror 和 edge 暴露都从 YAML 进入 CLI。AgentRun service repo 的 `deploy/deploy.json` 不能作为 UniDesk deployment truth;新 lane 不再维护该文件。 + +```bash +bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02 +bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 [--dry-run|--confirm] +bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 [--dry-run|--confirm] +bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 [--dry-run|--confirm] +bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02 [--full] +``` + +- `plan`: 只读解析 YAML,输出控制面、source、image build、GitOps、runtime 和 Secret plan,不打印 Secret value +- `apply`: 按 YAML 渲染并 apply Tekton RBAC/Pipeline、Argo AppProject/Application 和 runtime namespace +- `secret-sync`: 按 YAML 的 Secret sourceRef/keyMapping 同步 runtime Secret 和外置 DB Secret,只输出 fingerprint +- `trigger-current`: 确保 source branch/workspace,删除新 lane source branch 的 `deploy/deploy.json`,构建并推送 YAML 声明的 image,渲染 GitOps/artifact catalog,触发 git-mirror sync 和 provenance PipelineRun +- `status`: 汇总 node/lane 控制面、runtime、Argo、Secret、source workspace 和 GitOps 对齐状态 + +### AgentRun v0.1 兼容入口 ```bash bun scripts/cli.ts agentrun control-plane status \ diff --git a/.agents/skills/unidesk-ops/SKILL.md b/.agents/skills/unidesk-ops/SKILL.md index 99d7649f..096e1342 100644 --- a/.agents/skills/unidesk-ops/SKILL.md +++ b/.agents/skills/unidesk-ops/SKILL.md @@ -76,7 +76,7 @@ bun scripts/cli.ts server cleanup plan [--min-age-hours 24] [--limit N] bun scripts/cli.ts server cleanup run --confirm [--min-age-hours 24] [--limit N] ``` -`plan` 只生成 dry-run 计划;`run --confirm` 只删除同一 classifier 选出的 stale Docker images。保守白名单:保留 running/stopped 容器镜像、deploy.json/CI.json commit-pinned artifact、Compose stable image。禁止 `docker system prune`、`docker image prune`、`docker volume rm`、`docker compose down -v` 和数据库清理。高风险候选必须额外显式 `--include-high-risk` 才会执行。 +`plan` 只生成 dry-run 计划;`run --confirm` 只删除同一 classifier 选出的 stale Docker images。保守白名单:保留 running/stopped 容器镜像、UniDesk YAML/GitOps/image catalog 声明的 commit-pinned artifact、Compose stable image。禁止 `docker system prune`、`docker image prune`、`docker volume rm`、`docker compose down -v` 和数据库清理。高风险候选必须额外显式 `--include-high-risk` 才会执行。 --- @@ -174,6 +174,21 @@ SCRIPT 长期边界见 `docs/reference/pk01.md`;Sub2API 消费侧边界见 `docs/reference/platform-infra.md`。 +## YAML-First 分布式运维边界 + +UniDesk 自有分布式运维以 `config/**/*.yaml` 为 desired-state truth。服务仓库里的 `deploy.json` 不能作为 UniDesk deployment truth;node/lane、runtime namespace、GitOps branch/path、image artifact、public exposure、Secret、外置数据库、probe 和 rollout 等运维选择必须进入所属 UniDesk YAML,并通过受控 CLI 渲染或同步。 + +AgentRun v0.2/D601 这类 YAML-only lane 的控制面、Secret 同步、外置 DB wiring 和状态检查使用: + +```bash +bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02 +bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --confirm +bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --confirm +bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02 --full +``` + +部署触发和 GitOps promotion 入口归 `$unidesk-cicd`;本 skill 只记录手动运维边界和长期排障入口。长期架构见 `docs/reference/yaml-first-ops.md`,AgentRun 细则见 `docs/reference/agentrun.md`。 + --- ## Moon Bridge 管理 diff --git a/config/agentrun.yaml b/config/agentrun.yaml index b910350b..769e07c2 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -49,6 +49,7 @@ controlPlane: source: repository: pikasTech/agentrun branch: v0.1 + bootstrapFromBranch: v0.1 remote: git@github.com:pikasTech/agentrun.git workspace: /root/agentrun-v01 runtime: @@ -70,13 +71,83 @@ controlPlane: argoNamespace: argocd argoApplication: agentrun-g14-v01 repoURL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git + deployment: + format: unidesk-yaml-only + gitopsRoot: deploy/gitops/g14 + runtimeRenderDir: runtime-v01 + artifactCatalogPath: deploy/artifact-catalog.v01.json + argocd: + project: agentrun-v01 + applicationFile: application-v01.yaml + manager: + serviceAccount: agentrun-v01-mgr + apiKeySecretRef: + name: agentrun-v01-api-key + key: HWLAB_API_KEY + unideskSshEndpointEnv: + name: UNIDESK_MAIN_SERVER_IP + value: 74.48.78.17 + bootRepoUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git + imageBuild: + context: . + containerfile: deploy/container/Containerfile + repository: agentrun-mgr-env + network: host + httpProxy: http://127.0.0.1:10808 + httpsProxy: 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 + envIdentityFiles: + - deploy/container/Containerfile + - deploy/runtime/boot/agentrun-boot.sh + - deploy/runtime/boot/agentrun-mgr.sh + - deploy/runtime/boot/agentrun-runner.sh + - package.json + - bun.lock + - tsconfig.json + timeoutSeconds: 1800 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 800m + memory: 1Gi + runner: + serviceAccount: agentrun-v01-runner + jobNamePrefix: agentrun-v01-runner + apiKeySecretRef: + name: agentrun-v01-api-key + key: HWLAB_API_KEY + localPostgres: + enabled: true + serviceName: agentrun-v01-postgres + image: postgres:16-alpine + storage: 5Gi + port: 5432 gitMirror: namespace: devops-infra readService: git-mirror-http + readDeployment: git-mirror-http writeService: git-mirror-write + writeDeployment: git-mirror-write readUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/agentrun.git cachePvc: git-mirror-cache + cacheHostPath: null + sshSecretName: git-mirror-github-ssh + githubProxy: + host: 127.0.0.1 + port: 10808 toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 syncJobPrefix: git-mirror-agentrun-sync-manual flushJobPrefix: git-mirror-agentrun-flush-manual @@ -97,6 +168,7 @@ controlPlane: name: agentrun-v01-mgr-db key: DATABASE_URL localPostgresExpectedAbsent: false + secrets: [] v02: node: D601 @@ -104,6 +176,7 @@ controlPlane: source: repository: pikasTech/agentrun branch: v0.2 + bootstrapFromBranch: v0.1 remote: git@github.com:pikasTech/agentrun.git workspace: /home/ubuntu/workspace/agentrun-v02 runtime: @@ -125,13 +198,79 @@ controlPlane: argoNamespace: argocd argoApplication: agentrun-d601-v02 repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + deployment: + format: unidesk-yaml-only + gitopsRoot: deploy/gitops/node/d601 + runtimeRenderDir: runtime-v02 + artifactCatalogPath: deploy/artifact-catalog.v02.json + argocd: + project: agentrun-v02 + applicationFile: application-v02.yaml + manager: + serviceAccount: agentrun-v02-mgr + apiKeySecretRef: + name: agentrun-v02-api-key + key: HWLAB_API_KEY + unideskSshEndpointEnv: + name: UNIDESK_MAIN_SERVER_IP + value: 74.48.78.17 + bootRepoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + imageBuild: + context: . + containerfile: deploy/container/Containerfile + repository: agentrun-mgr-env + network: host + httpProxy: http://127.0.0.1:18789 + httpsProxy: http://127.0.0.1:18789 + noProxy: + - localhost + - 127.0.0.1 + - ::1 + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - hyueapi.com + - .hyueapi.com + envIdentityFiles: + - deploy/container/Containerfile + - deploy/runtime/boot/agentrun-boot.sh + - deploy/runtime/boot/agentrun-mgr.sh + - deploy/runtime/boot/agentrun-runner.sh + - package.json + - bun.lock + - tsconfig.json + timeoutSeconds: 1800 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 800m + memory: 1Gi + runner: + serviceAccount: agentrun-v02-runner + jobNamePrefix: agentrun-v02-runner + apiKeySecretRef: + name: agentrun-v02-api-key + key: HWLAB_API_KEY + localPostgres: + enabled: false gitMirror: namespace: devops-infra readService: git-mirror-http + readDeployment: git-mirror-http writeService: git-mirror-write + writeDeployment: git-mirror-write readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git cachePvc: hwlab-git-mirror-cache + cacheHostPath: /var/lib/rancher/k3s/storage/hwlab-d601-v03-git-mirror-cache + sshSecretName: git-mirror-github-ssh + githubProxy: + host: sub2api-egress-proxy.platform-infra.svc.cluster.local + port: 10808 toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 syncJobPrefix: git-mirror-agentrun-d601-v02-sync-manual flushJobPrefix: git-mirror-agentrun-d601-v02-flush-manual @@ -158,3 +297,11 @@ controlPlane: name: agentrun-v02-mgr-db key: DATABASE_URL localPostgresExpectedAbsent: true + secrets: + - id: manager-api-key + sourceRef: /root/.config/hwlab-v02/master-server-admin-api-key.env + sourceKey: HWLAB_API_KEY + targetRef: + namespace: agentrun-v02 + name: agentrun-v02-api-key + key: HWLAB_API_KEY diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index 77119f2a..68e2e48c 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -63,7 +63,7 @@ trans G14:k3s kubectl get pods -n agentrun-v01 ## 受控 CI/CD 入口 -AgentRun `v0.1` 的 Tekton/Argo 控制面写操作必须通过 UniDesk 高层 CLI 执行: +AgentRun 控制面写操作必须通过 UniDesk 高层 CLI 执行。历史 `v0.1` G14 lane 仍保留无 `--node/--lane` 的兼容入口;新增或迁移 lane 必须使用 `--node --lane ` 从 `config/agentrun.yaml` 解析目标,不得从 AgentRun service repo 的 `deploy.json` 读取部署真相。 ```bash bun scripts/cli.ts agentrun control-plane status @@ -77,8 +77,23 @@ bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --dry bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --confirm ``` +YAML-only lane 的标准入口是: + +```bash +bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02 +bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --dry-run +bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --confirm +bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run +bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --confirm +bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --dry-run +bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --confirm +bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02 --full +``` + `status` 只读观察 `G14:/root/agentrun-v01` 当前 commit、对应 PipelineRun、GitOps latest、Argo Application、`agentrun-v01` workload、manager source commit 和 git mirror 摘要,并报告 Argo revision 是否对齐 `v0.1-gitops` latest。默认输出是 compact commander 视图,只保留 `summary`、阶段耗时、对齐状态和 drill-down 命令;需要远端 stdout/stderr tail 时显式加 `--full`,需要原始 git mirror cache 输出时显式加 `--raw`。`status` 额外支持 `--pipeline-run ` 与 `--source-commit ` 定点查询,返回 `target`、`targetValidation` 和 `next.*` drill-down,便于直接判断某次 run 是成功、历史成功、运行中、缺失还是 source mismatch。`status` 会向 stderr 输出 `agentrun.control-plane.status.progress` 阶段事件,覆盖 `source`、`runtime` 和 `git-mirror`,避免长时间聚合时无可见进展。`trigger-current` 会先把固定 source worktree 快进到 `origin/v0.1`,再以当前 commit 创建 commit-pinned PipelineRun;同名 PipelineRun 正在运行或已经成功时必须拒绝重复触发,只允许在失败态或不存在时创建。该命令只提交 CI/CD 工作,不等待完整 PipelineRun 或 rollout 完成,后续用 `status` 轮询。`refresh` 只对 `argocd/agentrun-g14-v01` 执行 hard refresh,用于 GitOps promotion 已完成但 Argo 仍停留旧 revision 时的受控同步入口;它不直接 patch runtime workload。 +YAML-only lane 的 `trigger-current` 会先确保目标 source workspace/branch 存在,再从 UniDesk YAML 声明的 image build、GitOps branch/path、runtime namespace、Secret、数据库和 manager env 渲染 artifact catalog 与 GitOps desired state。该路径会删除新 lane source branch 中的 `deploy/deploy.json`,因为部署真相已经迁入 UniDesk YAML;旧 `v0.1` branch 中历史文件只作为迁移前遗留产物存在,不能作为新 lane 的事实来源。 + `cleanup-runs` 是 AgentRun `v0.1` 完成态 CI workspace retention 入口,只清理 `agentrun-ci` namespace 中超过 `--min-age-minutes` 的 `agentrun-v01-ci-*` PipelineRun,通过 Tekton ownerRef 释放临时 workspace PVC。dry-run 必须披露候选 PipelineRun、owned PVC、active mount 保护、local-path 实际估算 bytes 和 confirm 命令。默认保护最新完成的 PipelineRun,保留当前 CI/CD 状态证据。`cleanup-released-pvs` 是二次回收入口,只处理 `agentrun-ci`、`local-path`、`Delete` reclaim policy 的 `Released` PV;它不触碰 `agentrun-v01` runtime namespace、业务 PVC、Secret、registry storage 或 GitOps desired state。磁盘治理和 G14 safe-stop 规则见 `docs/reference/gc.md`。 涉及 AgentRun runner egress、`transientEnv` 或 Secret 不泄露的 closeout,必须用真实 `create/apply/send` 资源原语触发 `agentrun-v01` runner Job,再通过 `describe runnerjob/...`、`events run/...`、`logs session/...` 或必要的兼容 bridge 检查 runner job response、event/trace 和 Kubernetes Pod spec。通过证据应显示 proxy env 是否存在、`NO_PROXY` 是否包含 `hyueapi.com`/`.hyueapi.com`、短期 `HWLAB_API_KEY` 等 `transientEnv` 是否通过 per-job Secret 的 `valueFrom.secretKeyRef` 注入,以及 response/event 只输出 env name、Secret metadata 和 `valuesPrinted=false`。不得在 issue、trace 或 Pod spec 摘要中输出 Secret value。AgentRun 内部 SecretRef 合同以 AgentRun 仓库 `docs/reference/spec-v01-secret-distribution.md` 和 `docs/reference/spec-v01-runtime-assembly.md` 为权威;UniDesk 只记录验证入口和跨仓库归因。 @@ -115,7 +130,7 @@ UniDesk 指挥官新任务入口固定使用 `bun scripts/cli.ts agentrun get|de 资源原语和旧兼容 group 的默认 transport 是直连 AgentRun REST API,配置来源是 UniDesk 自有 YAML `config/agentrun.yaml`。鉴权可以复用 `HWLAB_API_KEY` 的环境变量/固定文件发现风格,但不得依赖 HWLAB runtime、HWLAB backend-core、HWLAB frontend 代理或 SSH official CLI;多一层转发会增加故障面,不能作为正式路径。`--raw` 只披露直连 AgentRun REST envelope 和必要的 `transport=direct-http`、`clientRole=render-only`、`configPath`、`baseUrl`、auth source/redacted metadata,不打印 token value。`agentrun control-plane ...` 和 `git-mirror ...` 仍属于 G14 source/runtime 运维控制路径,可以继续使用 UniDesk SSH capture bridge;这些控制面路径不得反向成为 queue/session 资源原语的默认 transport。 -AgentRun 公网 HTTPS 入口按 Sub2API 的 FRP+Caddy 模式维护:`agentrun-v01` runtime 仍保持 ClusterIP,AgentRun source branch 的 `deploy/deploy.json` 声明 G14 frpc,把 `agentrun-mgr.agentrun-v01.svc.cluster.local:8080` 暴露到 master `127.0.0.1:22880`;UniDesk `config/agentrun.yaml` 声明 `https://agentrun.74-48-78-17.nip.io/`、master frps allow port、master Caddy vhost 和 direct REST 鉴权。`bun scripts/cli.ts agentrun control-plane expose --confirm` 只负责补 master `frps` allow port 与 Caddy site,不在 AgentRun k3s 中创建 Ingress、NodePort、LoadBalancer、hostPort 或 HWLAB 转发层。 +AgentRun 公网 HTTPS 入口、FRP/Caddy edge、direct REST base URL 和鉴权来源都由 UniDesk `config/agentrun.yaml` 声明。YAML-only lane 不允许把这些部署选择写回 AgentRun source branch 的 `deploy/deploy.json`;AgentRun source repo 只保留应用代码、构建输入和 AgentRun 自身契约。`bun scripts/cli.ts agentrun control-plane expose --confirm` 只负责按 UniDesk YAML 补 edge 侧 allow port 与 Caddy site,不在 AgentRun k3s 中创建 Ingress、NodePort、LoadBalancer、hostPort 或 HWLAB 转发层。 AgentRun Queue 任务如果需要调用 UniDesk 维护桥,例如 `trans` / `unidesk-ssh`,长期契约以 AgentRun 仓库 `docs/reference/spec-v01-runtime-assembly.md` 和 `docs/reference/spec-v01-secret-distribution.md` 为准:调用方通过 `executionPolicy.secretScope.toolCredentials[].tool=unidesk-ssh` 请求 `UNIDESK_SSH_CLIENT_TOKEN` SecretRef;非敏感 endpoint 由 runner-job `transientEnv` 显式提供,或由 manager 受控默认值自动补齐。UniDesk bridge 提交 Queue payload 时不得在 prompt、payload 或 `transientEnv` 中携带 token,也不得使用 HWLAB runtime Web 入口冒充 UniDesk frontend。若 dispatcher 已正确请求 `unidesk-ssh` 但 trace 的 `runner-job-created.transientEnv.names` 没有 `UNIDESK_MAIN_SERVER_IP`、`UNIDESK_MAIN_SERVER_HOST` 或 `UNIDESK_FRONTEND_URL`,归为 AgentRun assembly 问题;若 endpoint env 已存在但 route denied/timeout,再按 UniDesk frontend/token scope 或 provider session 排查。 diff --git a/docs/reference/yaml-first-ops.md b/docs/reference/yaml-first-ops.md index b5cf3d42..ee71baee 100644 --- a/docs/reference/yaml-first-ops.md +++ b/docs/reference/yaml-first-ops.md @@ -32,6 +32,12 @@ Code may validate that YAML is present, typed, syntactically valid and renderabl External formats such as JSON, TOML, env files, Kubernetes YAML, Caddyfile, systemd units or app-specific config files may still be generated or consumed at the edge when the external tool requires them. They are inputs or rendered artifacts, not UniDesk desired-state truth. +## Service Deployment Declarations + +UniDesk-managed service deployment declarations must not live in service repository JSON such as `deploy.json`. A service repository may keep application source, build inputs, migrations, API/spec documentation and app-native runtime config required by the process. Node/lane selection, runtime namespace, image artifact selection, GitOps branch/path, public exposure, external database wiring, Secret mapping, service account, probes, rollout and cleanup settings belong in the owning UniDesk YAML and are rendered by UniDesk CLI. + +Generated GitOps YAML, image catalogs, env files, Kubernetes manifests or external tool config may be committed as rendered artifacts when the runtime requires them. They must carry enough provenance to point back to the owning YAML/source commit and must not become a second editable desired-state truth. + ## Architecture Layers YAML-first ops uses five layers. diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index fd2ebfb4..40812b86 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -10,6 +10,19 @@ export interface AgentRunGitMirrorRepositorySpec { readonly gitopsBranch?: string; } +export interface AgentRunSecretRef { + readonly namespace: string; + readonly name: string; + readonly key: string; +} + +export interface AgentRunLaneSecretSpec { + readonly id: string; + readonly sourceRef: string; + readonly sourceKey: string; + readonly targetRef: AgentRunSecretRef; +} + export interface AgentRunLaneSpec { readonly lane: string; readonly nodeId: string; @@ -19,6 +32,7 @@ export interface AgentRunLaneSpec { readonly source: { readonly repository: string; readonly branch: string; + readonly bootstrapFromBranch: string | null; readonly remote: string; readonly workspace: string; }; @@ -44,13 +58,51 @@ export interface AgentRunLaneSpec { readonly argoApplication: string; readonly repoURL: string; }; + readonly deployment: { + readonly format: "unidesk-yaml-only"; + readonly gitopsRoot: string; + readonly runtimeRenderDir: string; + readonly artifactCatalogPath: string; + readonly argocd: { + readonly project: string; + readonly applicationFile: string; + }; + readonly manager: { + readonly serviceAccount: string; + readonly apiKeySecretRef: { readonly name: string; readonly key: string }; + readonly unideskSshEndpointEnv: { readonly name: string; readonly value: string } | null; + readonly bootRepoUrl: string; + readonly imageBuild: AgentRunImageBuildSpec; + readonly resources: AgentRunContainerResources; + }; + readonly runner: { + readonly serviceAccount: string; + readonly jobNamePrefix: string; + readonly apiKeySecretRef: { readonly name: string; readonly key: string }; + }; + readonly localPostgres: { + readonly enabled: boolean; + readonly serviceName: string | null; + readonly image: string | null; + readonly storage: string | null; + readonly port: number | null; + }; + }; readonly gitMirror: { readonly namespace: string; readonly readService: string; + readonly readDeployment: string; readonly writeService: string; + readonly writeDeployment: string; readonly readUrl: string; readonly writeUrl: string; readonly cachePvc: string; + readonly cacheHostPath: string | null; + readonly sshSecretName: string; + readonly githubProxy: { + readonly host: string; + readonly port: number; + }; readonly toolsImage: string; readonly syncJobPrefix: string; readonly flushJobPrefix: string; @@ -67,6 +119,30 @@ export interface AgentRunLaneSpec { readonly secretRef: { readonly name: string; readonly key: string }; readonly localPostgresExpectedAbsent: boolean; }; + readonly secrets: readonly AgentRunLaneSecretSpec[]; +} + +export interface AgentRunContainerResources { + readonly requests: { + readonly cpu: string; + readonly memory: string; + }; + readonly limits: { + readonly cpu: string; + readonly memory: string; + }; +} + +export interface AgentRunImageBuildSpec { + readonly context: string; + readonly containerfile: string; + readonly repository: string; + readonly network: string; + readonly httpProxy: string | null; + readonly httpsProxy: string | null; + readonly noProxy: readonly string[]; + readonly envIdentityFiles: readonly string[]; + readonly timeoutSeconds: number; } export interface AgentRunLaneTarget { @@ -124,6 +200,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record ({ key: repo.key, repository: repo.repository, @@ -173,6 +287,13 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record ({ + id: secret.id, + sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`, + sourceKey: secret.sourceKey, + targetRef: secret.targetRef, + valuesPrinted: false, + })), }; } @@ -233,6 +354,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record parseGitMirrorRepository(repo, `${path}.gitMirror.repositories[${index}]`)), }, database: parseDatabase(database, `${path}.database`), + secrets: arrayField(input, "secrets", path).map((secret, index) => parseLaneSecret(secret, `${path}.secrets[${index}]`)), + }; +} + +function parseDeployment(input: Record, path: string): AgentRunLaneSpec["deployment"] { + const argocd = recordField(input, "argocd", path); + const manager = recordField(input, "manager", path); + const runner = recordField(input, "runner", path); + const localPostgres = recordField(input, "localPostgres", path); + return { + format: enumField(input, "format", path, ["unidesk-yaml-only"]), + gitopsRoot: relativePathField(input, "gitopsRoot", path), + runtimeRenderDir: relativePathField(input, "runtimeRenderDir", path), + artifactCatalogPath: relativePathField(input, "artifactCatalogPath", path), + argocd: { + project: stringField(argocd, "project", `${path}.argocd`), + applicationFile: relativePathField(argocd, "applicationFile", `${path}.argocd`), + }, + manager: { + serviceAccount: stringField(manager, "serviceAccount", `${path}.manager`), + apiKeySecretRef: parseSecretRef(recordField(manager, "apiKeySecretRef", `${path}.manager`), `${path}.manager.apiKeySecretRef`), + unideskSshEndpointEnv: optionalEnvPair(manager, "unideskSshEndpointEnv", `${path}.manager`), + bootRepoUrl: urlField(manager, "bootRepoUrl", `${path}.manager`), + imageBuild: parseImageBuild(recordField(manager, "imageBuild", `${path}.manager`), `${path}.manager.imageBuild`), + resources: parseContainerResources(recordField(manager, "resources", `${path}.manager`), `${path}.manager.resources`), + }, + runner: { + serviceAccount: stringField(runner, "serviceAccount", `${path}.runner`), + jobNamePrefix: stringField(runner, "jobNamePrefix", `${path}.runner`), + apiKeySecretRef: parseSecretRef(recordField(runner, "apiKeySecretRef", `${path}.runner`), `${path}.runner.apiKeySecretRef`), + }, + localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`), + }; +} + +function parseLocalPostgres(input: Record, path: string): AgentRunLaneSpec["deployment"]["localPostgres"] { + const enabled = booleanField(input, "enabled", path); + if (!enabled) { + return { + enabled: false, + serviceName: optionalStringField(input, "serviceName", path) ?? null, + image: optionalStringField(input, "image", path) ?? null, + storage: optionalStringField(input, "storage", path) ?? null, + port: optionalIntegerField(input, "port", path) ?? null, + }; + } + return { + enabled: true, + serviceName: stringField(input, "serviceName", path), + image: stringField(input, "image", path), + storage: stringField(input, "storage", path), + port: integerField(input, "port", path), + }; +} + +function parseContainerResources(input: Record, path: string): AgentRunContainerResources { + const requests = recordField(input, "requests", path); + const limits = recordField(input, "limits", path); + return { + requests: { + cpu: stringField(requests, "cpu", `${path}.requests`), + memory: stringField(requests, "memory", `${path}.requests`), + }, + limits: { + cpu: stringField(limits, "cpu", `${path}.limits`), + memory: stringField(limits, "memory", `${path}.limits`), + }, + }; +} + +function parseImageBuild(input: Record, path: string): AgentRunImageBuildSpec { + return { + context: relativePathField(input, "context", path), + containerfile: relativePathField(input, "containerfile", path), + repository: stringField(input, "repository", path), + network: stringField(input, "network", path), + httpProxy: optionalStringField(input, "httpProxy", path) ?? null, + httpsProxy: optionalStringField(input, "httpsProxy", path) ?? null, + noProxy: stringArrayField(input, "noProxy", path), + envIdentityFiles: stringArrayField(input, "envIdentityFiles", path).map((item, index) => { + if (item.startsWith("/") || item.includes("..")) throw new Error(`${path}.envIdentityFiles[${index}] must be a relative path without ..`); + return item; + }), + timeoutSeconds: integerField(input, "timeoutSeconds", path), + }; +} + +function parseLaneSecret(input: Record, path: string): AgentRunLaneSecretSpec { + return { + id: stringField(input, "id", path), + sourceRef: secretSourceRefField(input, "sourceRef", path), + sourceKey: stringField(input, "sourceKey", path), + targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`), }; } @@ -319,6 +541,31 @@ function parseSecretRef(input: Record, path: string): { name: s }; } +function parseNamespacedSecretRef(input: Record, path: string): AgentRunSecretRef { + return { + namespace: stringField(input, "namespace", path), + name: stringField(input, "name", path), + key: stringField(input, "key", path), + }; +} + +function parseGithubProxy(input: Record, path: string): { host: string; port: number } { + return { + host: stringField(input, "host", path), + port: integerField(input, "port", path), + }; +} + +function optionalEnvPair(obj: Record, key: string, path: string): { name: string; value: string } | null { + const value = obj[key]; + if (value === undefined || value === null) return null; + const record = asRecord(value, `${path}.${key}`); + return { + name: stringField(record, "name", `${path}.${key}`), + value: stringField(record, "value", `${path}.${key}`), + }; +} + function asRecord(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); return value as Record; @@ -353,12 +600,28 @@ function integerField(obj: Record, key: string, path: string): return Number(value); } +function optionalIntegerField(obj: Record, key: string, path: string): number | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer when set`); + return Number(value); +} + function arrayField(obj: Record, key: string, path: string): Record[] { const value = obj[key]; if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`); return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`)); } +function stringArrayField(obj: Record, key: string, path: string): string[] { + const value = obj[key]; + if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`); + return value.map((item, index) => { + if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${path}.${key}[${index}] must be a non-empty string`); + return item.trim(); + }); +} + function enumField(obj: Record, key: string, path: string, values: readonly T[]): T { const value = stringField(obj, key, path); if (!values.includes(value as T)) throw new Error(`${path}.${key} must be one of ${values.join(", ")}`); @@ -371,12 +634,28 @@ function absolutePathField(obj: Record, key: string, path: stri return value; } +function optionalAbsolutePathField(obj: Record, key: string, path: string): string | undefined { + const value = obj[key]; + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`); + const trimmed = value.trim(); + if (!trimmed.startsWith("/") || trimmed.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`); + return trimmed; +} + function relativePathField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be a relative path without ..`); return value; } +function secretSourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (value.includes("..")) throw new Error(`${path}.${key} must not contain ..`); + if (!value.startsWith("/") && value.startsWith(".")) throw new Error(`${path}.${key} must be absolute or relative without a leading dot`); + return value; +} + function urlField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); try { diff --git a/scripts/src/agentrun-manifests.ts b/scripts/src/agentrun-manifests.ts new file mode 100644 index 00000000..bdd2d64c --- /dev/null +++ b/scripts/src/agentrun-manifests.ts @@ -0,0 +1,489 @@ +import { createHash } from "node:crypto"; +import type { AgentRunLaneSpec } from "./agentrun-lanes"; + +export interface AgentRunArtifactService { + readonly serviceId: string; + readonly image: string; + readonly digest: string; + readonly repositoryDigest: string; + readonly imageTag: string; + readonly artifactKind: string; + readonly status: string; + readonly envIdentity: string; + readonly envImage: string; + readonly envDigest: string; + readonly envRepositoryDigest: string; + readonly bootCommit: string; + readonly bootScript: string; + readonly provenance: Record; +} + +export interface AgentRunArtifactCatalog { + readonly lane: string; + readonly sourceBranch: string; + readonly gitopsBranch: string; + readonly sourceCommitId: string; + readonly summary: string; + readonly services: readonly AgentRunArtifactService[]; +} + +export interface AgentRunGitopsRenderInput { + readonly sourceCommit: string; + readonly image: AgentRunArtifactService; +} + +export interface AgentRunRenderedFile { + readonly path: string; + readonly content: string; +} + +export function renderAgentRunControlPlaneManifests(spec: AgentRunLaneSpec): readonly Record[] { + return [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: spec.ci.namespace } }, + { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { + name: spec.ci.serviceAccountName, + namespace: spec.ci.namespace, + labels: agentRunLabels(spec), + }, + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { + name: spec.ci.serviceAccountName, + namespace: spec.ci.namespace, + labels: agentRunLabels(spec), + }, + rules: [ + { apiGroups: ["tekton.dev"], resources: ["pipelineruns", "taskruns"], verbs: ["get", "list", "watch", "create", "patch", "update"] }, + { apiGroups: [""], resources: ["pods", "pods/log", "secrets", "configmaps", "persistentvolumeclaims"], verbs: ["get", "list", "watch", "create", "patch", "update", "delete"] }, + ], + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { + name: spec.ci.serviceAccountName, + namespace: spec.ci.namespace, + labels: agentRunLabels(spec), + }, + subjects: [{ kind: "ServiceAccount", name: spec.ci.serviceAccountName }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: spec.ci.serviceAccountName }, + }, + agentRunPipelineManifest(spec), + agentRunArgoProjectManifest(spec), + agentRunArgoApplicationManifest(spec), + ]; +} + +export function renderAgentRunGitopsFiles(spec: AgentRunLaneSpec, input: AgentRunGitopsRenderInput): readonly AgentRunRenderedFile[] { + const catalog = agentRunArtifactCatalog(spec, input.sourceCommit, input.image); + const source = { + lane: spec.version, + sourceCommit: input.sourceCommit, + generatedBy: "unidesk config/agentrun.yaml", + configSource: "config/agentrun.yaml", + }; + return [ + { path: "source.json", content: `${JSON.stringify(source, null, 2)}\n` }, + { path: spec.deployment.artifactCatalogPath, content: `${JSON.stringify(catalog, null, 2)}\n` }, + { path: `${spec.deployment.gitopsRoot}/argocd/project.yaml`, content: yaml(agentRunArgoProjectManifest(spec)) }, + { path: `${spec.deployment.gitopsRoot}/argocd/${spec.deployment.argocd.applicationFile}`, content: yaml(agentRunArgoApplicationManifest(spec)) }, + { path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/kustomization.yaml`, content: yaml(agentRunKustomizationManifest(spec)) }, + { path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/namespace.yaml`, content: yaml(agentRunRuntimeNamespaceManifest(spec)) }, + ...(spec.deployment.localPostgres.enabled ? [{ path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/postgres.yaml`, content: yaml(agentRunPostgresManifest(spec)) }] : []), + { path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/mgr.yaml`, content: yamlAll(agentRunManagerManifests(spec, input.sourceCommit, input.image)) }, + { path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/runner-rbac.yaml`, content: yamlAll(agentRunRunnerRbacManifests(spec)) }, + ]; +} + +export function placeholderAgentRunImage(spec: AgentRunLaneSpec, sourceCommit: string): AgentRunArtifactService { + const digest = `sha256:${"0".repeat(64)}`; + const image = `${spec.ci.registryPrefix}/agentrun-mgr-env:${sourceCommit}`; + return { + serviceId: "agentrun-mgr", + artifactKind: "env-reuse", + status: "placeholder", + image, + digest, + repositoryDigest: `${spec.ci.registryPrefix}/agentrun-mgr-env@${digest}`, + imageTag: sourceCommit, + envIdentity: sourceCommit, + envImage: image, + envDigest: digest, + envRepositoryDigest: `${spec.ci.registryPrefix}/agentrun-mgr-env@${digest}`, + bootCommit: sourceCommit, + bootScript: "deploy/runtime/boot/agentrun-boot.sh", + provenance: { + sourceCommitId: sourceCommit, + source: "placeholder", + valuesPrinted: false, + }, + }; +} + +export function agentRunImageArtifact(spec: AgentRunLaneSpec, input: { + sourceCommit: string; + envIdentity: string; + digest: string; + status: string; +}): AgentRunArtifactService { + const image = `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}:${input.envIdentity}`; + return { + serviceId: "agentrun-mgr", + artifactKind: "env-reuse", + status: input.status, + image, + digest: input.digest, + repositoryDigest: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}@${input.digest}`, + imageTag: input.envIdentity, + envIdentity: input.envIdentity, + envImage: image, + envDigest: input.digest, + envRepositoryDigest: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}@${input.digest}`, + bootCommit: input.sourceCommit, + bootScript: "deploy/runtime/boot/agentrun-boot.sh", + provenance: { + sourceCommitId: input.sourceCommit, + source: "unidesk-yaml-only", + configSource: "config/agentrun.yaml", + valuesPrinted: false, + }, + }; +} + +export function renderedFilesDigest(files: readonly AgentRunRenderedFile[]): string { + const hash = createHash("sha256"); + for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) { + hash.update(file.path); + hash.update("\0"); + hash.update(file.content); + hash.update("\0"); + } + return `sha256:${hash.digest("hex")}`; +} + +export function renderedObjectsDigest(objects: readonly Record[]): string { + return `sha256:${createHash("sha256").update(yamlAll(objects)).digest("hex")}`; +} + +function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record { + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { + name: spec.ci.pipeline, + namespace: spec.ci.namespace, + labels: agentRunLabels(spec), + }, + spec: { + params: [ + { name: "git-url", type: "string", default: spec.source.remote }, + { name: "git-read-url", type: "string", default: spec.gitMirror.readUrl }, + { name: "git-write-url", type: "string", default: spec.gitMirror.writeUrl }, + { name: "source-branch", type: "string", default: spec.source.branch }, + { name: "gitops-branch", type: "string", default: spec.gitops.branch }, + { name: "revision", type: "string" }, + { name: "registry-prefix", type: "string", default: spec.ci.registryPrefix }, + { name: "tools-image", type: "string", default: spec.ci.toolsImage }, + ], + workspaces: [{ name: "source" }, { name: "git-ssh" }], + tasks: [ + gitopsSmokeTask(spec), + ], + }, + }; +} + +function gitopsSmokeTask(spec: AgentRunLaneSpec): Record { + return { + name: "render-smoke", + workspaces: [{ name: "source", workspace: "source" }], + taskSpec: { + params: [{ name: "revision" }, { name: "tools-image" }], + workspaces: [{ name: "source" }], + steps: [ + { + name: "render-smoke", + image: "$(params.tools-image)", + script: [ + "#!/bin/sh", + "set -eu", + "echo '{\"event\":\"agentrun-ci-render-smoke\",\"status\":\"placeholder\",\"reason\":\"unidesk-yaml-only-control-plane\",\"valuesPrinted\":false}'", + ].join("\n"), + }, + ], + }, + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "tools-image", value: "$(params.tools-image)" }, + ], + when: [{ input: spec.deployment.format, operator: "in", values: ["unidesk-yaml-only"] }], + }; +} + +function agentRunArgoProjectManifest(spec: AgentRunLaneSpec): Record { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "AppProject", + metadata: { + name: spec.deployment.argocd.project, + namespace: spec.gitops.argoNamespace, + labels: agentRunLabels(spec), + }, + spec: { + description: `AgentRun ${spec.version} GitOps lane`, + sourceRepos: [spec.gitops.repoURL, spec.source.remote], + destinations: [{ server: "https://kubernetes.default.svc", namespace: spec.runtime.namespace }], + clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], + namespaceResourceWhitelist: [{ group: "*", kind: "*" }], + }, + }; +} + +function agentRunArgoApplicationManifest(spec: AgentRunLaneSpec): Record { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "Application", + metadata: { + name: spec.gitops.argoApplication, + namespace: spec.gitops.argoNamespace, + labels: agentRunLabels(spec), + }, + spec: { + project: spec.deployment.argocd.project, + source: { + repoURL: spec.gitops.repoURL, + targetRevision: spec.gitops.branch, + path: spec.gitops.path, + }, + destination: { + server: "https://kubernetes.default.svc", + namespace: spec.runtime.namespace, + }, + syncPolicy: { + automated: { prune: false, selfHeal: true }, + syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"], + }, + }, + }; +} + +function agentRunKustomizationManifest(spec: AgentRunLaneSpec): Record { + return { + apiVersion: "kustomize.config.k8s.io/v1beta1", + kind: "Kustomization", + resources: [ + "namespace.yaml", + ...(spec.deployment.localPostgres.enabled ? ["postgres.yaml"] : []), + "mgr.yaml", + "runner-rbac.yaml", + ], + }; +} + +function agentRunRuntimeNamespaceManifest(spec: AgentRunLaneSpec): Record { + return { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: spec.runtime.namespace, + labels: agentRunLabels(spec), + }, + }; +} + +function agentRunPostgresManifest(spec: AgentRunLaneSpec): Record { + const localPostgres = spec.deployment.localPostgres; + if (!localPostgres.enabled || localPostgres.serviceName === null || localPostgres.image === null || localPostgres.storage === null || localPostgres.port === null) { + throw new Error(`localPostgres is enabled for ${spec.version} without renderable YAML fields`); + } + const name = localPostgres.serviceName; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + spec: { selector: { "app.kubernetes.io/name": name }, ports: [{ name: "postgres", port: localPostgres.port, targetPort: "postgres" }] }, + }, + { + apiVersion: "apps/v1", + kind: "StatefulSet", + metadata: { name, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + spec: { + serviceName: name, + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": name } }, + template: { + metadata: { labels: { ...agentRunLabels(spec), "app.kubernetes.io/name": name } }, + spec: { + containers: [ + { + name: "postgres", + image: localPostgres.image, + ports: [{ name: "postgres", containerPort: localPostgres.port }], + }, + ], + }, + }, + volumeClaimTemplates: [ + { + metadata: { name: "data" }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: localPostgres.storage } } }, + }, + ], + }, + }, + ], + }; +} + +function agentRunManagerManifests(spec: AgentRunLaneSpec, sourceCommit: string, image: AgentRunArtifactService): readonly Record[] { + const imageRef = image.envRepositoryDigest || image.repositoryDigest; + return [ + { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: spec.deployment.manager.serviceAccount, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) } }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name: spec.runtime.managerService, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + spec: { + selector: { "app.kubernetes.io/name": spec.runtime.managerDeployment }, + ports: [{ name: "http", port: spec.runtime.managerPort, targetPort: "http" }], + }, + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: spec.runtime.managerDeployment, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": spec.runtime.managerDeployment } }, + template: { + metadata: { + labels: { ...agentRunLabels(spec), "app.kubernetes.io/name": spec.runtime.managerDeployment }, + annotations: { + "agentrun.pikastech.local/lane": spec.version, + "agentrun.pikastech.local/source-commit": sourceCommit, + "agentrun.pikastech.local/env-identity": image.envIdentity, + }, + }, + spec: { + serviceAccountName: spec.deployment.manager.serviceAccount, + containers: [ + { + name: "mgr", + image: imageRef, + imagePullPolicy: "IfNotPresent", + ports: [{ name: "http", containerPort: 8080 }], + env: managerEnv(spec, sourceCommit, imageRef, image.envIdentity), + readinessProbe: { httpGet: { path: "/health/readiness", port: "http" } }, + livenessProbe: { httpGet: { path: "/health/live", port: "http" } }, + resources: spec.deployment.manager.resources, + }, + ], + }, + }, + }, + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: `${spec.deployment.manager.serviceAccount}-runner-job-controller`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + rules: [ + { apiGroups: ["batch"], resources: ["jobs"], verbs: ["create", "get", "list", "watch"] }, + { apiGroups: [""], resources: ["pods"], verbs: ["get", "list", "watch"] }, + { apiGroups: [""], resources: ["persistentvolumeclaims"], verbs: ["create", "get", "list", "watch", "delete"] }, + ], + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: `${spec.deployment.manager.serviceAccount}-runner-job-controller`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + subjects: [{ kind: "ServiceAccount", name: spec.deployment.manager.serviceAccount }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${spec.deployment.manager.serviceAccount}-runner-job-controller` }, + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: `${spec.deployment.manager.serviceAccount}-provider-secret-manager`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + rules: [{ apiGroups: [""], resources: ["secrets"], verbs: ["create", "delete", "get", "list", "patch", "update"] }], + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: `${spec.deployment.manager.serviceAccount}-provider-secret-manager`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + subjects: [{ kind: "ServiceAccount", name: spec.deployment.manager.serviceAccount }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${spec.deployment.manager.serviceAccount}-provider-secret-manager` }, + }, + ]; +} + +function managerEnv(spec: AgentRunLaneSpec, sourceCommit: string, imageRef: string, envIdentity: string): readonly Record[] { + return [ + { name: "AGENTRUN_LANE", value: spec.version }, + { name: "DATABASE_URL", valueFrom: { secretKeyRef: spec.database.secretRef } }, + { name: "AGENTRUN_SOURCE_COMMIT", value: sourceCommit }, + { name: "AGENTRUN_BOOT_COMMIT", value: sourceCommit }, + { name: "AGENTRUN_BOOT_MODE", value: "mgr" }, + { name: "AGENTRUN_BOOT_REPO_URL", value: spec.deployment.manager.bootRepoUrl }, + { name: "AGENTRUN_ENV_IDENTITY", value: envIdentity }, + { name: "AGENTRUN_RUNTIME_NAMESPACE", value: spec.runtime.namespace }, + { name: "AGENTRUN_INTERNAL_MGR_URL", value: spec.runtime.internalBaseUrl }, + { name: "AGENTRUN_RUNNER_IMAGE", value: imageRef }, + { name: "AGENTRUN_RUNNER_SERVICE_ACCOUNT", value: spec.deployment.runner.serviceAccount }, + { name: "AGENTRUN_API_KEY", valueFrom: { secretKeyRef: spec.deployment.manager.apiKeySecretRef } }, + ...(spec.deployment.manager.unideskSshEndpointEnv === null ? [] : [{ name: spec.deployment.manager.unideskSshEndpointEnv.name, value: spec.deployment.manager.unideskSshEndpointEnv.value }]), + ]; +} + +function agentRunRunnerRbacManifests(spec: AgentRunLaneSpec): readonly Record[] { + return [ + { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: spec.deployment.runner.serviceAccount, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) } }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: `${spec.deployment.runner.serviceAccount}-secret-reader`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + rules: [{ apiGroups: [""], resources: ["secrets"], verbs: ["get"] }], + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: `${spec.deployment.runner.serviceAccount}-secret-reader`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) }, + subjects: [{ kind: "ServiceAccount", name: spec.deployment.runner.serviceAccount }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${spec.deployment.runner.serviceAccount}-secret-reader` }, + }, + ]; +} + +function agentRunArtifactCatalog(spec: AgentRunLaneSpec, sourceCommit: string, image: AgentRunArtifactService): AgentRunArtifactCatalog { + return { + lane: spec.version, + sourceBranch: spec.source.branch, + gitopsBranch: spec.gitops.branch, + sourceCommitId: sourceCommit, + summary: image.status === "placeholder" ? "build=0 reuse=0 placeholder=1" : "build=1 reuse=0 placeholder=0", + services: [image], + }; +} + +function agentRunLabels(spec: AgentRunLaneSpec): Record { + return { + "app.kubernetes.io/part-of": "agentrun", + "agentrun.pikastech.local/lane": spec.version, + "agentrun.pikastech.local/node": spec.nodeId, + }; +} + +function yaml(value: unknown): string { + return `${Bun.YAML.stringify(value).trim()}\n`; +} + +function yamlAll(values: readonly unknown[]): string { + return `${values.map((value) => Bun.YAML.stringify(value).trim()).join("\n---\n")}\n`; +} diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index ddad76e7..4ba0fba0 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -14,6 +14,15 @@ import { resolveAgentRunLaneTarget, type AgentRunLaneSpec, } from "./agentrun-lanes"; +import { + agentRunImageArtifact, + placeholderAgentRunImage, + renderedFilesDigest, + renderedObjectsDigest, + renderAgentRunControlPlaneManifests, + renderAgentRunGitopsFiles, + type AgentRunArtifactService, +} from "./agentrun-manifests"; const g14SourceRoute = "G14:/root/agentrun-v01"; const g14K3sRoute = "G14:k3s"; @@ -66,9 +75,12 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun send session/ --aipod Artificer --prompt-stdin", "bun scripts/cli.ts agentrun explain task", "bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", + "bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --dry-run", "bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", "bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run", "bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --confirm", + "bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --dry-run", + "bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --confirm", "bun scripts/cli.ts agentrun control-plane status", "bun scripts/cli.ts agentrun control-plane status --full", "bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", @@ -106,6 +118,7 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str if (config === null) throw new Error("agentrun control-plane and git-mirror commands require UniDesk config"); if (group === "control-plane") { if (action === "plan") return await controlPlanePlan(config, parseStatusOptions(actionArgs)); + if (action === "apply") return await controlPlaneApply(config, parseLaneConfirmOptions(actionArgs)); if (action === "status") return await status(config, parseStatusOptions(actionArgs)); if (action === "secret-sync") return await secretSync(config, parseSecretSyncOptions(actionArgs)); if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs)); @@ -232,11 +245,13 @@ function agentRunHelpText(args: string[]): string { return [ "Usage: bun scripts/cli.ts agentrun control-plane [options]", "", - "Actions: plan, status, secret-sync, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", + "Actions: plan, apply, status, secret-sync, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", "Examples:", " bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", + " bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --dry-run", " bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", " bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run", + " bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --dry-run", " bun scripts/cli.ts agentrun control-plane status", " bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", " bun scripts/cli.ts agentrun control-plane expose --dry-run", @@ -1634,6 +1649,9 @@ function isRecord(value: unknown): value is Record { interface TriggerOptions { confirm: boolean; dryRun: boolean; + node: string | null; + lane: string | null; + wait: boolean; } interface ConfirmOptions { @@ -1646,6 +1664,11 @@ interface SecretSyncOptions extends ConfirmOptions { lane: string | null; } +interface LaneConfirmOptions extends ConfirmOptions { + node: string | null; + lane: string | null; +} + interface GitMirrorOptions extends ConfirmOptions { timeoutSeconds: number; wait: boolean; @@ -1759,7 +1782,34 @@ function parseStatusOptions(args: string[]): StatusOptions { function parseTriggerOptions(args: string[]): TriggerOptions { - return parseConfirmOptions(args); + const base = parseConfirmOptions(args); + let node: string | null = null; + let lane: string | null = null; + let wait = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm" || arg === "--dry-run") continue; + if (arg === "--wait") { + wait = true; + continue; + } + if (arg === "--node") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); + node = value; + index += 1; + continue; + } + if (arg === "--lane") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--lane requires a value"); + lane = value; + index += 1; + continue; + } + throw new Error(`unsupported trigger-current option: ${arg}`); + } + return { ...base, node, lane, wait }; } function parseSecretSyncOptions(args: string[]): SecretSyncOptions { @@ -1788,6 +1838,33 @@ function parseSecretSyncOptions(args: string[]): SecretSyncOptions { return { ...base, node, lane }; } +function parseLaneConfirmOptions(args: string[]): LaneConfirmOptions { + const base = parseConfirmOptions(args); + let node: string | null = null; + let lane: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm" || arg === "--dry-run") continue; + if (arg === "--node") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); + node = value; + index += 1; + continue; + } + if (arg === "--lane") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--lane requires a value"); + lane = value; + index += 1; + continue; + } + throw new Error(`unsupported control-plane option: ${arg}`); + } + if (node === null && lane === null) throw new Error("control-plane apply requires --node and --lane"); + return { ...base, node, lane }; +} + function parseConfirmOptions(args: string[]): ConfirmOptions { if (args.includes("--confirm") && args.includes("--dry-run")) throw new Error("accepts only one of --confirm or --dry-run"); return { @@ -1882,6 +1959,60 @@ async function controlPlanePlan(_config: UniDeskConfig, options: StatusOptions): status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null, postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null, + controlPlaneApply: `bun scripts/cli.ts agentrun control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --dry-run`, + }, + valuesPrinted: false, + }; +} + +async function controlPlaneApply(config: UniDeskConfig, options: LaneConfirmOptions): Promise> { + const { configPath, spec } = resolveAgentRunLaneTarget(options); + const objects = renderAgentRunControlPlaneManifests(spec); + const manifestYaml = `${objects.map((object) => Bun.YAML.stringify(object).trim()).join("\n---\n")}\n`; + const objectRefs = objects.map((object) => manifestObjectRef(object)); + const plan = { + node: spec.nodeId, + kubeRoute: spec.nodeKubeRoute, + lane: spec.lane, + version: spec.version, + objectCount: objects.length, + objects: objectRefs, + manifestBytes: Buffer.byteLength(manifestYaml, "utf8"), + manifestDigest: renderedObjectsDigest(objects), + fieldManager: "unidesk-agentrun-control-plane", + valuesPrinted: false, + }; + if (options.dryRun || !options.confirm) { + return { + ok: true, + command: "agentrun control-plane apply", + mode: "dry-run", + mutation: false, + configPath, + target: agentRunLaneSummary(spec), + plan, + next: { + confirm: `bun scripts/cli.ts agentrun control-plane apply --node ${spec.nodeId} --lane ${spec.lane} --confirm`, + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, + }, + valuesPrinted: false, + }; + } + const applied = await capture(config, spec.nodeKubeRoute, ["script", "--", applyYamlScript(manifestYaml, "unidesk-agentrun-control-plane", false)]); + const payload = captureJsonPayload(applied); + return { + ok: applied.exitCode === 0 && payload.ok !== false, + command: "agentrun control-plane apply", + mode: "confirmed-apply", + mutation: true, + configPath, + target: agentRunLaneSummary(spec), + plan, + result: payload, + capture: compactCapture(applied, { full: applied.exitCode !== 0, stdoutTailChars: 5000, stderrTailChars: 5000 }), + next: { + secretSync: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`, + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, }, valuesPrinted: false, }; @@ -2058,6 +2189,7 @@ async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, tar const argo = record(runtimePayload.argo); const manager = record(runtimePayload.manager); const database = record(runtimePayload.database); + const secrets = record(runtimePayload.secrets); const localPostgres = record(runtimePayload.localPostgres); const blockers = [ ...(sourcePayload.workspaceExists === true ? [] : ["source-worktree-missing"]), @@ -2074,6 +2206,7 @@ async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, tar ...(manager.deploymentExists === true ? [] : ["manager-deployment-missing"]), ...(manager.serviceExists === true ? [] : ["manager-service-missing"]), ...(spec.database.mode === "external-postgres" && database.secretPresent !== true ? ["database-secret-missing"] : []), + ...(secrets.ready === true ? [] : ["lane-secret-missing"]), ...(spec.database.localPostgresExpectedAbsent && localPostgres.absent !== true ? ["local-postgres-present"] : []), ]; return { @@ -2111,6 +2244,7 @@ async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, tar namespaceExists: runtimePayload.runtimeNamespaceExists ?? false, manager, database, + secrets, localPostgres, }, }, @@ -2132,6 +2266,7 @@ async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, tar plan: `bun scripts/cli.ts agentrun control-plane plan --node ${spec.nodeId} --lane ${spec.lane}`, postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null, postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null, + secretSync: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`, statusFull: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane} --full`, }, valuesPrinted: false, @@ -2140,28 +2275,31 @@ async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, tar async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise> { const { configPath, spec } = resolveAgentRunLaneTarget(options); - if (spec.database.secretSourceRef === null) { + const sources = collectLaneSecretSources(spec); + if (sources.length === 0) { return { ok: false, command: "agentrun control-plane secret-sync", mode: "yaml-declared-node-lane", configPath, target: agentRunLaneSummary(spec), - degradedReason: "database-secret-source-not-declared", + degradedReason: "lane-secret-sources-not-declared", valuesPrinted: false, }; } - const source = readSecretSourceValue(spec, spec.database.secretSourceRef, spec.database.secretRef.key); - const plan = { - namespace: spec.runtime.namespace, - secret: spec.database.secretRef.name, - key: spec.database.secretRef.key, - sourceRef: spec.database.secretSourceRef, - sourcePath: source.redactedPath, - fingerprint: source.fingerprint, - valueBytes: source.valueBytes, + const values = sources.map((source) => ({ spec: source, value: readSecretSourceValue(spec, source.sourceRef, source.sourceKey) })); + const plan = values.map(({ spec: item, value }) => ({ + id: item.id, + namespace: item.targetRef.namespace, + secret: item.targetRef.name, + key: item.targetRef.key, + sourceRef: item.sourceRef, + sourceKey: item.sourceKey, + sourcePath: value.redactedPath, + fingerprint: value.fingerprint, + valueBytes: value.valueBytes, valuesPrinted: false, - }; + })); if (options.dryRun || !options.confirm) { return { ok: true, @@ -2170,7 +2308,7 @@ async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Pr mutation: false, configPath, target: agentRunLaneSummary(spec), - plan, + plan: { secretCount: plan.length, items: plan, valuesPrinted: false }, next: { confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`, status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, @@ -2178,7 +2316,7 @@ async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Pr valuesPrinted: false, }; } - const result = await capture(config, spec.nodeKubeRoute, ["script", "--", secretSyncScript(spec, source.value)]); + const result = await capture(config, spec.nodeKubeRoute, ["script", "--", secretSyncScript(spec, values.map(({ spec: item, value }) => ({ targetRef: item.targetRef, value: value.value })))]); const payload = captureJsonPayload(result); return { ok: result.exitCode === 0 && payload.ok !== false, @@ -2187,7 +2325,7 @@ async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Pr mutation: true, configPath, target: agentRunLaneSummary(spec), - plan, + plan: { secretCount: plan.length, items: plan, valuesPrinted: false }, result: payload, capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }), next: { @@ -2401,6 +2539,10 @@ function escapeRegExp(value: string): string { } async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): Promise> { + if (options.node !== null || options.lane !== null) { + const target = resolveAgentRunLaneTarget(options); + return await triggerCurrentYamlLane(config, options, target); + } const source = await capture(config, g14SourceRoute, ["script", "--", [ "set -u", "cd /root/agentrun-v01", @@ -2502,6 +2644,200 @@ async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): P }; } +async function triggerCurrentYamlLane(config: UniDeskConfig, options: TriggerOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise> { + const spec = target.spec; + const probe = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneSourceBootstrapProbeScript(spec)]); + const source = captureJsonPayload(probe); + const sourceCommit = stringOrNull(source.sourceCommit); + const remoteBranchExists = source.remoteBranchExists === true; + const pipelineRun = sourceCommit !== null && isGitSha(sourceCommit) ? agentRunPipelineRunName(spec, sourceCommit) : null; + const placeholderImage = sourceCommit === null ? null : placeholderAgentRunImage(spec, sourceCommit); + const renderedFiles = placeholderImage === null ? [] : renderAgentRunGitopsFiles(spec, { sourceCommit, image: placeholderImage }); + const plan = { + node: spec.nodeId, + lane: spec.lane, + version: spec.version, + source: { + workspace: spec.source.workspace, + remote: spec.source.remote, + branch: spec.source.branch, + bootstrapFromBranch: spec.source.bootstrapFromBranch, + remoteBranchExists, + sourceCommit, + }, + deploymentFormat: spec.deployment.format, + deploymentTruth: "config/agentrun.yaml", + removedServiceDeployJson: true, + pipelineRun, + imageBuild: { + repository: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}`, + containerfile: spec.deployment.manager.imageBuild.containerfile, + timeoutSeconds: spec.deployment.manager.imageBuild.timeoutSeconds, + proxyConfigured: spec.deployment.manager.imageBuild.httpProxy !== null || spec.deployment.manager.imageBuild.httpsProxy !== null, + }, + gitops: { + branch: spec.gitops.branch, + path: spec.gitops.path, + renderedFileCount: renderedFiles.length, + renderedFilesDigest: renderedFiles.length === 0 ? null : renderedFilesDigest(renderedFiles), + }, + valuesPrinted: false, + }; + if (options.dryRun || !options.confirm) { + return { + ok: true, + command: "agentrun control-plane trigger-current", + mode: "dry-run", + mutation: false, + configPath: target.configPath, + target: agentRunLaneSummary(spec), + plan, + sourceProbe: compactCapture(probe, { full: probe.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }), + next: { + confirm: `bun scripts/cli.ts agentrun control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`, + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, + }, + valuesPrinted: false, + }; + } + if (options.wait) { + return await triggerCurrentYamlLaneConfirmed(config, spec, target.configPath, true); + } + return startAsyncAgentRunJob( + `agentrun_${spec.lane}_trigger_current`, + ["bun", "scripts/cli.ts", "agentrun", "control-plane", "trigger-current", "--node", spec.nodeId, "--lane", spec.lane, "--confirm", "--wait"], + `Run AgentRun ${spec.version} YAML-only trigger-current on ${spec.nodeId}`, + ); +} + +async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise> { + const bootstrap = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneSourceBootstrapScript(spec)]); + const bootstrapPayload = captureJsonPayload(bootstrap); + const sourceCommit = stringOrNull(bootstrapPayload.sourceCommit); + if (bootstrap.exitCode !== 0 || sourceCommit === null || !isGitSha(sourceCommit)) { + return { + ok: false, + command: "agentrun control-plane trigger-current", + mode: waited ? "confirmed-waited" : "confirmed-trigger", + configPath, + target: agentRunLaneSummary(spec), + phase: "source-bootstrap", + degradedReason: "yaml-lane-source-bootstrap-failed", + result: bootstrapPayload, + capture: compactCapture(bootstrap, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }), + valuesPrinted: false, + }; + } + const buildSubmit = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]); + const buildSubmitPayload = captureJsonPayload(buildSubmit); + if (buildSubmit.exitCode !== 0 || buildSubmitPayload.ok === false) { + return { + ok: false, + command: "agentrun control-plane trigger-current", + mode: waited ? "confirmed-waited" : "confirmed-trigger", + configPath, + target: agentRunLaneSummary(spec), + phase: "image-build-submit", + sourceCommit, + degradedReason: "yaml-lane-image-build-submit-failed", + sourceBootstrap: bootstrapPayload, + result: buildSubmitPayload, + capture: compactCapture(buildSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }), + valuesPrinted: false, + }; + } + const build = await waitForYamlLaneBuildImage(config, spec, sourceCommit, stringOrNull(buildSubmitPayload.jobId)); + const buildPayload = build.payload; + const digest = stringOrNull(buildPayload.digest); + const envIdentity = stringOrNull(buildPayload.envIdentity); + if (build.ok !== true || digest === null || envIdentity === null) { + return { + ok: false, + command: "agentrun control-plane trigger-current", + mode: waited ? "confirmed-waited" : "confirmed-trigger", + configPath, + target: agentRunLaneSummary(spec), + phase: "image-build", + sourceCommit, + degradedReason: "yaml-lane-image-build-failed", + sourceBootstrap: bootstrapPayload, + buildSubmit: buildSubmitPayload, + result: buildPayload, + buildStatus: build, + valuesPrinted: false, + }; + } + const image = agentRunImageArtifact(spec, { sourceCommit, envIdentity, digest, status: stringOrNull(buildPayload.status) ?? "built" }); + const renderedFiles = renderAgentRunGitopsFiles(spec, { sourceCommit, image }); + const gitops = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneGitopsPublishScript(spec, renderedFiles)]); + const gitopsPayload = captureJsonPayload(gitops); + if (gitops.exitCode !== 0 || gitopsPayload.ok === false) { + return { + ok: false, + command: "agentrun control-plane trigger-current", + mode: waited ? "confirmed-waited" : "confirmed-trigger", + configPath, + target: agentRunLaneSummary(spec), + phase: "gitops-publish", + sourceCommit, + image, + degradedReason: "yaml-lane-gitops-publish-failed", + sourceBootstrap: bootstrapPayload, + imageBuild: buildPayload, + result: gitopsPayload, + capture: compactCapture(gitops, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }), + valuesPrinted: false, + }; + } + const mirror = await runYamlLaneGitMirrorSyncJob(config, spec); + if (mirror.ok !== true) { + return { + ok: false, + command: "agentrun control-plane trigger-current", + mode: waited ? "confirmed-waited" : "confirmed-trigger", + configPath, + target: agentRunLaneSummary(spec), + phase: "git-mirror-sync", + sourceCommit, + image, + gitops: gitopsPayload, + degradedReason: "yaml-lane-git-mirror-sync-failed", + result: mirror, + valuesPrinted: false, + }; + } + const pipelineRun = agentRunPipelineRunName(spec, sourceCommit); + const created = await capture(config, spec.nodeKubeRoute, ["script", "--", yamlLanePipelineRunCreateScript(spec, sourceCommit, pipelineRun)]); + const createPayload = captureJsonPayload(created); + return { + ok: created.exitCode === 0 && createPayload.ok !== false, + command: "agentrun control-plane trigger-current", + mode: waited ? "confirmed-waited" : "confirmed-trigger", + mutation: true, + configPath, + target: agentRunLaneSummary(spec), + sourceCommit, + pipelineRun, + image, + sourceBootstrap: bootstrapPayload, + imageBuildSubmit: buildSubmitPayload, + imageBuild: buildPayload, + renderedFiles: { + count: renderedFiles.length, + digest: renderedFilesDigest(renderedFiles), + }, + gitops: gitopsPayload, + gitMirror: mirror, + created: createPayload, + capture: compactCapture(created, { full: created.exitCode !== 0, stdoutTailChars: 4000, stderrTailChars: 4000 }), + next: { + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, + statusByPipelineRun: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane} --pipeline-run ${pipelineRun} --full`, + }, + valuesPrinted: false, + }; +} + async function refresh(config: UniDeskConfig, options: ConfirmOptions): Promise> { const source = await capture(config, g14SourceRoute, ["script", "--", [ "cd /root/agentrun-v01", @@ -2738,7 +3074,8 @@ function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun: string `manager_service=${shQuote(spec.runtime.managerService)}`, `database_secret=${shQuote(spec.database.secretRef.name)}`, `database_key=${shQuote(spec.database.secretRef.key)}`, - "export runtime_namespace ci_namespace pipeline_name pipeline_run service_account argo_namespace argo_application manager_deployment manager_service database_secret database_key", + `secrets_json=${shQuote(JSON.stringify(collectLaneSecretSources(spec).map((item) => item.targetRef)))}`, + "export runtime_namespace ci_namespace pipeline_name pipeline_run service_account argo_namespace argo_application manager_deployment manager_service database_secret database_key secrets_json", "tmp_dir=$(mktemp -d)", "trap 'rm -rf \"$tmp_dir\"' EXIT", "kubectl get ns \"$runtime_namespace\" -o json > \"$tmp_dir/runtime-ns.json\" 2>/dev/null", @@ -2758,6 +3095,21 @@ function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun: string "manager_svc_exit=$?", "kubectl -n \"$runtime_namespace\" get secret \"$database_secret\" -o json > \"$tmp_dir/db-secret.json\" 2>/dev/null", "db_secret_exit=$?", + "SECRET_REFS_JSON=\"$secrets_json\" NODE_TMP=\"$tmp_dir\" node <<'NODE' > \"$tmp_dir/secrets.json\"", + "const fs = require('node:fs');", + "const cp = require('node:child_process');", + "const refs = JSON.parse(process.env.SECRET_REFS_JSON || '[]');", + "const result = [];", + "for (const ref of refs) {", + " const out = cp.spawnSync('kubectl', ['-n', ref.namespace, 'get', 'secret', ref.name, '-o', 'json'], { encoding: 'utf8' });", + " let keyPresent = false;", + " if (out.status === 0) {", + " try { keyPresent = Object.prototype.hasOwnProperty.call((JSON.parse(out.stdout).data || {}), ref.key); } catch {}", + " }", + " result.push({ namespace: ref.namespace, name: ref.name, key: ref.key, present: out.status === 0, keyPresent, valuesPrinted: false });", + "}", + "console.log(JSON.stringify({ ready: result.every((item) => item.present && item.keyPresent), count: result.length, items: result, valuesPrinted: false }));", + "NODE", "kubectl -n \"$runtime_namespace\" get deploy,sts,svc,secret -o name > \"$tmp_dir/runtime-names.txt\" 2>/dev/null", "NODE_TMP=\"$tmp_dir\" RUNTIME_NS_EXIT=\"$runtime_ns_exit\" CI_NS_EXIT=\"$ci_ns_exit\" PIPELINE_EXIT=\"$pipeline_exit\" SERVICE_ACCOUNT_EXIT=\"$sa_exit\" PIPELINERUN_EXIT=\"$pr_exit\" ARGO_EXIT=\"$argo_exit\" MANAGER_DEPLOY_EXIT=\"$manager_deploy_exit\" MANAGER_SVC_EXIT=\"$manager_svc_exit\" DB_SECRET_EXIT=\"$db_secret_exit\" node <<'NODE'", "const fs = require('node:fs');", @@ -2772,6 +3124,7 @@ function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun: string "const managerDeploy = readJson('manager-deploy.json');", "const managerSvc = readJson('manager-svc.json');", "const dbSecret = readJson('db-secret.json');", + "const secrets = readJson('secrets.json') || { ready: true, count: 0, items: [], valuesPrinted: false };", "let names = ''; try { names = fs.readFileSync(path.join(dir, 'runtime-names.txt'), 'utf8'); } catch {}", "const c = condition(pipelineRun);", "console.log(JSON.stringify({", @@ -2784,6 +3137,7 @@ function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun: string " argo: { exists: exists('ARGO_EXIT'), namespace: process.env.argo_namespace, application: process.env.argo_application, revision: argo?.status?.sync?.revision || null, syncStatus: argo?.status?.sync?.status || null, healthStatus: argo?.status?.health?.status || null },", " manager: { deploymentExists: exists('MANAGER_DEPLOY_EXIT'), serviceExists: exists('MANAGER_SVC_EXIT'), deployment: process.env.manager_deployment, service: process.env.manager_service, image: managerDeploy?.spec?.template?.spec?.containers?.[0]?.image || null, sourceCommit: envValue(managerDeploy, 'AGENTRUN_SOURCE_COMMIT'), servicePorts: Array.isArray(managerSvc?.spec?.ports) ? managerSvc.spec.ports.map((port) => ({ name: port.name || null, port: port.port || null, targetPort: port.targetPort || null })) : [] },", " database: { secretPresent: exists('DB_SECRET_EXIT'), secretName: process.env.database_secret, key: process.env.database_key, keyPresent: Boolean(dbSecret?.data && Object.prototype.hasOwnProperty.call(dbSecret.data, process.env.database_key || '')) , valuesPrinted: false },", + " secrets,", " localPostgres: { absent: !/postgres/i.test(names), matchingObjects: names.split(/\\r?\\n/).filter((line) => /postgres/i.test(line)).slice(0, 20) },", " valuesPrinted: false", "}));", @@ -2791,6 +3145,467 @@ function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun: string ].join("\n"); } +function yamlLaneSourceBootstrapProbeScript(spec: AgentRunLaneSpec): string { + return [ + "set +e", + `workspace=${shQuote(spec.source.workspace)}`, + `remote=${shQuote(spec.source.remote)}`, + `branch=${shQuote(spec.source.branch)}`, + "workspace_exists=false", + "remote_branch_exists=false", + "source_commit=null", + "if [ -d \"$workspace/.git\" ]; then", + " workspace_exists=true", + " cd \"$workspace\"", + " remote_branch=$(git ls-remote --heads \"$remote\" \"$branch\" 2>/dev/null | awk '{print $1}' | head -n 1)", + " if [ -n \"$remote_branch\" ]; then remote_branch_exists=true; source_commit=\"$remote_branch\"; else source_commit=$(git rev-parse HEAD 2>/dev/null || printf null); fi", + "else", + " remote_branch=$(git ls-remote --heads \"$remote\" \"$branch\" 2>/dev/null | awk '{print $1}' | head -n 1)", + " if [ -n \"$remote_branch\" ]; then remote_branch_exists=true; source_commit=\"$remote_branch\"; fi", + "fi", + "export workspace_exists remote_branch_exists source_commit", + "node <<'NODE'", + "console.log(JSON.stringify({ ok: true, workspaceExists: process.env.workspace_exists === 'true', remoteBranchExists: process.env.remote_branch_exists === 'true', sourceCommit: process.env.source_commit === 'null' ? null : process.env.source_commit, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function yamlLaneSourceBootstrapScript(spec: AgentRunLaneSpec): string { + const bootstrap = spec.source.bootstrapFromBranch ?? spec.source.branch; + return [ + "set -eu", + `workspace=${shQuote(spec.source.workspace)}`, + `remote=${shQuote(spec.source.remote)}`, + `branch=${shQuote(spec.source.branch)}`, + `bootstrap_branch=${shQuote(bootstrap)}`, + "mkdir -p \"$(dirname \"$workspace\")\"", + "if [ ! -d \"$workspace/.git\" ]; then", + " git clone --no-checkout \"$remote\" \"$workspace\"", + "fi", + "cd \"$workspace\"", + "git remote set-url origin \"$remote\" || git remote add origin \"$remote\"", + "git fetch origin \"$bootstrap_branch\" \"$branch\" || git fetch origin \"$bootstrap_branch\"", + "if git rev-parse --verify \"refs/remotes/origin/$branch^{commit}\" >/dev/null 2>&1; then", + " git checkout -B \"$branch\" \"refs/remotes/origin/$branch\"", + "else", + " git checkout -B \"$branch\" \"refs/remotes/origin/$bootstrap_branch\"", + "fi", + "if [ -f deploy/deploy.json ]; then rm deploy/deploy.json; fi", + "git add -A deploy/deploy.json 2>/dev/null || true", + "if ! git diff --quiet --cached -- deploy/deploy.json 2>/dev/null; then", + " git -c user.email=agentrun@unidesk.local -c user.name='UniDesk AgentRun Ops' commit -m 'chore: remove service deploy json truth'", + "fi", + "git push -u origin \"$branch\"", + "source_commit=$(git rev-parse HEAD)", + "status_short=$(git status --short)", + "SOURCE_COMMIT=\"$source_commit\" STATUS_SHORT=\"$status_short\" node <<'NODE'", + "console.log(JSON.stringify({ ok: process.env.STATUS_SHORT === '', sourceCommit: process.env.SOURCE_COMMIT, workspaceClean: process.env.STATUS_SHORT === '', statusShort: process.env.STATUS_SHORT || null, removedServiceDeployJson: true, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function yamlLaneBuildImageSubmitScript(spec: AgentRunLaneSpec, sourceCommit: string): string { + const build = spec.deployment.manager.imageBuild; + const noProxy = build.noProxy.join(","); + const imageRepository = `${spec.ci.registryPrefix}/${build.repository}`; + const stateDir = `/tmp/unidesk-agentrun-build-${spec.nodeId}-${spec.lane}`; + const script = [ + "set -eu", + `workspace=${shQuote(spec.source.workspace)}`, + `source_commit=${shQuote(sourceCommit)}`, + `state_dir=${shQuote(stateDir)}`, + `containerfile=${shQuote(build.containerfile)}`, + `context_dir=${shQuote(build.context)}`, + `image_repository=${shQuote(imageRepository)}`, + `network=${shQuote(build.network)}`, + `http_proxy_value=${build.httpProxy === null ? "''" : shQuote(build.httpProxy)}`, + `https_proxy_value=${build.httpsProxy === null ? "''" : shQuote(build.httpsProxy)}`, + `no_proxy_value=${shQuote(noProxy)}`, + `env_identity_files=${shQuote(JSON.stringify(build.envIdentityFiles))}`, + "mkdir -p \"$state_dir\"", + "cd \"$workspace\"", + "git checkout \"$source_commit\"", + "env_identity=$(ENV_IDENTITY_FILES=\"$env_identity_files\" node <<'NODE'", + "const { createHash } = require('node:crypto');", + "const { readFileSync, existsSync } = require('node:fs');", + "const files = JSON.parse(process.env.ENV_IDENTITY_FILES || '[]');", + "const hash = createHash('sha256');", + "for (const file of files) { hash.update(file); hash.update('\\0'); if (existsSync(file)) hash.update(readFileSync(file)); hash.update('\\0'); }", + "process.stdout.write(hash.digest('hex').slice(0, 24));", + "NODE", + ")", + "job_id=\"${source_commit:0:12}-$env_identity\"", + "status_file=\"$state_dir/$job_id.json\"", + "stdout_file=\"$state_dir/$job_id.stdout.log\"", + "stderr_file=\"$state_dir/$job_id.stderr.log\"", + "cat > \"$status_file\" </dev/null | sed 's/\"/\\\\\"/g' | tr '\\n' ' ' | cut -c1-2000); CODE=\"$code\" ERROR_TAIL=\"$tail_text\" JOB_ID=\"$job_id\" SOURCE_COMMIT=\"$source_commit\" ENV_IDENTITY=\"$env_identity\" IMAGE_REPOSITORY=\"$image_repository\" node <<'NODE' > \"$status_file\"", + "const code = Number(process.env.CODE || 1);", + "console.log(JSON.stringify({ ok: false, status: 'failed', exitCode: code, jobId: process.env.JOB_ID, sourceCommit: process.env.SOURCE_COMMIT, envIdentity: process.env.ENV_IDENTITY, image: `${process.env.IMAGE_REPOSITORY}:${process.env.ENV_IDENTITY}`, errorTail: process.env.ERROR_TAIL || null, valuesPrinted: false }));", + "NODE", + " fi; exit \"$code\"; }", + " trap write_failed_status EXIT", + " cd \"$workspace\"", + " image=\"$image_repository:$env_identity\"", + " args=\"--network $network\"", + " if [ -n \"$http_proxy_value\" ]; then args=\"$args --build-arg HTTP_PROXY=$http_proxy_value --build-arg http_proxy=$http_proxy_value\"; fi", + " if [ -n \"$https_proxy_value\" ]; then args=\"$args --build-arg HTTPS_PROXY=$https_proxy_value --build-arg https_proxy=$https_proxy_value\"; fi", + " if [ -n \"$no_proxy_value\" ]; then args=\"$args --build-arg NO_PROXY=$no_proxy_value --build-arg no_proxy=$no_proxy_value\"; fi", + " if docker image inspect \"$image\" >/dev/null 2>&1; then build_status=reused; else docker build $args -f \"$containerfile\" -t \"$image\" \"$context_dir\"; build_status=built; fi", + " docker push \"$image\"", + " digest=$(docker inspect --format='{{index .RepoDigests 0}}' \"$image\" 2>/dev/null | sed 's/^.*@//' || true)", + " if [ -z \"$digest\" ]; then digest=$(curl -fsSI -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \"http://127.0.0.1:5000/v2/${image_repository#127.0.0.1:5000/}/manifests/$env_identity\" 2>/dev/null | awk -F': ' 'tolower($1)==\"docker-content-digest\"{print $2}' | tr -d '\\r' | head -n 1 || true); fi", + " STATUS=\"$build_status\" DIGEST=\"$digest\" JOB_ID=\"$job_id\" SOURCE_COMMIT=\"$source_commit\" ENV_IDENTITY=\"$env_identity\" IMAGE_REPOSITORY=\"$image_repository\" node <<'NODE' > \"$status_file\"", + "const digest = process.env.DIGEST || null;", + "console.log(JSON.stringify({ ok: Boolean(digest), status: process.env.STATUS || 'built', jobId: process.env.JOB_ID, sourceCommit: process.env.SOURCE_COMMIT, envIdentity: process.env.ENV_IDENTITY, image: `${process.env.IMAGE_REPOSITORY}:${process.env.ENV_IDENTITY}`, digest, repositoryDigest: digest ? `${process.env.IMAGE_REPOSITORY}@${digest}` : null, valuesPrinted: false }));", + "NODE", + " trap - EXIT", + ") >\"$stdout_file\" 2>\"$stderr_file\" &", + "pid=$!", + "JOB_PID=\"$pid\" JOB_ID=\"$job_id\" STATUS_FILE=\"$status_file\" STDOUT_FILE=\"$stdout_file\" STDERR_FILE=\"$stderr_file\" node <<'NODE'", + "console.log(JSON.stringify({ ok: true, status: 'submitted', jobId: process.env.JOB_ID, pid: Number(process.env.JOB_PID), statusFile: process.env.STATUS_FILE, stdoutFile: process.env.STDOUT_FILE, stderrFile: process.env.STDERR_FILE, valuesPrinted: false }));", + "NODE", + ].join("\n"); + return script; +} + +async function waitForYamlLaneBuildImage(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string, jobId: string | null): Promise & { ok: boolean; payload: Record }> { + if (jobId === null) return { ok: false, payload: { ok: false, degradedReason: "build-job-id-missing", valuesPrinted: false } }; + const startedAt = Date.now(); + const timeoutMs = Math.max(60, spec.deployment.manager.imageBuild.timeoutSeconds) * 1000; + let lastPayload: Record = {}; + let polls = 0; + while (Date.now() - startedAt < timeoutMs) { + polls += 1; + const probe = await capture(config, spec.nodeRoute, ["script", "--", yamlLaneBuildImageStatusScript(spec, jobId)]); + const payload = captureJsonPayload(probe); + lastPayload = payload; + progressEvent("agentrun.yaml-lane.image-build.progress", { + node: spec.nodeId, + lane: spec.lane, + sourceCommit, + jobId, + polls, + status: stringOrNull(payload.status) ?? "unknown", + elapsedMs: Date.now() - startedAt, + }); + if (payload.ok === true && stringOrNull(payload.digest) !== null) return { ok: true, payload, polls, elapsedMs: Date.now() - startedAt }; + if (payload.status === "failed") return { ok: false, payload, polls, elapsedMs: Date.now() - startedAt }; + await sleep(15_000); + } + return { ok: false, payload: { ...lastPayload, ok: false, status: "timeout", degradedReason: "image-build-timeout", valuesPrinted: false }, polls, elapsedMs: Date.now() - startedAt }; +} + +function yamlLaneBuildImageStatusScript(spec: AgentRunLaneSpec, jobId: string): string { + const stateDir = `/tmp/unidesk-agentrun-build-${spec.nodeId}-${spec.lane}`; + return [ + "set +e", + `status_file=${shQuote(`${stateDir}/${jobId}.json`)}`, + `stdout_file=${shQuote(`${stateDir}/${jobId}.stdout.log`)}`, + `stderr_file=${shQuote(`${stateDir}/${jobId}.stderr.log`)}`, + "if [ -f \"$status_file\" ]; then cat \"$status_file\"; else printf '{\"ok\":false,\"status\":\"missing\",\"valuesPrinted\":false}\\n'; fi", + "if [ -f \"$stderr_file\" ] && tail -n 20 \"$stderr_file\" | grep -Eq 'ERROR|error|failed|denied'; then :; fi", + ].join("\n"); +} + +function yamlLaneGitopsPublishScript(spec: AgentRunLaneSpec, files: readonly { path: string; content: string }[]): string { + const filesB64 = Buffer.from(JSON.stringify(files.map((file) => ({ + path: file.path, + contentBase64: Buffer.from(file.content, "utf8").toString("base64"), + }))), "utf8").toString("base64"); + return [ + "set -eu", + `workspace=${shQuote(spec.source.workspace)}`, + `remote=${shQuote(spec.source.remote)}`, + `source_branch=${shQuote(spec.source.branch)}`, + `gitops_branch=${shQuote(spec.gitops.branch)}`, + `gitops_root=${shQuote(spec.deployment.gitopsRoot)}`, + `artifact_catalog=${shQuote(spec.deployment.artifactCatalogPath)}`, + `files_b64=${shQuote(filesB64)}`, + "cd \"$workspace\"", + "git fetch origin \"$gitops_branch\" || true", + "if git rev-parse --verify \"refs/remotes/origin/$gitops_branch^{commit}\" >/dev/null 2>&1; then", + " git checkout -B \"$gitops_branch\" \"refs/remotes/origin/$gitops_branch\"", + "else", + " git checkout --orphan \"$gitops_branch\"", + " git rm -rf . >/dev/null 2>&1 || true", + "fi", + "git rm -rf --ignore-unmatch \"$gitops_root\" \"$artifact_catalog\" source.json >/dev/null 2>&1 || true", + "rm -rf \"$gitops_root\" \"$artifact_catalog\" source.json", + "FILES_B64=\"$files_b64\" node <<'NODE'", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const files = JSON.parse(Buffer.from(process.env.FILES_B64 || '', 'base64').toString('utf8'));", + "for (const file of files) {", + " const target = path.resolve(process.cwd(), file.path);", + " if (!target.startsWith(process.cwd() + path.sep)) throw new Error(`refuse path outside workspace: ${file.path}`);", + " fs.mkdirSync(path.dirname(target), { recursive: true });", + " fs.writeFileSync(target, Buffer.from(file.contentBase64, 'base64'));", + "}", + "NODE", + "git add source.json \"$artifact_catalog\" \"$gitops_root\"", + "if git diff --quiet --cached; then changed=false; else changed=true; git -c user.email=agentrun@unidesk.local -c user.name='UniDesk AgentRun Ops' commit -m \"deploy: render AgentRun ${gitops_branch} from UniDesk YAML\"; fi", + "git push -u origin \"$gitops_branch\"", + "gitops_commit=$(git rev-parse HEAD)", + "git checkout \"$source_branch\" >/dev/null 2>&1 || true", + "CHANGED=\"$changed\" GITOPS_BRANCH=\"$gitops_branch\" GITOPS_COMMIT=\"$gitops_commit\" FILE_COUNT=\"" + String(files.length) + "\" node <<'NODE'", + "console.log(JSON.stringify({ ok: true, changed: process.env.CHANGED === 'true', gitopsBranch: process.env.GITOPS_BRANCH, gitopsCommit: process.env.GITOPS_COMMIT, fileCount: Number(process.env.FILE_COUNT || 0), valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +async function runYamlLaneGitMirrorSyncJob(config: UniDeskConfig, spec: AgentRunLaneSpec): Promise> { + const jobName = `${spec.gitMirror.syncJobPrefix}-${Date.now().toString(36)}`.slice(0, 63); + const manifest = yamlLaneGitMirrorJobManifest(spec, "sync", jobName); + const created = await capture(config, spec.nodeKubeRoute, ["script", "--", createYamlLaneJobScript(spec.gitMirror.namespace, jobName, manifest)]); + if (created.exitCode !== 0) { + return { ok: false, phase: "create-job", jobName, capture: compactCapture(created, { full: true, stdoutTailChars: 4000, stderrTailChars: 4000 }), valuesPrinted: false }; + } + const startedAt = Date.now(); + let polls = 0; + let lastProbe: SshCaptureResult | null = null; + while (Date.now() - startedAt < 300_000) { + polls += 1; + lastProbe = await capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneJobProbeScript(spec.gitMirror.namespace, jobName)]); + const payload = captureJsonPayload(lastProbe); + progressEvent("agentrun.yaml-lane.git-mirror.progress", { + node: spec.nodeId, + lane: spec.lane, + jobName, + polls, + succeeded: payload.succeeded === true, + failed: payload.failed === true, + elapsedMs: Date.now() - startedAt, + }); + if (payload.succeeded === true) return { ok: true, jobName, polls, elapsedMs: Date.now() - startedAt, result: payload, valuesPrinted: false }; + if (payload.failed === true) return { ok: false, jobName, polls, elapsedMs: Date.now() - startedAt, result: payload, capture: compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 3000 }), valuesPrinted: false }; + await sleep(5_000); + } + return { ok: false, jobName, polls, elapsedMs: Date.now() - startedAt, degradedReason: "git-mirror-sync-job-timeout", capture: lastProbe === null ? null : compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 3000 }), valuesPrinted: false }; +} + +function yamlLanePipelineRunCreateScript(spec: AgentRunLaneSpec, sourceCommit: string, pipelineRun: string): string { + const manifest = { + apiVersion: "tekton.dev/v1", + kind: "PipelineRun", + metadata: { + name: pipelineRun, + namespace: spec.ci.namespace, + labels: { + "app.kubernetes.io/part-of": "agentrun", + "agentrun.pikastech.local/lane": spec.version, + "agentrun.pikastech.local/node": spec.nodeId, + "agentrun.pikastech.local/source-commit": sourceCommit, + "agentrun.pikastech.local/trigger": "unidesk-yaml-only", + }, + }, + spec: { + pipelineRef: { name: spec.ci.pipeline }, + taskRunTemplate: { + serviceAccountName: spec.ci.serviceAccountName, + podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", securityContext: { fsGroup: 1000 } }, + }, + params: [ + { name: "git-url", value: spec.source.remote }, + { name: "git-read-url", value: spec.gitMirror.readUrl }, + { name: "git-write-url", value: spec.gitMirror.writeUrl }, + { name: "source-branch", value: spec.source.branch }, + { name: "gitops-branch", value: spec.gitops.branch }, + { name: "revision", value: sourceCommit }, + { name: "registry-prefix", value: spec.ci.registryPrefix }, + { name: "tools-image", value: spec.ci.toolsImage }, + ], + workspaces: [ + { name: "source", emptyDir: {} }, + { name: "git-ssh", secret: { secretName: spec.gitMirror.sshSecretName } }, + ], + }, + }; + const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); + return [ + "set -eu", + `namespace=${shQuote(spec.ci.namespace)}`, + `pipeline_run=${shQuote(pipelineRun)}`, + `manifest_b64=${shQuote(manifestB64)}`, + "tmp=$(mktemp)", + "trap 'rm -f \"$tmp\"' EXIT", + "printf '%s' \"$manifest_b64\" | base64 -d > \"$tmp\"", + "existing_status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)", + "if [ \"$existing_status\" = False ]; then kubectl -n \"$namespace\" delete pipelinerun \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true; fi", + "if kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" >/dev/null 2>&1; then created=false; else kubectl create -f \"$tmp\"; created=true; fi", + "status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)", + "CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" node <<'NODE'", + "console.log(JSON.stringify({ ok: true, pipelineRun: process.env.PIPELINE_RUN, created: process.env.CREATED === 'true', status: process.env.STATUS || null, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function createYamlLaneJobScript(namespace: string, jobName: string, manifest: Record): string { + const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); + return [ + "set -eu", + `namespace=${shQuote(namespace)}`, + `job=${shQuote(jobName)}`, + `manifest_b64=${shQuote(manifestB64)}`, + "tmp=$(mktemp)", + "trap 'rm -f \"$tmp\"' EXIT", + "printf '%s' \"$manifest_b64\" | base64 -d > \"$tmp\"", + "kubectl -n \"$namespace\" delete job \"$job\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true", + "kubectl create -f \"$tmp\"", + "JOB=\"$job\" node <<'NODE'", + "console.log(JSON.stringify({ ok: true, jobName: process.env.JOB, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function yamlLaneJobProbeScript(namespace: string, jobName: string): string { + return [ + "set +e", + `namespace=${shQuote(namespace)}`, + `job=${shQuote(jobName)}`, + "kubectl -n \"$namespace\" get job \"$job\" -o json > /tmp/agentrun-job.json 2>/dev/null", + "job_exit=$?", + "kubectl -n \"$namespace\" logs \"job/$job\" --tail=120 > /tmp/agentrun-job.log 2>/dev/null", + "JOB_EXIT=\"$job_exit\" JOB=\"$job\" node <<'NODE'", + "const fs = require('node:fs');", + "let job = null; try { job = JSON.parse(fs.readFileSync('/tmp/agentrun-job.json', 'utf8')); } catch {}", + "let log = ''; try { log = fs.readFileSync('/tmp/agentrun-job.log', 'utf8'); } catch {}", + "const succeeded = Number(job?.status?.succeeded || 0) > 0;", + "const failed = Number(job?.status?.failed || 0) > 0;", + "console.log(JSON.stringify({ ok: process.env.JOB_EXIT === '0', jobName: process.env.JOB, succeeded, failed, active: job?.status?.active || 0, logsTail: log.slice(-4000), valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function yamlLaneGitMirrorCacheVolume(spec: AgentRunLaneSpec): Record { + if (spec.gitMirror.cacheHostPath !== null) { + return { name: "cache", hostPath: { path: spec.gitMirror.cacheHostPath, type: "DirectoryOrCreate" } }; + } + return { name: "cache", persistentVolumeClaim: { claimName: spec.gitMirror.cachePvc } }; +} + +function yamlLaneGitMirrorJobManifest(spec: AgentRunLaneSpec, action: "sync", name: string): Record { + return { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name, + namespace: spec.gitMirror.namespace, + labels: { + "app.kubernetes.io/name": "git-mirror", + "app.kubernetes.io/part-of": "agentrun", + "agentrun.pikastech.local/lane": spec.version, + "agentrun.pikastech.local/node": spec.nodeId, + "agentrun.pikastech.local/component": action, + }, + }, + spec: { + backoffLimit: 0, + activeDeadlineSeconds: 600, + ttlSecondsAfterFinished: 3600, + template: { + metadata: { + labels: { + "app.kubernetes.io/name": "git-mirror", + "app.kubernetes.io/part-of": "agentrun", + "agentrun.pikastech.local/lane": spec.version, + "agentrun.pikastech.local/node": spec.nodeId, + "agentrun.pikastech.local/component": action, + }, + }, + spec: { + restartPolicy: "Never", + volumes: [ + yamlLaneGitMirrorCacheVolume(spec), + { name: "git-ssh", secret: { secretName: spec.gitMirror.sshSecretName, defaultMode: 0o400 } }, + ], + containers: [{ + name: action, + image: spec.gitMirror.toolsImage, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-ec", yamlLaneGitMirrorSyncShell(spec)], + volumeMounts: [ + { name: "cache", mountPath: "/cache" }, + { name: "git-ssh", mountPath: "/git-ssh", readOnly: true }, + ], + }], + }, + }, + }, + }; +} + +function yamlLaneGitMirrorSyncShell(spec: AgentRunLaneSpec): string { + return [ + "set -eu", + "mkdir -p /root/.ssh", + "cp /git-ssh/ssh-privatekey /root/.ssh/id_rsa", + "chmod 0400 /root/.ssh/id_rsa", + "cat > /tmp/agentrun-github-proxy-connect.cjs <<'NODE_PROXY'", + "const net = require('node:net');", + "const [proxyHost, proxyPortRaw, targetHost, targetPortRaw] = process.argv.slice(2);", + "const proxyPort = Number.parseInt(proxyPortRaw || '', 10);", + "const targetPort = Number.parseInt(targetPortRaw || '', 10);", + "if (!proxyHost || !Number.isInteger(proxyPort) || !targetHost || !Number.isInteger(targetPort)) process.exit(64);", + "const socket = net.createConnection({ host: proxyHost, port: proxyPort });", + "let buffer = Buffer.alloc(0);", + "socket.setTimeout(10000, () => { socket.destroy(); process.exit(65); });", + "socket.on('connect', () => socket.write('CONNECT ' + targetHost + ':' + targetPort + ' HTTP/1.1\\r\\nHost: ' + targetHost + ':' + targetPort + '\\r\\nProxy-Connection: Keep-Alive\\r\\n\\r\\n'));", + "socket.on('error', () => process.exit(66));", + "function onData(chunk) {", + " buffer = Buffer.concat([buffer, chunk]);", + " const headerEnd = buffer.indexOf('\\r\\n\\r\\n');", + " if (headerEnd === -1 && buffer.length < 8192) return;", + " const head = buffer.slice(0, headerEnd + 4).toString('latin1');", + " const statusLine = head.split('\\r\\n', 1)[0] || '';", + " const statusCode = Number.parseInt(statusLine.split(' ')[1] || '', 10);", + " if (!statusLine.startsWith('HTTP/1.') || !Number.isInteger(statusCode) || statusCode < 200 || statusCode > 299) { socket.destroy(); process.exit(67); }", + " socket.off('data', onData);", + " socket.setTimeout(0);", + " const rest = buffer.slice(headerEnd + 4);", + " if (rest.length) process.stdout.write(rest);", + " process.stdin.pipe(socket);", + " socket.pipe(process.stdout);", + "}", + "socket.on('data', onData);", + "socket.on('close', () => process.exit(0));", + "NODE_PROXY", + "chmod 0700 /tmp/agentrun-github-proxy-connect.cjs", + `export GIT_SSH_COMMAND=${shQuote(`ssh -i /root/.ssh/id_rsa -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts -o ConnectTimeout=15 -o ServerAliveInterval=5 -o ServerAliveCountMax=1 -o ProxyCommand='node /tmp/agentrun-github-proxy-connect.cjs ${spec.gitMirror.githubProxy.host} ${spec.gitMirror.githubProxy.port} %h %p'`)}`, + `repository=${shQuote(spec.source.repository)}`, + `source_branch=${shQuote(spec.source.branch)}`, + `gitops_branch=${shQuote(spec.gitops.branch)}`, + "repo=\"/cache/${repository}.git\"", + "remote=\"ssh://git@ssh.github.com:443/${repository}.git\"", + "mkdir -p \"$(dirname \"$repo\")\"", + "if [ -d \"$repo/objects\" ] && [ -f \"$repo/HEAD\" ]; then", + " git --git-dir=\"$repo\" remote set-url origin \"$remote\" || git --git-dir=\"$repo\" remote add origin \"$remote\"", + "else", + " rm -rf \"$repo\"", + " git init --bare \"$repo\"", + " git --git-dir=\"$repo\" remote add origin \"$remote\"", + "fi", + "git --git-dir=\"$repo\" config uploadpack.allowReachableSHA1InWant true", + "git --git-dir=\"$repo\" config uploadpack.allowAnySHA1InWant true", + "git --git-dir=\"$repo\" config http.uploadpack true", + "git --git-dir=\"$repo\" config http.receivepack true", + "timeout 240 git --git-dir=\"$repo\" fetch origin \"+refs/heads/${source_branch}:refs/mirror-stage/heads/${source_branch}\"", + "source_sha=$(git --git-dir=\"$repo\" rev-parse --verify \"refs/mirror-stage/heads/${source_branch}^{commit}\")", + "git --git-dir=\"$repo\" update-ref \"refs/heads/${source_branch}\" \"$source_sha\"", + "timeout 240 git --git-dir=\"$repo\" fetch origin \"+refs/heads/${gitops_branch}:refs/mirror-stage/heads/${gitops_branch}\"", + "gitops_sha=$(git --git-dir=\"$repo\" rev-parse --verify \"refs/mirror-stage/heads/${gitops_branch}^{commit}\")", + "git --git-dir=\"$repo\" update-ref \"refs/heads/${gitops_branch}\" \"$gitops_sha\"", + "git --git-dir=\"$repo\" update-server-info", + "SOURCE_SHA=\"$source_sha\" GITOPS_SHA=\"$gitops_sha\" node <<'NODE'", + "console.log(JSON.stringify({ ok: true, localSource: process.env.SOURCE_SHA, localGitops: process.env.GITOPS_SHA, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string { return [ "set +e", @@ -2828,21 +3643,27 @@ function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string { } function readSecretSourceValue(spec: AgentRunLaneSpec, sourceRef: string, key: string): { redactedPath: string; value: string; valueBytes: number; fingerprint: string } { - if (sourceRef.startsWith("/") || sourceRef.includes("..")) throw new Error(`secret sourceRef must be relative without ..: ${sourceRef}`); - const secretRoot = resolveSecretSourceRoot(spec); - const sourcePath = join(secretRoot, ...sourceRef.split("/")); - if (!existsSync(sourcePath)) throw new Error(`secret source ${sourceRef} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`); + if (sourceRef.includes("..")) throw new Error(`secret sourceRef must not contain ..: ${sourceRef}`); + const sourcePath = sourceRef.startsWith("/") + ? sourceRef + : join(resolveSecretSourceRoot(spec), ...sourceRef.split("/")); + if (!existsSync(sourcePath)) throw new Error(`secret source ${sourceRef} is missing`); const values = parseEnvFile(readFileSync(sourcePath, "utf8")); const value = values.get(key); if (value === undefined || value.length === 0) throw new Error(`secret source ${sourceRef} is missing required key ${key}`); return { - redactedPath: `.state/secrets/${sourceRef}`, + redactedPath: sourceRef.startsWith("/") ? redactAbsoluteSecretPath(sourceRef) : `.state/secrets/${sourceRef}`, value, valueBytes: Buffer.byteLength(value, "utf8"), fingerprint: `sha256:${createHash("sha256").update(value).digest("hex")}`, }; } +function redactAbsoluteSecretPath(sourceRef: string): string { + const parts = sourceRef.split("/").filter(Boolean); + return parts.length === 0 ? "/" : `/${parts.slice(0, -1).join("/")}/`; +} + function resolveSecretSourceRoot(spec: AgentRunLaneSpec): string { if (spec.database.configRef === null) return rootPath(".state", "secrets"); const configPath = spec.database.configRef.startsWith("/") ? spec.database.configRef : rootPath(spec.database.configRef); @@ -2870,35 +3691,116 @@ function parseEnvFile(text: string): Map { return result; } -function secretSyncScript(spec: AgentRunLaneSpec, value: string): string { - const encoded = Buffer.from(value, "utf8").toString("base64"); +function collectLaneSecretSources(spec: AgentRunLaneSpec): Array<{ id: string; sourceRef: string; sourceKey: string; targetRef: { namespace: string; name: string; key: string } }> { + const result: Array<{ id: string; sourceRef: string; sourceKey: string; targetRef: { namespace: string; name: string; key: string } }> = []; + if (spec.database.secretSourceRef !== null) { + result.push({ + id: "database", + sourceRef: spec.database.secretSourceRef, + sourceKey: spec.database.secretRef.key, + targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: spec.database.secretRef.key }, + }); + } + for (const secret of spec.secrets) { + result.push({ + id: secret.id, + sourceRef: secret.sourceRef, + sourceKey: secret.sourceKey, + targetRef: secret.targetRef, + }); + } + const seen = new Set(); + return result.filter((item) => { + const key = `${item.targetRef.namespace}/${item.targetRef.name}/${item.targetRef.key}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function secretSyncScript(_spec: AgentRunLaneSpec, values: Array<{ targetRef: { namespace: string; name: string; key: string }; value: string }>): string { + const encoded = Buffer.from(JSON.stringify(values.map((item) => ({ + targetRef: item.targetRef, + valueBase64: Buffer.from(item.value, "utf8").toString("base64"), + }))), "utf8").toString("base64"); return [ "set -eu", - `namespace=${shQuote(spec.runtime.namespace)}`, - `secret_name=${shQuote(spec.database.secretRef.name)}`, - `secret_key=${shQuote(spec.database.secretRef.key)}`, - `secret_value_b64=${shQuote(encoded)}`, + `payload_b64=${shQuote(encoded)}`, "tmp_dir=$(mktemp -d)", "trap 'rm -rf \"$tmp_dir\"' EXIT", - "secret_file=\"$tmp_dir/secret-value\"", - "printf '%s' \"$secret_value_b64\" | base64 -d > \"$secret_file\"", - "kubectl create namespace \"$namespace\" --dry-run=client -o yaml | kubectl apply --server-side --field-manager=unidesk-agentrun-secret-sync -f - >/dev/null", - "kubectl -n \"$namespace\" create secret generic \"$secret_name\" --from-file=\"$secret_key=$secret_file\" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=unidesk-agentrun-secret-sync -f - >/dev/null", - "rm -f \"$secret_file\"", - "kubectl -n \"$namespace\" get secret \"$secret_name\" -o json > \"$tmp_dir/secret.json\"", - "NAMESPACE=\"$namespace\" SECRET_NAME=\"$secret_name\" SECRET_KEY=\"$secret_key\" SECRET_JSON=\"$tmp_dir/secret.json\" node <<'NODE'", + "payload_json=\"$tmp_dir/payload.json\"", + "printf '%s' \"$payload_b64\" | base64 -d > \"$payload_json\"", + "PAYLOAD_JSON=\"$payload_json\" TMP_DIR=\"$tmp_dir\" node <<'NODE'", "const fs = require('node:fs');", + "const cp = require('node:child_process');", "const crypto = require('node:crypto');", - "const secret = JSON.parse(fs.readFileSync(process.env.SECRET_JSON, 'utf8'));", - "const data = secret.data || {};", - "const raw = data[process.env.SECRET_KEY] || '';", - "const bytes = raw ? Buffer.from(raw, 'base64').length : 0;", - "const fingerprint = raw ? 'sha256:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex') : null;", - "console.log(JSON.stringify({ ok: Boolean(raw), namespace: process.env.NAMESPACE, secret: process.env.SECRET_NAME, key: process.env.SECRET_KEY, valueBytes: bytes, fingerprint, valuesPrinted: false }));", + "const items = JSON.parse(fs.readFileSync(process.env.PAYLOAD_JSON, 'utf8'));", + "const results = [];", + "function run(argv, input) {", + " const out = cp.spawnSync(argv[0], argv.slice(1), { input, encoding: 'utf8' });", + " if (out.status !== 0) throw new Error(`${argv.join(' ')} failed: ${out.stderr || out.stdout}`);", + " return out;", + "}", + "for (let index = 0; index < items.length; index += 1) {", + " const item = items[index];", + " const ref = item.targetRef;", + " const value = Buffer.from(item.valueBase64, 'base64');", + " const file = `${process.env.TMP_DIR}/secret-${index}`;", + " fs.writeFileSync(file, value);", + " const ns = run(['kubectl', 'create', 'namespace', ref.namespace, '--dry-run=client', '-o', 'yaml']).stdout;", + " run(['kubectl', 'apply', '--server-side', '--field-manager=unidesk-agentrun-secret-sync', '-f', '-'], ns);", + " const secret = run(['kubectl', '-n', ref.namespace, 'create', 'secret', 'generic', ref.name, `--from-file=${ref.key}=${file}`, '--dry-run=client', '-o', 'yaml']).stdout;", + " run(['kubectl', 'apply', '--server-side', '--force-conflicts', '--field-manager=unidesk-agentrun-secret-sync', '-f', '-'], secret);", + " const fetched = JSON.parse(run(['kubectl', '-n', ref.namespace, 'get', 'secret', ref.name, '-o', 'json']).stdout);", + " const raw = fetched.data?.[ref.key] || '';", + " const decoded = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);", + " results.push({ namespace: ref.namespace, secret: ref.name, key: ref.key, ok: raw.length > 0, valueBytes: decoded.length, fingerprint: raw ? 'sha256:' + crypto.createHash('sha256').update(decoded).digest('hex') : null, valuesPrinted: false });", + "}", + "console.log(JSON.stringify({ ok: results.every((item) => item.ok), secretCount: results.length, items: results, valuesPrinted: false }));", "NODE", ].join("\n"); } +function applyYamlScript(yaml: string, fieldManager: string, dryRun: boolean): string { + const encoded = Buffer.from(yaml, "utf8").toString("base64"); + return [ + "set -eu", + `manifest_b64=${shQuote(encoded)}`, + `field_manager=${shQuote(fieldManager)}`, + `dry_run=${dryRun ? "true" : "false"}`, + "tmp_dir=$(mktemp -d)", + "trap 'rm -rf \"$tmp_dir\"' EXIT", + "manifest=\"$tmp_dir/manifest.yaml\"", + "printf '%s' \"$manifest_b64\" | base64 -d > \"$manifest\"", + "args=\"--server-side --force-conflicts --field-manager=$field_manager\"", + "if [ \"$dry_run\" = true ]; then args=\"$args --dry-run=server\"; fi", + "set +e", + "kubectl apply $args -f \"$manifest\" > \"$tmp_dir/apply.out\" 2> \"$tmp_dir/apply.err\"", + "apply_exit=$?", + "set -e", + "APPLY_EXIT=\"$apply_exit\" APPLY_OUT=\"$tmp_dir/apply.out\" APPLY_ERR=\"$tmp_dir/apply.err\" MANIFEST=\"$manifest\" node <<'NODE'", + "const fs = require('node:fs');", + "const crypto = require('node:crypto');", + "const out = fs.readFileSync(process.env.APPLY_OUT, 'utf8');", + "const err = fs.readFileSync(process.env.APPLY_ERR, 'utf8');", + "const manifest = fs.readFileSync(process.env.MANIFEST, 'utf8');", + "const resources = out.split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean).slice(0, 80);", + "console.log(JSON.stringify({ ok: process.env.APPLY_EXIT === '0', exitCode: Number(process.env.APPLY_EXIT), resourceCount: resources.length, resources, manifestBytes: Buffer.byteLength(manifest, 'utf8'), manifestDigest: 'sha256:' + crypto.createHash('sha256').update(manifest).digest('hex'), stderrTail: err.slice(-3000), valuesPrinted: false }));", + "NODE", + "exit \"$apply_exit\"", + ].join("\n"); +} + +function manifestObjectRef(object: Record): Record { + const metadata = record(object.metadata); + return { + apiVersion: stringOrNull(object.apiVersion), + kind: stringOrNull(object.kind), + namespace: stringOrNull(metadata.namespace), + name: stringOrNull(metadata.name), + }; +} + function cleanupRunsPlanNodeScript(): string { return String.raw` const fs = require("node:fs");