diff --git a/AGENTS.md b/AGENTS.md index e143e25..57b22d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ AgentRun 是面向 UniDesk 与 HWLAB 的共享 Agent 执行基础设施。本仓 - `docs/reference/spec-v01-documentation-governance.md`:v0.1 文档治理、唯一入口、spec 权威和过程材料承载规则。 - `docs/reference/spec-v01-services.md`:v0.1 服务总览、保留对象、deferred 对象和单服务规格索引。 -- `docs/reference/spec-v01-cicd.md`:v0.1 分支、source worktree、namespace、GitOps、registry、CI/CD 和发布验收规格。 +- `docs/reference/spec-v01-cicd.md`:v0.1 分支、source worktree、git mirror、env reuse、namespace、GitOps、registry、CI/CD 和发布验收规格。 - `docs/reference/spec-v01-postgres.md`:v0.1 Postgres durable store、schema migration 和 SecretRef 规格。 - `docs/reference/spec-v01-secret-distribution.md`:v0.1 Code Agent provider credential 和运行时 Secret 分发规格。 - `docs/reference/spec-v01-runtime-assembly.md`:v0.1 runner/backend 启动前的装配 SPEC,覆盖 BackendImageRef、ProfileRef、SessionRef、Git-only ResourceBundleRef 和 tool credential SecretRef scope。 diff --git a/deploy/container/Containerfile b/deploy/container/Containerfile index d5f8404..0402b4d 100644 --- a/deploy/container/Containerfile +++ b/deploy/container/Containerfile @@ -1,23 +1,24 @@ FROM oven/bun:1.2.15-alpine -WORKDIR /app +WORKDIR /opt/agentrun ARG HTTP_PROXY ARG HTTPS_PROXY ARG NO_PROXY ENV NODE_ENV=production ENV PORT=8080 -ENV AGENTRUN_CODEX_COMMAND=/app/node_modules/.bin/codex +ENV AGENTRUN_CODEX_COMMAND=/opt/agentrun/node_modules/.bin/codex +ENV AGENTRUN_APP_ROOT=/workspace/agentrun +ENV AGENTRUN_BOOT_REPO_URL=http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \ apk add --no-cache ca-certificates curl git github-cli kubectl nodejs openssh-client -COPY package.json tsconfig.json ./ +COPY package.json bun.lock tsconfig.json ./ RUN HTTP_PROXY="$HTTP_PROXY" HTTPS_PROXY="$HTTPS_PROXY" NO_PROXY="$NO_PROXY" http_proxy="$HTTP_PROXY" https_proxy="$HTTPS_PROXY" no_proxy="$NO_PROXY" \ bun install --production -RUN /app/node_modules/.bin/codex --version && /app/node_modules/.bin/codex app-server --help >/dev/null -COPY scripts ./scripts -COPY src ./src -COPY deploy/deploy.json ./deploy/deploy.json +RUN /opt/agentrun/node_modules/.bin/codex --version && /opt/agentrun/node_modules/.bin/codex app-server --help >/dev/null +COPY deploy/runtime/boot ./deploy/runtime/boot +RUN chmod +x /opt/agentrun/deploy/runtime/boot/*.sh EXPOSE 8080 -CMD ["bun", "src/mgr/main.ts"] +CMD ["/opt/agentrun/deploy/runtime/boot/agentrun-mgr.sh"] diff --git a/deploy/runtime/boot/agentrun-boot.sh b/deploy/runtime/boot/agentrun-boot.sh new file mode 100644 index 0000000..05b8497 --- /dev/null +++ b/deploy/runtime/boot/agentrun-boot.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +entrypoint="${1:-src/mgr/main.ts}" +repo_url="${AGENTRUN_BOOT_REPO_URL:-http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git}" +commit="${AGENTRUN_BOOT_COMMIT:-${AGENTRUN_SOURCE_COMMIT:-}}" +app_root="${AGENTRUN_APP_ROOT:-/workspace/agentrun}" + +if [ -z "$commit" ] || ! printf '%s' "$commit" | grep -Eq '^[0-9a-f]{40}$'; then + printf '{"ok":false,"event":"agentrun-boot","failureKind":"source-commit-invalid","message":"AGENTRUN_BOOT_COMMIT must be a full git SHA"}\n' >&2 + exit 64 +fi + +mkdir -p "$(dirname "$app_root")" +rm -rf "$app_root" +mkdir -p "$app_root" +cd "$app_root" +git init -q +git remote add origin "$repo_url" + +fetch_log=/tmp/agentrun-boot-fetch.log +if ! git fetch --depth=1 origin "$commit" >"$fetch_log" 2>&1; then + message=$(tail -n 20 "$fetch_log" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g') + failure_kind=git-mirror-fetch-failed + if printf '%s' "$message" | grep -Eiq 'not our ref|unadvertised|Server does not allow request'; then + failure_kind=git-mirror-exact-commit-unavailable + elif printf '%s' "$message" | grep -Eiq 'Could not resolve host|Connection timed out|Failed to connect'; then + failure_kind=git-mirror-network-failed + elif printf '%s' "$message" | grep -Eiq 'Authentication failed|Permission denied|repository.*not found'; then + failure_kind=git-mirror-auth-failed + fi + printf '{"ok":false,"event":"agentrun-boot","failureKind":"%s","repoUrl":"%s","commit":"%s","message":%s}\n' "$failure_kind" "$repo_url" "$commit" "$(node -e 'console.log(JSON.stringify(process.argv[1] || ""))' "$message")" >&2 + exit 65 +fi +git checkout -q --detach "$commit" +actual=$(git rev-parse HEAD) +if [ "$actual" != "$commit" ]; then + printf '{"ok":false,"event":"agentrun-boot","failureKind":"source-commit-mismatch","expected":"%s","actual":"%s"}\n' "$commit" "$actual" >&2 + exit 66 +fi + +ln -sfn /opt/agentrun/node_modules "$app_root/node_modules" +printf '{"ok":true,"event":"agentrun-boot","repoUrl":"%s","commit":"%s","entrypoint":"%s","nodeModules":"/opt/agentrun/node_modules"}\n' "$repo_url" "$commit" "$entrypoint" +exec bun "$entrypoint" diff --git a/deploy/runtime/boot/agentrun-mgr.sh b/deploy/runtime/boot/agentrun-mgr.sh new file mode 100644 index 0000000..ee30d32 --- /dev/null +++ b/deploy/runtime/boot/agentrun-mgr.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec /opt/agentrun/deploy/runtime/boot/agentrun-boot.sh src/mgr/main.ts diff --git a/deploy/runtime/boot/agentrun-runner.sh b/deploy/runtime/boot/agentrun-runner.sh new file mode 100644 index 0000000..564f783 --- /dev/null +++ b/deploy/runtime/boot/agentrun-runner.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec /opt/agentrun/deploy/runtime/boot/agentrun-boot.sh src/runner/main.ts diff --git a/deploy/templates/tekton/pipeline.yaml b/deploy/templates/tekton/pipeline.yaml index 6b84ffd..f383521 100644 --- a/deploy/templates/tekton/pipeline.yaml +++ b/deploy/templates/tekton/pipeline.yaml @@ -11,6 +11,12 @@ spec: - name: git-url type: string default: git@github.com:pikasTech/agentrun.git + - name: git-read-url + type: string + default: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git + - name: git-write-url + type: string + default: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/agentrun.git - name: source-branch type: string default: v0.1 @@ -33,17 +39,13 @@ spec: workspaces: - name: source workspace: source - - name: git-ssh - workspace: git-ssh taskSpec: params: - - name: git-url - - name: source-branch + - name: git-read-url - name: revision - name: tools-image workspaces: - name: source - - name: git-ssh steps: - name: clone-and-check image: $(params.tools-image) @@ -60,37 +62,132 @@ spec: value: http://127.0.0.1:10808 - name: no_proxy value: hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.42.0.0/16,10.43.0.0/16,.svc,.cluster.local + - name: GIT_TERMINAL_PROMPT + value: "0" script: | #!/bin/sh set -eu - apk add --no-cache git openssh-client curl - mkdir -p /root/.ssh - cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa - chmod 600 /root/.ssh/id_rsa - ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null + apk add --no-cache git curl rm -rf /workspace/source/repo - git clone --branch "$(params.source-branch)" "$(params.git-url)" /workspace/source/repo + mkdir -p /workspace/source/repo + git init /workspace/source/repo cd /workspace/source/repo - git config --global --add safe.directory /workspace/source/repo - git checkout "$(params.revision)" + git remote add origin "$(params.git-read-url)" + git fetch --depth=1 origin "$(params.revision)" + git checkout --detach FETCH_HEAD test "$(git rev-parse HEAD)" = "$(params.revision)" - bun install + bun install --frozen-lockfile bun run check bun scripts/agentrun-gitops-render.ts --out /tmp/agentrun-gitops-render-check --source-commit "$(params.revision)" --check AGENTRUN_SELFTEST_CODEX_COMMAND="$(command -v bun)" \ AGENTRUN_SELFTEST_CODEX_ARGS="[\"$PWD/src/selftest/fake-codex-app-server.ts\"]" \ bun run self-test params: - - name: git-url - value: $(params.git-url) - - name: source-branch - value: $(params.source-branch) + - name: git-read-url + value: $(params.git-read-url) - name: revision value: $(params.revision) - name: tools-image value: $(params.tools-image) - - name: image-publish + - name: plan-artifacts runAfter: [prepare-source] + workspaces: + - name: source + workspace: source + taskSpec: + params: + - name: git-read-url + - name: gitops-branch + - name: revision + - name: registry-prefix + results: + - name: env-identity + - name: build-count + - name: reuse-count + - name: summary + workspaces: + - name: source + steps: + - name: plan + image: oven/bun:1.2.15-alpine + env: + - name: GIT_TERMINAL_PROMPT + value: "0" + script: | + #!/bin/sh + set -eu + apk add --no-cache git nodejs + cd /workspace/source/repo + node <<'NODE' > /workspace/source/env-identity + const { createHash } = require("node:crypto"); + const { readFileSync } = require("node:fs"); + const inputs = [ + ["baseImage", "oven/bun:1.2.15-alpine"], + ["systemPackages", "ca-certificates git kubectl nodejs openssh-client"], + ["containerfile", readFileSync("deploy/container/Containerfile", "utf8")], + ["bootScript", readFileSync("deploy/runtime/boot/agentrun-boot.sh", "utf8")], + ["bootMgr", readFileSync("deploy/runtime/boot/agentrun-mgr.sh", "utf8")], + ["bootRunner", readFileSync("deploy/runtime/boot/agentrun-runner.sh", "utf8")], + ["packageJson", readFileSync("package.json", "utf8")], + ["bunLock", readFileSync("bun.lock", "utf8")], + ["tsconfig", readFileSync("tsconfig.json", "utf8")], + ]; + process.stdout.write(createHash("sha256").update(JSON.stringify(inputs)).digest("hex").slice(0, 20)); + NODE + env_identity="$(cat /workspace/source/env-identity)" + rm -rf /workspace/source/gitops-prev + if git clone --depth=1 --branch "$(params.gitops-branch)" "$(params.git-read-url)" /workspace/source/gitops-prev >/tmp/agentrun-prev-gitops.log 2>&1; then + prev_catalog=/workspace/source/gitops-prev/deploy/artifact-catalog.v01.json + else + prev_catalog=/dev/null + fi + AGENTRUN_ENV_IDENTITY="$env_identity" \ + AGENTRUN_PREV_CATALOG="$prev_catalog" \ + AGENTRUN_REVISION="$(params.revision)" \ + AGENTRUN_GITOPS_BRANCH="$(params.gitops-branch)" \ + AGENTRUN_REGISTRY_PREFIX="$(params.registry-prefix)" \ + node <<'NODE' + const { readFileSync, writeFileSync } = require("node:fs"); + const envIdentity = process.env.AGENTRUN_ENV_IDENTITY; + const revision = process.env.AGENTRUN_REVISION; + const gitopsBranch = process.env.AGENTRUN_GITOPS_BRANCH; + let previousService = null; + try { + const catalog = JSON.parse(readFileSync(process.env.AGENTRUN_PREV_CATALOG, "utf8")); + previousService = (catalog.services || []).find((item) => item.serviceId === "agentrun-mgr" && item.envIdentity === envIdentity && /^sha256:[a-f0-9]{64}$/.test(item.envDigest || item.digest || "")) || null; + } catch {} + const reused = previousService !== null; + const plan = { + lane: "v0.1", + sourceBranch: "v0.1", + gitopsBranch, + sourceCommitId: revision, + envIdentity, + toolchainInputs: ["oven/bun:1.2.15-alpine", "deploy/container/Containerfile", "deploy/runtime/boot/*.sh", "package.json", "bun.lock", "tsconfig.json", "apk:ca-certificates git kubectl nodejs openssh-client"], + buildServices: reused ? [] : ["agentrun-mgr"], + reusedServices: reused ? ["agentrun-mgr"] : [], + unsafeReuseServices: [], + previousService, + summary: `build=${reused ? 0 : 1} reuse=${reused ? 1 : 0} unsafeReuse=0`, + }; + writeFileSync("/workspace/source/ci-plan.json", `${JSON.stringify(plan, null, 2)}\n`); + writeFileSync("/tekton/results/env-identity", envIdentity); + writeFileSync("/tekton/results/build-count", String(plan.buildServices.length)); + writeFileSync("/tekton/results/reuse-count", String(plan.reusedServices.length)); + writeFileSync("/tekton/results/summary", plan.summary); + console.log(JSON.stringify({ event: "agentrun-ci-plan", status: "succeeded", sourceCommitId: revision, envIdentity, buildServices: plan.buildServices, reusedServices: plan.reusedServices, unsafeReuseServices: [], summary: plan.summary })); + NODE + params: + - name: git-read-url + value: $(params.git-read-url) + - name: gitops-branch + value: $(params.gitops-branch) + - name: revision + value: $(params.revision) + - name: registry-prefix + value: $(params.registry-prefix) + - name: image-publish + runAfter: [plan-artifacts] workspaces: - name: source workspace: source @@ -102,6 +199,8 @@ spec: - name: image - name: digest - name: repository-digest + - name: env-identity + - name: status sidecars: - name: buildkitd image: moby/buildkit:rootless @@ -145,7 +244,7 @@ spec: volumeMounts: - name: buildkit-bin mountPath: /workspace/buildkit-bin - - name: build-and-push + - name: build-or-reuse image: oven/bun:1.2.15-alpine env: - name: HTTP_PROXY @@ -157,9 +256,28 @@ spec: script: | #!/bin/sh set -eu - apk add --no-cache curl + apk add --no-cache curl nodejs cd /workspace/source/repo - image="$(params.registry-prefix)/agentrun-mgr:$(params.revision)" + env_identity="$(cat /workspace/source/env-identity)" + if node -e 'const p=require("/workspace/source/ci-plan.json"); process.exit((p.buildServices||[]).length===0 ? 0 : 1)'; then + node <<'NODE' + const { readFileSync, writeFileSync } = require("node:fs"); + const plan = JSON.parse(readFileSync("/workspace/source/ci-plan.json", "utf8")); + const service = plan.previousService; + if (!service) throw new Error("reuse plan missing previousService"); + const image = service.envImage || service.image; + const digest = service.envDigest || service.digest; + const repositoryDigest = service.envRepositoryDigest || service.repositoryDigest; + writeFileSync("/tekton/results/image", image); + writeFileSync("/tekton/results/digest", digest); + writeFileSync("/tekton/results/repository-digest", repositoryDigest); + writeFileSync("/tekton/results/env-identity", plan.envIdentity); + writeFileSync("/tekton/results/status", "reused"); + console.log(JSON.stringify({ event: "agentrun-env-image", status: "reused", serviceId: "agentrun-mgr", envIdentity: plan.envIdentity, image, digest, summary: plan.summary })); + NODE + exit 0 + fi + image="$(params.registry-prefix)/agentrun-mgr-env:${env_identity}" buildctl=/workspace/buildkit-bin/buildctl for attempt in $(seq 1 60); do if "$buildctl" --addr unix:///workspace/buildkit-run/buildkitd.sock debug workers >/dev/null 2>&1; then break; fi @@ -174,12 +292,15 @@ spec: --opt build-arg:HTTPS_PROXY=http://127.0.0.1:10808 \ --opt build-arg:NO_PROXY=hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.42.0.0/16,10.43.0.0/16,.svc,.cluster.local \ --output type=image,name="$image",push=true,registry.insecure=true - digest="$(curl -fsSI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "http://127.0.0.1:5000/v2/agentrun/agentrun-mgr/manifests/$(params.revision)" | awk -F': ' 'tolower($1)=="docker-content-digest" {gsub(/\r/,"",$2); print $2; exit}')" + digest="$(curl -fsSI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "http://127.0.0.1:5000/v2/agentrun/agentrun-mgr-env/manifests/$env_identity" | awk -F': ' 'tolower($1)=="docker-content-digest" {gsub(/\r/,"",$2); print $2; exit}')" test -n "$digest" - curl -fsSI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "http://127.0.0.1:5000/v2/agentrun/agentrun-mgr/manifests/$digest" >/dev/null + curl -fsSI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" "http://127.0.0.1:5000/v2/agentrun/agentrun-mgr-env/manifests/$digest" >/dev/null printf '%s' "$image" > /tekton/results/image printf '%s' "$digest" > /tekton/results/digest - printf '%s' "$(params.registry-prefix)/agentrun-mgr@$digest" > /tekton/results/repository-digest + printf '%s' "$(params.registry-prefix)/agentrun-mgr-env@$digest" > /tekton/results/repository-digest + printf '%s' "$env_identity" > /tekton/results/env-identity + printf '%s' built > /tekton/results/status + printf '{"event":"agentrun-env-image","status":"built","serviceId":"agentrun-mgr","envIdentity":"%s","image":"%s","digest":"%s"}\n' "$env_identity" "$image" "$digest" volumeMounts: - name: buildkit-bin mountPath: /workspace/buildkit-bin @@ -200,47 +321,85 @@ spec: workspaces: - name: source workspace: source - - name: git-ssh - workspace: git-ssh taskSpec: params: - - name: git-url + - name: git-write-url - name: gitops-branch - name: revision - name: registry-prefix - name: image - name: digest - name: repository-digest + - name: env-identity + - name: image-status workspaces: - name: source - - name: git-ssh steps: - name: promote image: oven/bun:1.2.15-alpine env: - - name: HTTP_PROXY - value: http://127.0.0.1:10808 - - name: HTTPS_PROXY - value: http://127.0.0.1:10808 - - name: NO_PROXY - value: hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.42.0.0/16,10.43.0.0/16,.svc,.cluster.local + - name: GIT_TERMINAL_PROMPT + value: "0" + - name: AGENTRUN_IMAGE + value: $(params.image) + - name: AGENTRUN_DIGEST + value: $(params.digest) + - name: AGENTRUN_REPOSITORY_DIGEST + value: $(params.repository-digest) + - name: AGENTRUN_ENV_IDENTITY + value: $(params.env-identity) + - name: AGENTRUN_IMAGE_STATUS + value: $(params.image-status) + - name: AGENTRUN_REVISION + value: $(params.revision) + - name: AGENTRUN_GITOPS_BRANCH + value: $(params.gitops-branch) script: | #!/bin/sh set -eu - apk add --no-cache git openssh-client - mkdir -p /root/.ssh - cp /workspace/git-ssh/ssh-privatekey /root/.ssh/id_rsa - chmod 600 /root/.ssh/id_rsa - ssh-keyscan github.com >> /root/.ssh/known_hosts 2>/dev/null + apk add --no-cache git openssh-client nodejs cd /workspace/source/repo - cat > /workspace/source/artifact-catalog.v01.json < /workspace/source/artifact-catalog.v01.json + const { readFileSync } = require("node:fs"); + const plan = JSON.parse(readFileSync("/workspace/source/ci-plan.json", "utf8")); + const service = { + serviceId: "agentrun-mgr", + artifactKind: "env-reuse", + status: process.env.AGENTRUN_IMAGE_STATUS, + image: process.env.AGENTRUN_IMAGE, + digest: process.env.AGENTRUN_DIGEST, + repositoryDigest: process.env.AGENTRUN_REPOSITORY_DIGEST, + imageTag: process.env.AGENTRUN_ENV_IDENTITY, + envIdentity: process.env.AGENTRUN_ENV_IDENTITY, + envImage: process.env.AGENTRUN_IMAGE, + envDigest: process.env.AGENTRUN_DIGEST, + envRepositoryDigest: process.env.AGENTRUN_REPOSITORY_DIGEST, + bootCommit: process.env.AGENTRUN_REVISION, + bootScript: "deploy/runtime/boot/agentrun-boot.sh", + provenance: { + sourceCommitId: process.env.AGENTRUN_REVISION, + toolchainInputs: plan.toolchainInputs, + buildServices: plan.buildServices, + reusedServices: plan.reusedServices, + unsafeReuseServices: plan.unsafeReuseServices, + previousSourceCommitId: plan.previousService?.bootCommit || null, + }, + }; + const catalog = { + lane: "v0.1", + sourceBranch: "v0.1", + gitopsBranch: process.env.AGENTRUN_GITOPS_BRANCH, + sourceCommitId: process.env.AGENTRUN_REVISION, + summary: plan.summary, + services: [service], + }; + console.log(JSON.stringify(catalog, null, 2)); + NODE rm -rf /workspace/source/rendered bun scripts/agentrun-gitops-render.ts --out /workspace/source/rendered --source-commit "$(params.revision)" --registry-prefix "$(params.registry-prefix)" --catalog /workspace/source/artifact-catalog.v01.json --require-catalog rm -rf /workspace/source/gitops - git clone --branch "$(params.gitops-branch)" "$(params.git-url)" /workspace/source/gitops || { - git clone "$(params.git-url)" /workspace/source/gitops + git clone --branch "$(params.gitops-branch)" "$(params.git-write-url)" /workspace/source/gitops || { + git clone "$(params.git-write-url)" /workspace/source/gitops cd /workspace/source/gitops git checkout --orphan "$(params.gitops-branch)" git rm -rf . >/dev/null 2>&1 || true @@ -256,9 +415,10 @@ spec: git add deploy git commit -m "gitops: promote agentrun v0.1 $(params.revision)" || true git push origin "$(params.gitops-branch)" + printf '{"event":"agentrun-gitops-promote","status":"succeeded","sourceCommitId":"%s","envIdentity":"%s","imageStatus":"%s","summary":%s}\n' "$(params.revision)" "$(params.env-identity)" "$(params.image-status)" "$(node -e 'const p=require("/workspace/source/ci-plan.json"); console.log(JSON.stringify(p.summary))')" params: - - name: git-url - value: $(params.git-url) + - name: git-write-url + value: $(params.git-write-url) - name: gitops-branch value: $(params.gitops-branch) - name: revision @@ -271,3 +431,7 @@ spec: value: $(tasks.image-publish.results.digest) - name: repository-digest value: $(tasks.image-publish.results.repository-digest) + - name: env-identity + value: $(tasks.image-publish.results.env-identity) + - name: image-status + value: $(tasks.image-publish.results.status) diff --git a/deploy/templates/tekton/pipelinerun.sample.yaml b/deploy/templates/tekton/pipelinerun.sample.yaml index 6c7eef5..b829af7 100644 --- a/deploy/templates/tekton/pipelinerun.sample.yaml +++ b/deploy/templates/tekton/pipelinerun.sample.yaml @@ -16,6 +16,10 @@ spec: params: - name: revision value: REPLACE_WITH_FULL_SOURCE_COMMIT + - name: git-read-url + value: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git + - name: git-write-url + value: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/agentrun.git workspaces: - name: source volumeClaimTemplate: diff --git a/docs/reference/spec-v01-cicd.md b/docs/reference/spec-v01-cicd.md index 5ed22c6..a6d4a4e 100644 --- a/docs/reference/spec-v01-cicd.md +++ b/docs/reference/spec-v01-cicd.md @@ -9,6 +9,8 @@ - `v0.1` 只能部署到 `agentrun-v01` namespace;`v0.2`、`v0.3` 后续使用自己的 namespace 和 GitOps 路径。 - CI/CD 必须使用 G14 原生 k3s、纯 Tekton Pipeline/Task/PipelineRun 和 Argo CD;不得引入自定义 runner、CI.json runner、长期自研 poller/reconciler、D601 legacy、临时 clone、手工 Pod patch 或本地镜像作为发布真相。 - `v0.1` 的 CD 唯一手写真相源是 source branch 内的 `deploy/deploy.json`;Tekton 生成的 artifact catalog 和 Argo desired state 必须与 source branch 分离,只写入 `v0.1-gitops`。 +- Source checkout 与 GitOps promotion 必须优先走 `devops-infra` git mirror/relay;Pipeline 不直接依赖 GitHub SSH fetch/push。 +- Runtime image 采用 env reuse:依赖、Bun/Node、系统包、lockfile、Containerfile 和 boot 脚本构成 env identity;普通 TS/文档/CLI 业务源码变更只改变 boot commit,不重建 env image。 - 发布证据以 live runtime、Argo desired state、GitOps branch、Tekton 证据和干净 source worktree 顺序判断。 ## 固定命名 @@ -21,12 +23,17 @@ | Worktree root | `G14:/root/agentrun-v01/.worktree/{task}` | | Runtime namespace | `agentrun-v01` | | GitOps branch | `v0.1-gitops` | +| Git mirror read URL | `http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git` | +| Git mirror write URL | `http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/agentrun.git` | +| Git mirror cache | `devops-infra:/cache/pikasTech/agentrun.git` | | Artifact catalog | `v0.1-gitops:deploy/artifact-catalog.v01.json` | | Runtime path | `v0.1-gitops:deploy/gitops/g14/runtime-v01` | | Tekton namespace | `agentrun-ci` | | Tekton Pipeline | `agentrun-v01-ci-image-publish` | | Tekton ServiceAccount | `agentrun-v01-tekton-runner` | | PipelineRun prefix | `agentrun-v01-ci-` | +| Env image repository | `127.0.0.1:5000/agentrun/agentrun-mgr-env:` | +| Boot script | `deploy/runtime/boot/agentrun-boot.sh` | | Argo CD AppProject | `argocd/agentrun-v01` | | Argo CD Application | `argocd/agentrun-g14-v01` | @@ -45,7 +52,9 @@ CI 的最小检查应覆盖: - Bun/TS 单元自测试,包括 manager schema、adapter mock、Codex fake app-server stdio client 和 CLI JSON 输出。 - `deploy/deploy.json` 与 GitOps render 只读校验。 -容器镜像可以直接运行 TS 入口,也可以运行同一 source commit 构建出的 JS artifact;无论选择哪种形式,artifact catalog 必须记录完整 source commit 和 image digest。CI/CD 仍然只允许纯 Tekton + Argo CD,不因 Bun 工具链引入自定义 runner、长期 poller 或源分支生成物提交。 +容器镜像必须区分 env identity 与 source commit。`agentrun-mgr-env:` 只包含 Bun runtime、生产依赖、Codex CLI、git/kubectl/node 等系统依赖和 boot 脚本,不 bake `src/`、`scripts/` 或某个业务 source commit。运行时通过 `AGENTRUN_BOOT_COMMIT` 从 git mirror 按完整 SHA 做 `git fetch --depth=1 origin `,再用 env image 内的 `node_modules` 启动 manager 或 runner。CI/CD 仍然只允许纯 Tekton + Argo CD,不因 Bun 工具链引入自定义 runner、长期 poller 或源分支生成物提交。 + +Env identity 的输入至少包含:Bun base image、系统包列表、`deploy/container/Containerfile`、`deploy/runtime/boot/*.sh`、`package.json`、`bun.lock` 和 `tsconfig.json`。只改业务 TS、文档、模板中不影响 runtime env 的内容时,planner 必须输出 `build=0 reuse=1 unsafeReuse=0`,并复用上一版 catalog 中同一 `envIdentity` 的 digest。 ## 真相源 @@ -54,9 +63,11 @@ CI 的最小检查应覆盖: 1. Live runtime:`agentrun-v01` namespace 中 Deployment/Job/Pod ready、事件、日志和 service health。 2. Argo desired state:`argocd/agentrun-g14-v01` 的 revision、sync、health、source branch 和 runtime path。 3. GitOps branch:`v0.1-gitops` 中的 `deploy/artifact-catalog.v01.json` 与 `deploy/gitops/g14/runtime-v01/**`。 -4. Tekton 执行证据:PipelineRun、TaskRun result、image digest 和 promotion 终态。 +4. Tekton 执行证据:PipelineRun、TaskRun result、env identity、build/reuse summary、image digest 和 promotion 终态。 5. 干净 source worktree:`G14:/root/agentrun-v01`、`origin/v0.1`、render 脚本、deploy intent 和 `--no-write` 输出。 +`devops-infra` git mirror 是 CI/CD source 与 GitOps relay,不是新的 source truth。source truth 仍是 GitHub `v0.1`,GitOps truth 仍是 `v0.1-gitops`;mirror 只负责降低 Pipeline 中的外网 fetch/push 抖动,并让 exact commit fetch 可在集群内稳定命中。受控入口是 UniDesk CLI:`agentrun v01 git-mirror status|sync|flush`。`status` 必须展示 `localV01`、`githubV01`、`localGitops`、`githubGitops`、`pendingFlush` 和 exact full-SHA fetch 结果;`trigger-current` 在创建 PipelineRun 前必须先检查 `localV01`,必要时同步 mirror,再继续。 + 旧 `master` 记忆、`/root/agentrun` 历史固定目录、`agentrun_dev`、`agentrun_prod`、D601 legacy 路径、临时 worktree 或本地容器只能作为线索,不能作为 `v0.1` 发布通过证据。 ## Source 与 GitOps 分层 @@ -76,7 +87,7 @@ CI 的最小检查应覆盖: `v0.1-gitops` branch 必须包含: -- `deploy/artifact-catalog.v01.json`,记录 image tag、digest、source commit、service identity、publish/reuse 状态。 +- `deploy/artifact-catalog.v01.json`,记录 env image tag、digest、source commit、env identity、service identity、build/reuse 状态和 provenance。 - `deploy/gitops/g14/runtime-v01/**`,作为 Argo CD 消费的 desired state。 - 必要 generated metadata,但不得包含 Secret 值。 @@ -107,11 +118,11 @@ Tekton promotion 可以读取 `deploy/deploy.json` 来 render runtime desired st 标准链路如下: 1. 人工或受控 GitHub/UniDesk 入口为某个 `origin/v0.1` source commit 创建 Tekton `PipelineRun`;触发器可以是 Tekton Triggers 或手动 `PipelineRun`,但不能是长期自定义 runner。 -2. `prepare-source` checkout `v0.1` source,并从 `v0.1-gitops` 读取上一版 `deploy/artifact-catalog.v01.json`。 +2. `prepare-source` 通过 git mirror read URL 按完整 SHA checkout `v0.1` source,不使用 branch tip 近似 checkout。 3. 原语校验 task 只覆盖文档治理、spec 链接、`deploy/deploy.json` schema、轻量语法和必要单元测试;旧 `dev/prod` gate 不进入 lane。 -4. Plan task 读取 `deploy/deploy.json` 与上一版 artifact catalog,判断 affected/reused services;planner 只能输出 TaskRun result 或临时 workspace 文件,不回写 source。 -5. Affected service 通过 Tekton Task 内的 BuildKit/kaniko/buildah 之一发布到 G14 本地 registry;reused service 复用 catalog digest。不得使用 Docker daemon、DIND 或仓库外自定义 runner。 -6. Promotion task 用 publish results 刷新 `deploy/artifact-catalog.v01.json`,并用 `deploy/deploy.json` render `deploy/gitops/g14/runtime-v01/**`,只推送到 `v0.1-gitops`。 +4. Plan task 通过 git mirror read URL 读取上一版 `v0.1-gitops:deploy/artifact-catalog.v01.json`,计算 `envIdentity` 并判断 affected/reused services;planner 只能输出 TaskRun result 或临时 workspace 文件,不回写 source。 +5. Affected env image 通过 Tekton Task 内的 BuildKit 发布到 G14 本地 registry;reused service 直接复用 catalog digest。不得使用 Docker daemon、DIND 或仓库外自定义 runner。 +6. Promotion task 用 publish/reuse results 刷新 `deploy/artifact-catalog.v01.json`,并用 `deploy/deploy.json` render `deploy/gitops/g14/runtime-v01/**`,只通过 git mirror write URL 推送到本地 `v0.1-gitops` relay。发布收口再用 `agentrun v01 git-mirror flush --confirm` 把 GitOps branch 快进回 GitHub。 7. Argo CD Application `agentrun-g14-v01` 从 `v0.1-gitops:deploy/gitops/g14/runtime-v01` 同步到 `agentrun-v01`。 8. 验收只观察 `agentrun-v01` runtime、Argo revision/sync/health 和对应 service health。 @@ -119,10 +130,10 @@ Tekton promotion 可以读取 `deploy/deploy.json` 来 render runtime desired st 镜像身份同时是 RuntimeAssembly 的 `BackendImageRef` 来源;四要素总模型见 [spec-v01-runtime-assembly.md](spec-v01-runtime-assembly.md)。本文只定义 image digest、artifact catalog 和 GitOps promotion 边界,不定义 profile、session 或 resource bundle 字段。 -- `v0.1` 镜像 tag 使用完整 40 位 source commitId。 +- `v0.1` env image tag 使用 `envIdentity`,而不是 source commitId。source commitId 通过 `AGENTRUN_BOOT_COMMIT` 和 catalog `bootCommit` 进入 runtime。 - Runtime manifest 必须使用 digest pin 作为部署身份;G14 本地 registry 对同一 tag 的默认 HEAD 可能返回 Docker schema1 compatibility digest,Tekton 必须用 `Accept: application/vnd.docker.distribution.manifest.v2+json` 采集 containerd 可直接拉取的 schema2 manifest digest,并在写入 catalog 前按 digest HEAD 验证。 -- Catalog 必须记录 lane、source branch、GitOps branch、source commitId、serviceId、image tag、digest、component identity 和 publish/reuse 状态。 -- 同一 source commit 对同一 service 应生成同一镜像;lane 差异放在 manifest、env、SecretRef、namespace、RBAC 和 runtime config 中,不 bake 进镜像。 +- Catalog 必须记录 lane、source branch、GitOps branch、source commitId、serviceId、env image tag、digest、env identity、boot commit、toolchain inputs 和 build/reuse 状态。 +- 同一 env identity 对同一 service 应生成同一镜像;lane 差异放在 manifest、env、SecretRef、namespace、RBAC 和 runtime config 中,不 bake 进镜像。普通 source commit 差异由 boot checkout 选择。 - `deploy/deploy.json` 只承载人写 runtime intent,不承载 digest、publish state 或 reuse evidence。 - Source branch 不得因为 promotion 出现自动提交;若发布后 source branch 变化,必须是人工修改源码、测试、文档、模板或 `deploy/deploy.json`。 @@ -152,7 +163,9 @@ Tekton promotion 可以读取 `deploy/deploy.json` 来 render runtime desired st - `v0.1-gitops` branch 和 `deploy/gitops/g14/runtime-v01` 成为 Argo desired state 来源。 - `deploy/deploy.json` 是 CD 唯一手写真相源;source branch 不跟踪 artifact catalog、runtime generated manifests、digest 或 publish state。 - Tekton/Argo 路径不包含自定义 runner、CI.json runner、长期自研 poller 或 control-plane reconciler。 -- Runtime manifest 使用 digest pin,catalog 记录完整 source commit 与 digest。 +- Runtime manifest 使用 digest pin,catalog 记录完整 source commit、env identity、build/reuse summary 与 digest。 +- `agentrun v01 git-mirror status` 中 `sourceInSync=true`、`exactFetch.localV01=true`,并能用 mirror 对当前 full SHA 执行 shallow fetch。 +- 小的业务源码变更完成后,PipelineRun `planArtifacts.summary` 应为 `build=0 reuse=1 unsafeReuse=0`,`image-publish` task result `status=reused`,且不出现新的 env image build。 - 发布完成后可通过 `G14:k3s` 读取 `agentrun-v01` Pod ready、service health 和对应 image digest。 ## 规格的实现情况 @@ -164,4 +177,6 @@ Tekton promotion 可以读取 `deploy/deploy.json` 来 render runtime desired st | `agentrun-v01` namespace | 已实现/已通过主闭环 | GitOps lane 已同步 manager、Postgres、ServiceAccount、SecretRef 和 runner Job 所需对象;发布前仍按 [spec-v01-validation.md](spec-v01-validation.md) 手动复验。 | | `v0.1-gitops` branch | 已实现 | Tekton promotion 生成 artifact catalog 与 runtime desired state,Argo 从 `deploy/gitops/g14/runtime-v01` 同步。 | | 纯 Tekton/Argo lane | 已实现/已通过主闭环 | `agentrun-ci` Pipeline、BuildKit 镜像发布、GitOps promotion 和 Argo Application 已形成闭环;不得回退到自定义 runner 或 dev/prod。 | +| git mirror/relay | 已实现 | UniDesk `agentrun v01 git-mirror status|sync|flush` 管理 `devops-infra:/cache/pikasTech/agentrun.git`;Pipeline read/write 默认使用 mirror URL,`trigger-current` 会在创建 PipelineRun 前 pre-sync `v0.1`。 | +| env reuse | 已实现/待持续验收 | Pipeline 计算 `envIdentity`,命中上一版 catalog 时输出 `build=0 reuse=1 unsafeReuse=0` 并复用 env image digest;首次 env identity 或依赖/Containerfile/boot 变化才构建 `agentrun-mgr-env:`。 | | `dev/prod` 废弃口径 | 已定义 | 本文明确 `agentrun_dev` 和 `agentrun_prod` 不作为当前规格目标。 | diff --git a/scripts/src/gitops-render.ts b/scripts/src/gitops-render.ts index 635738e..a84cf90 100644 --- a/scripts/src/gitops-render.ts +++ b/scripts/src/gitops-render.ts @@ -18,9 +18,29 @@ interface ArtifactCatalog { sourceBranch: string; gitopsBranch: string; sourceCommitId: string; - services: Array<{ serviceId: string; image: string; digest: string; repositoryDigest: string; imageTag: string }>; + summary?: string; + services: CatalogService[]; } +interface CatalogService { + serviceId: string; + image: string; + digest: string; + repositoryDigest: string; + imageTag: string; + artifactKind?: string; + status?: string; + envIdentity?: string; + envImage?: string; + envDigest?: string; + envRepositoryDigest?: string; + bootCommit?: string; + bootScript?: string; + provenance?: JsonRecord; +} + +const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git"; + export async function runGitopsRenderCli(argv: string[]): Promise { try { const options = parseArgs(argv); @@ -52,29 +72,39 @@ export async function renderGitops(options: RenderOptions): Promise await writeFile(path.join(options.outDir, "runtime-v01", "postgres.yaml"), postgresYaml(runtimeNamespace)); await writeFile(path.join(options.outDir, "runtime-v01", "mgr.yaml"), managerYaml(runtimeNamespace, image, options.sourceCommit)); await writeFile(path.join(options.outDir, "runtime-v01", "runner-rbac.yaml"), runnerRbacYaml(runtimeNamespace)); - return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: image.repositoryDigest, sourceCommit: options.sourceCommit }; + return { outDir: options.outDir, runtimeNamespace, gitopsBranch, runtimePath, image: repositoryDigestForService(image), sourceCommit: options.sourceCommit, envIdentity: image.envIdentity ?? null, artifactStatus: image.status ?? null }; } async function loadCatalog(options: RenderOptions, gitopsBranch: string): Promise { if (options.catalogFile) return JSON.parse(await readFile(options.catalogFile, "utf8")) as ArtifactCatalog; if (options.requireCatalog) throw new AgentRunError("schema-invalid", "artifact catalog is required for promotion render", { httpStatus: 2 }); const digest = `sha256:${"0".repeat(64)}`; - const image = `${options.registryPrefix}/agentrun-mgr:${options.sourceCommit}`; + const image = `${options.registryPrefix}/agentrun-mgr-env:${options.sourceCommit}`; return { lane: "v0.1", sourceBranch: "v0.1", gitopsBranch, sourceCommitId: options.sourceCommit, - services: [{ serviceId: "agentrun-mgr", image, digest, repositoryDigest: `${options.registryPrefix}/agentrun-mgr@${digest}`, imageTag: options.sourceCommit }], + summary: "build=1 reuse=0 unsafeReuse=0", + services: [{ serviceId: "agentrun-mgr", artifactKind: "env-reuse", status: "placeholder", image, digest, repositoryDigest: `${options.registryPrefix}/agentrun-mgr-env@${digest}`, imageTag: options.sourceCommit, envIdentity: options.sourceCommit, envImage: image, envDigest: digest, envRepositoryDigest: `${options.registryPrefix}/agentrun-mgr-env@${digest}`, bootCommit: options.sourceCommit, bootScript: "deploy/runtime/boot/agentrun-boot.sh" }], }; } -function imageForService(catalog: ArtifactCatalog, serviceId: string, options: RenderOptions): { repositoryDigest: string } { +function imageForService(catalog: ArtifactCatalog, serviceId: string, options: RenderOptions): CatalogService { const service = catalog.services.find((item) => item.serviceId === serviceId); if (!service) throw new AgentRunError("schema-invalid", `catalog missing service ${serviceId}`, { httpStatus: 2 }); - if (!/^sha256:[a-f0-9]{64}$/u.test(service.digest)) throw new AgentRunError("schema-invalid", `catalog service ${serviceId} has invalid digest`, { httpStatus: 2 }); - if (options.requireCatalog && service.digest === `sha256:${"0".repeat(64)}`) throw new AgentRunError("schema-invalid", "placeholder digest is not allowed in promotion render", { httpStatus: 2 }); - return { repositoryDigest: service.repositoryDigest || `${service.image.slice(0, service.image.lastIndexOf(":"))}@${service.digest}` }; + const digest = service.envDigest ?? service.digest; + if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) throw new AgentRunError("schema-invalid", `catalog service ${serviceId} has invalid digest`, { httpStatus: 2 }); + if (options.requireCatalog && digest === `sha256:${"0".repeat(64)}`) throw new AgentRunError("schema-invalid", "placeholder digest is not allowed in promotion render", { httpStatus: 2 }); + return service; +} + +function repositoryDigestForService(service: CatalogService): string { + if (service.envRepositoryDigest) return service.envRepositoryDigest; + if (service.repositoryDigest) return service.repositoryDigest; + const image = service.envImage ?? service.image; + const digest = service.envDigest ?? service.digest; + return `${image.slice(0, image.lastIndexOf(":"))}@${digest}`; } function projectYaml(namespace: string): string { @@ -209,7 +239,9 @@ spec: `; } -function managerYaml(namespace: string, image: { repositoryDigest: string }, sourceCommit: string): string { +function managerYaml(namespace: string, image: CatalogService, sourceCommit: string): string { + const imageRef = repositoryDigestForService(image); + const envIdentity = image.envIdentity ?? image.imageTag ?? "unknown"; return `apiVersion: v1 kind: ServiceAccount metadata: @@ -245,11 +277,13 @@ spec: app.kubernetes.io/name: agentrun-mgr annotations: agentrun.pikastech.local/lane: v0.1 + agentrun.pikastech.local/source-commit: ${JSON.stringify(sourceCommit)} + agentrun.pikastech.local/env-identity: ${JSON.stringify(envIdentity)} spec: serviceAccountName: agentrun-v01-mgr containers: - name: mgr - image: ${image.repositoryDigest} + image: ${imageRef} imagePullPolicy: IfNotPresent ports: - name: http @@ -264,12 +298,20 @@ spec: key: DATABASE_URL - name: AGENTRUN_SOURCE_COMMIT value: ${JSON.stringify(sourceCommit)} + - name: AGENTRUN_BOOT_COMMIT + value: ${JSON.stringify(sourceCommit)} + - name: AGENTRUN_BOOT_MODE + value: mgr + - name: AGENTRUN_BOOT_REPO_URL + value: ${JSON.stringify(defaultBootRepoUrl)} + - name: AGENTRUN_ENV_IDENTITY + value: ${JSON.stringify(envIdentity)} - name: AGENTRUN_RUNTIME_NAMESPACE value: ${JSON.stringify(namespace)} - name: AGENTRUN_INTERNAL_MGR_URL value: ${JSON.stringify(`http://agentrun-mgr.${namespace}.svc.cluster.local:8080`)} - name: AGENTRUN_RUNNER_IMAGE - value: ${JSON.stringify(image.repositoryDigest)} + value: ${JSON.stringify(imageRef)} - name: AGENTRUN_RUNNER_SERVICE_ACCOUNT value: "agentrun-v01-runner" readinessProbe: diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index a60a1a0..1839520 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -2,6 +2,8 @@ import { stableHash } from "../common/validation.js"; import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue, RunRecord, SecretRef } from "../common/types.js"; import { backendProfileSpec } from "../common/backend-profiles.js"; +const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git"; + export interface RunnerJobRenderOptions { run: RunRecord; commandId: string; @@ -124,7 +126,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani name: "runner", image: options.image, imagePullPolicy: options.imagePullPolicy ?? "IfNotPresent", - command: ["bun", "src/runner/main.ts"], + command: ["/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh"], env, volumeMounts: [ { name: "runner-home", mountPath: "/home/agentrun" }, @@ -164,6 +166,10 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string { name: "AGENTRUN_RESOURCE_BUNDLE_JSON", value: JSON.stringify(options.run.resourceBundleRef ?? null) }, { name: "AGENTRUN_WORKSPACE_ROOT", value: "/home/agentrun/workspaces" }, { name: "AGENTRUN_SOURCE_COMMIT", value: context.sourceCommit }, + { name: "AGENTRUN_BOOT_COMMIT", value: context.sourceCommit }, + { name: "AGENTRUN_BOOT_REPO_URL", value: defaultBootRepoUrl }, + { name: "AGENTRUN_BOOT_MODE", value: "runner" }, + { name: "AGENTRUN_APP_ROOT", value: "/home/agentrun/agentrun-source" }, { name: "AGENTRUN_RUNTIME_NAMESPACE", value: context.namespace }, { name: "AGENTRUN_K8S_JOB_NAME", value: context.jobName }, { name: "AGENTRUN_LOG_PATH", value: "/tmp/agentrun-runner.jsonl" }, diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index 5139ed3..bccd53f 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -15,6 +15,8 @@ const execFile = promisify(execFileCallback); const selfTest: SelfTestCase = async (context) => { const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8"); assert.ok(containerfile.includes(" git ") && containerfile.includes(" openssh-client"), "runtime image must include git and openssh-client for ResourceBundleRef checkout"); + assert.ok(containerfile.includes("deploy/runtime/boot") && containerfile.includes("agentrun-mgr.sh"), "runtime image must boot through the env-reuse source checkout script"); + assert.ok(!containerfile.includes("COPY src ./src"), "runtime env image must not bake source files into every source commit image"); const fakeKubectl = path.join(context.tmp, "fake-kubectl-hwlab.js"); const createdManifest = path.join(context.tmp, "created-hwlab-runner-job.json"); await writeFile(fakeKubectl, `#!/usr/bin/env bun @@ -54,6 +56,8 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin ); const manifest = JSON.parse(await readFile(createdManifest, "utf8")) as JsonRecord; assert.ok(JSON.stringify(manifest).includes("AGENTRUN_RESOURCE_BUNDLE_JSON")); + assert.ok(JSON.stringify(manifest).includes("/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh")); + assert.ok(JSON.stringify(manifest).includes("AGENTRUN_BOOT_COMMIT")); assertNoSecretLeak(created); const pendingCancel = await createHwlabRun(client, context, bundle, "hwlab-session-cancel-pending", "cancel pending", "hwlab-command-cancel-pending");