diff --git a/.agents/skills/agent-sandbox-deploy/SKILL.md b/.agents/skills/agent-sandbox-deploy/SKILL.md index f1533d6a4..1ea774acd 100644 --- a/.agents/skills/agent-sandbox-deploy/SKILL.md +++ b/.agents/skills/agent-sandbox-deploy/SKILL.md @@ -108,9 +108,8 @@ test -x "$kindctl" "$orka_kind_deploy" ``` - The harness-wrapper image must be present for the separate plain-agent model - smoke. The model-free direct workspace-adapter smoke bypasses the Task/harness - path. + The digest-pinned ACP runtime images must be present for the separate plain-agent + model smoke. The model-free direct workspace-adapter smoke bypasses the Task-to-RuntimeSession path. 3. **Install agent-sandbox** by driving the canonical script against the kindctl kubeconfig. Export `KUBECONFIG` from kindctl so the script's `kubectl` calls @@ -208,12 +207,132 @@ test -x "$kindctl" wget -qO- http://127.0.0.1:1337/readyz ``` - If you only need model-free confidence, run the CI parity script from the - `Model-free CI parity` section of `references/validate.md`. It validates - installation/configuration plus the direct workspace-adapter lifecycle - (SandboxClaim readiness, router exec, delete, retained reuse, and claim - cleanup); - only the full workspace-backed agent Task path remains gated. + If you only need model-free confidence, run the CI parity script below. It + validates installation/configuration only while workspace-backed agent Tasks + remain gated; it is not a claim/readiness/exec/cleanup smoke. + +## Validate + +> **Current boundary:** this skill validates the upstream agent-sandbox +> provider directly. Orka ACP RuntimeSessions do not yet map to sandbox claims; +> execution-workspace-backed agent Tasks remain expected-future evidence. The +> removed v1 harness-wrapper path must not be reintroduced. Validate plain +> Codex/Claude ACP Tasks with `scripts/live-acp-runtime-e2e.sh`. + +Do **not** use an execution-workspace agent Task as the success criterion yet. +Validate the two currently wired paths separately: + +- **Model path through ACP** (requires the optional `AGENTIC=1` step and + vekil ready): run a plain agent Task with no `execution.workspace` and wait + for it to succeed. + +```bash +"$kindctl" kubectl -n demo-magic apply -f - <<'YAML' +apiVersion: core.orka.ai/v1alpha1 +kind: Agent +metadata: + name: sandbox-codex-agent + namespace: demo-magic +spec: + runtime: + type: codex + defaultMaxTurns: 1 + defaultAllowBash: true + model: + name: gpt-5.5 + secretRef: + name: sandbox-model-key +--- +apiVersion: core.orka.ai/v1alpha1 +kind: Task +metadata: + name: orka-live-model-smoke + namespace: demo-magic +spec: + type: agent + agentRef: + name: sandbox-codex-agent + agentRuntime: + maxTurns: 1 + timeout: 10m0s + prompt: "Reply exactly: ORKA_LIVE_MODEL_OK" +YAML + +"$kindctl" kubectl -n demo-magic \ + wait --for=jsonpath='{.status.phase}'=Succeeded task/orka-live-model-smoke --timeout=10m +``` + +- **Installation/configuration parity**: run the model-free CI parity script + below when you want a self-contained cluster bring-up with fake model + credentials. It verifies the install/config path, but it does **not** exercise + claim → ready → exec → cleanup through the direct adapter. + +If you need to demonstrate the intended API shape before RuntimeSession-backed workspace support lands, run it only as an **expected-failure** check and wait for the gate +instead of `Succeeded`: + +```bash +"$kindctl" kubectl apply -f - <<'YAML' +apiVersion: core.orka.ai/v1alpha1 +kind: Agent +metadata: + name: sandbox-codex-agent + namespace: demo-magic +spec: + runtime: + type: codex + defaultMaxTurns: 1 + defaultAllowBash: true + model: + name: gpt-5.5 + secretRef: + name: sandbox-model-key +--- +apiVersion: core.orka.ai/v1alpha1 +kind: Task +metadata: + name: orka-live-sandbox-smoke + namespace: demo-magic +spec: + type: agent + agentRef: + name: sandbox-codex-agent + agentRuntime: + maxTurns: 1 + timeout: 10m0s + execution: + workspace: + enabled: true + templateRef: + name: orka-live-template + reusePolicy: none + cleanupPolicy: delete + prompt: "Reply exactly: ORKA_LIVE_SANDBOX_OK" +YAML + +"$kindctl" kubectl -n demo-magic \ + wait --for=jsonpath='{.status.executionWorkspace.reason}'=WorkspaceValidationFailed \ + task/orka-live-sandbox-smoke --timeout=2m +``` + +Once ACP RuntimeSessions map agent Tasks to execution workspaces, the expected-failure +check can become the live success smoke. At that point, a successful sandbox +wrapper log should include the claimed workspace name, e.g. `completed in +sandbox workspace sandbox-claim-...`. Orka Task status does **not** expose +sandbox claim/exec/cleanup state — read worker logs and upstream agent-sandbox +resources for lifecycle detail. + +### Model-free CI parity + +`scripts/live-agent-sandbox-e2e.sh` (run by the `Live Agent Sandbox E2E` +workflow) stands up a clean kind cluster with fake model credentials and **no +model access**. The script exercises the direct workspace adapter (claim, readiness, router exec, delete, retained reuse, and cleanup) but deliberately skips the unsupported full ACP Task-to-workspace path: + +```bash +bash scripts/live-agent-sandbox-e2e.sh +``` + +That script owns its own cluster lifecycle; do not run it against a kindctl +cluster you want to keep. ## Guardrails diff --git a/.agents/skills/agent-sandbox-deploy/references/validate.md b/.agents/skills/agent-sandbox-deploy/references/validate.md index 836e1d6c3..b63a55317 100644 --- a/.agents/skills/agent-sandbox-deploy/references/validate.md +++ b/.agents/skills/agent-sandbox-deploy/references/validate.md @@ -2,31 +2,14 @@ Validation steps for `$agent-sandbox-deploy`. Read after the standard workflow completes. -> **Known gate (verified live 2026-06): valid enabled provider-based agent -> workspace requests are rejected during execution planning by the current -> service-backed harness runtime.** After workspace validation/resolution, the -> provider-based request documented below fails with -> `status.executionWorkspace.reason=WorkspaceValidationFailed` and message -> `execution workspace is not supported by harness runtime yet`. The gate is in -> `internal/controller/agent_execution_plan.go` (`planAgentExecution`), not a -> misconfiguration. The agent CLI runtimes now -> run through the long-lived `agent-harness-wrapper` service, and the -> Task→sandbox-workspace path for agents is not wired through it yet. A **plain** -> agent Task (no `execution.workspace`) runs fine through the harness + model -> proxy, so use that to confirm the model path. The model-free e2e confirms -> installation/configuration and exercises the direct workspace adapter through -> SandboxClaim readiness, router exec, delete, retained release/reuse, and final -> claim cleanup. It skips only the full execution-workspace Task smoke while the -> harness gate is present. Treat the execution-workspace YAML in the optional -> expected-failure check as the intended future Task API once the harness wires -> workspaces. +> **Known gate:** Orka ACP RuntimeSessions do not yet map to agent-sandbox claims. The direct adapter lifecycle is supported for local validation, but a `Task.spec.execution.workspace` agent Task must still fail closed with `WorkspaceValidationFailed`. Plain Codex/Claude Tasks run through controller-owned ACP RuntimePools and validate the model path separately. Do **not** use an execution-workspace agent Task as the success criterion yet. Validate the three current surfaces separately: installation/configuration, direct workspace-adapter lifecycle, and the model path through a plain agent Task. -- **Model path through the harness** (requires the optional `AGENTIC=1` step and +- **Model path through ACP** (requires the optional `AGENTIC=1` step and vekil ready): run a plain agent Task with no `execution.workspace` and wait for it to succeed. @@ -73,7 +56,7 @@ YAML flags and confirms rollout, then exercises claim → ready → router exec → delete and retained release/reuse → claim cleanup through `AgentSandboxExecutor`. It skips only the full Orka agent Task - workspace path while the harness gate is present. + workspace path while the ACP workspace-dispatch gate is present. If you need to demonstrate the intended API shape before harness workspace support lands, run it only as an **expected-failure** check and wait for the gate @@ -123,7 +106,7 @@ YAML task/orka-live-sandbox-smoke --timeout=2m ``` -Once the harness wires agent Tasks to execution workspaces, the expected-failure +Once ACP RuntimeSessions map agent Tasks to execution workspaces, the expected-failure check can become the live success smoke. At that point, a successful sandbox wrapper log should include the claimed workspace name, e.g. `completed in sandbox workspace sandbox-claim-...`. Orka Task status does **not** expose @@ -140,7 +123,7 @@ smoke that creates SandboxClaims, waits for readiness, executes through the router, deletes one claim, retains and reuses another, and performs final claim cleanup. It skips only the full Orka agent Task workspace smoke, so it proves the provider-adapter path but not Task-to-workspace controller routing, Task status/ -result wiring, harness execution, or model access: +result wiring, ACP Task execution, or model access: ```bash bash scripts/live-agent-sandbox-e2e.sh diff --git a/.agents/skills/agent-substrate-deploy/SKILL.md b/.agents/skills/agent-substrate-deploy/SKILL.md index 36bd86ee3..01cf70b01 100644 --- a/.agents/skills/agent-substrate-deploy/SKILL.md +++ b/.agents/skills/agent-substrate-deploy/SKILL.md @@ -26,8 +26,7 @@ the CI-proven `scripts/agent-substrate-e2e.sh`. **Drive the installer in place; do not copy either script into the skill.** They pin the Substrate revision (`SUBSTRATE_REF`, default `b80031d260959b1fc5c6f61e3099fe2a6d368af1`) and own the heavy lifting: clone Substrate at the pinned ref, create the kind cluster + local -registry, deploy the `ate-system` control plane, build/push the controller, -agent-harness-wrapper, workspace-agent, MCP server, and tool-client images; +registry, deploy the `ate-system` control plane, build/push the controller, workspace-agent, MCP server, and tool-client images; publish the Substrate `ateom-gvisor` image; create a `WorkerPool` + gVisor `ActorTemplate`, initialize the RustFS snapshot bucket, and deploy Orka wired with `--substrate-*`. Re-pin by overriding `SUBSTRATE_REF`, not by editing @@ -120,17 +119,9 @@ is a larger task; confirm scope before attempting it. kind export kubeconfig --name "${cluster}" --kubeconfig "${KUBECONFIG}" DEMO_CLUSTER_REUSE=reuse bash hack/demos/cluster/install-substrate.sh - # The base e2e creates codex-substrate-ci without model env and patches the - # service-backed harness wrapper to use a fake Codex CLI. Patch the Agent with - # the model Secret from the agentic layer and remove the fake CLI override - # before using a plain agent Task as model-validation evidence. - kubectl --context "$ctx" -n default patch agent codex-substrate-ci --type=merge \ - -p "$(jq -cn \ - --arg ref substrate-model-key \ - --arg model gpt-5.5 \ - '{spec:{model:{name:$model},secretRef:{name:$ref}}}')" - kubectl --context "$ctx" -n orka-system set env deployment/orka-agent-harness-wrapper CODEX_CLI_PATH- - kubectl --context "$ctx" -n orka-system rollout status deployment/orka-agent-harness-wrapper --timeout=5m + # This installer validates only the direct Substrate and MCP paths. The + # pre-cutover AGENTIC/model layer is retired. Validate Codex or Claude ACP + # RuntimePools separately with scripts/live-acp-runtime-e2e.sh. ``` > **Login race (verified live 2026-06): disarm vekil's liveness probe before @@ -180,8 +171,85 @@ is a larger task; confirm scope before attempting it. ``` For a model-free validation, stay on `AGENTIC=0` and rely on the built-in - smoke exercises documented in `references/validate.md` instead of standing - up vekil. + smoke exercises (next section) instead of standing up vekil. + +## Validate + +> **Current boundary:** this skill validates direct Substrate Actor and MCP +> behavior only. Orka ACP RuntimeSessions do not yet map to Substrate Actors; +> execution-workspace-backed agent Tasks remain future integration evidence, +> not a success criterion. The removed v1 harness-wrapper path must not be +> reintroduced. Validate plain Codex/Claude ACP Tasks with +> `scripts/live-acp-runtime-e2e.sh` instead. + +The installer leaves a fully wired cluster. During standup it smoke-tests direct +actor create/resume/exec/suspend/delete and Substrate-backed MCP tool lifecycle. +It does **not** currently smoke-test retained workspace reuse for Orka agent +Tasks because ACP RuntimeSession-to-Actor dispatch is not yet wired. + +If you skipped the kubeconfig export in the workflow above, do it before any +manual `kubectl` commands — the e2e standup uses an isolated kubeconfig and does +**not** leave `kind-` in your default one. Keep using the scoped +`KUBECONFIG` in that shell: + +```bash +cluster="${KIND_CLUSTER:-orka-agent-substrate-e2e}" +ctx="kind-${cluster}" +export KUBECONFIG="$(mktemp -t orka-substrate-kubeconfig.XXXXXX)" +kind export kubeconfig --name "${cluster}" --kubeconfig "${KUBECONFIG}" +``` + +To drive an Orka Task yourself (intended shape; currently gated as noted above): + +```bash +cluster="${KIND_CLUSTER:-orka-agent-substrate-e2e}" +ctx="kind-${cluster}" +export KUBECONFIG="$(mktemp -t orka-substrate-kubeconfig.XXXXXX)" +kind export kubeconfig --name "${cluster}" --kubeconfig "${KUBECONFIG}" +kubectl --context "$ctx" -n default apply -f - <<'YAML' +apiVersion: core.orka.ai/v1alpha1 +kind: Task +metadata: + name: substrate-smoke + namespace: default +spec: + type: agent + agentRef: + name: codex-substrate-ci + prompt: "Run make test and summarize the result." + sessionRef: + name: substrate-demo + create: true + execution: + workspace: + enabled: true + provider: substrate + templateRef: + name: orka-codex-ci + namespace: ate-demo + reusePolicy: session + cleanupPolicy: retain +YAML + +kubectl --context "$ctx" -n default get task substrate-smoke -o yaml +``` + +Check the provider-neutral workspace lifecycle in +`status.executionWorkspace` (`phase`, `placement`, `density`, `resumeLatency`). +Status is intentionally sanitized — it must not expose actor IDs, snapshot URIs, +worker pod IPs, daemon URLs, or tokens. + +### CI parity + +`scripts/agent-substrate-e2e.sh` (the `Agent Substrate E2E` workflow) runs the +same path end-to-end and is secret-free. Run it directly when you want a clean, +self-contained validation with its own cluster lifecycle: + +```bash +PATH="$(go env GOPATH)/bin:$PATH" SUBSTRATE_E2E_EXTENDED=1 bash scripts/agent-substrate-e2e.sh +``` + +Set `KEEP_CLUSTER=1` to inspect the cluster after a failure. ## Guardrails diff --git a/.agents/skills/agent-substrate-deploy/references/validate.md b/.agents/skills/agent-substrate-deploy/references/validate.md index c9e48c6e4..a404efdd9 100644 --- a/.agents/skills/agent-substrate-deploy/references/validate.md +++ b/.agents/skills/agent-substrate-deploy/references/validate.md @@ -2,31 +2,12 @@ Validation steps for `$agent-substrate-deploy`. Read after the standard workflow completes. -> **Known gate (verified live 2026-06): valid enabled provider-based agent -> workspace requests are rejected during execution planning by the current -> service-backed harness runtime.** After workspace validation/resolution, a -> `provider: substrate` (or `agent-sandbox`) request fails with -> `status.executionWorkspace.reason=WorkspaceValidationFailed` and message -> `execution workspace is not supported by harness runtime yet`. The gate is in -> `internal/controller/agent_execution_plan.go` (`planAgentExecution`), not a -> misconfiguration — the agent CLI runtimes now -> run through the long-lived `agent-harness-wrapper` service, and the -> Task→workspace path for agents is not wired through it yet. The bundled e2e -> reflects this: it prints `Skipping agent Task execution-workspace checks: -> harness-wrapper runtime is service-backed`. The bundled e2e validates the -> **direct** Substrate path (actor create/resume/router/daemon exec/suspend/delete) -> plus Substrate-backed MCP tool create/reconcile/cleanup. It does not run a plain -> agent Task. After clearing the fake `CODEX_CLI_PATH` override in standard -> workflow step 4 (`Add the model proxy (vekil) — pause for the human`) of -> `../SKILL.md`, use a **plain** agent Task (no `execution.workspace`) to validate -> the harness + model proxy separately. Treat the Task YAML below as the intended -> workspace API once the harness wires workspaces; until then, validate the -> workspace provider via the e2e's direct-actor exercises. +> **Known gate:** Orka ACP RuntimeSessions do not yet map to Substrate Actors. The bundled E2E validates direct Actor create/resume/exec/suspend/delete plus Substrate-backed MCP lifecycle; a provider-backed agent Task remains expected-failure evidence. Validate plain Codex/Claude ACP Tasks separately with `scripts/live-acp-runtime-e2e.sh`. The installer leaves a fully wired cluster. During standup it smoke-tests direct actor create/resume/exec/suspend/delete and Substrate-backed MCP tool lifecycle. It does **not** currently smoke-test retained workspace reuse for Orka agent -Tasks because those execution-workspace checks are skipped by the harness gate. +Tasks because those execution-workspace checks are skipped by the ACP workspace-dispatch gate. If you skipped standard workflow step 3 (`Export kubeconfig for follow-up kubectl commands`) in `../SKILL.md`, do it before any diff --git a/.dockerignore b/.dockerignore index dc21294ef..ccf43037f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,9 +29,15 @@ # Re-include Go module files !go.mod !go.sum +!LICENSE +!NOTICE.md # Re-include script-local Go helpers used by live E2E Docker builds !scripts/ +!scripts/fixtures/ +!scripts/fixtures/security-scan-fake-acp/ +!scripts/fixtures/security-scan-fake-acp/*.go +scripts/fixtures/security-scan-fake-acp/*_test.go # Re-include UI source files for frontend build !ui/ @@ -55,3 +61,10 @@ ui/tsconfig.tsbuildinfo # Re-include pre-built UI assets for embed !internal/uiembed/dist/ !internal/uiembed/dist/** + +# Re-include the checksum-pinned Codex ACP source patch used by its image build +!workers/acp/images/codex/patch-agent-mode.mjs + +# Re-include pinned OpenCode runtime policy and notice inputs. +!workers/acp/images/opencode/AGENTS.md +!workers/acp/images/opencode/NOTICE.md diff --git a/.github/actions/free-disk-space/action.yml b/.github/actions/free-disk-space/action.yml new file mode 100644 index 000000000..cecf48632 --- /dev/null +++ b/.github/actions/free-disk-space/action.yml @@ -0,0 +1,28 @@ +name: Free disk space +description: >- + Remove large preinstalled toolchains and prune Docker caches to free disk + space for Docker-heavy E2E jobs on GitHub-hosted runners. +runs: + using: composite + steps: + - name: Free disk space + shell: bash + run: | + set -Eeuxo pipefail + echo "Disk usage before cleanup" + df -h + docker system df || true + + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo rm -rf /usr/share/swift + + docker system prune -af || true + docker builder prune -af || true + + echo "Disk usage after cleanup" + df -h + docker system df || true diff --git a/.github/actions/setup-kind/action.yml b/.github/actions/setup-kind/action.yml new file mode 100644 index 000000000..aec47dcd2 --- /dev/null +++ b/.github/actions/setup-kind/action.yml @@ -0,0 +1,46 @@ +name: Set up kind +description: >- + Install a pinned kind release for the runner architecture and always verify + the download against a pinned per-arch SHA256 before installing it. +inputs: + version: + description: kind release tag to install. + required: false + default: v0.31.0 + sha256-amd64: + description: Pinned SHA256 of kind-linux-amd64 for the requested version. + required: false + default: eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa + sha256-arm64: + description: Pinned SHA256 of kind-linux-arm64 for the requested version. + required: false + default: 8e1014e87c34901cc422a1445866835d1e666f2a61301c27e722bdeab5a1f7e4 +runs: + using: composite + steps: + - name: Install kind + shell: bash + env: + KIND_VERSION: ${{ inputs.version }} + KIND_SHA256_AMD64: ${{ inputs.sha256-amd64 }} + KIND_SHA256_ARM64: ${{ inputs.sha256-arm64 }} + run: | + set -Eeuo pipefail + arch="$(uname -m)" + case "${arch}" in + x86_64|amd64) arch=amd64; expected_sha="${KIND_SHA256_AMD64}" ;; + aarch64|arm64) arch=arm64; expected_sha="${KIND_SHA256_ARM64}" ;; + *) echo "unsupported runner architecture: ${arch}" >&2; exit 1 ;; + esac + if [[ -z "${expected_sha}" ]]; then + echo "missing pinned kind SHA256 for ${arch}" >&2 + exit 1 + fi + tmp_dir="$(mktemp -d)" + curl -fsSL --retry 3 --retry-delay 2 -o "${tmp_dir}/kind" \ + "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-${arch}" + printf '%s %s\n' "${expected_sha}" "${tmp_dir}/kind" | sha256sum --check --strict - + chmod +x "${tmp_dir}/kind" + sudo mv "${tmp_dir}/kind" /usr/local/bin/kind + rmdir "${tmp_dir}" + kind version diff --git a/.github/workflows/agent-substrate-e2e.yml b/.github/workflows/agent-substrate-e2e.yml index 6f6a5d710..596f576e4 100644 --- a/.github/workflows/agent-substrate-e2e.yml +++ b/.github/workflows/agent-substrate-e2e.yml @@ -17,6 +17,7 @@ on: - "internal/**" - "pkg/**" - "scripts/agent-substrate-e2e.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" - "workers/**" push: paths: @@ -33,6 +34,7 @@ on: - "internal/**" - "pkg/**" - "scripts/agent-substrate-e2e.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" - "workers/**" permissions: @@ -48,7 +50,6 @@ jobs: timeout-minutes: 90 env: KIND_CLUSTER: orka-agent-substrate-e2e - KIND_VERSION: v0.31.0 KO_VERSION: v0.18.1 SUBSTRATE_REF: b80031d260959b1fc5c6f61e3099fe2a6d368af1 SUBSTRATE_E2E_EXTENDED: "1" @@ -57,39 +58,24 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Free disk space for Docker-heavy E2E - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true + uses: ./.github/actions/free-disk-space - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: + # The upstream Substrate clone's go.mod requires a newer toolchain + # than this repository's go directive, and the E2E script runs with + # GOTOOLCHAIN=local, so this floor must track Substrate, not go.mod. go-version: "1.26.3" cache: true + - name: Install kind + uses: ./.github/actions/setup-kind + - name: Install prerequisites run: | sudo apt-get update sudo apt-get install -y jq - curl -Lo ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind echo "$(go env GOPATH)/bin" >> "${GITHUB_PATH}" go install "github.com/google/ko@${KO_VERSION}" diff --git a/.github/workflows/agentruntime-external-e2e.yml b/.github/workflows/agentruntime-external-e2e.yml deleted file mode 100644 index 3bc7e4e8b..000000000 --- a/.github/workflows/agentruntime-external-e2e.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: AgentRuntime External Endpoint E2E - -on: - workflow_dispatch: - push: - paths: - - ".dockerignore" - - ".github/workflows/agentruntime-external-e2e.yml" - - "api/**" - - "cmd/**" - - "config/**" - - "examples/harness/**" - - "internal/controller/**" - - "internal/harness/**" - - "test/e2e/**" - - "test/utils/**" - - "Makefile" - - "go.mod" - - "go.sum" - pull_request: - paths: - - ".dockerignore" - - ".github/workflows/agentruntime-external-e2e.yml" - - "api/**" - - "cmd/**" - - "config/**" - - "examples/harness/**" - - "internal/controller/**" - - "internal/harness/**" - - "test/e2e/**" - - "test/utils/**" - - "Makefile" - - "go.mod" - - "go.sum" - -permissions: - contents: read - -concurrency: - group: agentruntime-external-e2e-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - agentruntime-external-e2e: - name: AgentRuntime external endpoint - runs-on: ubuntu-latest - env: - E2E_AGENTRUNTIME_EXTERNAL: "true" - E2E_GO_TEST_TIMEOUT: 25m - KIND_CLUSTER: orka-agentruntime-external-e2e - KIND_VERSION: v0.31.0 - steps: - - name: Clone the code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Free disk space for Docker-heavy E2E - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true - - - name: Setup Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - - - name: Install kind - run: | - curl -fsSL --retry 3 --retry-delay 2 -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind - - - name: Verify kind installation - run: kind version - - - name: Run AgentRuntime external endpoint E2E - run: | - set -euxo pipefail - trap 'kind delete cluster --name "${KIND_CLUSTER}" || true' EXIT - make setup-test-e2e KIND_CLUSTER="${KIND_CLUSTER}" - go test -tags=e2e ./test/e2e/ \ - -timeout "${E2E_GO_TEST_TIMEOUT}" \ - -v \ - -ginkgo.v \ - -ginkgo.focus "AgentRuntime external endpoint" diff --git a/.github/workflows/approval-gate-e2e.yml b/.github/workflows/approval-gate-e2e.yml index e3d0e0491..eac3046da 100644 --- a/.github/workflows/approval-gate-e2e.yml +++ b/.github/workflows/approval-gate-e2e.yml @@ -17,6 +17,8 @@ on: - "internal/worker/**" - "workers/ai/**" - "workers/common/**" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" - "test/e2e/**" - "test/utils/**" - "config/**" @@ -36,6 +38,8 @@ on: - "internal/worker/**" - "workers/ai/**" - "workers/common/**" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" - "test/e2e/**" - "test/utils/**" - "config/**" @@ -53,33 +57,15 @@ jobs: runs-on: ubuntu-latest env: KIND_CLUSTER: orka-approval-gate-e2e - KIND_VERSION: v0.31.0 - E2E_GO_TEST_TIMEOUT: 20m + E2E_EPHEMERAL_CLUSTER: "true" + E2E_GO_TEST_TIMEOUT: 30m E2E_GINKGO_FOCUS: Human Approval Gate steps: - name: Clone the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Free disk space for Docker-heavy E2E - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true + uses: ./.github/actions/free-disk-space - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -87,10 +73,7 @@ jobs: go-version-file: go.mod - name: Install kind - run: | - curl -fsSL --retry 3 --retry-delay 2 -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation run: kind version diff --git a/.github/workflows/coexistence-smoke.yml b/.github/workflows/coexistence-smoke.yml new file mode 100644 index 000000000..d65e88ba8 --- /dev/null +++ b/.github/workflows/coexistence-smoke.yml @@ -0,0 +1,129 @@ +name: Static Harness Modes + +on: + push: + paths: + - ".github/workflows/coexistence-smoke.yml" + - "Makefile" + - "api/**" + - "cmd/main.go" + - "cmd/build/helmify/**" + - "cmd/orka-admission/**" + - "cmd/orka-agent-harness-wrapper/**" + - "config/harness-wrapper/**" + - "config/acp-production/**" + - "config/acp-workload/**" + - "config/orka-admission/**" + - "config/orka-admission-webhooks/**" + - "go.mod" + - "go.sum" + - "internal/admission/**" + - "internal/controller/agent_execution*" + - "internal/controller/acp_upgrade_drain*" + - "internal/controller/harness_v1*" + - "internal/executionmode/**" + - "internal/harness/**" + - "internal/store/**" + - "pkg/harness/**" + # The script jobs syntax-check every script and execute every + # scripts/tests/*-test.sh suite, so any script change is relevant. + - "scripts/**" + - "workers/harness/**" + pull_request: + paths: + - ".github/workflows/coexistence-smoke.yml" + - "Makefile" + - "api/**" + - "cmd/main.go" + - "cmd/build/helmify/**" + - "cmd/orka-admission/**" + - "cmd/orka-agent-harness-wrapper/**" + - "config/harness-wrapper/**" + - "config/acp-production/**" + - "config/acp-workload/**" + - "config/orka-admission/**" + - "config/orka-admission-webhooks/**" + - "go.mod" + - "go.sum" + - "internal/admission/**" + - "internal/controller/agent_execution*" + - "internal/controller/acp_upgrade_drain*" + - "internal/controller/harness_v1*" + - "internal/executionmode/**" + - "internal/harness/**" + - "internal/store/**" + - "pkg/harness/**" + # The script jobs syntax-check every script and execute every + # scripts/tests/*-test.sh suite, so any script change is relevant. + - "scripts/**" + - "workers/harness/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: static-harness-modes-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + focused-go: + name: Focused Go tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Create UI embed stub + run: make ensure-ui-embed + + - name: Run static-mode focused tests + run: | + go test \ + ./api/v1alpha1 \ + ./internal/admission \ + ./internal/controller \ + ./internal/executionmode \ + ./internal/harness/... \ + ./internal/store/... \ + ./workers/harness/... \ + ./cmd/orka-admission \ + ./cmd/orka-agent-harness-wrapper \ + -run '(ExecutionMode|ControllerMode|AgentContract|AgentRuntimeContract|TaskExecutionAuthority|HarnessV1|ACPUpgradeDrain|AgentExecutionSnapshotRetention|RecoverAmbiguousSubmission|RecoverActiveAttempt|DurableAdmission|DurableTurn|Ledger|SessionLineage|ReceiptPersistence|AdmissionClose)' \ + -count=1 + + shell-syntax: + name: Static-mode deployment scripts + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate scripts + run: | + set -Eeuo pipefail + for script in scripts/*.sh scripts/lib/*.sh scripts/tests/*.sh; do + bash -n "${script}" + done + + - name: Install kustomize + run: make kustomize + + - name: Run script test suites + run: | + set -Eeuo pipefail + for suite in scripts/tests/*-test.sh; do + echo "==> ${suite}" + KUSTOMIZE="$PWD/bin/kustomize" bash "${suite}" + done diff --git a/.github/workflows/gateway-e2e.yml b/.github/workflows/gateway-e2e.yml index 0081ff5ec..df2cb59e2 100644 --- a/.github/workflows/gateway-e2e.yml +++ b/.github/workflows/gateway-e2e.yml @@ -15,6 +15,8 @@ on: - "go.mod" - "go.sum" - "internal/**" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" - "test/e2e/**" - "test/utils/**" - "ui/**" @@ -32,6 +34,8 @@ on: - "go.mod" - "go.sum" - "internal/**" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" - "test/e2e/**" - "test/utils/**" - "ui/**" @@ -50,36 +54,16 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 env: - E2E_AGENTRUNTIME_EXTERNAL: "true" E2E_EPHEMERAL_CLUSTER: "true" E2E_GATEWAY: "true" E2E_GO_TEST_TIMEOUT: 30m KIND_CLUSTER: orka-gateway-e2e-${{ github.run_id }}-${{ github.run_attempt }} - KIND_VERSION: v0.31.0 steps: - name: Clone the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Free disk space for Docker-heavy E2E - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true + uses: ./.github/actions/free-disk-space - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -87,10 +71,7 @@ jobs: go-version-file: go.mod - name: Install kind - run: | - curl -fsSL --retry 3 --retry-delay 2 -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation run: kind version diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index 9dbf89c47..7d4642cb3 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -8,6 +8,7 @@ on: paths: - "api/**" - "cmd/build/helmify/**" + - "cmd/orka-agent-harness-wrapper/**" - "config/**" - "internal/controller/**" - "manifest_staging/**" @@ -16,12 +17,20 @@ on: - "go.mod" - "go.sum" - "Makefile" + - "workers/harness/**" - "scripts/apply-helm-crds.sh" + - "scripts/tests/release-manifest-test.sh" + - "scripts/static-mode-crd-validate.sh" + - "scripts/update-release-version.py" + - "scripts/validate-release-manifest.sh" + - ".github/workflows/release-pr.yml" + - ".github/workflows/release.yml" - ".github/workflows/helm-chart.yml" pull_request: paths: - "api/**" - "cmd/build/helmify/**" + - "cmd/orka-agent-harness-wrapper/**" - "config/**" - "internal/controller/**" - "manifest_staging/**" @@ -30,7 +39,14 @@ on: - "go.mod" - "go.sum" - "Makefile" + - "workers/harness/**" - "scripts/apply-helm-crds.sh" + - "scripts/tests/release-manifest-test.sh" + - "scripts/static-mode-crd-validate.sh" + - "scripts/update-release-version.py" + - "scripts/validate-release-manifest.sh" + - ".github/workflows/release-pr.yml" + - ".github/workflows/release.yml" - ".github/workflows/helm-chart.yml" workflow_dispatch: @@ -49,9 +65,9 @@ jobs: env: KIND_CLUSTER: orka-helm-crd-lifecycle KIND_IMAGE: kindest/node:v1.33.7@sha256:d26ef333bdb2cbe9862a0f7c3803ecc7b4303d8cea8e814b481b09949d353040 - KIND_SHA256: eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa - KIND_VERSION: v0.31.0 NAMESPACE: orka-helm-crd-lifecycle + SNAPSHOT_KEY: snapshot-key + SNAPSHOT_SECRET: agent-execution-snapshot-key PAUSE_IMAGE_REPOSITORY: registry.k8s.io/pause PAUSE_IMAGE_TAG: 3.10.2@sha256:f548e0e8e3dc1896ca956272154dde3314e8cc4fde0a57577ee9fa1c63f5baf4 steps: @@ -65,10 +81,19 @@ jobs: with: go-version-file: go.mod + - name: Run chart package comparison unit tests + run: | + set -euo pipefail + python3 -m pip install --quiet pytest + python3 -m pytest scripts/test_compare_helm_chart_packages.py -q + - name: Verify generated chart and package run: | set -euo pipefail + bash -n scripts/static-mode-crd-validate.sh + bash -n scripts/validate-release-manifest.sh scripts/tests/release-manifest-test.sh + bash scripts/tests/release-manifest-test.sh make manifests if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then echo "Generated staging manifests are stale." >&2 @@ -76,23 +101,20 @@ jobs: exit 1 fi - grep -Fq 'get secret harness-wrapper-auth' config/harness-wrapper/README.md - grep -Fq 'create secret generic harness-wrapper-auth' config/harness-wrapper/README.md - test "$(grep -c 'secretName: harness-wrapper-auth' manifest_staging/deploy/orka.yaml)" -eq 2 - if awk ' - /^---$/ { kind = ""; name = "" } - /^kind: / { kind = $2 } - /^ name: / { name = $2 } - kind == "Secret" && name == "harness-wrapper-auth" { found = 1 } - END { exit(found ? 0 : 1) } - ' manifest_staging/deploy/orka.yaml; then - echo "The raw installer must require a pre-created harness-wrapper-auth Secret, not embed one." >&2 + for secret in acp-artifact-capability workspace-publisher-auth provider-auth-proxy scm-egress-proxy-auth; do + grep -Fq "${secret}" config/acp-workload/README.md + test "$(grep -c "secretName: ${secret}" manifest_staging/deploy/orka.yaml)" -ge 1 + done + if grep -Fq 'harness-wrapper' manifest_staging/deploy/orka.yaml; then + echo "The default ACP Kustomize installer must not enable the opt-in harness v1 wrapper." >&2 + exit 1 + fi + if grep -q '^kind: Secret$' manifest_staging/deploy/orka.yaml; then + echo "The raw installer must require pre-created ACP Secrets, not embed credentials." >&2 exit 1 fi go test ./cmd/build/helmify - helm lint cmd/build/helmify/static - helm lint manifest_staging/charts/orka default_render=$(mktemp) secondary_render=$(mktemp) @@ -100,52 +122,97 @@ jobs: secondary_serviceaccounts=$(mktemp) secondary_gateway_policy=$(mktemp) long_render=$(mktemp) - long_wrapper_service=$(mktemp) - long_wrapper_secret=$(mktemp) - trap 'rm -f "${default_render}" "${secondary_render}" "${secondary_rbac}" "${secondary_serviceaccounts}" "${secondary_gateway_policy}" "${long_render}" "${long_wrapper_service}" "${long_wrapper_secret}"' EXIT - helm template orka manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - > "${default_render}" - helm template secondary manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - > "${secondary_render}" - helm template secondary manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - --show-only templates/rbac.yaml \ - > "${secondary_rbac}" - helm template secondary manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - --show-only templates/serviceaccount.yaml \ - > "${secondary_serviceaccounts}" - helm template secondary manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - --show-only templates/gateway-task-admission-policy.yaml \ - > "${secondary_gateway_policy}" + long_publisher_service=$(mktemp) + long_publisher_secret=$(mktemp) + long_provider_service=$(mktemp) + long_provider_secret=$(mktemp) + long_scm_service=$(mktemp) + long_scm_secret=$(mktemp) + harness_v1_render=$(mktemp) + harness_v1_deployment=$(mktemp) + harness_v1_network_policy=$(mktemp) + harness_v1_kustomize=$(mktemp) + unsafe_render=$(mktemp) + trap 'rm -f "${default_render}" "${secondary_render}" "${secondary_rbac}" "${secondary_serviceaccounts}" "${secondary_gateway_policy}" "${long_render}" "${long_publisher_service}" "${long_publisher_secret}" "${long_provider_service}" "${long_provider_secret}" "${long_scm_service}" "${long_scm_secret}" "${harness_v1_render}" "${harness_v1_deployment}" "${harness_v1_network_policy}" "${harness_v1_kustomize}" "${unsafe_render}"' EXIT + controller_digest="sha256:$(printf '0%.0s' {1..64})" + snapshot_args=( + --set-string controller.mode=harness-v2 + --set-string "controller.watchNamespace=${NAMESPACE}" + --set-string "controller.image.digest=${controller_digest}" + --set-string "controller.agentExecutionSnapshot.existingSecret=${SNAPSHOT_SECRET}" + --set-string "controller.agentExecutionSnapshot.key=${SNAPSHOT_KEY}" + --set-string webhooks.tls.existingSecret=controller-webhook-tls + --set-string webhooks.caBundle=Y2E= + --set-string "publisher.image.digest=${controller_digest}" + --set providerProxy.enabled=true + ) + helm lint cmd/build/helmify/static --namespace "${NAMESPACE}" "${snapshot_args[@]}" + helm lint manifest_staging/charts/orka --namespace "${NAMESPACE}" "${snapshot_args[@]}" + helm template orka manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" > "${default_render}" + helm template secondary manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" > "${secondary_render}" + helm template secondary manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/rbac.yaml > "${secondary_rbac}" + helm template secondary manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/serviceaccount.yaml > "${secondary_serviceaccounts}" + helm template secondary manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/gateway-task-admission-policy.yaml > "${secondary_gateway_policy}" long_release=$(printf 'r%.0s' {1..53}) - helm template "${long_release}" manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - > "${long_render}" - helm template "${long_release}" manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - --show-only templates/harness-wrapper-service.yaml \ - > "${long_wrapper_service}" - helm template "${long_release}" manifest_staging/charts/orka \ - --namespace "${NAMESPACE}" \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - --show-only templates/harness-wrapper-secret.yaml \ - > "${long_wrapper_secret}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" > "${long_render}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/publisher-service.yaml > "${long_publisher_service}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/publisher-secret.yaml > "${long_publisher_secret}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --set providerProxy.enabled=true --show-only templates/provider-proxy-service.yaml > "${long_provider_service}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --set providerProxy.enabled=true --show-only templates/provider-proxy-secret.yaml > "${long_provider_secret}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/scm-egress-proxy-service.yaml > "${long_scm_service}" + helm template "${long_release}" manifest_staging/charts/orka "${snapshot_args[@]}" --namespace "${NAMESPACE}" --show-only templates/scm-egress-proxy-secret.yaml > "${long_scm_secret}" + + harness_v1_digest="sha256:$(printf '1%.0s' {1..64})" + harness_v1_args=( + "${snapshot_args[@]}" + --namespace "${NAMESPACE}" + --set-string controller.mode=harness-v1 + --set providerProxy.enabled=false + --set-string "harnessV1.image.digest=${harness_v1_digest}" + --set-string harnessV1.auth.existingSecret=harness-wrapper-auth + --set-string harnessV1.tls.existingSecret=harness-wrapper-tls + ) + helm template harness-v1 manifest_staging/charts/orka "${harness_v1_args[@]}" > "${harness_v1_render}" + helm template harness-v1 manifest_staging/charts/orka "${harness_v1_args[@]}" --show-only templates/harness-wrapper-deployment.yaml > "${harness_v1_deployment}" + helm template harness-v1 manifest_staging/charts/orka "${harness_v1_args[@]}" --show-only templates/harness-wrapper-networkpolicy.yaml > "${harness_v1_network_policy}" + "$(pwd)/bin/kustomize" build config/harness-wrapper > "${harness_v1_kustomize}" grep -Fq -- "--controller-url=http://orka.${NAMESPACE}.svc:8080" "${default_render}" grep -Fq -- "--controller-url=http://secondary-orka.${NAMESPACE}.svc:8080" "${secondary_render}" grep -Fq -- "--ai-worker-service-account-name=orka-ai-worker" "${default_render}" + if ! grep -Fq -- "namespace: vekil-system" "${default_render}"; then + echo "Harness v2 must render the fixed Vekil ingress boundary for its required provider proxy." >&2 + exit 1 + fi + if grep -Fq -- "agent-harness-wrapper" "${default_render}"; then + echo "Default Helm values must keep harness v1 disabled." >&2 + exit 1 + fi + if helm template unsafe manifest_staging/charts/orka "${harness_v1_args[@]}" --set-string harnessV1.image.digest= >"${unsafe_render}" 2>&1; then + echo "Harness v1 must reject an enabled render without an immutable image digest." >&2 + exit 1 + fi + grep -Fq -- "harnessV1.image.digest must be a sha256 digest when controller.mode=harness-v1" "${unsafe_render}" + for kind in Deployment Service ServiceAccount PersistentVolumeClaim NetworkPolicy; do + grep -Fq -- "kind: ${kind}" "${harness_v1_render}" + done + grep -Fq -- "agent-harness-wrapper@${harness_v1_digest}" "${harness_v1_deployment}" + grep -Fq -- "type: Recreate" "${harness_v1_deployment}" + grep -Fq -- "automountServiceAccountToken: false" "${harness_v1_deployment}" + grep -Fq -- "ORKA_HARNESS_WRAPPER_ADMISSION_LEDGER_PATH" "${harness_v1_deployment}" + grep -Fq -- "/var/lib/orka/harness-v1/admission-ledger.db" "${harness_v1_deployment}" + if grep -Eq -- 'ORKA_SA_TOKEN_PATH|upload-token|GIT_TOKEN|GITHUB_TOKEN|ORKA_WORKSPACE_PUBLISHER|provider-auth' "${harness_v1_deployment}"; then + echo "Harness v1 received an ambient Git, publication, or provider credential surface." >&2 + exit 1 + fi + grep -Fq -- "app.kubernetes.io/component: controller" "${harness_v1_network_policy}" + grep -Fq -- "kubernetes.io/metadata.name: kube-system" "${harness_v1_network_policy}" + grep -Fq -- "cidr: 0.0.0.0/0" "${harness_v1_network_policy}" + grep -Fq -- "port: 443" "${harness_v1_network_policy}" + grep -Fq -- "agent-harness-wrapper@sha256:0000000000000000000000000000000000000000000000000000000000000000" "${harness_v1_kustomize}" + grep -Fq -- "ORKA_HARNESS_WRAPPER_ADMISSION_LEDGER_PATH" "${harness_v1_kustomize}" + grep -Fq -- "kind: NetworkPolicy" "${harness_v1_kustomize}" for tier in ai vendor container; do account="secondary-orka-${tier}-worker" grep -Fq -- "--${tier}-worker-service-account-name=${account}" "${secondary_render}" @@ -153,26 +220,60 @@ jobs: grep -Fq -- "name: ${account}" "${secondary_rbac}" grep -Fq -- ":${account}'" "${secondary_gateway_policy}" done - wrapper_name=$(awk '$1 == "name:" { print $2; exit }' "${long_wrapper_service}") - wrapper_secret_name=$(awk '$1 == "name:" { print $2; exit }' "${long_wrapper_secret}") - [[ -n "${wrapper_name}" && ${#wrapper_name} -le 63 ]] - [[ -n "${wrapper_secret_name}" && ${#wrapper_secret_name} -le 63 ]] - grep -Fq -- "http://${wrapper_name}:8080" "${long_render}" - grep -Fq -- "serviceAccountName: ${wrapper_name}" "${long_render}" - grep -Fq -- "secretName: ${wrapper_secret_name}" "${long_render}" + for rendered in "${long_publisher_service}" "${long_publisher_secret}" "${long_provider_service}" "${long_provider_secret}" "${long_scm_service}" "${long_scm_secret}"; do + resource_name=$(awk '$1 == "name:" { print $2; exit }' "${rendered}") + [[ -n "${resource_name}" && ${#resource_name} -le 63 ]] + done + publisher_name=$(awk '$1 == "name:" { print $2; exit }' "${long_publisher_service}") + scm_name=$(awk '$1 == "name:" { print $2; exit }' "${long_scm_service}") + grep -Fq -- "http://${publisher_name}:8080" "${long_render}" + grep -Fq -- "@${scm_name}.${NAMESPACE}.svc:8080" "${long_render}" chart_version=$(awk '$1 == "version:" { print $2; exit }' manifest_staging/charts/orka/Chart.yaml) grep -Fq -- "--general-worker-image=ghcr.io/orka-agents/orka/general-worker:${chart_version}" "${secondary_render}" - for resource in agentruntimes substrateactorpools; do + for resource in agentruntimes branchclaims controllerepochs externaleffects promptattempts publications runtimepools runtimesessioncontrols substrateactorpools; do grep -Eq "resources: \\[.*\"${resource}\"" "${secondary_rbac}" grep -Eq "resources: \\[.*\"${resource}/status\"" "${secondary_rbac}" - grep -Eq "resources: \\[.*\"${resource}/finalizers\"" "${secondary_rbac}" done - test "$(grep -Fc 'resources: ["serviceaccounts/token"]' manifest_staging/charts/orka/templates/rbac.yaml)" -eq 1 + if ! ruby -ryaml -e ' + rendered_rbac, expected_name, expected_namespace = ARGV + token_request_grants = [] + YAML.load_stream(File.read(rendered_rbac)).each do |document| + next unless document.is_a?(Hash) + + Array(document["rules"]).each do |rule| + api_groups = Array(rule["apiGroups"]) + resources = Array(rule["resources"]) + verbs = Array(rule["verbs"]) + next unless api_groups.include?("") || api_groups.include?("*") + next unless resources.include?("serviceaccounts/token") || resources.include?("*") + next unless verbs.include?("create") || verbs.include?("*") + + token_request_grants << [document, rule] + end + end + valid = token_request_grants.length == 1 && + token_request_grants[0][0]["kind"] == "Role" && + token_request_grants[0][0].dig("metadata", "name") == expected_name && + token_request_grants[0][0].dig("metadata", "namespace") == expected_namespace && + Array(token_request_grants[0][1]["apiGroups"]) == [""] && + Array(token_request_grants[0][1]["verbs"]) == ["create"] + exit(valid ? 0 : 1) + ' "${secondary_rbac}" secondary-orka-controller "${NAMESPACE}"; then + echo "Harness v2 must render exactly one namespaced, create-only controller TokenRequest rule." >&2 + exit 1 + fi if grep -Fq 'ai-worker-tokenrequest' manifest_staging/charts/orka/templates/rbac.yaml; then echo "AI workers must not receive an unrestricted TokenRequest ClusterRole." >&2 exit 1 fi + for template in deployment service serviceaccount pvc networkpolicy; do + test -f "manifest_staging/charts/orka/templates/harness-wrapper-${template}.yaml" + done + if test -f "manifest_staging/charts/orka/templates/harness-wrapper-secret.yaml"; then + echo "Harness v1 credentials must come from an operator-created Secret, not a chart template." >&2 + exit 1 + fi mkdir -p cr-release-packages helm package manifest_staging/charts/orka --destination cr-release-packages @@ -182,17 +283,10 @@ jobs: echo "Expected exactly one packaged Orka chart, found ${#chart_packages[@]}." >&2 exit 1 fi - test "$(helm show crds "${chart_packages[0]}" | grep -c '^kind: CustomResourceDefinition$')" -eq 19 + test "$(helm show crds "${chart_packages[0]}" | grep -c '^kind: CustomResourceDefinition$')" -eq 26 - name: Install kind - run: | - set -euo pipefail - - curl -fsSL --retry 3 --retry-delay 2 -o ./kind \ - "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" - echo "${KIND_SHA256} ./kind" | sha256sum --check --strict - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify install, apply, and uninstall CRD behavior run: | @@ -207,6 +301,7 @@ jobs: --name "${KIND_CLUSTER}" \ --image "${KIND_IMAGE}" \ --wait 90s + kubectl create namespace vekil-system shopt -s nullglob chart_packages=(cr-release-packages/orka-*.tgz) @@ -216,19 +311,26 @@ jobs: expected_crds=$(cat <<'CRDS' agentruntimes.core.orka.ai agents.core.orka.ai + branchclaims.core.orka.ai + controllerepochs.core.orka.ai executionworkspaceclasses.workspace.orka.ai executionworkspacepools.workspace.orka.ai executionworkspaceproviders.workspace.orka.ai executionworkspaces.workspace.orka.ai + externaleffects.core.orka.ai fakepoolparameters.fake.workspace.orka.ai fakeproviderconfigs.fake.workspace.orka.ai gatewaybindings.gateway.orka.ai gatewayclasses.gateway.orka.ai gateways.gateway.orka.ai outboundaccesspolicies.core.orka.ai + promptattempts.core.orka.ai providers.core.orka.ai + publications.core.orka.ai repositorymonitors.core.orka.ai repositoryscans.core.orka.ai + runtimepools.core.orka.ai + runtimesessioncontrols.core.orka.ai skills.core.orka.ai substrateactorpools.core.orka.ai tasks.core.orka.ai @@ -257,21 +359,30 @@ jobs: done <<< "${expected_crds}" } + pause_digest=${PAUSE_IMAGE_TAG#*@} helm install orka "${chart_package}" \ --namespace "${NAMESPACE}" \ --create-namespace \ - --set controller.replicas=0 \ + --set-string controller.mode=harness-v2 \ + --set-string "controller.watchNamespace=${NAMESPACE}" \ + --set-string "controller.acpRuntime.namespace=${NAMESPACE}-runtimes" \ + --set-string "controller.agentExecutionSnapshot.existingSecret=${SNAPSHOT_SECRET}" \ + --set-string "controller.agentExecutionSnapshot.key=${SNAPSHOT_KEY}" \ --set-string controller.image.repository="${PAUSE_IMAGE_REPOSITORY}" \ --set-string controller.image.tag="${PAUSE_IMAGE_TAG}" \ + --set-string controller.image.digest="${pause_digest}" \ --set controller.image.pullPolicy=IfNotPresent \ + --set-string webhooks.tls.existingSecret=controller-webhook-tls \ + --set-string webhooks.caBundle=Y2E= \ --set-string workers.ai.image.repository="${PAUSE_IMAGE_REPOSITORY}" \ --set-string workers.ai.image.tag="${PAUSE_IMAGE_TAG}" \ - --set-string workers.harnessWrapper.image.repository="${PAUSE_IMAGE_REPOSITORY}" \ - --set-string workers.harnessWrapper.image.tag="${PAUSE_IMAGE_TAG}" \ - --set workers.harnessWrapper.image.pullPolicy=IfNotPresent \ - --set-string workers.harnessWrapper.auth.token=mock-token + --set-string publisher.image.repository="${PAUSE_IMAGE_REPOSITORY}" \ + --set-string publisher.image.digest="${pause_digest}" \ + --set publisher.enabled=true \ + --set providerProxy.enabled=true assert_all_orka_crds "after helm install" + NAMESPACE="${NAMESPACE}-static" scripts/static-mode-crd-validate.sh kubectl patch crd tasks.core.orka.ai --type=json -p='[ {"op":"add","path":"/spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/staleReviewField","value":{"type":"string"}} diff --git a/.github/workflows/live-acp-release-gate.yml b/.github/workflows/live-acp-release-gate.yml new file mode 100644 index 000000000..88b895f86 --- /dev/null +++ b/.github/workflows/live-acp-release-gate.yml @@ -0,0 +1,150 @@ +name: Live ACP Release Gate + +on: + workflow_dispatch: + inputs: + source_repository: + description: HTTPS GitHub source repository URL used for the release canary + required: true + default: https://github.com/orka-agents/orka.git + type: string + publication_repository: + description: HTTPS URL of a distinct GitHub fork used for publication + required: true + type: string + source_ref: + description: Full SHA of the dispatched workflow commit; must equal the selected base branch head + required: true + type: string + pr_base: + description: Default branch targeted by the temporary canary PR + required: true + default: main + type: string + +permissions: + contents: read + +concurrency: + group: live-acp-release-gate + cancel-in-progress: false + +jobs: + live-acp-release-gate: + name: ACP destructive release acceptance + runs-on: ubuntu-latest + timeout-minutes: 240 + environment: live-acp-release-gate + env: + ACP_E2E_OPENCODE_CONTEXT_WINDOW: "32768" + ACP_E2E_OPENCODE_MAX_TOKENS: "4096" + ACP_E2E_OPENCODE_MODEL: "openai/gpt-5.4" + ACP_E2E_KIND_TAG: acp-release-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Validate trusted dispatch inputs + env: + CHECKED_OUT_SHA: ${{ github.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PR_BASE: ${{ inputs.pr_base }} + PUBLICATION_REPOSITORY: ${{ inputs.publication_repository }} + SOURCE_REF: ${{ inputs.source_ref }} + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + run: | + set -Eeuo pipefail + if [[ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "Dispatch this workflow from the default branch (${DEFAULT_BRANCH}) only." >&2 + exit 1 + fi + if [[ "${PR_BASE}" != "${DEFAULT_BRANCH}" ]]; then + echo "pr_base must equal the default branch (${DEFAULT_BRANCH})." >&2 + exit 1 + fi + if [[ ! "${SOURCE_REPOSITORY}" =~ ^https://github\.com/[^/?#]+/[^/?#]+(\.git)?$ ]]; then + echo "source_repository must be an HTTPS github.com repository URL." >&2 + exit 1 + fi + expected_source="https://github.com/${GITHUB_REPOSITORY}" + normalized_source="${SOURCE_REPOSITORY%.git}" + if [[ "${normalized_source,,}" != "${expected_source,,}" ]]; then + echo "source_repository must identify this repository: ${expected_source}.git" >&2 + exit 1 + fi + if [[ ! "${PUBLICATION_REPOSITORY}" =~ ^https://github\.com/[^/?#]+/[^/?#]+(\.git)?$ ]]; then + echo "publication_repository must be an HTTPS github.com repository URL." >&2 + exit 1 + fi + normalized_publication="${PUBLICATION_REPOSITORY%.git}" + if [[ "${normalized_source,,}" == "${normalized_publication,,}" ]]; then + echo "publication_repository must be a distinct fork." >&2 + exit 1 + fi + if [[ ! "${SOURCE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "source_ref must be a full 40-character commit SHA." >&2 + exit 1 + fi + if [[ "${SOURCE_REF,,}" != "${CHECKED_OUT_SHA,,}" ]]; then + echo "source_ref must equal the dispatched workflow commit ${CHECKED_OUT_SHA}." >&2 + exit 1 + fi + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Free disk space for Docker-heavy live E2E + uses: ./.github/actions/free-disk-space + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + + - name: Install kind + uses: ./.github/actions/setup-kind + + - name: Verify toolchain + run: | + set -Eeuo pipefail + docker version + git --version + gh --version + jq --version + kind version + kubectl version --client=true + + - name: Run destructive canonical ACP release gate in Kind + env: + ACP_E2E_REF: ${{ inputs.source_ref }} + ACP_E2E_REPO: ${{ inputs.source_repository }} + ACP_E2E_WRITE_CREATE_PR: "1" + ACP_E2E_WRITE_CREDENTIAL_TOKEN: ${{ secrets.ACP_E2E_WRITE_CREDENTIAL_TOKEN }} + ACP_E2E_WRITE_FORGE_CREDENTIAL_TOKEN: ${{ secrets.ACP_E2E_WRITE_FORGE_CREDENTIAL_TOKEN }} + ACP_E2E_WRITE_PR_BASE: ${{ inputs.pr_base }} + ACP_E2E_WRITE_PUBLICATION_REPO: ${{ inputs.publication_repository }} + ACP_E2E_WRITE_READ_CREDENTIAL_TOKEN: ${{ secrets.ACP_E2E_WRITE_READ_CREDENTIAL_TOKEN }} + ACP_E2E_WRITE_SOURCE_REF: ${{ inputs.source_ref }} + ACP_E2E_WRITE_SOURCE_REPO: ${{ inputs.source_repository }} + ACP_E2E_WRITE_TARGET_READ_CREDENTIAL_TOKEN: ${{ secrets.ACP_E2E_WRITE_TARGET_READ_CREDENTIAL_TOKEN }} + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.ACP_E2E_WRITE_FORGE_CREDENTIAL_TOKEN }} + RELEASE_GATE: "1" + run: | + set -Eeuo pipefail + + required_secrets=( + COPILOT_GITHUB_TOKEN + ACP_E2E_WRITE_READ_CREDENTIAL_TOKEN + ACP_E2E_WRITE_TARGET_READ_CREDENTIAL_TOKEN + ACP_E2E_WRITE_CREDENTIAL_TOKEN + ACP_E2E_WRITE_FORGE_CREDENTIAL_TOKEN + ) + for name in "${required_secrets[@]}"; do + if [[ -z "${!name}" ]]; then + echo "${name} must be configured in the live-acp-release-gate environment." >&2 + exit 1 + fi + done + + bash scripts/live-acp-runtime-kind-e2e.sh diff --git a/.github/workflows/live-acp-runtime-e2e.yml b/.github/workflows/live-acp-runtime-e2e.yml new file mode 100644 index 000000000..e8fa09fab --- /dev/null +++ b/.github/workflows/live-acp-runtime-e2e.yml @@ -0,0 +1,102 @@ +name: Live ACP Runtime E2E + +on: + workflow_dispatch: + schedule: + - cron: "23 8 * * *" + push: + branches: + - main + paths: + - ".dockerignore" + - ".github/workflows/live-acp-runtime-e2e.yml" + - "Dockerfile" + - "Makefile" + - ".agents/skills/kindctl/**" + - ".agents/skills/vekil-reverse-proxy-deploy/**" + - "api/**" + - "cmd/**" + - "config/**" + - "go.mod" + - "go.sum" + - "internal/**" + - "pkg/**" + - "scripts/apply-acp-production.sh" + - "scripts/check-acp-crd-cutover.sh" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" + - "scripts/lib/kind-local-registry.sh" + - "scripts/lib/live-acp-runtime-kind-bootstrap.sh" + - "scripts/live-acp-runtime-e2e.sh" + - "scripts/live-acp-runtime-kind-e2e.sh" + - "scripts/render-acp-runtime-images.sh" + - "scripts/render-worker-images.sh" + - "workers/**" + +permissions: + contents: read + +concurrency: + group: live-acp-runtime-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + live-acp-runtime-e2e: + name: ACP provider smoke + runs-on: ubuntu-latest + timeout-minutes: 180 + environment: live-acp-runtime-smoke + env: + ACP_E2E_OPENCODE_CONTEXT_WINDOW: "32768" + ACP_E2E_OPENCODE_MAX_TOKENS: "4096" + ACP_E2E_OPENCODE_MODEL: "openai/gpt-5.4" + ACP_E2E_REQUIRE_PARALLEL: "1" + ACP_E2E_KIND_TAG: live-acp-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Validate trusted workflow ref + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -Eeuo pipefail + if [[ "${GITHUB_REF}" != "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "Run this secret-backed workflow from the default branch (${DEFAULT_BRANCH}) only." >&2 + exit 1 + fi + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Free disk space for Docker-heavy live E2E + uses: ./.github/actions/free-disk-space + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + + - name: Install kind + uses: ./.github/actions/setup-kind + + - name: Verify toolchain + run: | + set -Eeuo pipefail + docker version + git --version + gh --version + jq --version + kind version + kubectl version --client=true + + - name: Run canonical ACP smoke in Kind + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + run: | + set -Eeuo pipefail + if [[ -z "${COPILOT_GITHUB_TOKEN}" ]]; then + echo "COPILOT_GITHUB_TOKEN must be configured in the live-acp-runtime-smoke environment." >&2 + exit 1 + fi + bash scripts/live-acp-runtime-kind-e2e.sh diff --git a/.github/workflows/live-agent-sandbox-e2e.yml b/.github/workflows/live-agent-sandbox-e2e.yml index 6c16ea088..4e7a044b7 100644 --- a/.github/workflows/live-agent-sandbox-e2e.yml +++ b/.github/workflows/live-agent-sandbox-e2e.yml @@ -25,31 +25,12 @@ jobs: timeout-minutes: 90 env: KIND_CLUSTER: orka-live-agent-sandbox-e2e - KIND_VERSION: v0.31.0 steps: - name: Clone Orka uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Free disk space for Docker-heavy live E2E - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true + uses: ./.github/actions/free-disk-space - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -57,10 +38,7 @@ jobs: go-version-file: go.mod - name: Install kind - run: | - curl -fsSL -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation run: kind version diff --git a/.github/workflows/live-copilot-proxy-e2e.yml b/.github/workflows/live-copilot-proxy-e2e.yml index 3c2f8ddfd..3e78af0eb 100644 --- a/.github/workflows/live-copilot-proxy-e2e.yml +++ b/.github/workflows/live-copilot-proxy-e2e.yml @@ -26,7 +26,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} KIND_CLUSTER: orka-live-copilot-proxy-e2e - COPILOT_PROXY_IMAGE: ghcr.io/sozercan/vekil:v0.14.0@sha256:9e6ab58b9c27888db34d76422c3520b3bf103742a058572439a1fe0aa35a2ade + COPILOT_PROXY_IMAGE: ghcr.io/sozercan/vekil:v0.14.1@sha256:2fa0558f6304cc6ed1fb5b0135f62f12f28f1cdd0a8c057c4283414bceac1362 steps: - name: Decide whether live e2e can run id: gate @@ -63,25 +63,7 @@ jobs: - name: Free disk space for Docker-heavy live E2E if: steps.gate.outputs.run == 'true' - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true + uses: ./.github/actions/free-disk-space - name: Setup Go if: steps.gate.outputs.run == 'true' @@ -91,10 +73,7 @@ jobs: - name: Install kind if: steps.gate.outputs.run == 'true' - run: | - curl -fsSL -o ./kind "https://kind.sigs.k8s.io/dl/latest/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation if: steps.gate.outputs.run == 'true' diff --git a/.github/workflows/live-github-label-trigger-e2e.yml b/.github/workflows/live-github-label-trigger-e2e.yml index bd4ce5a38..0b95c0c11 100644 --- a/.github/workflows/live-github-label-trigger-e2e.yml +++ b/.github/workflows/live-github-label-trigger-e2e.yml @@ -28,7 +28,6 @@ jobs: timeout-minutes: 75 env: KIND_CLUSTER: orka-live-github-label-trigger-e2e - KIND_VERSION: v0.31.0 GITHUB_LABEL_TRIGGER_TARGET_REPO_URL: ${{ inputs.target_repo_url }} GITHUB_LABEL_TRIGGER_TARGET_NUMBER: ${{ inputs.target_number }} steps: @@ -51,10 +50,7 @@ jobs: -v - name: Install kind - run: | - curl -fsSL -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation run: kind version diff --git a/.github/workflows/live-github-oidc-e2e.yml b/.github/workflows/live-github-oidc-e2e.yml index 5161f4525..3b8379cdd 100644 --- a/.github/workflows/live-github-oidc-e2e.yml +++ b/.github/workflows/live-github-oidc-e2e.yml @@ -24,9 +24,6 @@ jobs: env: KIND_CLUSTER: orka-live-github-oidc-e2e ORKA_GITHUB_OIDC_AUDIENCE: orka-live-github-oidc-e2e - KIND_VERSION: v0.31.0 - KIND_SHA256_AMD64: eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa - KIND_SHA256_ARM64: 8e1014e87c34901cc422a1445866835d1e666f2a61301c27e722bdeab5a1f7e4 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -35,17 +32,7 @@ jobs: with: go-version-file: go.mod - name: Install kind - run: | - arch="$(go env GOARCH)" - case "${arch}" in - amd64) expected_sha="${KIND_SHA256_AMD64}" ;; - arm64) expected_sha="${KIND_SHA256_ARM64}" ;; - *) echo "unsupported runner architecture: ${arch}" >&2; exit 1 ;; - esac - curl -fsSL -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-${arch}" - printf '%s %s\n' "${expected_sha}" ./kind | sha256sum --check --strict - - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation run: kind version - run: bash scripts/live-github-oidc-e2e.sh diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 38a9099f8..6178aa3b0 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -98,9 +98,7 @@ jobs: run: | set -euo pipefail - helm lint cmd/build/helmify/static - helm lint charts/orka - test "$(helm show crds charts/orka | grep -c '^kind: CustomResourceDefinition$')" -eq 19 + make verify-release-manifest NEWVERSION="${NEWVERSION}" git diff --check - name: Create release pull request diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22babf2b7..0bef9748b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,82 +31,7 @@ jobs: persist-credentials: false - name: Validate tag and promoted chart - run: | - set -euo pipefail - - tag=${GITHUB_REF_NAME} - if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then - echo "Release tag must match vX.Y.Z, vX.Y.Z-beta.N, or vX.Y.Z-rc.N; got ${tag}." >&2 - exit 1 - fi - - chart_field() { - local field=$1 - local value - value=$(awk -v key="${field}:" '$1 == key { print $2; exit }' charts/orka/Chart.yaml) - value=${value#\"} - value=${value%\"} - value=${value#\'} - value=${value%\'} - if [[ -z "${value}" ]]; then - echo "Could not read ${field} from charts/orka/Chart.yaml." >&2 - return 1 - fi - printf '%s\n' "${value}" - } - - expected_version=${tag#v} - chart_version=$(chart_field version) - app_version=$(chart_field appVersion) - if [[ "${chart_version}" != "${expected_version}" ]]; then - echo "Tag ${tag} requires charts/orka version ${expected_version}; found ${chart_version}." >&2 - exit 1 - fi - if [[ "${app_version}" != "${tag}" ]]; then - echo "Tag ${tag} requires charts/orka appVersion ${tag}; found ${app_version}." >&2 - exit 1 - fi - - diff --no-dereference --recursive --unified manifest_staging/charts/orka charts/orka - diff --no-dereference --recursive --unified manifest_staging/deploy deploy - helm lint charts/orka - test "$(helm show crds charts/orka | grep -c '^kind: CustomResourceDefinition$')" -eq 19 - - rendered_chart=$(mktemp) - trap 'rm -f "${rendered_chart}"' EXIT - helm template release-validation charts/orka \ - --namespace orka-system \ - --set-string workers.harnessWrapper.auth.token=mock-token \ - > "${rendered_chart}" - - runtime_images=() - while IFS= read -r image_line; do - image_ref=${image_line#*image: } - image_ref=${image_ref#\"} - image_ref=${image_ref%\"} - runtime_images+=("${image_ref}") - done < <(grep -E '^[[:space:]]+image:[[:space:]]+' "${rendered_chart}") - if [[ ${#runtime_images[@]} -ne 2 ]]; then - echo "Expected two rendered runtime images; found ${#runtime_images[@]}." >&2 - exit 1 - fi - for image_ref in "${runtime_images[@]}"; do - if [[ "${image_ref##*:}" != "${expected_version}" ]]; then - echo "Tag ${tag} requires runtime image ${image_ref} to use ${expected_version}." >&2 - exit 1 - fi - done - - for worker in ai general; do - worker_refs=() - while IFS= read -r worker_line; do - worker_refs+=("${worker_line#*=}") - done < <(grep -E "^[[:space:]]+- --${worker}-worker-image=" "${rendered_chart}") - if [[ ${#worker_refs[@]} -ne 1 || "${worker_refs[0]##*:}" != "${expected_version}" ]]; then - echo "Tag ${tag} requires exactly one rendered ${worker} worker image using ${expected_version}." >&2 - exit 1 - fi - done + run: scripts/validate-release-manifest.sh "${GITHUB_REF_NAME}" - name: Package promoted chart run: | @@ -121,8 +46,9 @@ jobs: echo "Expected exactly one packaged Orka chart, found ${#chart_packages[@]}." >&2 exit 1 fi - if [[ $(helm show crds "${chart_packages[0]}" | grep -c '^kind: CustomResourceDefinition$') -ne 19 ]]; then - echo "Packaged Orka chart must contain exactly 19 CRDs." >&2 + expected_crds=$(find config/crd/bases -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ') + if [[ $(helm show crds "${chart_packages[0]}" | grep -c '^kind: CustomResourceDefinition$') -ne ${expected_crds} ]]; then + echo "Packaged Orka chart CRD count does not match config/crd/bases (${expected_crds})." >&2 exit 1 fi @@ -204,6 +130,8 @@ jobs: contents: read packages: write strategy: + # Tagged application releases publish the default harness-v2 stack and + # the digest-pinned harness-v1 compatibility wrapper. matrix: include: - image: controller @@ -222,6 +150,26 @@ jobs: image_suffix: "/agent-harness-wrapper" dockerfile: workers/harness/Dockerfile context: . + - image: acp-codex-runtime + image_suffix: "/acp-codex-runtime" + dockerfile: workers/acp/images/codex/Dockerfile + context: . + - image: acp-claude-runtime + image_suffix: "/acp-claude-runtime" + dockerfile: workers/acp/images/claude/Dockerfile + context: . + - image: acp-copilot-runtime + image_suffix: "/acp-copilot-runtime" + dockerfile: workers/acp/images/copilot/Dockerfile + context: . + - image: acp-opencode-runtime + image_suffix: "/acp-opencode-runtime" + dockerfile: workers/acp/images/opencode/Dockerfile + context: . + - image: workspace-publisher + image_suffix: "/workspace-publisher" + dockerfile: workers/publisher/Dockerfile + context: . steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -344,6 +292,46 @@ jobs: image_suffix: "/agent-harness-wrapper" platform: linux/arm64 platform_slug: linux-arm64 + - image: acp-codex-runtime + image_suffix: "/acp-codex-runtime" + platform: linux/amd64 + platform_slug: linux-amd64 + - image: acp-codex-runtime + image_suffix: "/acp-codex-runtime" + platform: linux/arm64 + platform_slug: linux-arm64 + - image: acp-claude-runtime + image_suffix: "/acp-claude-runtime" + platform: linux/amd64 + platform_slug: linux-amd64 + - image: acp-claude-runtime + image_suffix: "/acp-claude-runtime" + platform: linux/arm64 + platform_slug: linux-arm64 + - image: acp-copilot-runtime + image_suffix: "/acp-copilot-runtime" + platform: linux/amd64 + platform_slug: linux-amd64 + - image: acp-copilot-runtime + image_suffix: "/acp-copilot-runtime" + platform: linux/arm64 + platform_slug: linux-arm64 + - image: acp-opencode-runtime + image_suffix: "/acp-opencode-runtime" + platform: linux/amd64 + platform_slug: linux-amd64 + - image: acp-opencode-runtime + image_suffix: "/acp-opencode-runtime" + platform: linux/arm64 + platform_slug: linux-arm64 + - image: workspace-publisher + image_suffix: "/workspace-publisher" + platform: linux/amd64 + platform_slug: linux-amd64 + - image: workspace-publisher + image_suffix: "/workspace-publisher" + platform: linux/arm64 + platform_slug: linux-arm64 steps: - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 @@ -402,6 +390,16 @@ jobs: image_suffix: "/general-worker" - image: agent-harness-wrapper image_suffix: "/agent-harness-wrapper" + - image: acp-codex-runtime + image_suffix: "/acp-codex-runtime" + - image: acp-claude-runtime + image_suffix: "/acp-claude-runtime" + - image: acp-copilot-runtime + image_suffix: "/acp-copilot-runtime" + - image: acp-opencode-runtime + image_suffix: "/acp-opencode-runtime" + - image: workspace-publisher + image_suffix: "/workspace-publisher" steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 @@ -520,6 +518,11 @@ jobs: promote_image ai-worker "/ai-worker" promote_image general-worker "/general-worker" promote_image agent-harness-wrapper "/agent-harness-wrapper" + promote_image acp-codex-runtime "/acp-codex-runtime" + promote_image acp-claude-runtime "/acp-claude-runtime" + promote_image acp-copilot-runtime "/acp-copilot-runtime" + promote_image acp-opencode-runtime "/acp-opencode-runtime" + promote_image workspace-publisher "/workspace-publisher" publish-helm-chart: diff --git a/.github/workflows/security-scan-e2e.yml b/.github/workflows/security-scan-e2e.yml index 139eb9f5f..fc0a61a3f 100644 --- a/.github/workflows/security-scan-e2e.yml +++ b/.github/workflows/security-scan-e2e.yml @@ -16,7 +16,12 @@ on: - "hack/**" - "internal/**" - "pkg/**" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" + - "scripts/lib/kind-local-registry.sh" + - "scripts/fixtures/security-scan-fake-acp/**" - "scripts/security-scan-e2e.sh" + - "scripts/tests/security-scan-e2e-test.sh" - "workers/**" push: paths: @@ -32,7 +37,12 @@ on: - "hack/**" - "internal/**" - "pkg/**" + - "scripts/lib/e2e-admission-tls.sh" + - "scripts/lib/ensure-static-mode-namespace.sh" + - "scripts/lib/kind-local-registry.sh" + - "scripts/fixtures/security-scan-fake-acp/**" - "scripts/security-scan-e2e.sh" + - "scripts/tests/security-scan-e2e-test.sh" - "workers/**" permissions: @@ -48,8 +58,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 env: - KIND_CLUSTER: orka-security-scan-e2e - KIND_VERSION: v0.31.0 + KIND_CLUSTER: orka-security-scan-${{ github.run_id }}-${{ github.run_attempt }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -59,13 +68,13 @@ jobs: with: go-version-file: go.mod + - name: Install kind + uses: ./.github/actions/setup-kind + - name: Install prerequisites run: | sudo apt-get update sudo apt-get install -y jq - curl -fsSL -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind - name: Verify toolchain run: | @@ -73,5 +82,10 @@ jobs: kubectl version --client=true jq --version + - name: Verify security scan harness-v2 assertions + run: | + bash -n scripts/security-scan-e2e.sh scripts/tests/security-scan-e2e-test.sh + bash scripts/tests/security-scan-e2e-test.sh + - name: Run security scan e2e run: bash scripts/security-scan-e2e.sh diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 7d01ebc6d..dadfa0dc7 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -25,32 +25,13 @@ jobs: test-e2e: name: Run on Ubuntu runs-on: ubuntu-latest - env: - KIND_VERSION: v0.31.0 + timeout-minutes: 60 steps: - name: Clone the code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Free disk space for Docker-heavy E2E - run: | - set -euxo pipefail - echo "Disk usage before cleanup" - df -h - docker system df || true - - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /usr/local/share/boost - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/hostedtoolcache/CodeQL - sudo rm -rf /usr/share/swift - - docker system prune -af || true - docker builder prune -af || true - - echo "Disk usage after cleanup" - df -h - docker system df || true + uses: ./.github/actions/free-disk-space - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -58,10 +39,7 @@ jobs: go-version-file: go.mod - name: Install kind - run: | - curl -fsSL --retry 3 --retry-delay 2 -o ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-$(go env GOARCH)" - chmod +x ./kind - sudo mv ./kind /usr/local/bin/kind + uses: ./.github/actions/setup-kind - name: Verify kind installation run: kind version diff --git a/.golangci.yml b/.golangci.yml index a8e84c1f3..d93d39b5c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -40,6 +40,18 @@ linters: - dupl - lll path: internal/* + - linters: + - lll + path: workers/acp/supervisor/* + - linters: + - lll + path: cmd/cli/* + - linters: + - lll + path: cmd/orka-provider-auth-proxy/* + - linters: + - lll + path: cmd/main.go paths: - third_party$ - builtin$ diff --git a/AGENTS.md b/AGENTS.md index a32115828..2980ae712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,94 +13,86 @@ Orka is a Kubernetes-native task execution platform that manages Jobs and Pods f ## Generated — do not edit -| Path | Regenerate with | -| --- | --- | -| `config/crd/bases/*.yaml`, `config/rbac/role.yaml` | `make manifests` | -| `manifest_staging/deploy/orka.yaml`, `manifest_staging/charts/orka/**` | `make manifests` | -| `deploy/**`, `charts/orka/**` | `make promote-staging-manifest` (release-preparation flow only) | -| `**/zz_generated.*.go` | `make generate` | -| `PROJECT` | kubebuilder CLI | -| `ui/src/routeTree.gen.ts` | TanStack Router | +For non-trivial code changes, run `$autoreview` (`.agents/skills/autoreview/SKILL.md`) before final/commit/ship and keep going until there are no accepted/actionable findings, unless the change is trivial/docs-only, equivalent manual review already happened, or the human opts out. -## Gotchas - -Execution model: +- Treat review output as advisory: verify every finding against the real code path before changing code. +- If review-triggered fixes change code, rerun focused tests and rerun `$autoreview`. +- Format before review when formatting can move line locations; focused tests and review may run in parallel only after formatting is stable. -- `runtimeRef` AgentRuntime tasks are remote-runtime tasks — there is no Kubernetes Job/Pod per task. Orka stays the governance plane, brokered tools execute through Orka, and remote adapters receive only harness auth plus safe tool schemas, never downstream production tool credentials. -- Agent CLI runtimes (`codex`, `claude`, `copilot`, `opencode`) run through the `agent-harness-wrapper`. The old per-runtime worker images and entrypoints are gone. -- Harness-wrapper success maps `TurnCompleted` to `AgentRuntimeCompleted` plus terminal task events. Do not expect a worker `ResultSubmitted` event on harness-backed agent tasks. -- Harness wrapper `GET /v1/health` and `GET /v1/capabilities` are intentionally unauthenticated; mutating turn endpoints (`POST /v1/turns`, cancel) require the wrapper bearer token. -- The harness wrapper may emit restricted PodSecurity warnings — it runs as root with limited capabilities for child process/credential setup. Rollout success plus runtime live tests are the source of truth. -- When changing the harness wrapper, run the canonical [live validation checklist](website/docs/guides/cli-harness-wrapper.md#live-validation-checklist). +## PR Closeout -Workers: +After creating or updating an agent-authored PR, use `$pr-closeout` (`.agents/skills/pr-closeout/SKILL.md`) by default, like `$autoreview` is used before landing. Resolve merge conflicts, fix failing CI, address or push back on unresolved review threads, reply on GitHub and resolve addressed comments, push the non-main PR branch, and repeat until current CI is green and no unresolved actionable review threads remain. Skip only when the human opts out, the PR is intentionally draft/WIP, or the remaining blocker is external/human-only. Do not merge or enable auto-merge unless explicitly asked. -- Worker filesystem is read-only except `/tmp`, `/home/worker`, and `/workspace`. -- AI worker truncates messages on context overflow — keeps system prompt plus newest, drops the middle atomically with structured metadata. -- Built-in AI worker tools: `web_search`, `code_exec`, `file_read`, `web_fetch`, `file_write`. +## Build & Test -Memory and coordination: - -- Coordination memory tools: `recall_memory`, `remember`, `propose_memory`, `search_transcript`. -- Memory is governance-first: `remember` and `propose_memory` create review proposals, not durable memories. -- Reviewing a memory proposal does not apply it. Use the explicit proposal apply endpoint for accepted `memory` proposals when durable memory should be created. -- Never put secrets, credentials, tokens, raw transcripts, or one-off task status in durable memory. +```bash +make manifests # Regenerate CRDs (after editing *_types.go or markers) +make generate # Regenerate Go types +make build # Build (includes UI) +make test # Run tests +make lint-fix # Lint and fix +make docker-build-all # Controller, AI/general workers, ACP runtimes, publisher +make deploy IMG=@sha256: ACP_CODEX_RUNTIME_IMG=@sha256: ACP_CLAUDE_RUNTIME_IMG=@sha256: ACP_COPILOT_RUNTIME_IMG=@sha256: ACP_OPENCODE_RUNTIME_IMG=@sha256: WORKSPACE_PUBLISHER_IMG=@sha256: +``` -Auth and telemetry: +UI: `cd ui && bun install && bun run dev` (dev server on :5173). See @website/docs/development/development.md for full commands. -- Transaction tokens are accepted via `Txn-Token` by default. `Authorization: Bearer` context-token support is opt-in so ServiceAccount/OIDC auth can coexist. -- Live GitHub OIDC E2E requires GitHub Actions `id-token: write` or `ORKA_GITHUB_OIDC_TOKEN`. Redact JWTs, TxTokens, and request tokens in logs. -- OpenTelemetry GenAI constants are hand-rolled in `internal/tracing/genai` because the GenAI conventions are still Development-stage. Telemetry is enabled with `--enable-telemetry`/`--enable-tracing`, workers honor `ORKA_ENABLE_TELEMETRY`, and prompt/completion content capture stays default-off and fail-closed. +For testing against a local Kubernetes cluster, use the `$kindctl` skill to manage repo/worktree-scoped kind clusters without touching the global kubeconfig. -Build: +To stand up a reverse proxy for Anthropic/Gemini/OpenAI-compatible clients, use the `$vekil-reverse-proxy-deploy` skill. When it falls back to GitHub Copilot device-code login, surface the login code and URL to the user and wait for their confirmation before continuing — never complete the login on their behalf. -- `make build` requires UI assets — run `make ui-build` first, or `ensure-ui-embed` creates a stub and the embedded UI won't work. +To stand up an execution-workspace provider on a local kind cluster for evaluation, use the `$agent-sandbox-deploy` skill (kubernetes-sigs agent-sandbox; pairs with `$kindctl` for the cluster and `$orka-kind-deploy` for the controller) or the `$agent-substrate-deploy` skill (Agent Substrate; owns its own gVisor kind cluster, so it is not hosted on a `$kindctl` cluster). Both are local/kind eval only — Orka does not install or manage these providers in production — and both surface the `$vekil-reverse-proxy-deploy` device-code login to the user for confirmation rather than completing it. -Helm manifests and release snapshots: +## Verification -- Helm generator inputs live under `cmd/build/helmify/`; canonical Kubernetes inputs remain under `config/`. -- `make manifests` regenerates the committed next-release outputs in `manifest_staging/deploy/orka.yaml` and `manifest_staging/charts/orka/`. Edit the source inputs, not generated staging files, and commit both source and regenerated output. -- Root `deploy/` and `charts/orka/` are promoted release snapshots. Do not edit them directly; only the release-preparation flow runs `make release-manifest` and `make promote-staging-manifest`. Staging may intentionally be ahead of the root snapshots. -- A pushed `v*` tag packages and publishes the already-reviewed root snapshot. Tag publication must not regenerate or promote manifests. -- Chart CRDs are generated from `config/crd/bases/`. Helm does not update them during `helm upgrade`; apply the CRDs from the exact target chart before upgrading the release. +Run after every change: -## Code style +```bash +make manifests generate # After *_types.go or marker edits +make lint-fix && make test # After any *.go edits +cd ui && bun run lint && bun run test # After UI edits +bash -n scripts/*.sh # After shell script edits +go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/.yml # After workflow edits +``` -- Structured logging: `log := log.FromContext(ctx); log.Info("msg", "key", val)` -- LLM tool args for nested objects arrive as `map[string]any`, not strings — always type-switch. -- Put model-readable tool constraints in the JSON Schema (`maximum`, `minimum`, `enum`, `default`), not only in description prose, and validate and enforce them at runtime in `Execute`; schema guides the model but is not a runtime trust boundary. +Single test: `go test ./internal/api/ -run TestHandlerName -v` -## Build and verify +## Auto-Generated — Do NOT Edit -```bash -make manifests # After CRD/RBAC/Kustomize or Helm generator input changes -make generate # After generated Go type input changes -make lint-fix && make test # After any *.go edits -make build # Includes UI; see ui-build gotcha above -make docker-build-all # Controller, AI/general workers, harness wrapper -make deploy IMG=/orka:tag HARNESS_WRAPPER_IMG=/agent-harness-wrapper:tag -``` +- `config/crd/bases/*.yaml`, `config/rbac/role.yaml` — `make manifests` +- `manifest_staging/deploy/orka.yaml`, `manifest_staging/charts/orka/**` — `make manifests` +- `deploy/**`, `charts/orka/**` — `make promote-staging-manifest` (release-preparation only) +- `**/zz_generated.*.go` — `make generate` +- `PROJECT` — kubebuilder CLI +- `ui/src/routeTree.gen.ts` — TanStack Router -```bash -cd ui && bun run lint && bun run test # After UI edits -bash -n scripts/*.sh # After shell script edits -go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/.yml -``` +Do NOT delete `// +kubebuilder:scaffold:*` comments. -Single Go test: `go test ./internal/api/ -run TestHandlerName -v`. -UI dev server: `cd ui && bun install && bun run dev` (:5173). +## Code Style -Full command reference, CI workflow catalog, and OpenTelemetry development notes: -`website/docs/development/development.md`. +- Structured logging: `log := log.FromContext(ctx); log.Info("msg", "key", val)` +- LLM tool args for nested objects arrive as `map[string]any`, not strings — always type-switch +- Put model-readable tool constraints in JSON Schema (`maximum`, `minimum`, `enum`, `default`), then validate and enforce them again in `Execute`; schema is guidance, not a runtime trust boundary +- Memory features are governance-first: `remember` and `propose_memory` create review proposals, not durable memories +- Kontxt integration is fail-closed: never store raw TxTokens in Task specs/status/logs; use owner-referenced Secrets for child tokens, safe metadata/digests for audit, subset checks for child scopes, and fail-closed TTS exchanges for outbound scopes. -## Skills +## Gotchas -| Skill | Use for | -| --- | --- | -| `$autoreview` | Review before commit/land on non-trivial code changes. Repeat until no accepted/actionable findings remain. Skip for trivial/docs-only work, equivalent manual review, or when the human opts out. | -| `$pr-closeout` | After creating or updating an agent-authored PR, drive it to green. Skip when the human opts out, the PR is intentionally draft/WIP, or the blocker is external/human-only. | -| `$kindctl` | Repo/worktree-scoped kind clusters, without touching the global kubeconfig. | -| `$orka-kind-deploy` | Rebuild and redeploy the full local stack into a kind cluster. | -| `$vekil-reverse-proxy-deploy` | Reverse proxy for Anthropic/Gemini/OpenAI-compatible clients. | -| `$agent-sandbox-deploy` | kubernetes-sigs agent-sandbox workspace provider (local/kind eval only). | -| `$agent-substrate-deploy` | Agent Substrate workspace provider (local/kind eval only; owns its own cluster). | +- Worker filesystem is read-only except `/tmp`, `/home/worker`, and `/workspace` +- `make build` requires UI assets — run `make ui-build` first (or `ensure-ui-embed` creates a stub) +- AI worker truncates messages on context overflow — keeps system prompt + newest, drops middle atomically with structured metadata +- `code_exec` timeout max is 60s — values above are ignored (30s default used) +- Built-in AI worker tools: `web_search`, `code_exec`, `file_read`, `web_fetch`, `file_write` +- Built-in agent runtimes (`codex`, `claude`, `copilot`, `opencode`) use only the `orka.harness.v2` ACP RuntimePool path; there is no per-Task Job or legacy fallback. +- `Task.spec.workspace` is the only agent repository surface. Keep clone/read credentials in `readCredentialRef` and publication/forge credentials in `publicationCredentialRef`; neither enters the ACP process tree. +- RuntimePools are controller-owned, digest-pinned, scale-to-zero resources. Only `Serving` + `Accepting` admits new RuntimeSessions; drain/finalization must complete before replacement or scale-down. +- Safe v2 probes are `GET /v2/health` and `GET /v2/capabilities`; status and all mutations require controller authentication plus operation-scoped authorization and exact fences. +- External `runtimeRef` registrations are v2-only. Registration/conformance exists, but external Task dispatch currently fails closed until its v2 dispatcher is wired. +- ACP runtime Pods run the supervisor as root with narrowly added process/identity capabilities; ACP children use distinct non-reused UIDs/GIDs, private session trees, and no Git credentials. +- Coordination memory tools: `recall_memory`, `remember`, `propose_memory`, `search_transcript` +- Do not store secrets, credentials, tokens, raw transcripts, or one-off task status in durable memory +- Reviewing a memory proposal does not apply it; use the explicit proposal apply endpoint for accepted `memory` proposals when durable memory should be created +- Kontxt TxTokens are accepted via `Txn-Token` by default; `Authorization: Bearer` context-token support is opt-in so ServiceAccount/OIDC auth can coexist +- Live GitHub OIDC/kontxt E2E requires GitHub Actions `id-token: write` or `ORKA_GITHUB_OIDC_TOKEN`; redact JWTs, TxTokens, and request tokens in logs +- OpenTelemetry GenAI constants are hand-rolled in `internal/tracing/genai`; telemetry is enabled with `--enable-telemetry`/`--enable-tracing`, workers honor `ORKA_ENABLE_TELEMETRY`, and prompt/completion content capture remains default-off/fail-closed +- ACP real-world validation should include Codex, Claude, and OpenCode through Vekil, Copilot image/profile admission (plus live execution when provider auth is available), workspace clone/read, Session continuation, cancellation/timeout, unsafe workspace rejection, controller restart, pool replacement, clean-room branch publication, PR reconciliation, and cleanup. diff --git a/Dockerfile b/Dockerfile index 3a277801a..d1cf8b173 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ +# syntax=docker/dockerfile:1.7.1@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + # Build the UI -FROM oven/bun:1 AS ui-builder +FROM --platform=$BUILDPLATFORM docker.io/oven/bun:1@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 AS ui-builder WORKDIR /app COPY ui/package.json ui/bun.lock ./ RUN bun install --frozen-lockfile @@ -7,7 +9,7 @@ COPY ui/ . RUN bun run build # Build the manager binary -FROM golang:1.26 AS builder +FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26@sha256:3aff6657219a4d9c14e27fb1d8976c49c29fddb70ba835014f477e1c70636647 AS builder ARG TARGETOS ARG TARGETARCH @@ -30,13 +32,19 @@ COPY --from=ui-builder /app/dist/ internal/uiembed/dist/ # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager ./cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager ./cmd \ + && CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o orka-admission ./cmd/orka-admission \ + && CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o provider-auth-proxy ./cmd/orka-provider-auth-proxy \ + && CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o scm-egress-proxy ./cmd/orka-scm-egress-proxy # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details -FROM gcr.io/distroless/static:nonroot +FROM gcr.io/distroless/static:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6 WORKDIR / COPY --from=builder /workspace/manager . +COPY --from=builder /workspace/orka-admission . +COPY --from=builder /workspace/provider-auth-proxy . +COPY --from=builder /workspace/scm-egress-proxy . USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/Makefile b/Makefile index c5298fe4f..213d0cb49 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,19 @@ IMG ?= controller:latest AI_WORKER_IMG ?= ghcr.io/orka-agents/orka/ai-worker:latest GENERAL_WORKER_IMG ?= ghcr.io/orka-agents/orka/general-worker:latest HARNESS_WRAPPER_IMG ?= ghcr.io/orka-agents/orka/agent-harness-wrapper:latest +ACP_CODEX_RUNTIME_IMG ?= ghcr.io/orka-agents/orka/acp-codex-runtime:latest +ACP_CLAUDE_RUNTIME_IMG ?= ghcr.io/orka-agents/orka/acp-claude-runtime:latest +ACP_COPILOT_RUNTIME_IMG ?= ghcr.io/orka-agents/orka/acp-copilot-runtime:latest +ACP_OPENCODE_RUNTIME_IMG ?= ghcr.io/orka-agents/orka/acp-opencode-runtime:latest +WORKSPACE_PUBLISHER_IMG ?= ghcr.io/orka-agents/orka/workspace-publisher:latest +# Providers backing the generated docker-build-acp--runtime and +# docker-push-acp--runtime targets. +ACP_RUNTIME_PROVIDERS = codex claude copilot opencode +ACP_RUNTIME_IMGS = $(ACP_CODEX_RUNTIME_IMG) $(ACP_CLAUDE_RUNTIME_IMG) $(ACP_COPILOT_RUNTIME_IMG) $(ACP_OPENCODE_RUNTIME_IMG) +RUN_CONTROLLER_MODE ?= harness-v2 +RUN_WATCH_NAMESPACE ?= orka-system +RUN_AGENT_EXECUTION_SNAPSHOT_KEY_FILE ?= +RUN_EXECUTION_MODE_CONTROLLER_USERNAMES ?= $(shell "$(KUBECTL)" auth whoami -o jsonpath='{.status.userInfo.username}' 2>/dev/null) # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) ifeq (,$(shell go env GOBIN)) @@ -64,7 +77,7 @@ manifests: controller-gen kustomize ## Generate canonical and Gatekeeper-style s }; \ trap cleanup EXIT; \ mkdir -p "$$tmp/deploy" "$$tmp/charts/orka"; \ - "$(KUSTOMIZE)" build config/default -o "$$tmp/deploy/orka.yaml"; \ + "$(KUSTOMIZE)" build config/acp-production -o "$$tmp/deploy/orka.yaml"; \ "$(KUSTOMIZE)" build \ --load-restrictor LoadRestrictionsNone \ cmd/build/helmify | go run ./cmd/build/helmify -output-dir "$$tmp/charts/orka"; \ @@ -112,6 +125,33 @@ promote-staging-manifest: ## Promote committed staging manifests into release sn mv "$$stage/charts" charts; installed_charts=1; \ trap - EXIT; \ rm -rf "$$stage" "$$backup" + diff --no-dereference --recursive manifest_staging/deploy deploy + diff --no-dereference --recursive manifest_staging/charts/orka charts/orka + +.PHONY: verify-release-manifest +verify-release-manifest: ## Validate promoted release snapshots and the harness-v2 Helm render contract. + scripts/validate-release-manifest.sh "$(if $(NEWVERSION),$(NEWVERSION),$(VERSION))" + +.PHONY: test-release-manifest +test-release-manifest: ## Test release versioning, image matrices, and the harness-v2 render policy. + bash scripts/tests/release-manifest-test.sh + +.PHONY: sync-helm-crds +sync-helm-crds: ## Synchronize generated CRDs into the promoted Helm chart while preserving non-CRD files. + scripts/sync-helm-crds.sh + +.PHONY: verify-helm-crds +verify-helm-crds: ## Verify generated and promoted Helm chart CRDs are identical. + scripts/sync-helm-crds.sh --check + +.PHONY: test-helm-crd-sync +test-helm-crd-sync: ## Test Helm CRD synchronization and drift detection. + bash scripts/tests/sync-helm-crds-test.sh + +.PHONY: test-static-mode-deploy-gate +test-static-mode-deploy-gate: ## Test the static harness-mode CRD deployment gate. + bash scripts/tests/static-mode-deploy-gate-test.sh + .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. @@ -155,7 +195,7 @@ test: manifests generate fmt vet setup-envtest ## Run tests. # The default e2e setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. KIND_CLUSTER ?= orka-test-e2e -E2E_GO_TEST_TIMEOUT ?= 30m +E2E_GO_TEST_TIMEOUT ?= 50m E2E_GINKGO_FOCUS ?= E2E_GINKGO_FOCUS_ARG = $(if $(E2E_GINKGO_FOCUS),-ginkgo.focus="$(E2E_GINKGO_FOCUS)",) @@ -189,6 +229,8 @@ test-e2e-setup-only: setup-test-e2e docker-build-all ## Set up Kind cluster and $(KIND) load docker-image $(AI_WORKER_IMG) --name $(KIND_CLUSTER) $(KIND) load docker-image $(GENERAL_WORKER_IMG) --name $(KIND_CLUSTER) $(KIND) load docker-image $(HARNESS_WRAPPER_IMG) --name $(KIND_CLUSTER) + set -e; for img in $(ACP_RUNTIME_IMGS); do $(KIND) load docker-image $$img --name $(KIND_CLUSTER); done + $(KIND) load docker-image $(WORKSPACE_PUBLISHER_IMG) --name $(KIND_CLUSTER) .PHONY: test-e2e-run-only test-e2e-run-only: manifests generate fmt vet ## Run e2e tests without rebuilding images (for fast iteration). @@ -276,8 +318,13 @@ ui-test-coverage: ## Run UI unit tests with coverage. ##@ Build .PHONY: build -build: manifests generate fmt vet ui-build ## Build manager binary. - go build -o bin/manager cmd/main.go +build: manifests generate fmt vet ui-build ## Build manager and admission binaries. + go build -o bin/manager ./cmd + go build -o bin/orka-admission ./cmd/orka-admission + +.PHONY: build-admission +build-admission: ## Build the stateless admission binary. + go build -o bin/orka-admission ./cmd/orka-admission .PHONY: docs-cli @@ -297,7 +344,12 @@ build-all: build build-cli ## Build all binaries. .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/main.go + POD_NAMESPACE="$(RUN_WATCH_NAMESPACE)" go run ./cmd --leader-elect=true \ + --controller-mode="$(RUN_CONTROLLER_MODE)" \ + --watch-namespace="$(RUN_WATCH_NAMESPACE)" \ + --agent-execution-snapshot-key-file="$(RUN_AGENT_EXECUTION_SNAPSHOT_KEY_FILE)" \ + --enforce-namespace-isolation=true \ + --execution-mode-controller-usernames="$(RUN_EXECUTION_MODE_CONTROLLER_USERNAMES)" # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. @@ -314,31 +366,72 @@ docker-push: ## Push docker image with the manager. docker-build-ai-worker: ## Build docker image for the AI worker. $(CONTAINER_TOOL) build -t ${AI_WORKER_IMG} -f workers/ai/Dockerfile . -.PHONY: docker-build-harness-wrapper -docker-build-harness-wrapper: ## Build docker image for the agent harness wrapper. - $(CONTAINER_TOOL) build -t ${HARNESS_WRAPPER_IMG} -f workers/harness/Dockerfile . - .PHONY: docker-build-general-worker docker-build-general-worker: ## Build docker image for the general worker. $(CONTAINER_TOOL) build -t ${GENERAL_WORKER_IMG} -f workers/general/Dockerfile . +.PHONY: docker-build-harness-wrapper +docker-build-harness-wrapper: ## Build the opt-in harness v1 compatibility wrapper image. + $(CONTAINER_TOOL) build -t ${HARNESS_WRAPPER_IMG} -f workers/harness/Dockerfile . + +# Recipes for the docker-build-acp--runtime targets are generated +# from ACP_RUNTIME_PROVIDERS below; these dependency-less rules carry their +# `make help` entries. +docker-build-acp-codex-runtime: ## Build the immutable Codex ACP runtime image. +docker-build-acp-claude-runtime: ## Build the immutable Claude ACP runtime image. +docker-build-acp-copilot-runtime: ## Build the immutable GitHub Copilot ACP runtime image. +docker-build-acp-opencode-runtime: ## Build the immutable OpenCode ACP runtime image. + +.PHONY: docker-build-workspace-publisher +docker-build-workspace-publisher: ## Build the clean-room workspace publisher image. + $(CONTAINER_TOOL) build -t ${WORKSPACE_PUBLISHER_IMG} -f workers/publisher/Dockerfile . + .PHONY: docker-push-ai-worker docker-push-ai-worker: ## Push docker image for the AI worker. $(CONTAINER_TOOL) push ${AI_WORKER_IMG} -.PHONY: docker-push-harness-wrapper -docker-push-harness-wrapper: ## Push docker image for the agent harness wrapper. - $(CONTAINER_TOOL) push ${HARNESS_WRAPPER_IMG} - .PHONY: docker-push-general-worker docker-push-general-worker: ## Push docker image for the general worker. $(CONTAINER_TOOL) push ${GENERAL_WORKER_IMG} +.PHONY: docker-push-harness-wrapper +docker-push-harness-wrapper: ## Push the opt-in harness v1 compatibility wrapper image. + $(CONTAINER_TOOL) push ${HARNESS_WRAPPER_IMG} + +# Recipes for the docker-push-acp--runtime targets are generated +# from ACP_RUNTIME_PROVIDERS below; these dependency-less rules carry their +# `make help` entries. +docker-push-acp-codex-runtime: ## Push the immutable Codex ACP runtime image. +docker-push-acp-claude-runtime: ## Push the immutable Claude ACP runtime image. +docker-push-acp-copilot-runtime: ## Push the immutable GitHub Copilot ACP runtime image. +docker-push-acp-opencode-runtime: ## Push the immutable OpenCode ACP runtime image. + +# acp-provider-uc maps an ACP runtime provider word to the uppercase form used +# in its image variable name (ACP__RUNTIME_IMG). +acp-provider-uc = $(subst codex,CODEX,$(subst claude,CLAUDE,$(subst copilot,COPILOT,$(subst opencode,OPENCODE,$(1))))) + +# acp-runtime-image-targets generates the build/push recipes for one ACP +# runtime provider word $(1) (target names, Dockerfile directory, and image +# variable). +define acp-runtime-image-targets +.PHONY: docker-build-acp-$(1)-runtime docker-push-acp-$(1)-runtime +docker-build-acp-$(1)-runtime: + $$(CONTAINER_TOOL) build -t $${ACP_$(call acp-provider-uc,$(1))_RUNTIME_IMG} -f workers/acp/images/$(1)/Dockerfile . +docker-push-acp-$(1)-runtime: + $$(CONTAINER_TOOL) push $${ACP_$(call acp-provider-uc,$(1))_RUNTIME_IMG} +endef + +$(foreach provider,$(ACP_RUNTIME_PROVIDERS),$(eval $(call acp-runtime-image-targets,$(provider)))) + +.PHONY: docker-push-workspace-publisher +docker-push-workspace-publisher: ## Push the clean-room workspace publisher image. + $(CONTAINER_TOOL) push ${WORKSPACE_PUBLISHER_IMG} + .PHONY: docker-build-all -docker-build-all: docker-build docker-build-ai-worker docker-build-general-worker docker-build-harness-wrapper ## Build all docker images. +docker-build-all: docker-build docker-build-ai-worker docker-build-general-worker docker-build-harness-wrapper docker-build-acp-codex-runtime docker-build-acp-claude-runtime docker-build-acp-copilot-runtime docker-build-acp-opencode-runtime docker-build-workspace-publisher ## Build all docker images. .PHONY: docker-push-all -docker-push-all: docker-push docker-push-ai-worker docker-push-general-worker docker-push-harness-wrapper ## Push all docker images. +docker-push-all: docker-push docker-push-ai-worker docker-push-general-worker docker-push-harness-wrapper docker-push-acp-codex-runtime docker-push-acp-claude-runtime docker-push-acp-copilot-runtime docker-push-acp-opencode-runtime docker-push-workspace-publisher ## Push all docker images. ##@ Deployment @@ -356,24 +449,107 @@ uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi +.PHONY: verify-acp-runtime-images +verify-acp-runtime-images: ## Require digest-pinned ACP runtime images for supported deployments. + @for entry in \ + "IMG=$(IMG)" \ + "WORKSPACE_PUBLISHER_IMG=$(WORKSPACE_PUBLISHER_IMG)" \ + "ACP_CODEX_RUNTIME_IMG=$(ACP_CODEX_RUNTIME_IMG)" \ + "ACP_CLAUDE_RUNTIME_IMG=$(ACP_CLAUDE_RUNTIME_IMG)" \ + "ACP_COPILOT_RUNTIME_IMG=$(ACP_COPILOT_RUNTIME_IMG)" \ + "ACP_OPENCODE_RUNTIME_IMG=$(ACP_OPENCODE_RUNTIME_IMG)"; do \ + name="$${entry%%=*}"; ref="$${entry#*=}"; \ + if [[ ! "$${ref}" =~ ^.+@sha256:[0-9a-f]{64}$$ ]]; then \ + echo "$$name must be an immutable image reference ending in @sha256:<64 lowercase hex characters>; got '$$ref'" >&2; \ + exit 1; \ + fi; \ + done + +.PHONY: verify-static-mode-crds +verify-static-mode-crds: ## Refuse workload deployment until the platform-owned shared CRD bundle is ready. + @for crd in \ + agentruntimes.core.orka.ai \ + agents.core.orka.ai \ + branchclaims.core.orka.ai \ + controllerepochs.core.orka.ai \ + executionworkspaceclasses.workspace.orka.ai \ + executionworkspacepools.workspace.orka.ai \ + executionworkspaceproviders.workspace.orka.ai \ + executionworkspaces.workspace.orka.ai \ + externaleffects.core.orka.ai \ + fakepoolparameters.fake.workspace.orka.ai \ + fakeproviderconfigs.fake.workspace.orka.ai \ + gatewaybindings.gateway.orka.ai \ + gatewayclasses.gateway.orka.ai \ + gateways.gateway.orka.ai \ + outboundaccesspolicies.core.orka.ai \ + promptattempts.core.orka.ai \ + providers.core.orka.ai \ + publications.core.orka.ai \ + repositorymonitors.core.orka.ai \ + repositoryscans.core.orka.ai \ + runtimepools.core.orka.ai \ + runtimesessioncontrols.core.orka.ai \ + skills.core.orka.ai \ + substrateactorpools.core.orka.ai \ + tasks.core.orka.ai \ + tools.core.orka.ai; do \ + "$(KUBECTL)" get crd "$$crd" >/dev/null || { echo "missing shared CRD: $$crd; apply the platform-owned static-mode CRD wave before workloads" >&2; exit 1; }; \ + "$(KUBECTL)" wait --for=condition=Established --timeout=60s "crd/$$crd" >/dev/null || { echo "shared CRD is not Established: $$crd" >&2; exit 1; }; \ + done + @for crd in agentexecutioncontrols.core.orka.ai agentexecutionpolicies.core.orka.ai agentexecutionadjudications.core.orka.ai; do \ + if "$(KUBECTL)" get crd "$$crd" >/dev/null 2>&1; then \ + echo "unsupported superseded coexistence CRD remains installed: $$crd" >&2; \ + exit 1; \ + fi; \ + done + @"$(KUBECTL)" get crd agentruntimes.core.orka.ai -o json | jq -e \ + '[.spec.versions[] | select(.served == true) | .schema.openAPIV3Schema.properties.spec.properties.contractVersion.enum] as $$enums | ($$enums | length) > 0 and ($$enums | all(sort == ["orka.harness.v1","orka.harness.v2"]))' >/dev/null || \ + { echo "AgentRuntime CRD is not the shared orka.harness.v1/orka.harness.v2 schema; apply the platform-owned static-mode CRD wave before workloads" >&2; exit 1; } + @"$(KUBECTL)" get crd agents.core.orka.ai -o json | jq -e \ + '[.spec.versions[] | select(.served == true) | .schema.openAPIV3Schema.properties.spec.properties.runtime as $$runtime | (((($$runtime.properties.contractVersion.enum // []) | sort) == ["orka.harness.v1","orka.harness.v2"]) and ((($$runtime["x-kubernetes-validations"] // []) | map(.message) | index("runtime.contractVersion is immutable once set")) != null))] as $$checks | ($$checks | length) > 0 and ($$checks | all)' >/dev/null || \ + { echo "Agent CRD is missing the immutable shared contract selector; apply the platform-owned static-mode CRD wave before workloads" >&2; exit 1; } + @"$(KUBECTL)" get crd tasks.core.orka.ai -o json | jq -e \ + '[.spec.versions[] | select(.served == true) | .schema.openAPIV3Schema as $$schema | $$schema.properties.status as $$status | ((($$status.properties.agentExecutionBinding.type // "") == "object") and ((($$status.properties.agentExecutionBinding.properties.contractVersion.enum // []) | sort) == ["orka.harness.v1","orka.harness.v2"]) and (($$status.properties | has("agentExecutionNoExecution")) | not) and (($$status.properties | has("agentExecutionQuarantine")) | not) and (($$status.properties | has("agentExecutionResolutionRef")) | not) and ((($$status["x-kubernetes-validations"] // []) | map(.message) | index("agentExecutionBinding is write-once and immutable")) != null) and ((($$schema["x-kubernetes-validations"] // []) | map(.message) | index("Task spec is immutable after execution authority is recorded")) != null))] as $$checks | ($$checks | length) > 0 and ($$checks | all)' >/dev/null || \ + { echo "Task CRD is missing the static-mode execution-authority schema; apply the platform-owned static-mode CRD wave before workloads" >&2; exit 1; } + .PHONY: deploy -deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. - cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} - cd config/harness-wrapper && "$(KUSTOMIZE)" edit set image ghcr.io/orka-agents/orka/agent-harness-wrapper=${HARNESS_WRAPPER_IMG} - @"$(KUBECTL)" create namespace orka-system --dry-run=client -o yaml | "$(KUBECTL)" apply -f - - @if ! "$(KUBECTL)" -n orka-system get secret harness-wrapper-auth >/dev/null 2>&1; then \ +deploy: verify-acp-runtime-images verify-static-mode-crds manifests kustomize ## Deploy the static harness-v2 installation after the shared CRD wave. + @bash "$(CURDIR)/scripts/lib/ensure-static-mode-namespace.sh" "$(KUBECTL)" orka-system harness-v2 + @if ! "$(KUBECTL)" -n orka-system get secret acp-artifact-capability >/dev/null 2>&1; then \ + secret="$$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d '\n')"; \ + "$(KUBECTL)" -n orka-system create secret generic acp-artifact-capability --from-literal=capability-secret="$$secret"; \ + fi + @if ! "$(KUBECTL)" -n orka-system get secret workspace-publisher-auth >/dev/null 2>&1; then \ + bearer="$$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d '\n')"; \ + capability="$$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d '\n')"; \ + "$(KUBECTL)" -n orka-system create secret generic workspace-publisher-auth --from-literal=controller-token="$$bearer" --from-literal=operation-capability-secret="$$capability"; \ + fi + @if ! "$(KUBECTL)" -n orka-system get secret provider-auth-proxy >/dev/null 2>&1; then \ token="$$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d '\n')"; \ - "$(KUBECTL)" -n orka-system create secret generic harness-wrapper-auth --from-literal=token="$$token"; \ + "$(KUBECTL)" -n orka-system create secret generic provider-auth-proxy --from-literal=token="$$token"; \ fi - "$(KUSTOMIZE)" build config/default | \ - sed -E \ - -e 's|^([[:space:]]*- --ai-worker-image=).*$$|\1$(AI_WORKER_IMG)|' \ - -e 's|^([[:space:]]*- --general-worker-image=).*$$|\1$(GENERAL_WORKER_IMG)|' | \ - "$(KUBECTL)" apply -f - + @if ! "$(KUBECTL)" -n orka-system get secret scm-egress-proxy-auth >/dev/null 2>&1; then \ + token="$$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"; \ + "$(KUBECTL)" -n orka-system create secret generic scm-egress-proxy-auth --from-literal=token="$$token"; \ + fi + @set -eu; tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ + cp -R config "$$tmp/config"; \ + "$(CURDIR)/scripts/render-worker-images.sh" "$$tmp/config/manager/manager.yaml" \ + "$(AI_WORKER_IMG)" "$(GENERAL_WORKER_IMG)"; \ + "$(CURDIR)/scripts/render-acp-runtime-images.sh" "$$tmp/config/acp-production" \ + "${ACP_CODEX_RUNTIME_IMG}" "${ACP_CLAUDE_RUNTIME_IMG}" "${ACP_COPILOT_RUNTIME_IMG}" "${ACP_OPENCODE_RUNTIME_IMG}"; \ + cd "$$tmp/config/acp-production"; \ + "$(KUSTOMIZE)" edit set image \ + controller=${IMG} \ + ghcr.io/orka-agents/orka=${IMG} \ + docker.io/sozercan/orka-workspace-publisher=${WORKSPACE_PUBLISHER_IMG}; \ + "$(CURDIR)/scripts/apply-acp-production.sh" "$$PWD" "$(KUSTOMIZE)" "$(KUBECTL)" + .PHONY: undeploy undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f - + "$(KUSTOMIZE)" build config/acp-production | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies diff --git a/NOTICE.md b/NOTICE.md index 44afa52c8..3ad99591c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -25,7 +25,7 @@ upstream revision, and Orka modification notes. ## GitHub Copilot CLI -Orka embeds the GitHub Copilot CLI in the `agent-harness-wrapper` binary using `github.com/github/copilot-sdk/go/cmd/bundler`. The embedded CLI version is resolved at build time by the bundler based on the `github.com/github/copilot-sdk/go` version in `go.mod`. +The digest-pinned Copilot ACP runtime image redistributes the unmodified official GitHub Copilot CLI executable as one component of Orka's fenced RuntimePool service. The image includes this license at `/usr/share/licenses/github-copilot-cli/LICENSE.md`; the controller and Publisher images do not include Copilot CLI. GitHub Copilot CLI License @@ -65,7 +65,7 @@ GitHub Copilot CLI License ## GitHub Copilot SDK for Go -Orka uses `github.com/github/copilot-sdk/go` to integrate with GitHub Copilot CLI. +The source dependency graph includes `github.com/github/copilot-sdk/go` for compatibility and integration work. The supported built-in ACP runtime profiles are Codex, Claude, Copilot, and OpenCode. MIT License @@ -89,15 +89,41 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## OpenCode + +The digest-pinned OpenCode ACP runtime image redistributes the unmodified OpenCode 1.18.9 native binary under the following MIT license. + +MIT License + +Copyright (c) 2025 opencode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## Anthropic Claude Code -Orka installs `@anthropic-ai/claude-code` and the corresponding Linux platform package in the agent harness image. +The digest-pinned Claude ACP runtime image installs `@anthropic-ai/claude-code` and the corresponding Linux platform package. The controller and Publisher images do not include Claude Code. © Anthropic PBC. All rights reserved. Use is subject to the Legal Agreements outlined here: https://code.claude.com/docs/en/legal-and-compliance. ## OpenAI Codex CLI -Orka installs `@openai/codex` and the corresponding Linux platform package in the agent harness image. The npm package declares the Apache License 2.0. +The digest-pinned Codex ACP runtime image installs `@openai/codex` and the corresponding Linux platform package. The controller and Publisher images do not include Codex. The npm package declares the Apache License 2.0. Apache License Version 2.0, January 2004 diff --git a/README.md b/README.md index 26a3d8127..1de5fdaf2 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ --- -Orka turns your Kubernetes cluster into an AI-powered task execution platform. Spin up swarms of AI agents that write code, review PRs, research topics, or run containers — each as an isolated Kubernetes Job with full scheduling, retries, and observability. A coordinator agent dynamically decomposes complex tasks, spawns specialist agents to work in parallel, and synthesizes their results — no manual orchestration graphs required. +Orka turns your Kubernetes cluster into an AI-powered task execution platform. Native AI and container work run as Kubernetes Jobs; ACP coding agents run as fenced RuntimeSessions in controller-owned, scale-to-zero RuntimePools. A coordinator agent dynamically decomposes complex tasks, spawns specialist agents to work in parallel, and synthesizes their results — no manual orchestration graphs required. One `helm install`, one LLM secret, and you're chatting with an orchestrator that handles the rest. @@ -28,9 +28,9 @@ One `helm install`, one LLM secret, and you're chatting with an orchestrator tha **Centralized control** — One place to set model policies, rate limits, and allowed providers across every team. Swap models or providers without touching developer configs. -**Every agent action is auditable** — Tasks run as Kubernetes Jobs with full logs, Prometheus metrics, and result storage. Know exactly what every agent did, when, and at what cost. +**Every agent action is auditable** — Tasks have durable execution events, Prometheus metrics, structured results, and, for ACP agents, fenced attempt/session and delivery receipts. Know exactly what every agent did, when, and at what cost. -**Isolated execution** — Each agent runs in its own Pod with a hardened security context: non-root, read-only rootfs, all capabilities dropped, seccomp enforced. Agents can't escape their sandbox. +**Hardened execution** — Native workers use hardened per-Task Pods. ACP runtimes use digest-pinned shared Pods with private per-session directories and identities; a RuntimePool is a same-trust-domain boundary, not cross-tenant isolation. **Scale with your cluster** — Priority scheduling, retry policies, concurrency limits, and cron-based execution — all handled by the Kubernetes control plane you already operate. @@ -51,7 +51,7 @@ One `helm install`, one LLM secret, and you're chatting with an orchestrator tha ## Features - 🤖 **AI Agents** — Anthropic, OpenAI, or Azure OpenAI with tools, skills, and session persistence -- 🛠️ **Agent Runtimes** — Delegate repo-backed coding tasks to Codex CLI, Claude Code CLI, GitHub Copilot CLI, or OpenCode CLI +- 🛠️ **ACP Agent Runtimes** — Run Codex, Claude, Copilot, and OpenCode through digest-pinned RuntimePools; external `orka.harness.v2` registration and conformance are available while `runtimeRef` Task dispatch remains fail-closed - 🔁 **Autonomous Task Loops** — Coordinators can iterate on long-running goals until complete, canceled, or at an iteration limit - 🔀 **Multi-Agent Coordination** — Coordinators delegate to specialists with depth and concurrency controls - 💬 **Interactive Chat** — Agentic orchestrator with SSE streaming that creates and manages agents and tasks for you @@ -59,55 +59,80 @@ One `helm install`, one LLM secret, and you're chatting with an orchestrator tha - 🧠 **Durable Memory** — Namespace-scoped recall, transcript search, and reviewable memory proposals that can be applied - 🛡️ **Repository Security Scanning** — Scheduled and incremental repository scans with threat models, validated findings, patch generation, and remediation PRs - 🔎 **Repository Monitors** — Durable GitHub PR review queues with scheduled and webhook-triggered review runs -- 🧰 **Agent Sandbox Workspaces** — Experimental durable, reusable coding workspaces through `agent-sandbox` +- 🧰 **Deferred Workspace Providers** — Evaluate `agent-sandbox` or Substrate separately; neither is a current ACP execution path - 🖥️ **Web Dashboard** — Built-in React UI embedded in the controller binary — zero extra deployments -- 📦 **Declarative CRDs** — Task, Agent, AgentRuntime, Tool, Provider, Skill, RepositoryScan, RepositoryMonitor, and SubstrateActorPool custom resources for GitOps workflows +- 📦 **Declarative Control** — Workload, gateway, workspace, and Kubernetes-authoritative ACP control CRDs for GitOps workflows - ⏰ **Scheduled Tasks** — Cron-based recurring execution with concurrency policies - 🔌 **REST & OpenAI-Compatible API** — Full CRUD + `/openai/v1/chat/completions` endpoint for Continue, Cursor, and any OpenAI-compatible client - 🔐 **Kubernetes, OIDC & Transaction-Token Auth** — ServiceAccount tokens by default, with optional OIDC and scoped vendor-neutral transaction governance - 🔮 **Anthropic-Compatible API** — `/anthropic/v1/messages` endpoint for Claude Code and other Anthropic-native clients - 📊 **Observability** — Prometheus metrics, structured logging, health probes, and optional OpenTelemetry traces + GenAI OTLP metrics -- 🔒 **Hardened by Default** — Non-root containers, read-only rootfs, ServiceAccount token auth +- 🔒 **Hardened by Default** — Non-root native workers, fenced private ACP child identities, read-only filesystems, and authenticated broker boundaries + +The ACP hard cutover keeps control authority in Kubernetes: +`ControllerEpoch`, `PromptAttempt`, `RuntimeSessionControl`, `BranchClaim`, +`Publication`, and `ExternalEffect` status plus coordination Leases. SQLite is +limited to transcript/SessionTurn payloads, deferred outbox projections, and +artifact payloads (including result bodies). Provider traffic uses the central authenticated proxy; +prompt tools use prompt-scoped MCP; and source-read, target-read, target-write, +and forge credentials reach only the clean-room Publisher through the +credential broker. Artifact access is separately operation-scoped. ## Quick Start ### Install ```bash +kubectl create -f - <<'EOF' +apiVersion: v1 +kind: Namespace +metadata: + name: orka-system + labels: + orka.ai/controller-mode: harness-v2 +EOF + helm install orka charts/orka \ --namespace orka-system \ - --create-namespace + --set controller.mode=harness-v2 \ + --set controller.watchNamespace=orka-system \ + --set controller.image.repository=docker.io/sozercan/orka \ + --set controller.image.digest=sha256: \ + --set publisher.image.repository=docker.io/sozercan/orka-workspace-publisher \ + --set publisher.image.digest=sha256: \ + --set controller.acpRuntime.codexImage=docker.io/sozercan/orka-acp-codex@sha256: \ + --set controller.acpRuntime.claudeImage=docker.io/sozercan/orka-acp-claude@sha256: \ + --set controller.acpRuntime.copilotImage=docker.io/sozercan/orka-acp-copilot@sha256: \ + --set controller.acpRuntime.opencodeImage=docker.io/sozercan/orka-acp-opencode@sha256: ``` -A fresh install creates all twelve cluster-scoped Orka CRDs. Use `--skip-crds` -only when one designated platform or release owner already manages compatible -Orka CRDs for the cluster. - -> [!IMPORTANT] -> Helm does not create or update files from `crds/` during `helm upgrade`. -> Apply the CRDs from the exact target chart before -> **every** upgrade, including an upgrade from the previous chart that installed -> zero CRDs. Helm retains CRDs -> on uninstall. See the [Helm CRD lifecycle guide](charts/orka/README.md). - -For the promoted raw installer, pre-create the harness-wrapper authentication -Secret before applying the manifest; the token is intentionally not committed: +For direct Kustomize deployments, use `config/acp-production`, not +`config/default`. The production overlay includes the cross-namespace Vekil +ingress policy that permits model traffic only through Orka's authenticated +provider proxy: ```bash -set -euo pipefail +kubectl create -f - <<'EOF' +apiVersion: v1 +kind: Namespace +metadata: + name: orka-system + labels: + orka.ai/controller-mode: harness-v2 +EOF +kubectl apply -k config/acp-production +``` -kubectl create namespace orka-system --dry-run=client -o yaml | kubectl apply -f - -if ! kubectl -n orka-system get secret harness-wrapper-auth >/dev/null 2>&1; then - openssl rand -hex 32 | \ - kubectl -n orka-system create secret generic harness-wrapper-auth \ - --from-file=token=/dev/stdin -fi +Provision the required system Secrets and digest-pinned images before applying +the overlay; `make deploy` performs those checks and applies the equivalent +resource set. -kubectl apply -f deploy/orka.yaml -``` +A fresh Helm install creates the chart CRDs unless `--skip-crds` is used. Helm does not update CRDs during `helm upgrade`, so apply the CRDs from the exact target chart before every controller upgrade. Designate one lifecycle owner for cluster-scoped CRDs and see the [Helm CRD lifecycle guide](charts/orka/README.md). -See [`config/harness-wrapper/README.md`](config/harness-wrapper/README.md) for -the canonical installer prerequisite. +Harness v1 and v2 may share a cluster only as separate static-mode releases +with disjoint namespaces, endpoints, RBAC, Leases, stores, and data planes. +Tasks and Sessions never migrate between them. See the +[harness mode operations guide](website/docs/operations/harness-modes.md). ### Set Up a Provider @@ -129,12 +154,18 @@ spec: EOF ``` +That `Provider` Secret is used by native `type: ai` Tasks and the compatible +chat APIs. Built-in ACP Agents do **not** reference provider Secrets. Codex, +Claude, Copilot, and OpenCode RuntimeSessions reach Vekil only through the central authenticated +provider proxy. Source-read, target-read, target-write, and forge credentials +are brokered separately to the clean-room Workspace/Publisher. + ### Start Chatting Use the built-in dashboard, or connect any OpenAI-compatible client: ```bash -kubectl port-forward -n orka-system svc/orka-api 8080:8080 +kubectl port-forward -n orka-system svc/orka 8080:8080 # Open the web dashboard open http://localhost:8080 @@ -150,9 +181,9 @@ The built-in orchestrator creates agents, runs tasks, monitors progress, and ret | [Architecture](website/docs/concepts/architecture.md) | System design, components, and data flow | | [Configuration](website/docs/concepts/configuration.md) | CRD reference, Helm values, controller flags, metrics | | [Observability](website/docs/guides/observability.md) | OpenTelemetry traces, GenAI metrics, and task trace guidance | -| [Agent Runtimes](website/docs/concepts/agent-runtimes.md) | Built-in CLI runtimes and bring-your-own remote AgentRuntime backends | -| [CLI Harness Wrapper](website/docs/guides/cli-harness-wrapper.md) | Harness protocol wrapper for Codex, Claude, Copilot, and OpenCode CLI runtimes | -| [Agent Sandbox](website/docs/concepts/agent-sandbox.md) | Experimental upstream `agent-sandbox` workspace execution for agent runtimes | +| [Agent Runtimes](website/docs/concepts/agent-runtimes.md) | ACP v2 RuntimePools, workspace policy, delivery, and external registrations | +| [AgentRuntime Adapter Contract](website/docs/development/agent-runtime-adapter-contract.md) | Portable `orka.harness.v2` session and fencing contract | +| [Agent Sandbox](website/docs/concepts/agent-sandbox.md) | Deferred execution-workspace integration behind the ACP v2 lifecycle | | [Interactive Chat](website/docs/guides/chat.md) | Chat endpoint, tools, and SSE streaming | | [Multi-Agent Coordination](website/docs/guides/multi-agent-coordination.md) | Coordinator agents and task delegation | | [Autonomous Tasks](website/docs/guides/autonomous-tasks.md) | Long-running coordinator loops with persisted plan state | @@ -161,6 +192,7 @@ The built-in orchestrator creates agents, runs tasks, monitors progress, and ret | [OpenAI Compatibility](website/docs/reference/openai-compat.md) | OpenAI-compatible chat completions API | | [Anthropic Compatibility](website/docs/reference/anthropic-compat.md) | Anthropic-compatible Messages API | | [Gateway API](website/docs/reference/gateway-api.md) | Generic Gateway resources, ingress, delivery, and operator APIs | +| [Harness Modes](website/docs/operations/harness-modes.md) | Isolated v1/v2 releases, rollout, rollback, and retirement | | [Operating Gateways](website/docs/operations/gateways.md) | Gateway readiness, TLS, recovery, upgrades, and operations | | [Web Dashboard](website/docs/guides/ui.md) | Frontend architecture and pages | | [Security](website/docs/concepts/security.md) | Security model and hardening | diff --git a/api/v1alpha1/agent_execution_binding_types.go b/api/v1alpha1/agent_execution_binding_types.go new file mode 100644 index 000000000..c37a15b79 --- /dev/null +++ b/api/v1alpha1/agent_execution_binding_types.go @@ -0,0 +1,142 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// AgentExecutionBackend identifies the isolated execution dispatcher backend. +// +kubebuilder:validation:Enum=harness-wrapper;runtime-pool;external-endpoint +type AgentExecutionBackend string + +const ( + // AgentExecutionBackendHarnessWrapper is the built-in harness v1 wrapper. + AgentExecutionBackendHarnessWrapper AgentExecutionBackend = "harness-wrapper" + // AgentExecutionBackendRuntimePool is the managed ACP v2 RuntimePool path. + AgentExecutionBackendRuntimePool AgentExecutionBackend = "runtime-pool" + // AgentExecutionBackendExternalEndpoint is an external AgentRuntime endpoint. + AgentExecutionBackendExternalEndpoint AgentExecutionBackend = "external-endpoint" +) + +// AgentExecutionBindingTaskRef pins the bound Task identity. +type AgentExecutionBindingTaskRef struct { + // NamespaceUID is the UID of the Task namespace, preventing same-name + // namespace recreation from satisfying old identities. + // +kubebuilder:validation:Required + NamespaceUID types.UID `json:"namespaceUID"` + + // +kubebuilder:validation:Required + UID types.UID `json:"uid"` + + // BoundSpecGeneration is the Task spec generation frozen into the snapshot. + // +kubebuilder:validation:Minimum=1 + BoundSpecGeneration int64 `json:"boundSpecGeneration"` +} + +// AgentExecutionAgentRef pins the exact Agent identity resolved at binding. +type AgentExecutionAgentRef struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Namespace string `json:"namespace"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // +kubebuilder:validation:Required + UID types.UID `json:"uid"` + + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation"` +} + +// AgentExecutionSnapshotRef links the immutable non-secret execution snapshot. +type AgentExecutionSnapshotRef struct { + // ID is the snapshot identity in the form /sha256:. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=512 + ID string `json:"id"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + Digest string `json:"digest"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=1 + SchemaVersion int32 `json:"schemaVersion"` +} + +// AgentExecutionRuntimeRef pins a referenced AgentRuntime identity. +type AgentExecutionRuntimeRef struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // +kubebuilder:validation:Required + UID types.UID `json:"uid"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation"` +} + +// AgentExecutionBinding is the controller-owned, write-once, immutable +// execution binding for one executable agent Task. It is authoritative for +// routing, recovery, cancellation, terminal settlement, and finalization, and +// is preserved across retries. Dispatchers build requests only from the +// referenced immutable snapshot. +// +kubebuilder:validation:XValidation:rule="self.backend != 'harness-wrapper' || self.contractVersion == 'orka.harness.v1'",message="the harness-wrapper backend requires an orka.harness.v1 binding" +// +kubebuilder:validation:XValidation:rule="self.backend != 'runtime-pool' || self.contractVersion == 'orka.harness.v2'",message="the runtime-pool backend requires an orka.harness.v2 binding" +type AgentExecutionBinding struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=1 + SchemaVersion int32 `json:"schemaVersion"` + + // ContractVersion is the frozen execution protocol for the Task lifetime. + // +kubebuilder:validation:Required + ContractVersion AgentRuntimeContractVersion `json:"contractVersion"` + + // +kubebuilder:validation:Required + Backend AgentExecutionBackend `json:"backend"` + + // BindingDigest is the canonical digest of this binding; every durable + // demand, attempt, turn, Session lease, publication, and cleanup record + // copies it. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + BindingDigest string `json:"bindingDigest"` + + // +kubebuilder:validation:Required + Task AgentExecutionBindingTaskRef `json:"task"` + + // +optional + Agent *AgentExecutionAgentRef `json:"agent,omitempty"` + + // +kubebuilder:validation:Required + Snapshot AgentExecutionSnapshotRef `json:"snapshot"` + + // RuntimeType is the built-in runtime type, empty for runtimeRef bindings. + // +optional + RuntimeType AgentRuntimeType `json:"runtimeType,omitempty"` + + // +optional + RuntimeRef *AgentExecutionRuntimeRef `json:"runtimeRef,omitempty"` + + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RuntimeProfileDigest string `json:"runtimeProfileDigest,omitempty"` + + // +optional + // +kubebuilder:validation:Enum=1 + RuntimeProfileDigestSchemaVersion int32 `json:"runtimeProfileDigestSchemaVersion,omitempty"` + + // +kubebuilder:validation:Required + BoundAt metav1.Time `json:"boundAt"` +} diff --git a/api/v1alpha1/agent_runtime_crd_types.go b/api/v1alpha1/agent_runtime_crd_types.go index 7d54c6ec9..ba929e41b 100644 --- a/api/v1alpha1/agent_runtime_crd_types.go +++ b/api/v1alpha1/agent_runtime_crd_types.go @@ -6,15 +6,37 @@ MIT License - see LICENSE file for details. package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) // AgentRuntimeContractVersion identifies the Orka-facing runtime contract. -// +kubebuilder:validation:Enum=orka.harness.v1 +// During harness coexistence both protocol values are schema-valid; omission is +// never protocol evidence and is tolerated only for stored objects awaiting the +// one-time bridge classification. +// +kubebuilder:validation:Enum=orka.harness.v1;orka.harness.v2 type AgentRuntimeContractVersion string const ( // AgentRuntimeContractHarnessV1 is the frozen harness v1 HTTP+SSE contract. AgentRuntimeContractHarnessV1 AgentRuntimeContractVersion = "orka.harness.v1" + // AgentRuntimeContractHarnessV2 is the session-centric HTTP+NDJSON contract. + AgentRuntimeContractHarnessV2 AgentRuntimeContractVersion = "orka.harness.v2" +) + +// AgentRuntimeToolExecutionMode describes how a harness v1 runtime executes tools. +// +kubebuilder:validation:Enum=observed;brokered +type AgentRuntimeToolExecutionMode string + +const ( + // AgentRuntimeToolExecutionModeObserved marks runtimes that execute tools + // themselves; Orka only observes emitted frames. + AgentRuntimeToolExecutionModeObserved AgentRuntimeToolExecutionMode = "observed" + // AgentRuntimeToolExecutionModeBrokered marks runtimes whose tool calls are + // executed by Orka and continued back into the turn. + AgentRuntimeToolExecutionModeBrokered AgentRuntimeToolExecutionMode = "brokered" ) // AgentRuntimeDeploymentMode selects how the runtime endpoint is provided. @@ -26,27 +48,14 @@ const ( AgentRuntimeDeploymentModeExternalEndpoint AgentRuntimeDeploymentMode = "external-endpoint" ) -// AgentRuntimeToolExecutionMode declares how custom runtimes interact with tools. -// +kubebuilder:validation:Enum=observed;brokered -type AgentRuntimeToolExecutionMode string - -const ( - // AgentRuntimeToolExecutionModeObserved means the runtime owns its internal tools and Orka observes lifecycle only. - AgentRuntimeToolExecutionModeObserved AgentRuntimeToolExecutionMode = "observed" - // AgentRuntimeToolExecutionModeBrokered means the runtime asks Orka to execute governed Tool CRDs. - AgentRuntimeToolExecutionModeBrokered AgentRuntimeToolExecutionMode = "brokered" -) - -// AgentRuntimeBrokeredToolClass declares which classes of Orka-brokered tools a runtime can request. +// AgentRuntimeBrokeredToolClass classifies Tool CRDs. It is shared by the Tool +// API and by harness v1 AgentRuntime capability declarations. // +kubebuilder:validation:Enum=read;write;coordination type AgentRuntimeBrokeredToolClass string const ( - // AgentRuntimeBrokeredToolClassRead covers read-only evidence and lookup tools. - AgentRuntimeBrokeredToolClassRead AgentRuntimeBrokeredToolClass = "read" - // AgentRuntimeBrokeredToolClassWrite covers consequential tools that may require approval and idempotency. - AgentRuntimeBrokeredToolClassWrite AgentRuntimeBrokeredToolClass = "write" - // AgentRuntimeBrokeredToolClassCoordination covers Orka coordination tools such as delegate_task/wait_for_tasks. + AgentRuntimeBrokeredToolClassRead AgentRuntimeBrokeredToolClass = "read" + AgentRuntimeBrokeredToolClassWrite AgentRuntimeBrokeredToolClass = "write" AgentRuntimeBrokeredToolClassCoordination AgentRuntimeBrokeredToolClass = "coordination" ) @@ -59,18 +68,20 @@ type AgentRuntimeReference struct { // AgentRuntimeDeploymentSpec configures where Orka reaches the harness runtime. type AgentRuntimeDeploymentSpec struct { - // Mode is the deployment mode. The first milestone supports external endpoints only. + // Mode is the deployment mode. External AgentRuntime registrations are not + // scaled or recycled by Orka. // +kubebuilder:validation:Required Mode AgentRuntimeDeploymentMode `json:"mode"` - // Endpoint is the base URL for a pre-deployed or external orka.harness.v1 service. - // It must not contain credentials; bearer auth is configured via clientAuth. + // Endpoint is the base URL for an external harness service. It must not + // contain credentials, query parameters, or fragments. // +kubebuilder:validation:Required // +kubebuilder:validation:Pattern=`^https?://[^\s@?#]+$` Endpoint string `json:"endpoint"` } -// AgentRuntimeBearerAuthReference identifies the Secret key holding a harness bearer token. +// AgentRuntimeBearerAuthReference identifies the Secret key holding a harness +// v1 bearer token. Preserved verbatim from the harness v1 schema. type AgentRuntimeBearerAuthReference struct { // Name is the Secret name. // +kubebuilder:validation:Required @@ -83,132 +94,366 @@ type AgentRuntimeBearerAuthReference struct { Key string `json:"key"` } -// AgentRuntimeClientAuth configures Orka's client authentication to the harness endpoint. -type AgentRuntimeClientAuth struct { - // BearerAuthRef points to the bearer token Secret used for mutating harness endpoints. - // The referenced Secret must opt in with label orka.ai/agent-runtime-auth=true, - // may set orka.ai/agent-runtime-name= to restrict use to one AgentRuntime, - // and must set annotation orka.ai/agent-runtime-endpoint= to bind the token to one endpoint. +// AgentRuntimeSecretKeyReference identifies one Secret key used for v2 control traffic. +type AgentRuntimeSecretKeyReference struct { + // Name is the Secret name in the AgentRuntime namespace. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + Name string `json:"name"` + + // Key is the Secret data key. // +kubebuilder:validation:Required - BearerAuthRef AgentRuntimeBearerAuthReference `json:"bearerTokenSecretRef"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Key string `json:"key"` } -// AgentRuntimeCapabilitiesSpec describes required capabilities for runtime readiness. -type AgentRuntimeCapabilitiesSpec struct { - // ToolExecutionModes lists tool execution modes the runtime must advertise. - // +listType=set +// AgentRuntimeClientAuth configures controller authentication and per-operation +// authorization. Exactly one contract-specific shape may be present: the legacy +// v1 bearer reference, or the v2 controller-bearer plus operation-capability pair. +// +kubebuilder:validation:XValidation:rule="!(has(self.bearerTokenSecretRef) && (has(self.controllerBearerTokenSecretRef) || has(self.operationCapabilitySecretRef)))",message="legacy v1 and v2 client auth shapes are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="has(self.controllerBearerTokenSecretRef) == has(self.operationCapabilitySecretRef)",message="v2 client auth requires both controllerBearerTokenSecretRef and operationCapabilitySecretRef" +// +kubebuilder:validation:XValidation:rule="has(self.bearerTokenSecretRef) || has(self.controllerBearerTokenSecretRef)",message="client auth requires either the v1 or the v2 credential shape" +type AgentRuntimeClientAuth struct { + // BearerAuthRef points to the harness v1 bearer token Secret used for + // mutating v1 harness endpoints. The referenced Secret must opt in with + // label orka.ai/agent-runtime-auth=true, may set + // orka.ai/agent-runtime-name= to restrict use to one AgentRuntime, + // and must set annotation orka.ai/agent-runtime-endpoint= + // to bind the token to one endpoint. // +optional - ToolExecutionModes []AgentRuntimeToolExecutionMode `json:"toolExecutionModes,omitempty"` + BearerAuthRef *AgentRuntimeBearerAuthReference `json:"bearerTokenSecretRef,omitempty"` - // BrokeredToolClasses lists brokered tool classes the runtime must advertise when brokered mode is required. - // +listType=set + // ControllerBearerTokenSecretRef supplies the controller bearer token used by + // authenticated v2 status and mutation endpoints. // +optional - BrokeredToolClasses []AgentRuntimeBrokeredToolClass `json:"brokeredToolClasses,omitempty"` + ControllerBearerTokenSecretRef *AgentRuntimeSecretKeyReference `json:"controllerBearerTokenSecretRef,omitempty"` - // SupportsCancel requires the runtime to advertise cancellation support when true. + // OperationCapabilitySecretRef supplies the HMAC secret used to bind every + // v2 mutation to its exact fence, operation identity, request digest, and expiry. // +optional - SupportsCancel *bool `json:"supportsCancel,omitempty"` + OperationCapabilitySecretRef *AgentRuntimeSecretKeyReference `json:"operationCapabilitySecretRef,omitempty"` +} - // SupportsRuntimeSessions requires the runtime to advertise stable runtime sessions when true. - // +optional - SupportsRuntimeSessions *bool `json:"supportsRuntimeSessions,omitempty"` +// AgentRuntimeWorkspaceGovernanceMode describes whether Orka may rely on the +// runtime for strict workspace guarantees. +// +kubebuilder:validation:Enum=strict-governed;trusted-non-governed +type AgentRuntimeWorkspaceGovernanceMode string - // SupportsContinuation requires the runtime to advertise continuation after Orka-brokered tool results when true. - // +optional - SupportsContinuation *bool `json:"supportsContinuation,omitempty"` +const ( + // AgentRuntimeWorkspaceGovernanceStrict is eligible only for the exact + // workspace intent pinned in the immutable runtime profile. + AgentRuntimeWorkspaceGovernanceStrict AgentRuntimeWorkspaceGovernanceMode = "strict-governed" + // AgentRuntimeWorkspaceGovernanceTrusted marks an explicitly trusted runtime + // whose tools and workspace behavior are outside Orka governance. + AgentRuntimeWorkspaceGovernanceTrusted AgentRuntimeWorkspaceGovernanceMode = "trusted-non-governed" +) - // SupportsArtifacts requires the runtime to advertise artifact/result reference support when true. - // +optional - SupportsArtifacts *bool `json:"supportsArtifacts,omitempty"` +// AgentRuntimeWorkspaceGovernanceCapabilities are static claims advertised by +// /v2/capabilities and exercised by the hostile conformance cycle. +// +kubebuilder:validation:XValidation:rule="self.mode != 'trusted-non-governed' || self.trusted",message="trusted-non-governed runtimes must be explicitly marked trusted" +// +kubebuilder:validation:XValidation:rule="self.mode != 'strict-governed' || !self.trusted",message="strict-governed runtimes must not use the trusted non-governed escape hatch" +// +kubebuilder:validation:XValidation:rule="self.mode != 'strict-governed' || (self.orkaOwnedWorkspaceDeltas && self.promptScopedBrokerAuthorization && self.noDirectSCMPublication && self.orkaOwnedCleanRoomPublication && self.exactInstanceFencing && self.duplicateSafeMutations && self.cancellationSettlement)",message="strict-governed runtimes must claim every strict workspace governance guarantee" +// +kubebuilder:validation:XValidation:rule="self.mode != 'trusted-non-governed' || (!self.orkaOwnedWorkspaceDeltas && !self.promptScopedBrokerAuthorization && !self.noDirectSCMPublication && !self.orkaOwnedCleanRoomPublication && !self.exactInstanceFencing && !self.duplicateSafeMutations && !self.cancellationSettlement)",message="trusted-non-governed runtimes must not claim strict workspace guarantees" +type AgentRuntimeWorkspaceGovernanceCapabilities struct { + // Mode selects strict Orka governance or an explicit trusted escape hatch. + // +kubebuilder:validation:Required + Mode AgentRuntimeWorkspaceGovernanceMode `json:"mode"` + + // Trusted must be true only for trusted-non-governed runtimes. Such runtimes + // are ineligible for Tasks requesting strict read or write guarantees. + Trusted bool `json:"trusted"` + + OrkaOwnedWorkspaceDeltas bool `json:"orkaOwnedWorkspaceDeltas"` + PromptScopedBrokerAuthorization bool `json:"promptScopedBrokerAuthorization"` + NoDirectSCMPublication bool `json:"noDirectSCMPublication"` + OrkaOwnedCleanRoomPublication bool `json:"orkaOwnedCleanRoomPublication"` + ExactInstanceFencing bool `json:"exactInstanceFencing"` + DuplicateSafeMutations bool `json:"duplicateSafeMutations"` + CancellationSettlement bool `json:"cancellationSettlement"` } -// AgentRuntimeRegistrySpec defines the desired state of a registered Orka harness runtime. -type AgentRuntimeRegistrySpec struct { - // ContractVersion is the Orka harness contract this runtime must implement. +// Strict reports whether every strict workspace governance guarantee is claimed. +func (c AgentRuntimeWorkspaceGovernanceCapabilities) Strict() bool { + return c.Mode == AgentRuntimeWorkspaceGovernanceStrict && !c.Trusted && + c.OrkaOwnedWorkspaceDeltas && c.PromptScopedBrokerAuthorization && + c.NoDirectSCMPublication && c.OrkaOwnedCleanRoomPublication && + c.ExactInstanceFencing && c.DuplicateSafeMutations && c.CancellationSettlement +} + +// AgentRuntimeProtocolLimits pins the exact bounded v2 limits expected from the runtime. +type AgentRuntimeProtocolLimits struct { + // +kubebuilder:validation:Minimum=1 + MaxResidentSessions int32 `json:"maxResidentSessions"` + // +kubebuilder:validation:Minimum=1 + MaxConcurrentPrompts int32 `json:"maxConcurrentPrompts"` + // +kubebuilder:validation:Minimum=1 + MaxRequestBytes int32 `json:"maxRequestBytes"` + // +kubebuilder:validation:Minimum=1 + MaxEventLineBytes int32 `json:"maxEventLineBytes"` + // +kubebuilder:validation:Minimum=1 + MaxTerminalResultBytes int32 `json:"maxTerminalResultBytes"` + // +kubebuilder:validation:Minimum=1 + MaxBufferedEvents int32 `json:"maxBufferedEvents"` + // +kubebuilder:validation:Minimum=1 + MaxUpdateEventsPerSecond int32 `json:"maxUpdateEventsPerSecond"` + // +kubebuilder:validation:Minimum=1 + MinPromptLeaseMillis int64 `json:"minPromptLeaseMillis"` + // +kubebuilder:validation:Minimum=1 + MaxPromptLeaseMillis int64 `json:"maxPromptLeaseMillis"` + // +kubebuilder:validation:Minimum=1 + MaxPendingPermissions int32 `json:"maxPendingPermissions"` + // +kubebuilder:validation:Minimum=1 + MaxWorkspaceDeltaBytes int64 `json:"maxWorkspaceDeltaBytes"` +} + +// AgentRuntimeProfileSpec pins one immutable, single-adapter v2 runtime profile. +type AgentRuntimeProfileSpec struct { + // Digest is the canonical orka.harness.v2 runtime-profile digest. // +kubebuilder:validation:Required - // +kubebuilder:default=orka.harness.v1 - ContractVersion AgentRuntimeContractVersion `json:"contractVersion"` + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + Digest string `json:"digest"` - // Deployment identifies the runtime endpoint provider. + // DigestSchemaVersion identifies the canonical profile digest schema. // +kubebuilder:validation:Required - Deployment AgentRuntimeDeploymentSpec `json:"deployment"` + // +kubebuilder:validation:Enum=1 + DigestSchemaVersion int32 `json:"digestSchemaVersion"` - // ClientAuth configures controller-to-runtime authentication. + // ACPProfile is the reviewed ACP profile. // +kubebuilder:validation:Required - ClientAuth AgentRuntimeClientAuth `json:"clientAuth"` + // +kubebuilder:validation:Enum=acp.v1 + ACPProfile string `json:"acpProfile"` + + // AdapterName identifies the sole adapter contained by this external profile. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + AdapterName string `json:"adapterName"` - // Capabilities declares readiness requirements Orka checks against the runtime. + // AdapterDigest pins the adapter/CLI artifact set. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + AdapterDigest string `json:"adapterDigest"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + ProviderKind string `json:"providerKind"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Model string `json:"model"` + + // ModelLimits pins optional reviewed model token capacities. // +optional - Capabilities *AgentRuntimeCapabilitiesSpec `json:"capabilities,omitempty"` + ModelLimits *ModelTokenLimits `json:"modelLimits,omitempty"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + AgentConfigurationDigest string `json:"agentConfigurationDigest"` + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ToolPolicyDigest string `json:"toolPolicyDigest"` + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ApprovalPolicyDigest string `json:"approvalPolicyDigest"` + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + MCPConfigurationDigest string `json:"mcpConfigurationDigest"` + + // WorkspaceIntent is the one immutable strict intent represented by this profile. + // +kubebuilder:validation:Required + WorkspaceIntent WorkspaceIntent `json:"workspaceIntent"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + ProxyCredentialRole string `json:"proxyCredentialRole"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ProxyCredentialScope string `json:"proxyCredentialScope"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + ResourceClass string `json:"resourceClass"` } -// AgentRuntimeObservedCapabilities records the sanitized capability data observed by Orka. -type AgentRuntimeObservedCapabilities struct { - // ProtocolVersion is the runtime's advertised Orka protocol version. +// AgentRuntimeCapabilitiesSpec pins runtime capability claims for both harness +// contracts. Variant-specific fields are optional in the shared schema; the +// contract discriminator CEL on the spec enforces the selected variant's shape. +type AgentRuntimeCapabilitiesSpec struct { + // RuntimeInstanceID is the immutable external supervisor instance expected from + // authenticated /v2/status and every conformance response. v2 only. // +optional - ProtocolVersion string `json:"protocolVersion,omitempty"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + RuntimeInstanceID string `json:"runtimeInstanceID,omitempty"` - // Transport is the runtime transport, normally http+sse. + // Profile is the exact immutable profile accepted by v2 session creation. // +optional - Transport string `json:"transport,omitempty"` + Profile *AgentRuntimeProfileSpec `json:"profile,omitempty"` - // RuntimeName is the runtime name advertised by /v1/capabilities. + // Limits must exactly match /v2/capabilities. v2 only. // +optional - RuntimeName string `json:"runtimeName,omitempty"` + Limits *AgentRuntimeProtocolLimits `json:"limits,omitempty"` - // RuntimeVersion is the runtime version advertised by /v1/capabilities. + // SupportsDrain must exactly match the static v2 capability claim. // +optional - RuntimeVersion string `json:"runtimeVersion,omitempty"` + SupportsDrain bool `json:"supportsDrain,omitempty"` + + // SupportsPublicationFinalization must exactly match the static v2 capability claim. + // +optional + SupportsPublicationFinalization bool `json:"supportsPublicationFinalization,omitempty"` - // ProviderKind is the provider kind advertised by /v1/capabilities. + // WorkspaceGovernance must exactly match the static v2 capability claim. // +optional - ProviderKind string `json:"providerKind,omitempty"` + WorkspaceGovernance *AgentRuntimeWorkspaceGovernanceCapabilities `json:"workspaceGovernance,omitempty"` - // ToolExecutionModes are the tool modes advertised by /v1/capabilities. + // ToolExecutionModes lists the harness v1 tool execution modes supported by + // the runtime. v1 only; historically optional. // +listType=set // +optional ToolExecutionModes []AgentRuntimeToolExecutionMode `json:"toolExecutionModes,omitempty"` - // BrokeredToolClasses are the brokered tool classes advertised by /v1/capabilities. + // BrokeredToolClasses lists the harness v1 brokered tool classes supported + // by the runtime. v1 only; historically optional. // +listType=set // +optional BrokeredToolClasses []AgentRuntimeBrokeredToolClass `json:"brokeredToolClasses,omitempty"` - // SupportsCancel reports whether the runtime advertises cancellation support. + // SupportsCancel declares harness v1 turn cancellation support. v1 only. // +optional - SupportsCancel bool `json:"supportsCancel,omitempty"` + SupportsCancel *bool `json:"supportsCancel,omitempty"` - // SupportsRuntimeSessions reports whether the runtime advertises runtime-session support. + // SupportsRuntimeSessions declares harness v1 runtime session support. v1 only. // +optional - SupportsRuntimeSessions bool `json:"supportsRuntimeSessions,omitempty"` + SupportsRuntimeSessions *bool `json:"supportsRuntimeSessions,omitempty"` - // SupportsContinuation reports whether the runtime advertises continuation support. + // SupportsContinuation declares harness v1 brokered continuation support. v1 only. // +optional - SupportsContinuation bool `json:"supportsContinuation,omitempty"` + SupportsContinuation *bool `json:"supportsContinuation,omitempty"` - // SupportsArtifacts reports whether the runtime advertises artifact/result reference support. + // SupportsArtifacts declares harness v1 artifact support. v1 only. // +optional - SupportsArtifacts bool `json:"supportsArtifacts,omitempty"` + SupportsArtifacts *bool `json:"supportsArtifacts,omitempty"` +} - // SupportsSuspend reports whether the runtime advertises suspend support. - // +optional - SupportsSuspend bool `json:"supportsSuspend,omitempty"` +// SupportsStrictWorkspaceIntent returns true only for the exact intent pinned +// by a fully governed v2 profile. Trusted/non-governed and v1 runtimes always +// return false. +func (c AgentRuntimeCapabilitiesSpec) SupportsStrictWorkspaceIntent(intent WorkspaceIntent) bool { + if c.WorkspaceGovernance == nil || c.Profile == nil { + return false + } + return c.WorkspaceGovernance.Strict() && c.Profile.WorkspaceIntent == intent && + (intent != WorkspaceIntentWrite || c.SupportsPublicationFinalization) +} - // SupportsWorkspaceSnapshot reports whether the runtime advertises workspace snapshots. +// ValidateStrictWorkspaceIntent rejects trusted/non-governed runtimes and exact-profile intent mismatches. +func (c AgentRuntimeCapabilitiesSpec) ValidateStrictWorkspaceIntent(intent WorkspaceIntent) error { + if intent != WorkspaceIntentRead && intent != WorkspaceIntentWrite { + return fmt.Errorf("unsupported strict workspace intent %q", intent) + } + if c.WorkspaceGovernance == nil || c.Profile == nil { + return fmt.Errorf("AgentRuntime does not pin the v2 workspace governance capabilities required for strict %q workspace intent", intent) + } + if c.WorkspaceGovernance.Mode == AgentRuntimeWorkspaceGovernanceTrusted { + return fmt.Errorf("trusted-non-governed AgentRuntime cannot satisfy strict %q workspace intent", intent) + } + if !c.WorkspaceGovernance.Strict() { + return fmt.Errorf("AgentRuntime does not provide all strict workspace governance guarantees") + } + if c.Profile.WorkspaceIntent != intent { + return fmt.Errorf("AgentRuntime profile is pinned to workspace intent %q, not %q", c.Profile.WorkspaceIntent, intent) + } + if intent == WorkspaceIntentWrite && !c.SupportsPublicationFinalization { + return fmt.Errorf("AgentRuntime does not support controller-owned RuntimeSession publication finalization required for write workspaces") + } + return nil +} + +// AgentRuntimeRegistrySpec defines the desired state of a registered Orka harness runtime. +// The dual schema has no contractVersion default: omission is tolerated only for +// stored objects awaiting the one-time bridge classification and is never +// interpreted as either protocol. Fail-closed admission requires an explicit +// value for new registrations. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.contractVersion) || (has(self.contractVersion) && self.contractVersion == oldSelf.contractVersion)",message="contractVersion is immutable once set" +// +kubebuilder:validation:XValidation:rule="!has(self.contractVersion) || self.contractVersion != 'orka.harness.v1' || (has(self.clientAuth.bearerTokenSecretRef) && !has(self.clientAuth.controllerBearerTokenSecretRef) && !has(self.clientAuth.operationCapabilitySecretRef))",message="orka.harness.v1 requires the legacy bearerTokenSecretRef client auth shape" +// +kubebuilder:validation:XValidation:rule="!has(self.contractVersion) || self.contractVersion != 'orka.harness.v1' || !has(self.capabilities) || (!has(self.capabilities.runtimeInstanceID) && !has(self.capabilities.profile) && !has(self.capabilities.limits) && !has(self.capabilities.workspaceGovernance) && !has(self.capabilities.supportsDrain) && !has(self.capabilities.supportsPublicationFinalization))",message="orka.harness.v1 capabilities must not carry v2 capability fields" +// +kubebuilder:validation:XValidation:rule="!has(self.contractVersion) || self.contractVersion != 'orka.harness.v2' || (has(self.clientAuth.controllerBearerTokenSecretRef) && has(self.clientAuth.operationCapabilitySecretRef) && !has(self.clientAuth.bearerTokenSecretRef))",message="orka.harness.v2 requires the v2 controller bearer and operation capability client auth shape" +// +kubebuilder:validation:XValidation:rule="!has(self.contractVersion) || self.contractVersion != 'orka.harness.v2' || (has(self.capabilities) && has(self.capabilities.runtimeInstanceID) && has(self.capabilities.profile) && has(self.capabilities.limits) && has(self.capabilities.workspaceGovernance))",message="orka.harness.v2 requires pinned instance, profile, limits, and workspace governance capabilities" +// +kubebuilder:validation:XValidation:rule="!has(self.contractVersion) || self.contractVersion != 'orka.harness.v2' || !has(self.capabilities) || (!has(self.capabilities.toolExecutionModes) && !has(self.capabilities.brokeredToolClasses) && !has(self.capabilities.supportsCancel) && !has(self.capabilities.supportsRuntimeSessions) && !has(self.capabilities.supportsContinuation) && !has(self.capabilities.supportsArtifacts))",message="orka.harness.v2 capabilities must not carry v1 capability fields" +type AgentRuntimeRegistrySpec struct { + // ContractVersion is the Orka harness contract this runtime must implement. + // It is immutable once set. Required for new registrations through + // fail-closed admission; the bridge schema tolerates omission only on + // unchanged stored objects while execution admission is closed. // +optional - SupportsWorkspaceSnapshot bool `json:"supportsWorkspaceSnapshot,omitempty"` + ContractVersion *AgentRuntimeContractVersion `json:"contractVersion,omitempty"` + + // Deployment identifies the runtime endpoint provider. + // +kubebuilder:validation:Required + Deployment AgentRuntimeDeploymentSpec `json:"deployment"` + + // ClientAuth configures controller authentication and mutation authorization. + // +kubebuilder:validation:Required + ClientAuth AgentRuntimeClientAuth `json:"clientAuth"` - // MaxConcurrentTurns is the advertised concurrency ceiling. + // Capabilities pins the runtime capability claims. Required with the exact + // instance/profile/limits/governance shape for orka.harness.v2; historically + // optional for orka.harness.v1. // +optional - MaxConcurrentTurns int `json:"maxConcurrentTurns,omitempty"` + Capabilities *AgentRuntimeCapabilitiesSpec `json:"capabilities,omitempty"` +} - // MaxTurnSeconds is the advertised per-turn duration ceiling. +// AgentRuntimeObservedCapabilities records sanitized conformance data for both +// contracts. Variant-specific observed fields are written only by the matching +// probe implementation. +type AgentRuntimeObservedCapabilities struct { + ProtocolVersion string `json:"protocolVersion,omitempty"` + Transport string `json:"transport,omitempty"` + ACPVersion string `json:"acpVersion,omitempty"` + RuntimeInstanceID string `json:"runtimeInstanceID,omitempty"` + SupervisorBootID string `json:"supervisorBootID,omitempty"` + ControllerEpoch int64 `json:"controllerEpoch,omitempty"` + RuntimePoolUID string `json:"runtimePoolUID,omitempty"` + RuntimePoolGeneration int64 `json:"runtimePoolGeneration,omitempty"` + RuntimeProfileDigest string `json:"runtimeProfileDigest,omitempty"` + ProfileDigestSchemaVersion int32 `json:"profileDigestSchemaVersion,omitempty"` + AdapterName string `json:"adapterName,omitempty"` + AdapterDigest string `json:"adapterDigest,omitempty"` + ProviderKind string `json:"providerKind,omitempty"` + Model string `json:"model,omitempty"` + // Limits records the v2 protocol bounds. It is absent for harness v1. + // +optional + Limits *AgentRuntimeProtocolLimits `json:"limits,omitempty"` + SupportsDrain bool `json:"supportsDrain,omitempty"` + SupportsPublicationFinalization bool `json:"supportsPublicationFinalization,omitempty"` + // WorkspaceGovernance records the v2 workspace guarantees. It is absent for harness v1. // +optional - MaxTurnSeconds int `json:"maxTurnSeconds,omitempty"` + WorkspaceGovernance *AgentRuntimeWorkspaceGovernanceCapabilities `json:"workspaceGovernance,omitempty"` + Lifecycle string `json:"lifecycle,omitempty"` + + // Harness v1 observed fields, written only by the v1 conformance probe. - // MaxOutputBytes is the advertised maximum output payload size. + RuntimeName string `json:"runtimeName,omitempty"` + RuntimeVersion string `json:"runtimeVersion,omitempty"` + + // +listType=set // +optional - MaxOutputBytes int64 `json:"maxOutputBytes,omitempty"` + ToolExecutionModes []AgentRuntimeToolExecutionMode `json:"toolExecutionModes,omitempty"` + // +listType=set + // +optional + BrokeredToolClasses []AgentRuntimeBrokeredToolClass `json:"brokeredToolClasses,omitempty"` + + SupportsCancel bool `json:"supportsCancel,omitempty"` + SupportsRuntimeSessions bool `json:"supportsRuntimeSessions,omitempty"` + SupportsContinuation bool `json:"supportsContinuation,omitempty"` + SupportsArtifacts bool `json:"supportsArtifacts,omitempty"` + SupportsSuspend bool `json:"supportsSuspend,omitempty"` + SupportsWorkspaceSnapshot bool `json:"supportsWorkspaceSnapshot,omitempty"` + MaxConcurrentTurns int `json:"maxConcurrentTurns,omitempty"` + MaxTurnSeconds int `json:"maxTurnSeconds,omitempty"` + MaxOutputBytes int64 `json:"maxOutputBytes,omitempty"` } // AgentRuntimeStatus defines the observed state of an AgentRuntime. @@ -229,9 +474,20 @@ type AgentRuntimeStatus struct { // +optional LastValidated *metav1.Time `json:"lastValidated,omitempty"` - // ObservedAuthRefResourceVersion is the resourceVersion of the bearer auth Secret - // used for the last readiness probe. It is non-secret metadata used to decide - // when token rotation requires a fresh authenticated conformance turn. + // ObservedControllerAuthRefResourceVersion is the bearer Secret version used + // by the last successful or failed authenticated conformance probe. + // +optional + ObservedControllerAuthRefResourceVersion string `json:"observedControllerAuthRefResourceVersion,omitempty"` + + // ObservedOperationCapabilityRefResourceVersion is the HMAC Secret version used + // by the last mutation conformance probe. + // +optional + ObservedOperationCapabilityRefResourceVersion string `json:"observedOperationCapabilityRefResourceVersion,omitempty"` + + // ObservedAuthRefResourceVersion is the resourceVersion of the harness v1 + // bearer auth Secret used for the last v1 readiness probe. It is non-secret + // metadata used to decide when token rotation requires a fresh authenticated + // conformance turn. v2 probes use the two v2 auth resource-version fields. // +optional ObservedAuthRefResourceVersion string `json:"observedAuthRefResourceVersion,omitempty"` @@ -252,10 +508,10 @@ type AgentRuntimeStatus struct { // +kubebuilder:printcolumn:name="Ready",type=boolean,JSONPath=`.status.ready` // +kubebuilder:printcolumn:name="Contract",type=string,JSONPath=`.spec.contractVersion` // +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.deployment.mode` -// +kubebuilder:printcolumn:name="Runtime",type=string,JSONPath=`.status.observedCapabilities.runtimeName` +// +kubebuilder:printcolumn:name="Instance",type=string,JSONPath=`.status.observedCapabilities.runtimeInstanceID` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` -// AgentRuntime is the Schema for registered Orka harness runtimes. +// AgentRuntime is the Schema for registered external Orka harness runtimes. type AgentRuntime struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -264,6 +520,16 @@ type AgentRuntime struct { Status AgentRuntimeStatus `json:"status,omitempty"` } +// RegisteredContractVersion returns the explicit contract selector, or empty +// when the registration is still unclassified. Callers must treat empty as +// neither protocol and fail closed. +func (in *AgentRuntime) RegisteredContractVersion() AgentRuntimeContractVersion { + if in == nil || in.Spec.ContractVersion == nil { + return "" + } + return *in.Spec.ContractVersion +} + // +kubebuilder:object:root=true // AgentRuntimeList contains a list of AgentRuntime. diff --git a/api/v1alpha1/agent_runtime_types_test.go b/api/v1alpha1/agent_runtime_types_test.go index 7e6b0ea18..85dd0fb4f 100644 --- a/api/v1alpha1/agent_runtime_types_test.go +++ b/api/v1alpha1/agent_runtime_types_test.go @@ -7,6 +7,11 @@ MIT License - see LICENSE file for details. package v1alpha1 import ( + "encoding/json" + "os" + "path/filepath" + goruntime "runtime" + "strings" "testing" corev1 "k8s.io/api/core/v1" @@ -16,6 +21,10 @@ const ( testExecutionRuntimeClassGVisor = "gvisor" testExecutionRuntimeClassKata = "kata-qemu" testExecutionNodeLabelKey = "sandbox-runtime" + testAgentRuntimeControllerKey = "controller-token" + testAgentRuntimeCapabilityKey = "capability-secret" + testAgentRuntimeInstanceID = "runtime-instance-1" + testAgentRuntimeAuthSecretName = "runtime-auth" ) func TestTaskTypeAgentConstant(t *testing.T) { @@ -63,6 +72,41 @@ func TestAgentRuntimeTypeConstants(t *testing.T) { } } +func readTaskTypesSource(t *testing.T) []byte { + t.Helper() + _, testFile, _, ok := goruntime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + paths := []string{filepath.Join(filepath.Dir(testFile), "task_types.go"), "task_types.go"} + var lastErr error + for _, path := range paths { + source, err := os.ReadFile(path) + if err == nil { + return source + } + lastErr = err + } + t.Fatalf("read task_types.go: %v", lastErr) + return nil +} + +func TestAgentRuntimeTypeKubebuilderEnumIncludesSupportedBuiltIns(t *testing.T) { + source := readTaskTypesSource(t) + const marker = "// +kubebuilder:validation:Enum=claude;codex;copilot" + if !strings.Contains(string(source), marker) { + t.Fatalf("AgentRuntimeType marker does not include all supported built-ins: want %q", marker) + } +} + +func TestAgentPromptImmutabilityMarkerHandlesOmittedPrompt(t *testing.T) { + source := readTaskTypesSource(t) + const marker = "has(self.prompt) == has(oldSelf.prompt)" + if !strings.Contains(string(source), marker) { + t.Fatalf("agent prompt immutability marker is not presence-aware: want %q", marker) + } +} + func TestAgentRuntimeSpecFields(t *testing.T) { maxTurns := int32(10) allowBash := true @@ -71,10 +115,6 @@ func TestAgentRuntimeSpecFields(t *testing.T) { AllowedTools: []string{"read", "write"}, DisallowedTools: []string{"delete"}, AllowBash: &allowBash, - Workspace: &WorkspaceConfig{ - GitRepo: "https://github.com/example/repo", - Branch: "main", - }, } if *spec.MaxTurns != 10 { @@ -92,12 +132,6 @@ func TestAgentRuntimeSpecFields(t *testing.T) { if *spec.AllowBash != true { t.Errorf("AllowBash = %v, want true", *spec.AllowBash) } - if spec.Workspace == nil { - t.Fatal("Workspace should not be nil") - } - if spec.Workspace.GitRepo != "https://github.com/example/repo" { - t.Errorf("Workspace.GitRepo = %q, want %q", spec.Workspace.GitRepo, "https://github.com/example/repo") - } } func TestAgentRuntimeSpecDefaults(t *testing.T) { @@ -115,19 +149,19 @@ func TestAgentRuntimeSpecDefaults(t *testing.T) { if spec.AllowBash != nil { t.Errorf("AllowBash should be nil by default, got %v", spec.AllowBash) } - if spec.Workspace != nil { - t.Errorf("Workspace should be nil by default, got %v", spec.Workspace) - } } func TestWorkspaceConfigFields(t *testing.T) { - secretRef := &corev1.LocalObjectReference{Name: "git-secret"} + readRef := &WorkspaceCredentialReference{Name: "git-read"} + publicationRef := &WorkspaceCredentialReference{Name: "git-publish"} ws := WorkspaceConfig{ - GitRepo: "https://github.com/example/repo", - Branch: "develop", - Ref: "abc123", - GitSecretRef: secretRef, - SubPath: "src/app", + GitRepo: "https://github.com/example/repo", + Branch: "develop", + Ref: "abc123", + ReadCredentialRef: readRef, + PublicationGitRepo: "https://github.com/example/repo-fork", + PublicationCredentialRef: publicationRef, + SubPath: "src/app", } if ws.GitRepo != "https://github.com/example/repo" { @@ -139,8 +173,14 @@ func TestWorkspaceConfigFields(t *testing.T) { if ws.Ref != "abc123" { t.Errorf("Ref = %q, want %q", ws.Ref, "abc123") } - if ws.GitSecretRef == nil || ws.GitSecretRef.Name != "git-secret" { - t.Errorf("GitSecretRef.Name = %v, want %q", ws.GitSecretRef, "git-secret") + if ws.ReadCredentialRef == nil || ws.ReadCredentialRef.Name != "git-read" { + t.Errorf("ReadCredentialRef.Name = %v, want %q", ws.ReadCredentialRef, "git-read") + } + if ws.PublicationCredentialRef == nil || ws.PublicationCredentialRef.Name != "git-publish" { + t.Errorf("PublicationCredentialRef.Name = %v, want %q", ws.PublicationCredentialRef, "git-publish") + } + if ws.PublicationGitRepo != "https://github.com/example/repo-fork" { + t.Errorf("PublicationGitRepo = %q, want %q", ws.PublicationGitRepo, "https://github.com/example/repo-fork") } if ws.SubPath != "src/app" { t.Errorf("SubPath = %q, want %q", ws.SubPath, "src/app") @@ -159,8 +199,11 @@ func TestWorkspaceConfigDefaults(t *testing.T) { if ws.Ref != "" { t.Errorf("Ref should be empty by default, got %q", ws.Ref) } - if ws.GitSecretRef != nil { - t.Errorf("GitSecretRef should be nil by default, got %v", ws.GitSecretRef) + if ws.ReadCredentialRef != nil { + t.Errorf("ReadCredentialRef should be nil by default, got %v", ws.ReadCredentialRef) + } + if ws.PublicationCredentialRef != nil { + t.Errorf("PublicationCredentialRef should be nil by default, got %v", ws.PublicationCredentialRef) } if ws.SubPath != "" { t.Errorf("SubPath should be empty by default, got %q", ws.SubPath) @@ -195,6 +238,38 @@ func TestAgentCLIRuntimeFields(t *testing.T) { } } +func TestAgentCLIRuntimeJSONPreservesExplicitEmptyAllowedTools(t *testing.T) { + omitted, err := json.Marshal(AgentCLIRuntime{Type: AgentRuntimeOpencode}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(omitted), `"defaultAllowedTools"`) { + t.Fatalf("omitted runtime JSON = %s, want no defaultAllowedTools field", omitted) + } + + explicit, err := json.Marshal(AgentCLIRuntime{ + Type: AgentRuntimeOpencode, + DefaultAllowedTools: []string{}, + DefaultReasoningEffort: "high", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(explicit), `"defaultAllowedTools":[]`) { + t.Fatalf("explicit empty runtime JSON = %s, want defaultAllowedTools:[]", explicit) + } + var roundTrip AgentCLIRuntime + if err := json.Unmarshal(explicit, &roundTrip); err != nil { + t.Fatal(err) + } + if roundTrip.DefaultAllowedTools == nil || len(roundTrip.DefaultAllowedTools) != 0 { + t.Fatalf("round-trip defaultAllowedTools = %#v, want explicit empty slice", roundTrip.DefaultAllowedTools) + } + if roundTrip.DefaultReasoningEffort != "high" { + t.Fatalf("round-trip defaultReasoningEffort = %q, want high", roundTrip.DefaultReasoningEffort) + } +} + func TestAgentCLIRuntimeOnAgentSpec(t *testing.T) { maxTurns := int32(25) allowBash := false @@ -231,10 +306,10 @@ func TestTaskSpecAgentRuntimeField(t *testing.T) { AgentRuntime: &AgentRuntimeSpec{ MaxTurns: &maxTurns, AllowedTools: []string{"bash", "read"}, - Workspace: &WorkspaceConfig{ - GitRepo: "https://github.com/example/repo", - Branch: "main", - }, + }, + Workspace: &WorkspaceConfig{ + GitRepo: "https://github.com/example/repo", + Branch: "main", }, } @@ -247,11 +322,11 @@ func TestTaskSpecAgentRuntimeField(t *testing.T) { if *task.AgentRuntime.MaxTurns != 15 { t.Errorf("AgentRuntime.MaxTurns = %d, want 15", *task.AgentRuntime.MaxTurns) } - if task.AgentRuntime.Workspace == nil { - t.Fatal("AgentRuntime.Workspace should not be nil") + if task.Workspace == nil { + t.Fatal("Task.Workspace should not be nil") } - if task.AgentRuntime.Workspace.Branch != "main" { - t.Errorf("Workspace.Branch = %q, want %q", task.AgentRuntime.Workspace.Branch, "main") + if task.Workspace.Branch != "main" { + t.Errorf("Workspace.Branch = %q, want %q", task.Workspace.Branch, "main") } } @@ -378,40 +453,197 @@ func TestAgentRuntimeReferenceOnAgentCLI(t *testing.T) { } func TestAgentRuntimeCRDSpecFields(t *testing.T) { - supportsCancel := true - supportsContinuation := true + strict := AgentRuntimeWorkspaceGovernanceCapabilities{ + Mode: AgentRuntimeWorkspaceGovernanceStrict, + OrkaOwnedWorkspaceDeltas: true, + PromptScopedBrokerAuthorization: true, + NoDirectSCMPublication: true, + OrkaOwnedCleanRoomPublication: true, + ExactInstanceFencing: true, + DuplicateSafeMutations: true, + CancellationSettlement: true, + } runtime := AgentRuntime{ Spec: AgentRuntimeRegistrySpec{ - ContractVersion: AgentRuntimeContractHarnessV1, + ContractVersion: new(AgentRuntimeContractHarnessV2), Deployment: AgentRuntimeDeploymentSpec{ Mode: AgentRuntimeDeploymentModeExternalEndpoint, - Endpoint: "http://fibey-agentkit.default.svc.cluster.local:8080", + Endpoint: "https://runtime.example.com", + }, + ClientAuth: AgentRuntimeClientAuth{ + ControllerBearerTokenSecretRef: &AgentRuntimeSecretKeyReference{Name: testAgentRuntimeAuthSecretName, Key: testAgentRuntimeControllerKey}, + OperationCapabilitySecretRef: &AgentRuntimeSecretKeyReference{Name: testAgentRuntimeAuthSecretName, Key: testAgentRuntimeCapabilityKey}, }, - ClientAuth: AgentRuntimeClientAuth{BearerAuthRef: AgentRuntimeBearerAuthReference{ - Name: "fibey-agentkit-harness-token", - Key: "token", - }}, Capabilities: &AgentRuntimeCapabilitiesSpec{ - ToolExecutionModes: []AgentRuntimeToolExecutionMode{AgentRuntimeToolExecutionModeObserved, AgentRuntimeToolExecutionModeBrokered}, - BrokeredToolClasses: []AgentRuntimeBrokeredToolClass{AgentRuntimeBrokeredToolClassRead}, - SupportsCancel: &supportsCancel, - SupportsContinuation: &supportsContinuation, + RuntimeInstanceID: testAgentRuntimeInstanceID, + Profile: &AgentRuntimeProfileSpec{ + Digest: "sha256:" + strings.Repeat("a", 64), DigestSchemaVersion: 1, + ACPProfile: "acp.v1", AdapterName: "codex", AdapterDigest: "sha256:" + strings.Repeat("b", 64), + ProviderKind: "codex", Model: "gpt-test", + AgentConfigurationDigest: "sha256:" + strings.Repeat("c", 64), + ToolPolicyDigest: "sha256:" + strings.Repeat("d", 64), ApprovalPolicyDigest: "sha256:" + strings.Repeat("e", 64), + MCPConfigurationDigest: "sha256:" + strings.Repeat("f", 64), WorkspaceIntent: WorkspaceIntentRead, + ProxyCredentialRole: "provider-proxy", ProxyCredentialScope: "session-and-prompt", ResourceClass: "standard", + }, + Limits: &AgentRuntimeProtocolLimits{ + MaxResidentSessions: 10, MaxConcurrentPrompts: 4, MaxRequestBytes: 1 << 20, + MaxEventLineBytes: 1 << 20, MaxTerminalResultBytes: 1 << 20, MaxBufferedEvents: 256, + MaxUpdateEventsPerSecond: 100, MinPromptLeaseMillis: 5000, MaxPromptLeaseMillis: 120000, + MaxPendingPermissions: 32, MaxWorkspaceDeltaBytes: 512 << 20, + }, + SupportsDrain: true, + WorkspaceGovernance: &strict, }, }, } - if runtime.Spec.ContractVersion != AgentRuntimeContractHarnessV1 { - t.Fatalf("ContractVersion = %q", runtime.Spec.ContractVersion) + if runtime.RegisteredContractVersion() != AgentRuntimeContractHarnessV2 { + t.Fatalf("ContractVersion = %q", runtime.RegisteredContractVersion()) + } + if runtime.Spec.ClientAuth.ControllerBearerTokenSecretRef.Key != testAgentRuntimeControllerKey || + runtime.Spec.ClientAuth.OperationCapabilitySecretRef.Key != testAgentRuntimeCapabilityKey { + t.Fatalf("ClientAuth = %#v", runtime.Spec.ClientAuth) + } + if !runtime.Spec.Capabilities.SupportsStrictWorkspaceIntent(WorkspaceIntentRead) { + t.Fatal("strict read profile was not eligible for strict read intent") + } + if runtime.Spec.Capabilities.SupportsStrictWorkspaceIntent(WorkspaceIntentWrite) { + t.Fatal("exact read profile was incorrectly eligible for strict write intent") + } +} + +func TestAgentRuntimeWriteIntentRequiresPublicationFinalization(t *testing.T) { + capabilities := AgentRuntimeCapabilitiesSpec{ + Profile: &AgentRuntimeProfileSpec{WorkspaceIntent: WorkspaceIntentWrite}, + WorkspaceGovernance: &AgentRuntimeWorkspaceGovernanceCapabilities{ + Mode: AgentRuntimeWorkspaceGovernanceStrict, OrkaOwnedWorkspaceDeltas: true, PromptScopedBrokerAuthorization: true, + NoDirectSCMPublication: true, OrkaOwnedCleanRoomPublication: true, ExactInstanceFencing: true, + DuplicateSafeMutations: true, CancellationSettlement: true, + }, + } + if capabilities.SupportsStrictWorkspaceIntent(WorkspaceIntentWrite) { + t.Fatal("write runtime without publication finalization was strict-write eligible") + } + if err := capabilities.ValidateStrictWorkspaceIntent(WorkspaceIntentWrite); err == nil || !strings.Contains(err.Error(), "publication finalization") { + t.Fatalf("ValidateStrictWorkspaceIntent(write) = %v, want publication-finalization rejection", err) + } + capabilities.SupportsPublicationFinalization = true + if !capabilities.SupportsStrictWorkspaceIntent(WorkspaceIntentWrite) { + t.Fatal("write runtime with publication finalization was not strict-write eligible") + } + if err := capabilities.ValidateStrictWorkspaceIntent(WorkspaceIntentWrite); err != nil { + t.Fatalf("ValidateStrictWorkspaceIntent(write) = %v", err) + } +} + +func TestAgentRuntimeTrustedNonGovernedIsNeverStrictEligible(t *testing.T) { + capabilities := AgentRuntimeCapabilitiesSpec{ + Profile: &AgentRuntimeProfileSpec{WorkspaceIntent: WorkspaceIntentRead}, + WorkspaceGovernance: &AgentRuntimeWorkspaceGovernanceCapabilities{ + Mode: AgentRuntimeWorkspaceGovernanceTrusted, + Trusted: true, + }, + } + if capabilities.SupportsStrictWorkspaceIntent(WorkspaceIntentRead) || capabilities.SupportsStrictWorkspaceIntent(WorkspaceIntentWrite) { + t.Fatal("trusted non-governed runtime was eligible for strict workspace intent") + } + for _, intent := range []WorkspaceIntent{WorkspaceIntentRead, WorkspaceIntentWrite} { + if err := capabilities.ValidateStrictWorkspaceIntent(intent); err == nil || !strings.Contains(err.Error(), "trusted-non-governed") { + t.Fatalf("ValidateStrictWorkspaceIntent(%q) = %v, want explicit trusted rejection", intent, err) + } } - if runtime.Spec.Deployment.Mode != AgentRuntimeDeploymentModeExternalEndpoint { - t.Fatalf("Deployment.Mode = %q", runtime.Spec.Deployment.Mode) +} + +func TestAgentRuntimeV1CapabilityFieldsAreAbsentFromSerializedCRDSurface(t *testing.T) { + digest := func(char string) string { return "sha256:" + strings.Repeat(char, 64) } + profile := AgentRuntimeProfileSpec{ + Digest: digest("a"), DigestSchemaVersion: 1, ACPProfile: "acp.v1", AdapterName: "codex", AdapterDigest: digest("b"), + ProviderKind: "codex", Model: "gpt-test", AgentConfigurationDigest: digest("c"), ToolPolicyDigest: digest("d"), + ApprovalPolicyDigest: digest("e"), MCPConfigurationDigest: digest("f"), WorkspaceIntent: WorkspaceIntentRead, + ProxyCredentialRole: "provider-proxy", ProxyCredentialScope: "model:gpt-test", ResourceClass: "standard", + } + claims := AgentRuntimeWorkspaceGovernanceCapabilities{ + Mode: AgentRuntimeWorkspaceGovernanceStrict, OrkaOwnedWorkspaceDeltas: true, PromptScopedBrokerAuthorization: true, + NoDirectSCMPublication: true, OrkaOwnedCleanRoomPublication: true, ExactInstanceFencing: true, + DuplicateSafeMutations: true, CancellationSettlement: true, + } + limits := AgentRuntimeProtocolLimits{ + MaxResidentSessions: 10, MaxConcurrentPrompts: 4, MaxRequestBytes: 1 << 20, MaxEventLineBytes: 1 << 20, + MaxTerminalResultBytes: 1 << 20, MaxBufferedEvents: 256, MaxUpdateEventsPerSecond: 100, + MinPromptLeaseMillis: 5000, MaxPromptLeaseMillis: 120000, MaxPendingPermissions: 32, MaxWorkspaceDeltaBytes: 100 << 20, + } + spec := AgentRuntimeRegistrySpec{ + ContractVersion: new(AgentRuntimeContractHarnessV2), + ClientAuth: AgentRuntimeClientAuth{ + ControllerBearerTokenSecretRef: &AgentRuntimeSecretKeyReference{Name: testAgentRuntimeAuthSecretName, Key: testAgentRuntimeControllerKey}, + OperationCapabilitySecretRef: &AgentRuntimeSecretKeyReference{Name: testAgentRuntimeAuthSecretName, Key: testAgentRuntimeCapabilityKey}, + }, + Capabilities: &AgentRuntimeCapabilitiesSpec{RuntimeInstanceID: testAgentRuntimeInstanceID, Profile: &profile, Limits: &limits, SupportsDrain: true, WorkspaceGovernance: &claims}, } - if runtime.Spec.ClientAuth.BearerAuthRef.Name != "fibey-agentkit-harness-token" { - t.Fatalf("BearerAuthRef.Name = %q", runtime.Spec.ClientAuth.BearerAuthRef.Name) + encoded, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) } - if len(runtime.Spec.Capabilities.BrokeredToolClasses) != 1 || runtime.Spec.Capabilities.BrokeredToolClasses[0] != AgentRuntimeBrokeredToolClassRead { - t.Fatalf("BrokeredToolClasses = %#v", runtime.Spec.Capabilities.BrokeredToolClasses) + serialized := string(encoded) + for _, forbidden := range []string{"orka.harness.v1", "bearerTokenSecretRef", "toolExecutionModes", "brokeredToolClasses", "supportsContinuation", "supportsRuntimeSessions", "supportsArtifacts"} { + if strings.Contains(serialized, forbidden) { + t.Fatalf("serialized AgentRuntime still contains v1-only field %q: %s", forbidden, serialized) + } + } + for _, required := range []string{"orka.harness.v2", "controllerBearerTokenSecretRef", "operationCapabilitySecretRef", "runtimeInstanceID", "workspaceGovernance"} { + if !strings.Contains(serialized, required) { + t.Fatalf("serialized AgentRuntime is missing v2 field %q: %s", required, serialized) + } + } +} + +func TestAgentRuntimeSpecJSONPreservesExplicitEmptyAllowedTools(t *testing.T) { + omitted, err := json.Marshal(AgentRuntimeSpec{}) + if err != nil { + t.Fatal(err) } - if runtime.Spec.Capabilities.SupportsContinuation == nil || !*runtime.Spec.Capabilities.SupportsContinuation { - t.Fatalf("SupportsContinuation = %#v, want true", runtime.Spec.Capabilities.SupportsContinuation) + if strings.Contains(string(omitted), `"allowedTools"`) { + t.Fatalf("omitted runtime JSON = %s, want no allowedTools field", omitted) + } + + explicit, err := json.Marshal(AgentRuntimeSpec{AllowedTools: []string{}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(explicit), `"allowedTools":[]`) { + t.Fatalf("explicit empty runtime JSON = %s, want allowedTools:[]", explicit) + } + var roundTrip AgentRuntimeSpec + if err := json.Unmarshal(explicit, &roundTrip); err != nil { + t.Fatal(err) + } + if roundTrip.AllowedTools == nil || len(roundTrip.AllowedTools) != 0 { + t.Fatalf("round-trip allowedTools = %#v, want explicit empty slice", roundTrip.AllowedTools) + } +} + +func TestModelConfigTokenLimitsJSONRoundTrip(t *testing.T) { + contextWindow := int32(32768) + maxTokens := int32(4096) + original := ModelConfig{ + Name: "openai/gpt-test", + ContextWindow: &contextWindow, + MaxTokens: &maxTokens, + } + encoded, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var decoded ModelConfig + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.ContextWindow == nil || *decoded.ContextWindow != contextWindow || + decoded.MaxTokens == nil || *decoded.MaxTokens != maxTokens { + t.Fatalf("round-trip model limits = %#v", decoded) + } + copy := original.DeepCopy() + *copy.ContextWindow++ + if *original.ContextWindow != contextWindow { + t.Fatal("ModelConfig.DeepCopy shared contextWindow storage") } } diff --git a/api/v1alpha1/agent_types.go b/api/v1alpha1/agent_types.go index 15a5697ca..bfbf84269 100644 --- a/api/v1alpha1/agent_types.go +++ b/api/v1alpha1/agent_types.go @@ -7,12 +7,15 @@ MIT License - see LICENSE file for details. package v1alpha1 import ( + "encoding/json" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // AgentSpec defines the desired state of Agent // +kubebuilder:validation:XValidation:rule="!has(self.execution) || !has(self.execution.workspace) || !has(self.execution.workspace.classRef)",message="execution.workspace.classRef is only supported on Task specs" +// +kubebuilder:validation:XValidation:rule="!(has(self.runtime) && has(self.runtime.type) && self.runtime.type == 'opencode' && has(self.runtime.contractVersion) && self.runtime.contractVersion == 'orka.harness.v2' && has(self.systemPrompt) && ((has(self.systemPrompt.inline) && self.systemPrompt.inline.size() > 0) || has(self.systemPrompt.configMapRef)))",message="opencode orka.harness.v2 runtime does not support spec.systemPrompt" type AgentSpec struct { // ProviderRef references a Provider CRD for LLM configuration // If set, model.provider is optional (inherited from Provider) @@ -74,11 +77,21 @@ type AgentSpec struct { // AgentCLIRuntime defines agent CLI runtime configuration for an Agent. // +kubebuilder:validation:XValidation:rule="has(self.type) != has(self.runtimeRef)",message="exactly one of type or runtimeRef is required" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.contractVersion) || (has(self.contractVersion) && self.contractVersion == oldSelf.contractVersion)",message="runtime.contractVersion is immutable once set" +// +kubebuilder:validation:XValidation:rule="!has(self.contractVersion) || has(self.type)",message="runtime.contractVersion applies only to built-in runtime types; runtimeRef derives the protocol from the referenced AgentRuntime" type AgentCLIRuntime struct { // Type specifies which built-in CLI runtime to use. Use runtimeRef for admin-registered custom runtimes. // +optional Type AgentRuntimeType `json:"type,omitempty"` + // ContractVersion is the immutable harness protocol selector for built-in + // runtime types. There is no default: a missing selector is never + // interpreted as either protocol, and fail-closed admission requires an + // explicit value on new built-in Agents. runtime.type alone (including + // opencode, which exists in both protocols) is never protocol evidence. + // +optional + ContractVersion *AgentRuntimeContractVersion `json:"contractVersion,omitempty"` + // RuntimeRef selects an admin-governed AgentRuntime for custom/BYO harness runtimes. // +optional RuntimeRef *AgentRuntimeReference `json:"runtimeRef,omitempty"` @@ -106,6 +119,46 @@ type AgentCLIRuntime struct { DefaultReasoningEffort string `json:"defaultReasoningEffort,omitempty"` } +// MarshalJSON preserves the distinction between an omitted tool allowlist and +// an explicitly empty deny-all allowlist. The standard omitempty handling for +// slices would otherwise serialize both states as omission. +func (in AgentCLIRuntime) MarshalJSON() ([]byte, error) { + type agentCLIRuntimeJSON struct { + Type AgentRuntimeType `json:"type,omitempty"` + ContractVersion *AgentRuntimeContractVersion `json:"contractVersion,omitempty"` + RuntimeRef *AgentRuntimeReference `json:"runtimeRef,omitempty"` + DefaultMaxTurns *int32 `json:"defaultMaxTurns,omitempty"` + DefaultAllowedTools *[]string `json:"defaultAllowedTools,omitempty"` + DefaultAllowBash *bool `json:"defaultAllowBash,omitempty"` + DefaultReasoningEffort string `json:"defaultReasoningEffort,omitempty"` + } + var defaultAllowedTools *[]string + if in.DefaultAllowedTools != nil { + tools := append([]string{}, in.DefaultAllowedTools...) + defaultAllowedTools = &tools + } + return json.Marshal(agentCLIRuntimeJSON{ + Type: in.Type, + ContractVersion: in.ContractVersion, + RuntimeRef: in.RuntimeRef, + DefaultMaxTurns: in.DefaultMaxTurns, + DefaultAllowedTools: defaultAllowedTools, + DefaultAllowBash: in.DefaultAllowBash, + DefaultReasoningEffort: in.DefaultReasoningEffort, + }) +} + +// BuiltInContractVersion returns the Agent's explicit built-in harness +// protocol selector, or empty when unclassified. Callers must treat empty as +// neither protocol and fail closed; runtime.type alone is never protocol +// evidence. +func (in *Agent) BuiltInContractVersion() AgentRuntimeContractVersion { + if in == nil || in.Spec.Runtime == nil || in.Spec.Runtime.ContractVersion == nil { + return "" + } + return *in.Spec.Runtime.ContractVersion +} + // ModelFallback defines a fallback provider configuration type ModelFallback struct { // ProviderRef is the name of a Provider CRD to fall back to @@ -133,11 +186,18 @@ type ModelConfig struct { // Temperature controls randomness in generation // +kubebuilder:validation:Minimum=0 // +kubebuilder:validation:Maximum=2 - // +kubebuilder:default=0.7 // +optional Temperature *float64 `json:"temperature,omitempty"` - // MaxTokens limits the response length + // ContextWindow is the reviewed model context capacity in tokens. Built-in + // runtimes that manage their own compaction require this value explicitly. + // +kubebuilder:validation:Minimum=1 + // +optional + ContextWindow *int32 `json:"contextWindow,omitempty"` + + // MaxTokens limits the response length. OpenCode validates positive reviewed + // limits at its runtime-specific admission boundary; existing Agent objects + // may retain the legacy zero value. // +optional MaxTokens *int32 `json:"maxTokens,omitempty"` @@ -148,6 +208,7 @@ type ModelConfig struct { } // PromptSource defines where to get a prompt from +// +kubebuilder:validation:XValidation:rule="!(has(self.inline) && self.inline.size() > 0 && has(self.configMapRef))",message="system prompt must use only one of inline or configMapRef" type PromptSource struct { // Inline is the inline prompt text // +optional diff --git a/api/v1alpha1/branch_claim_types.go b/api/v1alpha1/branch_claim_types.go new file mode 100644 index 000000000..0a2d282b2 --- /dev/null +++ b/api/v1alpha1/branch_claim_types.go @@ -0,0 +1,101 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// BranchClaimOwnerKind identifies the durable owner of an Orka-managed branch. +// +kubebuilder:validation:Enum=Task;Session +type BranchClaimOwnerKind string + +// BranchClaimAvailability gates further branch mutation. +// +kubebuilder:validation:Enum=Available;ReconciliationBlocked +type BranchClaimAvailability string + +// BranchClaimSpec is the immutable repository/ref ownership identity. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="branch claim spec is immutable" +type BranchClaimSpec struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ID string `json:"id"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + RepositoryID string `json:"repositoryId"` + + // +kubebuilder:validation:Pattern=`^refs/heads/.+$` + // +kubebuilder:validation:MaxLength=1024 + Ref string `json:"ref"` + + OwnerKind BranchClaimOwnerKind `json:"ownerKind"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + OwnerUID string `json:"ownerUid"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` +} + +// BranchClaimStatus is the exact generation, baseline, and availability CAS. +// +kubebuilder:validation:XValidation:rule="!has(self.availability) || self.availability != 'Available' || ((!has(self.blockedReason) || size(self.blockedReason) == 0) && (!has(self.relatedPublicationId) || size(self.relatedPublicationId) == 0))",message="available branch claims must clear block metadata" +// +kubebuilder:validation:XValidation:rule="!has(self.availability) || self.availability != 'ReconciliationBlocked' || (has(self.blockedReason) && size(self.blockedReason) > 0)",message="reconciliation-blocked branch claims require a reason" +type BranchClaimStatus struct { + // +optional + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation,omitempty"` + + // LastVerified is the independently observed exact target ref. + // +optional + LastVerified *ControlRemoteRefState `json:"lastVerified,omitempty"` + + // +optional + Availability BranchClaimAvailability `json:"availability,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=16384 + BlockedReason string `json:"blockedReason,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=1024 + RelatedPublicationID string `json:"relatedPublicationId,omitempty"` + + ControlRecordMutationStatus `json:",inline"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,shortName=bclaim +// +kubebuilder:printcolumn:name="Repository",type=string,JSONPath=`.spec.repositoryId` +// +kubebuilder:printcolumn:name="Ref",type=string,JSONPath=`.spec.ref` +// +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.spec.ownerKind` +// +kubebuilder:printcolumn:name="Generation",type=integer,JSONPath=`.status.generation` +// +kubebuilder:printcolumn:name="Availability",type=string,JSONPath=`.status.availability` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// BranchClaim is the cluster-wide Kubernetes-authoritative ownership and exact +// baseline record for one canonical repository branch. +type BranchClaim struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BranchClaimSpec `json:"spec"` + Status BranchClaimStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BranchClaimList contains a list of BranchClaim. +type BranchClaimList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []BranchClaim `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BranchClaim{}, &BranchClaimList{}) +} diff --git a/api/v1alpha1/coexistence_bridge_test.go b/api/v1alpha1/coexistence_bridge_test.go new file mode 100644 index 000000000..f9a872abd --- /dev/null +++ b/api/v1alpha1/coexistence_bridge_test.go @@ -0,0 +1,275 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + "encoding/json" + "strings" + "testing" +) + +// The bridge schema must decode and round-trip stored objects from BOTH +// pre-coexistence baselines without pruning: the harness v1 baseline +// (origin/main) and the v2-only ACP cutover baseline. These fixtures mirror +// the exact historical JSON shapes. + +const storedV1AgentRuntimeJSON = `{ + "contractVersion": "orka.harness.v1", + "deployment": {"mode": "external-endpoint", "endpoint": "https://harness.example.com"}, + "clientAuth": {"bearerTokenSecretRef": {"name": "harness-auth", "key": "token"}}, + "capabilities": { + "toolExecutionModes": ["observed", "brokered"], + "brokeredToolClasses": ["read", "coordination"], + "supportsCancel": true, + "supportsRuntimeSessions": true, + "supportsContinuation": false, + "supportsArtifacts": true + } +}` + +const storedV1AgentRuntimeStatusJSON = `{ + "ready": true, + "observedGeneration": 3, + "observedAuthRefResourceVersion": "12345", + "observedCapabilities": { + "protocolVersion": "orka.harness.v1", + "transport": "http+sse", + "runtimeName": "agentkit", + "runtimeVersion": "1.4.2", + "providerKind": "generic", + "toolExecutionModes": ["observed"], + "supportsCancel": true, + "maxConcurrentTurns": 4, + "maxTurnSeconds": 1800, + "maxOutputBytes": 1048576 + } +}` + +func TestBridgeStoredV1AgentRuntimeRoundTrips(t *testing.T) { + var spec AgentRuntimeRegistrySpec + if err := json.Unmarshal([]byte(storedV1AgentRuntimeJSON), &spec); err != nil { + t.Fatalf("decode stored v1 AgentRuntime spec: %v", err) + } + if spec.ContractVersion == nil || *spec.ContractVersion != AgentRuntimeContractHarnessV1 { + t.Fatalf("contractVersion = %v", spec.ContractVersion) + } + if spec.ClientAuth.BearerAuthRef == nil || spec.ClientAuth.BearerAuthRef.Name != "harness-auth" || spec.ClientAuth.BearerAuthRef.Key != "token" { + t.Fatalf("bearer auth ref = %+v", spec.ClientAuth.BearerAuthRef) + } + if spec.ClientAuth.ControllerBearerTokenSecretRef != nil || spec.ClientAuth.OperationCapabilitySecretRef != nil { + t.Fatal("v1 auth decode must not synthesize v2 auth fields") + } + if spec.Capabilities == nil || len(spec.Capabilities.ToolExecutionModes) != 2 || + spec.Capabilities.SupportsCancel == nil || !*spec.Capabilities.SupportsCancel || + spec.Capabilities.SupportsContinuation == nil || *spec.Capabilities.SupportsContinuation { + t.Fatalf("v1 capabilities = %+v", spec.Capabilities) + } + if spec.Capabilities.Profile != nil || spec.Capabilities.Limits != nil || spec.Capabilities.WorkspaceGovernance != nil { + t.Fatal("v1 capabilities decode must not synthesize v2 capability fields") + } + + reencoded, err := json.Marshal(spec) + if err != nil { + t.Fatalf("re-encode stored v1 AgentRuntime spec: %v", err) + } + serialized := string(reencoded) + for _, required := range []string{`"orka.harness.v1"`, `"bearerTokenSecretRef"`, `"toolExecutionModes"`, `"brokeredToolClasses"`, `"supportsCancel"`} { + if !strings.Contains(serialized, required) { + t.Fatalf("re-encoded v1 spec lost %s: %s", required, serialized) + } + } + for _, forbidden := range []string{"controllerBearerTokenSecretRef", "operationCapabilitySecretRef", "workspaceGovernance", `"profile"`} { + if strings.Contains(serialized, forbidden) { + t.Fatalf("re-encoded v1 spec gained v2 field %s: %s", forbidden, serialized) + } + } + +} + +func TestBridgeStoredV1AgentRuntimeStatusRoundTrips(t *testing.T) { + var status AgentRuntimeStatus + if err := json.Unmarshal([]byte(storedV1AgentRuntimeStatusJSON), &status); err != nil { + t.Fatalf("decode stored v1 AgentRuntime status: %v", err) + } + if status.ObservedAuthRefResourceVersion != "12345" { + t.Fatalf("observedAuthRefResourceVersion = %q; the v1 status surface must serialize", status.ObservedAuthRefResourceVersion) + } + if status.ObservedCapabilities == nil || status.ObservedCapabilities.RuntimeName != "agentkit" || + status.ObservedCapabilities.MaxConcurrentTurns != 4 || status.ObservedCapabilities.MaxOutputBytes != 1048576 { + t.Fatalf("v1 observed capabilities = %+v", status.ObservedCapabilities) + } + if status.ObservedCapabilities.Limits != nil || status.ObservedCapabilities.WorkspaceGovernance != nil { + t.Fatalf("v1 observed capabilities gained v2-only fields: %+v", status.ObservedCapabilities) + } + statusJSON, err := json.Marshal(status) + if err != nil { + t.Fatalf("re-encode v1 status: %v", err) + } + serialized := string(statusJSON) + if !strings.Contains(serialized, `"observedAuthRefResourceVersion":"12345"`) { + t.Fatalf("v1 status round trip lost observedAuthRefResourceVersion: %s", statusJSON) + } + for _, forbidden := range []string{`"limits"`, `"workspaceGovernance"`} { + if strings.Contains(serialized, forbidden) { + t.Fatalf("v1 status serialized schema-invalid v2 field %s: %s", forbidden, statusJSON) + } + } +} + +const storedV1TaskSpecFragmentJSON = `{ + "type": "agent", + "prompt": "fix the bug", + "agentRef": {"name": "coder"}, + "agentRuntime": { + "workspace": { + "gitRepo": "https://github.com/org/repo.git", + "branch": "main", + "ref": "abc123", + "gitSecretRef": {"name": "git-credentials"}, + "subPath": "services/api", + "forkRepo": "https://github.com/bot/repo.git", + "prBaseBranch": "main", + "pushBranch": "agent/fix-1" + }, + "maxTurns": 25, + "allowedTools": [] + } +}` + +const storedV1TaskStatusFragmentJSON = `{ + "phase": "Running", + "harnessRuntime": { + "runtimeRefName": "external-harness", + "runtimeName": "agentkit", + "contractVersion": "orka.harness.v1", + "endpoint": "https://harness.example.com", + "runtimeGeneration": 7, + "authRefName": "harness-auth", + "authRefField": "token", + "authRefResourceVersion": "999" + } +}` + +func TestBridgeStoredV1TaskRoundTrips(t *testing.T) { + var spec TaskSpec + if err := json.Unmarshal([]byte(storedV1TaskSpecFragmentJSON), &spec); err != nil { + t.Fatalf("decode stored v1 Task spec: %v", err) + } + workspace := spec.AgentRuntime.Workspace + if workspace == nil || workspace.GitRepo != "https://github.com/org/repo.git" || + workspace.GitSecretRef == nil || workspace.GitSecretRef.Name != "git-credentials" || + workspace.ForkRepo != "https://github.com/bot/repo.git" || workspace.PushBranch != "agent/fix-1" { + t.Fatalf("legacy workspace = %+v", workspace) + } + // The explicit-empty allowlist must survive alongside the legacy workspace. + if spec.AgentRuntime.AllowedTools == nil || len(spec.AgentRuntime.AllowedTools) != 0 { + t.Fatalf("explicit empty allowedTools was not preserved: %#v", spec.AgentRuntime.AllowedTools) + } + + reencoded, err := json.Marshal(spec.AgentRuntime) + if err != nil { + t.Fatalf("re-encode legacy agentRuntime: %v", err) + } + serialized := string(reencoded) + for _, required := range []string{`"gitSecretRef"`, `"forkRepo"`, `"pushBranch"`, `"allowedTools":[]`} { + if !strings.Contains(serialized, required) { + t.Fatalf("legacy agentRuntime round trip lost %s: %s", required, serialized) + } + } + + var status TaskStatus + if err := json.Unmarshal([]byte(storedV1TaskStatusFragmentJSON), &status); err != nil { + t.Fatalf("decode stored v1 Task status: %v", err) + } + if status.HarnessRuntime == nil || status.HarnessRuntime.ContractVersion != "orka.harness.v1" || + status.HarnessRuntime.RuntimeGeneration != 7 || status.HarnessRuntime.AuthRefResourceVersion != "999" { + t.Fatalf("harnessRuntime status = %+v", status.HarnessRuntime) + } + statusJSON, err := json.Marshal(status) + if err != nil { + t.Fatalf("re-encode v1 Task status: %v", err) + } + if !strings.Contains(string(statusJSON), `"harnessRuntime"`) { + t.Fatalf("v1 Task status round trip lost harnessRuntime: %s", statusJSON) + } +} + +const storedV1OpenCodeAgentJSON = `{ + "runtime": {"type": "opencode", "contractVersion": "orka.harness.v1", "defaultMaxTurns": 50}, + "model": {"name": "gpt-5.2", "maxTokens": 8192}, + "systemPrompt": {"inline": "You are a careful engineer."}, + "secretRef": {"name": "opencode-credentials"} +}` + +func TestBridgeStoredV1OpenCodeAgentPreserved(t *testing.T) { + var spec AgentSpec + if err := json.Unmarshal([]byte(storedV1OpenCodeAgentJSON), &spec); err != nil { + t.Fatalf("decode stored v1 OpenCode Agent: %v", err) + } + agent := &Agent{Spec: spec} + if agent.BuiltInContractVersion() != AgentRuntimeContractHarnessV1 { + t.Fatalf("contract = %q", agent.BuiltInContractVersion()) + } + // Historical v1 OpenCode shape: legacy model ID without provider prefix, + // Agent-level system prompt, and provider Secret all round-trip. + if spec.Model == nil || spec.Model.Name != "gpt-5.2" || spec.SystemPrompt == nil || + spec.SystemPrompt.Inline == "" || spec.SecretRef == nil || spec.SecretRef.Name != "opencode-credentials" { + t.Fatalf("v1 OpenCode fields = %+v", spec) + } + reencoded, err := json.Marshal(spec) + if err != nil { + t.Fatalf("re-encode v1 OpenCode Agent: %v", err) + } + for _, required := range []string{`"orka.harness.v1"`, `"gpt-5.2"`, `"systemPrompt"`, `"opencode-credentials"`} { + if !strings.Contains(string(reencoded), required) { + t.Fatalf("v1 OpenCode Agent round trip lost %s: %s", required, reencoded) + } + } +} + +func TestAgentCLIRuntimeContractVersionSerialization(t *testing.T) { + v2 := AgentRuntimeContractHarnessV2 + runtime := AgentCLIRuntime{Type: AgentRuntimeCodex, ContractVersion: &v2} + encoded, err := json.Marshal(runtime) + if err != nil { + t.Fatalf("marshal runtime: %v", err) + } + if !strings.Contains(string(encoded), `"contractVersion":"orka.harness.v2"`) { + t.Fatalf("contractVersion missing from custom marshal output: %s", encoded) + } + + // The omitted-versus-explicit-empty allowlist distinction survives the + // selector addition. + runtime.DefaultAllowedTools = []string{} + encoded, err = json.Marshal(runtime) + if err != nil { + t.Fatalf("marshal runtime with empty allowlist: %v", err) + } + if !strings.Contains(string(encoded), `"defaultAllowedTools":[]`) { + t.Fatalf("explicit empty allowlist was dropped: %s", encoded) + } + runtime.DefaultAllowedTools = nil + encoded, err = json.Marshal(runtime) + if err != nil { + t.Fatalf("marshal runtime with omitted allowlist: %v", err) + } + if strings.Contains(string(encoded), "defaultAllowedTools") { + t.Fatalf("omitted allowlist serialized: %s", encoded) + } + + unclassified := AgentCLIRuntime{Type: AgentRuntimeOpencode} + encoded, err = json.Marshal(unclassified) + if err != nil { + t.Fatalf("marshal unclassified runtime: %v", err) + } + if strings.Contains(string(encoded), "contractVersion") { + t.Fatalf("unclassified runtime must omit contractVersion: %s", encoded) + } + if (&Agent{Spec: AgentSpec{Runtime: &unclassified}}).BuiltInContractVersion() != "" { + t.Fatal("unclassified agent must report empty contract") + } +} diff --git a/api/v1alpha1/control_record_types.go b/api/v1alpha1/control_record_types.go new file mode 100644 index 000000000..d9cf61ed9 --- /dev/null +++ b/api/v1alpha1/control_record_types.go @@ -0,0 +1,117 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + // ControlRecordArtifactRetentionFinalizer prevents deletion while a control + // record can still retain or reconcile a content-addressed artifact. + ControlRecordArtifactRetentionFinalizer = "core.orka.ai/artifact-retention" + // ControlRecordLeaseProtectionFinalizer prevents deletion of a control record + // while a Kubernetes Lease still fences mutations for that record. + ControlRecordLeaseProtectionFinalizer = "core.orka.ai/lease-protection" + + // ControlRecordIDHashLabel stores a DNS-safe digest of the immutable logical + // record ID so namespaced records can be found without trusting mutable data. + ControlRecordIDHashLabel = "core.orka.ai/control-record-id-hash" + // ControlRecordTaskUIDLabel associates a controller-owned record with the + // immutable UID of the Task that caused it to be created. + ControlRecordTaskUIDLabel = "core.orka.ai/task-uid" +) + +// ControlRecordMutationStatus is the common fenced mutation metadata embedded +// in Kubernetes-authoritative ACP control records. Kubernetes resourceVersion +// provides the storage CAS; Version is the monotonic domain version expected by +// DurableControlStore callers. +type ControlRecordMutationStatus struct { + // ControllerEpochName identifies the controller epoch domain checked before + // the mutation. + // +optional + // +kubebuilder:validation:MaxLength=253 + ControllerEpochName string `json:"controllerEpochName,omitempty"` + + // ControllerEpoch is the exact epoch that performed the last mutation. + // +optional + // +kubebuilder:validation:Minimum=1 + ControllerEpoch int64 `json:"controllerEpoch,omitempty"` + + // ControllerEpochLeaseResourceVersion is the resourceVersion of the + // authoritative controller-epoch Lease observed by the mutation. + // +optional + // +kubebuilder:validation:MaxLength=64 + ControllerEpochLeaseResourceVersion string `json:"controllerEpochLeaseResourceVersion,omitempty"` + + // LastOperationID is the last idempotent mutation identity applied. + // +optional + // +kubebuilder:validation:MaxLength=1024 + LastOperationID string `json:"lastOperationId,omitempty"` + + // LastOperationDigest binds LastOperationID to exact canonical input. + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + LastOperationDigest string `json:"lastOperationDigest,omitempty"` + + // Version is the monotonic domain CAS version. It advances once for each + // successfully persisted logical mutation. + // +optional + // +kubebuilder:validation:Minimum=1 + Version int64 `json:"version,omitempty"` + + // CreatedAt is the normalized logical creation time. + // +optional + CreatedAt *metav1.Time `json:"createdAt,omitempty"` + + // UpdatedAt is the normalized logical mutation time. + // +optional + UpdatedAt *metav1.Time `json:"updatedAt,omitempty"` +} + +// ControlRemoteRefState is an exact remote-ref observation. Absent and SHA are +// mutually exclusive. The all-zero value is reserved for an explicitly unknown +// observation in PublicationOutcomeUnknown receipts. +// +kubebuilder:validation:XValidation:rule="!(self.absent && has(self.sha) && size(self.sha) > 0)",message="absent and sha are mutually exclusive" +type ControlRemoteRefState struct { + Absent bool `json:"absent"` + + // +optional + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + SHA string `json:"sha,omitempty"` +} + +// ControlVerifiedBranchBaseline is an independently verified branch baseline. +type ControlVerifiedBranchBaseline struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + RepositoryID string `json:"repositoryId"` + + // +kubebuilder:validation:Pattern=`^refs/heads/.+$` + // +kubebuilder:validation:MaxLength=1024 + Ref string `json:"ref"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + SHA string `json:"sha"` +} + +// ControlRecordOwner identifies an immutable namespaced owner when the +// Kubernetes object name is not part of the DurableControlStore interface. +type ControlRecordOwner struct { + // +kubebuilder:validation:Enum=Task;Session;RuntimePool;PromptAttempt + Kind string `json:"kind"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + UID string `json:"uid"` +} + +// RBAC for the Kubernetes-authoritative ACP control-record store. These +// markers intentionally live with the API surface so generation does not +// require wiring the store into cmd/main.go. +// +kubebuilder:rbac:groups=core.orka.ai,resources=promptattempts;runtimesessioncontrols;branchclaims;publications;externaleffects;controllerepochs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core.orka.ai,resources=promptattempts/status;runtimesessioncontrols/status;branchclaims/status;publications/status;externaleffects/status;controllerepochs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=core.orka.ai,resources=promptattempts/finalizers;runtimesessioncontrols/finalizers;branchclaims/finalizers;publications/finalizers;externaleffects/finalizers;controllerepochs/finalizers,verbs=update +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete diff --git a/api/v1alpha1/controller_epoch_types.go b/api/v1alpha1/controller_epoch_types.go new file mode 100644 index 000000000..5c968748f --- /dev/null +++ b/api/v1alpha1/controller_epoch_types.go @@ -0,0 +1,83 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// ControllerEpochSpec is the immutable epoch-domain identity. The associated +// coordination.k8s.io Lease is the CAS authority for holder and epoch changes. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="controller epoch spec is immutable" +type ControllerEpochSpec struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` +} + +// ControllerEpochStatus mirrors the authoritative Lease state for inspection +// and recovery. LeaseResourceVersion identifies the exact Lease revision. +type ControllerEpochStatus struct { + // +optional + // +kubebuilder:validation:Minimum=1 + Epoch int64 `json:"epoch,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=1024 + HolderID string `json:"holderId,omitempty"` + + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest,omitempty"` + + // +optional + // +kubebuilder:validation:Minimum=1 + Version int64 `json:"version,omitempty"` + + // +optional + AcquiredAt *metav1.Time `json:"acquiredAt,omitempty"` + + // +optional + UpdatedAt *metav1.Time `json:"updatedAt,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=253 + LeaseName string `json:"leaseName,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=64 + LeaseResourceVersion string `json:"leaseResourceVersion,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=controllerepochs,scope=Namespaced,shortName=cepoch +// +kubebuilder:printcolumn:name="Epoch",type=integer,JSONPath=`.status.epoch` +// +kubebuilder:printcolumn:name="Holder",type=string,JSONPath=`.status.holderId` +// +kubebuilder:printcolumn:name="Version",type=integer,JSONPath=`.status.version` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// ControllerEpoch is the human-visible Kubernetes control record paired with +// an authoritative namespaced Lease. +type ControllerEpoch struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ControllerEpochSpec `json:"spec"` + Status ControllerEpochStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ControllerEpochList contains a list of ControllerEpoch. +type ControllerEpochList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ControllerEpoch `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ControllerEpoch{}, &ControllerEpochList{}) +} diff --git a/api/v1alpha1/execution_types.go b/api/v1alpha1/execution_types.go index 0a1baba1b..9f2064dda 100644 --- a/api/v1alpha1/execution_types.go +++ b/api/v1alpha1/execution_types.go @@ -26,10 +26,10 @@ type ExecutionSpec struct { // +optional Affinity *corev1.Affinity `json:"affinity,omitempty"` - // Workspace requests an upstream agent-sandbox execution workspace for agent Tasks. - // When enabled, the Task controller validates the request and propagates the - // resolved sandbox settings to the agent worker Job. The worker wrapper then - // claims the sandbox workspace and runs the configured agent runtime inside it. + // Workspace requests an execution workspace for worker-backed Task types. + // ACP core agent Tasks reject this field because their ephemeral workspace is + // owned by RuntimeSession lifecycle and clean-room publication. Actor-backed + // RuntimeSession support is a future integration behind the v2 lifecycle seam. // +optional Workspace *ExecutionWorkspaceSpec `json:"workspace,omitempty"` } diff --git a/api/v1alpha1/external_effect_types.go b/api/v1alpha1/external_effect_types.go new file mode 100644 index 000000000..88812c619 --- /dev/null +++ b/api/v1alpha1/external_effect_types.go @@ -0,0 +1,189 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// ExternalEffectControlState is the durable state of one idempotent operation +// performed outside the controller's SQLite transaction boundary. +// +kubebuilder:validation:Enum=Pending;InFlight;Succeeded;Failed;OutcomeUnknown +type ExternalEffectControlState string + +// ExternalEffectSpec is the immutable canonical identity and request binding. +// The identity namespace intentionally duplicates metadata.namespace so a +// serialized record remains self-describing and can be checked fail-closed. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="external effect spec is immutable" +type ExternalEffectSpec struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ID string `json:"id"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + Kind string `json:"kind"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + IdentityNamespace string `json:"identityNamespace"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + AggregateID string `json:"aggregateId"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + OperationID string `json:"operationId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` +} + +// ExternalEffectStatus contains the mutable state, response, lease, and epoch +// fence for one canonical external effect. +// +kubebuilder:validation:XValidation:rule="!has(self.state) || self.state != 'InFlight' || (has(self.leaseOwner) && size(self.leaseOwner) > 0 && has(self.leaseExpiresAt))",message="in-flight external effects require a lease owner and expiry" +// +kubebuilder:validation:XValidation:rule="!has(self.state) || self.state == 'InFlight' || ((!has(self.leaseOwner) || size(self.leaseOwner) == 0) && !has(self.leaseExpiresAt))",message="non-in-flight external effects must clear lease fields" +type ExternalEffectStatus struct { + // +optional + State ExternalEffectControlState `json:"state,omitempty"` + + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ResponseDigest string `json:"responseDigest,omitempty"` + + // Response stores a bounded JSON response for idempotent replay. Large + // response bodies should remain in the artifact store and be referenced by + // a compact receipt instead. + // +optional + Response *apiextensionsv1.JSON `json:"response,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=1024 + LeaseOwner string `json:"leaseOwner,omitempty"` + + // +optional + LeaseExpiresAt *metav1.Time `json:"leaseExpiresAt,omitempty"` + + // +optional + // +kubebuilder:validation:Minimum=0 + Attempts int64 `json:"attempts,omitempty"` + + ControlRecordMutationStatus `json:",inline"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:object:generate=false +// +k8s:deepcopy-gen=false +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=eeffect +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.state` +// +kubebuilder:printcolumn:name="Kind",type=string,JSONPath=`.spec.kind` +// +kubebuilder:printcolumn:name="Attempts",type=integer,JSONPath=`.status.attempts` +// +kubebuilder:printcolumn:name="Version",type=integer,JSONPath=`.status.version` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// ExternalEffect is the Kubernetes-authoritative canonical idempotency record +// for an operation outside SQLite. +type ExternalEffect struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ExternalEffectSpec `json:"spec"` + Status ExternalEffectStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:object:generate=false +// +k8s:deepcopy-gen=false + +// ExternalEffectList contains a list of ExternalEffect. +type ExternalEffectList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ExternalEffect `json:"items"` +} + +// DeepCopyInto is hand-written so this new root type is usable before the +// repository owner regenerates zz_generated.deepcopy.go. +func (in *ExternalEffect) DeepCopyInto(out *ExternalEffect) { + *out = *in + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if in.Status.Response != nil { + out.Status.Response = &apiextensionsv1.JSON{} + if in.Status.Response.Raw != nil { + out.Status.Response.Raw = append([]byte(nil), in.Status.Response.Raw...) + } + } + if in.Status.LeaseExpiresAt != nil { + out.Status.LeaseExpiresAt = in.Status.LeaseExpiresAt.DeepCopy() + } + deepCopyControlRecordMutationStatus(&in.Status.ControlRecordMutationStatus, &out.Status.ControlRecordMutationStatus) +} + +// DeepCopy creates an independent copy. +func (in *ExternalEffect) DeepCopy() *ExternalEffect { + if in == nil { + return nil + } + out := new(ExternalEffect) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject implements runtime.Object. +func (in *ExternalEffect) DeepCopyObject() runtime.Object { + if copy := in.DeepCopy(); copy != nil { + return copy + } + return nil +} + +// DeepCopyInto copies a list and all of its items. +func (in *ExternalEffectList) DeepCopyInto(out *ExternalEffectList) { + *out = *in + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]ExternalEffect, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} + +// DeepCopy creates an independent list copy. +func (in *ExternalEffectList) DeepCopy() *ExternalEffectList { + if in == nil { + return nil + } + out := new(ExternalEffectList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject implements runtime.Object. +func (in *ExternalEffectList) DeepCopyObject() runtime.Object { + if copy := in.DeepCopy(); copy != nil { + return copy + } + return nil +} + +func deepCopyControlRecordMutationStatus(in, out *ControlRecordMutationStatus) { + if in.CreatedAt != nil { + out.CreatedAt = in.CreatedAt.DeepCopy() + } + if in.UpdatedAt != nil { + out.UpdatedAt = in.UpdatedAt.DeepCopy() + } +} + +func init() { + SchemeBuilder.Register(&ExternalEffect{}, &ExternalEffectList{}) +} diff --git a/api/v1alpha1/external_effect_types_test.go b/api/v1alpha1/external_effect_types_test.go new file mode 100644 index 000000000..1333b99da --- /dev/null +++ b/api/v1alpha1/external_effect_types_test.go @@ -0,0 +1,55 @@ +package v1alpha1 + +import ( + "testing" + "time" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestExternalEffectDeepCopyIsIndependentAndRegistered(t *testing.T) { + expires := metav1.NewTime(time.Date(2026, time.July, 25, 6, 0, 0, 0, time.UTC)) + created := expires.DeepCopy() + updated := expires.DeepCopy() + original := &ExternalEffect{ + ObjectMeta: metav1.ObjectMeta{Name: "effect", Namespace: "tenant-a", Labels: map[string]string{"a": "b"}}, + Spec: ExternalEffectSpec{ID: "effect-1", Kind: "PullRequest", IdentityNamespace: "tenant-a", AggregateID: "publication-1", OperationID: "op-1", RequestDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Status: ExternalEffectStatus{ + State: "InFlight", + Response: &apiextensionsv1.JSON{Raw: []byte(`{"ok":true}`)}, + LeaseExpiresAt: &expires, + ControlRecordMutationStatus: ControlRecordMutationStatus{ + CreatedAt: created, + UpdatedAt: updated, + }, + }, + } + copy := original.DeepCopy() + copy.Labels["a"] = "changed" + copy.Status.Response.Raw[2] = 'X' + copy.Status.LeaseExpiresAt.Time = copy.Status.LeaseExpiresAt.Add(time.Hour) + copy.Status.CreatedAt.Time = copy.Status.CreatedAt.Add(time.Hour) + + if original.Labels["a"] != "b" { + t.Fatalf("labels were aliased: %#v", original.Labels) + } + if string(original.Status.Response.Raw) != `{"ok":true}` { + t.Fatalf("response bytes were aliased: %s", original.Status.Response.Raw) + } + if !original.Status.LeaseExpiresAt.Equal(&expires) { + t.Fatalf("lease expiry was aliased: %s", original.Status.LeaseExpiresAt) + } + if !original.Status.CreatedAt.Equal(created) { + t.Fatalf("mutation timestamp was aliased: %s", original.Status.CreatedAt) + } + + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + if _, err := scheme.New(GroupVersion.WithKind("ExternalEffect")); err != nil { + t.Fatalf("ExternalEffect is not registered: %v", err) + } +} diff --git a/api/v1alpha1/prompt_attempt_types.go b/api/v1alpha1/prompt_attempt_types.go new file mode 100644 index 000000000..93cc48169 --- /dev/null +++ b/api/v1alpha1/prompt_attempt_types.go @@ -0,0 +1,161 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// PromptAttemptExecutionState is the durable prompt execution state. +// +kubebuilder:validation:Enum=Queued;Reserved;SessionStarting;Planned;Submitting;SubmittedUnknown;Accepted;Running;Settling;Succeeded;Failed;Cancelled;OutcomeUnknown +type PromptAttemptExecutionState string + +// PromptAttemptDeliveryState is the durable delivery state for one prompt. +// +kubebuilder:validation:Enum=NotRequested;Validating;Preparing;Prepared;Publishing;Verifying;VerifiedExact;DeliveredSuperseded;ReadValidated;NoChange;CancelledBeforePublish;ReadOnlyWorkspaceModified;DeliveryConflict;CredentialBlocked;PublicationOutcomeUnknown +type PromptAttemptDeliveryState string + +// PromptCredentialBinding freezes one role-specific Secret identity without +// storing credential material. +type PromptCredentialBinding struct { + // +kubebuilder:validation:Enum=SourceRead;TargetRead;TargetWrite;Forge + Role string `json:"role"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Namespace string `json:"namespace"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + SecretName string `json:"secretName"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + SecretKey string `json:"secretKey"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + SecretUID string `json:"secretUid"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + ResourceVersion string `json:"resourceVersion"` +} + +// PromptAttemptSpec is the immutable identity and request binding for one +// Task prompt attempt. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="prompt attempt spec is immutable" +// +kubebuilder:validation:XValidation:rule="has(self.bindingDigest) == has(self.snapshotDigest)",message="bindingDigest and snapshotDigest must be recorded together" +type PromptAttemptSpec struct { + // ID is the canonical DurableControlStore prompt-attempt ID. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ID string `json:"id"` + + // TaskUID is the immutable Kubernetes UID of the Task. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + TaskUID string `json:"taskUid"` + + // Attempt is the one-based Task attempt number. + // +kubebuilder:validation:Minimum=1 + Attempt int64 `json:"attempt"` + + // PromptID is the immutable prompt identity within the attempt. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + PromptID string `json:"promptId"` + + // RequestDigest binds the prompt identity to exact canonical input. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + // BindingDigest identifies the immutable Task-lifetime v2 execution + // binding. It is optional only so pre-coexistence records remain readable; + // all PromptAttempts newly created through DurableControlStore require it. + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + BindingDigest string `json:"bindingDigest,omitempty"` + + // SnapshotDigest identifies the immutable encrypted execution snapshot. + // It is optional only so pre-coexistence records remain readable; all + // PromptAttempts newly created through DurableControlStore require it. + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + SnapshotDigest string `json:"snapshotDigest,omitempty"` + + // CredentialBindings is the immutable, role-separated Secret identity set. + // +optional + // +listType=map + // +listMapKey=role + // +kubebuilder:validation:MaxItems=4 + CredentialBindings []PromptCredentialBinding `json:"credentialBindings,omitempty"` +} + +// PromptAttemptStatus holds the exact execution and delivery state machines. +// +kubebuilder:validation:XValidation:rule="!has(self.executionState) || self.executionState != 'OutcomeUnknown' || (has(self.outcomeMarker) && size(self.outcomeMarker) > 0)",message="OutcomeUnknown requires an explicit outcome marker" +type PromptAttemptStatus struct { + // SessionUID is immutable after first binding. + // +optional + // +kubebuilder:validation:MaxLength=1024 + SessionUID string `json:"sessionUid,omitempty"` + + // SessionLeaseGeneration is immutable after first binding. + // +optional + // +kubebuilder:validation:Minimum=1 + SessionLeaseGeneration int64 `json:"sessionLeaseGeneration,omitempty"` + + // RuntimeInstanceID is immutable after first binding. + // +optional + // +kubebuilder:validation:MaxLength=1024 + RuntimeInstanceID string `json:"runtimeInstanceId,omitempty"` + + // +optional + ExecutionState PromptAttemptExecutionState `json:"executionState,omitempty"` + + // +optional + DeliveryState PromptAttemptDeliveryState `json:"deliveryState,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=16384 + TerminalReason string `json:"terminalReason,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=16384 + OutcomeMarker string `json:"outcomeMarker,omitempty"` + + ControlRecordMutationStatus `json:",inline"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=pattempt +// +kubebuilder:printcolumn:name="Execution",type=string,JSONPath=`.status.executionState` +// +kubebuilder:printcolumn:name="Delivery",type=string,JSONPath=`.status.deliveryState` +// +kubebuilder:printcolumn:name="Attempt",type=integer,JSONPath=`.spec.attempt` +// +kubebuilder:printcolumn:name="Version",type=integer,JSONPath=`.status.version` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// PromptAttempt is the Kubernetes-authoritative prompt execution and delivery +// control record. +type PromptAttempt struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PromptAttemptSpec `json:"spec"` + Status PromptAttemptStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// PromptAttemptList contains a list of PromptAttempt. +type PromptAttemptList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []PromptAttempt `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PromptAttempt{}, &PromptAttemptList{}) +} diff --git a/api/v1alpha1/publication_types.go b/api/v1alpha1/publication_types.go new file mode 100644 index 000000000..1256dbc26 --- /dev/null +++ b/api/v1alpha1/publication_types.go @@ -0,0 +1,313 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// PublicationControlState is the clean-room publication state machine. +// +kubebuilder:validation:Enum=Preparing;Prepared;Publishing;Verifying;VerifiedExact;DeliveredSuperseded;CancelledBeforePublish;DeliveryConflict;CredentialBlocked;PreparationFailed;PublicationOutcomeUnknown +type PublicationControlState string + +// PublicationSpec is the immutable clean-room publication identity and input. +// Mutable receipts and forge intent live only in status. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="publication spec is immutable" +type PublicationSpec struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ID string `json:"id"` + + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + TaskUID string `json:"taskUid"` + + // +kubebuilder:validation:Minimum=1 + Attempt int64 `json:"attempt"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + PromptID string `json:"promptId"` + + // +optional + // +kubebuilder:validation:MaxLength=1024 + SessionUID string `json:"sessionUid,omitempty"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + BranchClaimID string `json:"branchClaimId"` + + // +kubebuilder:validation:Minimum=1 + BranchClaimGeneration int64 `json:"branchClaimGeneration"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + SourceRepositoryID string `json:"sourceRepositoryId"` + + // SourceRef is the exact immutable source ref or revision selector. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + SourceRef string `json:"sourceRef"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + SourceBaselineSHA string `json:"sourceBaselineSha"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + TargetRepositoryID string `json:"targetRepositoryId"` + + // +kubebuilder:validation:Pattern=`^refs/heads/.+$` + // +kubebuilder:validation:MaxLength=1024 + TargetRef string `json:"targetRef"` + + Baseline ControlRemoteRefState `json:"baseline"` + + // ArtifactID identifies the durable content-addressed change artifact. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ArtifactID string `json:"artifactId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ArtifactDigest string `json:"artifactDigest"` + + // +kubebuilder:validation:Minimum=1 + ArtifactSizeBytes int64 `json:"artifactSizeBytes"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + ArtifactMediaType string `json:"artifactMediaType"` + + // PublicationCredentialRef identifies an operation-scoped Secret reference; + // it never contains credential material. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + PublicationCredentialRef string `json:"publicationCredentialRef"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + CommitIdentity string `json:"commitIdentity"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=16384 + CommitMessage string `json:"commitMessage"` + + CommitTimestamp metav1.Time `json:"commitTimestamp"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` +} + +// PublicationPullRequestIntent is the exact forge tuple persisted before the +// first forge API call. +type PublicationPullRequestIntent struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + BaseRepositoryID string `json:"baseRepositoryId"` + + // +kubebuilder:validation:Pattern=`^refs/heads/.+$` + // +kubebuilder:validation:MaxLength=1024 + BaseRef string `json:"baseRef"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + HeadRepositoryID string `json:"headRepositoryId"` + + // +kubebuilder:validation:Pattern=`^refs/heads/.+$` + // +kubebuilder:validation:MaxLength=1024 + HeadRef string `json:"headRef"` + + // +kubebuilder:validation:Minimum=1 + PublicationGeneration int64 `json:"publicationGeneration"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + ExpectedHeadSHA string `json:"expectedHeadSha"` +} + +// PreparedPublicationControlReceipt records deterministic commit preparation. +type PreparedPublicationControlReceipt struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + OperationID string `json:"operationId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + TreeSHA string `json:"treeSha"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + CommitSHA string `json:"commitSha"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ManifestDigest string `json:"manifestDigest"` + + // RelativeRoot is the canonical repository-relative workspace root applied + // to every path in the immutable delta artifact. + // +optional + // +kubebuilder:validation:MaxLength=1024 + RelativeRoot string `json:"relativeRoot,omitempty"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + BundleArtifactID string `json:"bundleArtifactId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + BundleDigest string `json:"bundleDigest"` + + // +kubebuilder:validation:Minimum=1 + BundleSizeBytes int64 `json:"bundleSizeBytes"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + BundleMediaType string `json:"bundleMediaType"` + + // +kubebuilder:validation:Pattern=`^refs/orka/publications/[a-f0-9]{64}$` + BundleRef string `json:"bundleRef"` + + PreparedAt metav1.Time `json:"preparedAt"` +} + +// PublishOperationControlReceipt records the exact server-enforced ref CAS. +type PublishOperationControlReceipt struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + OperationID string `json:"operationId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + TargetRepositoryID string `json:"targetRepositoryId"` + + // +kubebuilder:validation:Pattern=`^refs/heads/.+$` + // +kubebuilder:validation:MaxLength=1024 + TargetRef string `json:"targetRef"` + + RemoteBefore ControlRemoteRefState `json:"remoteBefore"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + ExpectedCommitSHA string `json:"expectedCommitSha"` + + AcknowledgementUnknown bool `json:"acknowledgementUnknown"` + + PublishedAt metav1.Time `json:"publishedAt"` +} + +// PublicationVerificationControlReceipt is an independent remote observation. +type PublicationVerificationControlReceipt struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + OperationID string `json:"operationId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + Outcome PublicationControlState `json:"outcome"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + ExpectedCommitSHA string `json:"expectedCommitSha"` + + ObservedRemote ControlRemoteRefState `json:"observedRemote"` + + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + DescendantProofDigest string `json:"descendantProofDigest,omitempty"` + + VerifiedAt metav1.Time `json:"verifiedAt"` +} + +// PullRequestOperationControlReceipt snapshots exact forge reconciliation. +type PullRequestOperationControlReceipt struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + OperationID string `json:"operationId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + IntentKey string `json:"intentKey"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ForgeID string `json:"forgeId"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + URL string `json:"url"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + State string `json:"state"` + + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + HeadSHA string `json:"headSha"` + + ReconciledAt metav1.Time `json:"reconciledAt"` +} + +// PublicationStatus contains mutable state, exact receipts, and epoch fencing. +// +kubebuilder:validation:XValidation:rule="!has(self.state) || !(self.state in ['DeliveryConflict', 'CredentialBlocked', 'PreparationFailed', 'PublicationOutcomeUnknown']) || (has(self.terminalReason) && size(self.terminalReason) > 0)",message="failure and unknown publication states require a terminal reason" +type PublicationStatus struct { + // +optional + State PublicationControlState `json:"state,omitempty"` + + // +optional + PRIntent *PublicationPullRequestIntent `json:"prIntent,omitempty"` + + // +optional + PreparedReceipt *PreparedPublicationControlReceipt `json:"preparedReceipt,omitempty"` + + // +optional + PublishReceipt *PublishOperationControlReceipt `json:"publishReceipt,omitempty"` + + // +optional + VerificationReceipt *PublicationVerificationControlReceipt `json:"verificationReceipt,omitempty"` + + // +optional + PullRequestReceipt *PullRequestOperationControlReceipt `json:"pullRequestReceipt,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=16384 + TerminalReason string `json:"terminalReason,omitempty"` + + ControlRecordMutationStatus `json:",inline"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=pubctl +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=`.status.state` +// +kubebuilder:printcolumn:name="Generation",type=integer,JSONPath=`.spec.generation` +// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef` +// +kubebuilder:printcolumn:name="Version",type=integer,JSONPath=`.status.version` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// Publication is the Kubernetes-authoritative clean-room publication record. +type Publication struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PublicationSpec `json:"spec"` + Status PublicationStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// PublicationList contains a list of Publication. +type PublicationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Publication `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Publication{}, &PublicationList{}) +} diff --git a/api/v1alpha1/repositorymonitor_types.go b/api/v1alpha1/repositorymonitor_types.go index c9793fad7..e7e048934 100644 --- a/api/v1alpha1/repositorymonitor_types.go +++ b/api/v1alpha1/repositorymonitor_types.go @@ -38,10 +38,36 @@ type RepositoryMonitorSpec struct { // +optional Branch string `json:"branch,omitempty"` - // GitSecretRef references GitHub credentials for repository monitor operations. + // GitSecretRef is the backward-compatible source-read credential reference. + // ReadCredentialRef takes precedence when both are set. GitSecretRef is never + // used for publication writes or forge mutations. // +optional GitSecretRef *corev1.LocalObjectReference `json:"gitSecretRef,omitempty"` + // ReadCredentialRef references the source-repository clone/read credential. + // It is resolved only by the clean-room workspace boundary. When omitted, + // GitSecretRef remains the backward-compatible read-only fallback. + // +optional + ReadCredentialRef *corev1.LocalObjectReference `json:"readCredentialRef,omitempty"` + + // PublicationReadCredentialRef references the target-repository read + // credential used only for publication preflight and independent verification. + // Write workflows require this explicit reference. + // +optional + PublicationReadCredentialRef *corev1.LocalObjectReference `json:"publicationReadCredentialRef,omitempty"` + + // PublicationCredentialRef references the target-repository write credential + // used only for exact compare-and-swap publication. Write workflows require + // this explicit reference. + // +optional + PublicationCredentialRef *corev1.LocalObjectReference `json:"publicationCredentialRef,omitempty"` + + // ForgeCredentialRef references the GitHub API credential used only for + // controller-owned forge reads and mutations. Write workflows and GitHub label + // triggers require this explicit reference. + // +optional + ForgeCredentialRef *corev1.LocalObjectReference `json:"forgeCredentialRef,omitempty"` + // Schedule is the cron expression for background monitor runs. // +optional Schedule string `json:"schedule,omitempty"` diff --git a/api/v1alpha1/repositoryscan_types.go b/api/v1alpha1/repositoryscan_types.go index a9ced3df4..9f944e9a8 100644 --- a/api/v1alpha1/repositoryscan_types.go +++ b/api/v1alpha1/repositoryscan_types.go @@ -54,10 +54,36 @@ type RepositoryScanSpec struct { // +optional SubPath string `json:"subPath,omitempty"` - // GitSecretRef references git credentials for private repositories. + // GitSecretRef is the backward-compatible source-read credential reference. + // ReadCredentialRef takes precedence when both are set. GitSecretRef is never + // used for publication writes or forge mutations. // +optional GitSecretRef *corev1.LocalObjectReference `json:"gitSecretRef,omitempty"` + // ReadCredentialRef references the source-repository clone/read credential. + // It is resolved only by the clean-room workspace boundary. When omitted, + // GitSecretRef remains the backward-compatible read-only fallback. + // +optional + ReadCredentialRef *corev1.LocalObjectReference `json:"readCredentialRef,omitempty"` + + // PublicationReadCredentialRef references the target-repository read + // credential used only for publication preflight and independent verification. + // Patch workflows require this explicit reference. + // +optional + PublicationReadCredentialRef *corev1.LocalObjectReference `json:"publicationReadCredentialRef,omitempty"` + + // PublicationCredentialRef references the target-repository write credential + // used only for exact compare-and-swap publication. Patch workflows require + // this explicit reference. + // +optional + PublicationCredentialRef *corev1.LocalObjectReference `json:"publicationCredentialRef,omitempty"` + + // ForgeCredentialRef references the GitHub API credential used only for + // controller-owned pull request reconciliation. Patch workflows require this + // explicit reference. + // +optional + ForgeCredentialRef *corev1.LocalObjectReference `json:"forgeCredentialRef,omitempty"` + // ForkRepo is the writable fork repository URL used for patch proposals. // +optional ForkRepo string `json:"forkRepo,omitempty"` diff --git a/api/v1alpha1/runtime_pool_types.go b/api/v1alpha1/runtime_pool_types.go new file mode 100644 index 000000000..e320ab98c --- /dev/null +++ b/api/v1alpha1/runtime_pool_types.go @@ -0,0 +1,556 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + // DefaultRuntimePoolDesiredReplicas keeps an idle pool scaled to zero until + // durable demand is present. + DefaultRuntimePoolDesiredReplicas int32 = 0 + // DefaultRuntimePoolMaxResidentSessions is the first-release per-pool + // resident-session limit. + DefaultRuntimePoolMaxResidentSessions int32 = 10 + // DefaultRuntimePoolMaxRunningPrompts is the first-release per-pool prompt + // concurrency limit. + DefaultRuntimePoolMaxRunningPrompts int32 = 4 + // DefaultRuntimePoolColdStartTimeoutSeconds bounds a 0 -> 1 pool startup. + DefaultRuntimePoolColdStartTimeoutSeconds int32 = 120 + // MaxRuntimePoolCapacityReservations bounds the durable reservation list in + // RuntimePool status. RuntimePool capacity is itself limited to 1000. + MaxRuntimePoolCapacityReservations = 1000 +) + +// RuntimePoolProtocolVersion is the controller-to-supervisor protocol profile. +// +kubebuilder:validation:Enum=orka.harness.v2 +type RuntimePoolProtocolVersion string + +const ( + // RuntimePoolProtocolHarnessV2 is the ACP session-centric harness contract. + RuntimePoolProtocolHarnessV2 RuntimePoolProtocolVersion = "orka.harness.v2" +) + +// RuntimePoolLifecycle is the controller-observed lifecycle of a logical pool. +// Only Serving may admit new RuntimeSessions. +// +kubebuilder:validation:Enum=Stopped;Starting;Serving;Draining;Quiescent;Stopping;Degraded;Ambiguous +type RuntimePoolLifecycle string + +const ( + RuntimePoolLifecycleStopped RuntimePoolLifecycle = "Stopped" + RuntimePoolLifecycleStarting RuntimePoolLifecycle = "Starting" + RuntimePoolLifecycleServing RuntimePoolLifecycle = "Serving" + RuntimePoolLifecycleDraining RuntimePoolLifecycle = "Draining" + RuntimePoolLifecycleQuiescent RuntimePoolLifecycle = "Quiescent" + RuntimePoolLifecycleStopping RuntimePoolLifecycle = "Stopping" + RuntimePoolLifecycleDegraded RuntimePoolLifecycle = "Degraded" + RuntimePoolLifecycleAmbiguous RuntimePoolLifecycle = "Ambiguous" +) + +// RuntimePoolAdmissionState is the authoritative admission gate for new +// RuntimeSessions. Existing session control traffic may continue while draining. +// +kubebuilder:validation:Enum=Closed;Accepting;Draining;Ambiguous +type RuntimePoolAdmissionState string + +const ( + RuntimePoolAdmissionClosed RuntimePoolAdmissionState = "Closed" + RuntimePoolAdmissionAccepting RuntimePoolAdmissionState = "Accepting" + RuntimePoolAdmissionDraining RuntimePoolAdmissionState = "Draining" + RuntimePoolAdmissionAmbiguous RuntimePoolAdmissionState = "Ambiguous" +) + +const ( + // RuntimePoolConditionAdmissionReady reports whether new RuntimeSessions may + // be admitted without violating lifecycle, fencing, or capacity rules. + RuntimePoolConditionAdmissionReady = "AdmissionReady" + // RuntimePoolConditionPodSecurityReady reports whether the runtime Pod passes + // the selected namespace's Pod Security admission requirements. + RuntimePoolConditionPodSecurityReady = "PodSecurityReady" + // RuntimePoolConditionQuotaReady reports whether quota permits the pool's + // controller-owned resources. + RuntimePoolConditionQuotaReady = "QuotaReady" + // RuntimePoolConditionSchedulingReady reports whether the selected runtime + // Pod can be scheduled. + RuntimePoolConditionSchedulingReady = "SchedulingReady" + // RuntimePoolConditionRolloutReady reports whether the immutable runtime + // profile has been installed without version skew. + RuntimePoolConditionRolloutReady = "RolloutReady" +) + +const ( + RuntimePoolReasonAdmissionClosed = "AdmissionClosed" + RuntimePoolReasonAtCapacity = "AtCapacity" + RuntimePoolReasonPodSecurityRejected = "PodSecurityRejected" + RuntimePoolReasonQuotaRejected = "QuotaRejected" + RuntimePoolReasonSchedulingFailed = "SchedulingFailed" + RuntimePoolReasonRolloutFailed = "RolloutFailed" + RuntimePoolReasonRuntimeAmbiguous = "RuntimeAmbiguous" +) + +// RuntimePoolTrustDomain identifies the logical same-trust-domain boundary +// served by one pool. It is not a tenant-isolation claim. +type RuntimePoolTrustDomain struct { + // Namespace is the Task namespace represented by this trust domain. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + Namespace string `json:"namespace"` + + // Identity is the controller-defined, canonical trust-domain identity. It + // must remain stable across physical runtime namespace or Pod replacement. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Identity string `json:"identity"` +} + +// ModelTokenLimits pins the reviewed token capacities used by a +// runtime's local context-management policy. +// +kubebuilder:validation:XValidation:rule="self.context > self.output",message="model context limit must exceed output limit" +type ModelTokenLimits struct { + // Context is the maximum model context capacity in tokens. + // +kubebuilder:validation:Minimum=1 + Context int64 `json:"context"` + + // Output is the maximum generated output in tokens. + // +kubebuilder:validation:Minimum=1 + Output int64 `json:"output"` +} + +// RuntimePoolProfileSpec pins the immutable runtime behavior selected for a +// pool. The digest covers the adapter and CLI builds, ACP profile, provider and +// model limits, agent configuration, tool and approval policy, MCP +// configuration, workspace intent, proxy credential scope, and resource class. +// +kubebuilder:validation:XValidation:rule="self.providerKind != 'opencode' || has(self.modelLimits)",message="OpenCode runtime profiles require modelLimits" +type RuntimePoolProfileSpec struct { + // ProtocolVersion is the controller-to-supervisor protocol profile. + // +kubebuilder:default=orka.harness.v2 + // +optional + ProtocolVersion RuntimePoolProtocolVersion `json:"protocolVersion,omitempty"` + + // Digest is the canonical immutable runtime-profile digest. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + Digest string `json:"digest"` + + // DigestSchemaVersion identifies the canonicalization schema used to compute Digest. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` + DigestSchemaVersion string `json:"digestSchemaVersion"` + + // ACPProfile is the reviewed ACP wire/profile identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=acp.v1 + ACPProfile string `json:"acpProfile"` + + // AdapterDigests pins every adapter and provider CLI artifact used by the pool. + // +kubebuilder:validation:MinProperties=1 + // +kubebuilder:validation:MaxProperties=32 + AdapterDigests map[string]string `json:"adapterDigests"` + + // ProviderKind selects the one provider adapter present in the immutable image. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=codex;claude;copilot;opencode + ProviderKind string `json:"providerKind"` + + // Model is the exact reviewed model identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Model string `json:"model"` + + // ModelLimits pins the reviewed context and output capacities used by the + // runtime's local compaction policy. + // +optional + ModelLimits *ModelTokenLimits `json:"modelLimits,omitempty"` + + // AgentConfigurationDigest freezes non-secret Agent/runtime configuration. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + AgentConfigurationDigest string `json:"agentConfigurationDigest"` + + // ToolPolicyDigest freezes the effective tool allow/deny policy. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ToolPolicyDigest string `json:"toolPolicyDigest"` + + // ApprovalPolicyDigest freezes the effective approval policy. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ApprovalPolicyDigest string `json:"approvalPolicyDigest"` + + // MCPConfigurationDigest freezes prompt-scoped broker/MCP configuration. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + MCPConfigurationDigest string `json:"mcpConfigurationDigest"` + + // WorkspaceIntent is part of the immutable runtime profile. + // +kubebuilder:validation:Required + WorkspaceIntent WorkspaceIntent `json:"workspaceIntent"` + + // ProxyCredentialRole identifies the provider-proxy client role, never a secret value. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + ProxyCredentialRole string `json:"proxyCredentialRole"` + + // ProxyCredentialScope is the bounded model/session capability scope. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + ProxyCredentialScope string `json:"proxyCredentialScope"` + + // ResourceClass is the controller-supported pool resource class included in Digest. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$` + ResourceClass string `json:"resourceClass"` +} + +// RuntimePoolRuntimeSpec selects the immutable supervisor image and profile. +type RuntimePoolRuntimeSpec struct { + // Image is a digest-pinned OCI image. Mutable tags are intentionally rejected. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=2048 + // +kubebuilder:validation:Pattern=`^[^\s@]+@sha256:[a-f0-9]{64}$` + Image string `json:"image"` + + // Profile is the immutable runtime profile enforced for every active instance. + // +kubebuilder:validation:Required + Profile RuntimePoolProfileSpec `json:"profile"` +} + +// RuntimePoolCapacitySpec defines hard logical pool concurrency limits. +// +kubebuilder:validation:XValidation:rule="self.maxRunningPrompts <= self.maxResidentSessions",message="maxRunningPrompts cannot exceed maxResidentSessions" +type RuntimePoolCapacitySpec struct { + // MaxResidentSessions is the maximum number of resident RuntimeSessions. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=1000 + // +kubebuilder:default=10 + // +optional + MaxResidentSessions int32 `json:"maxResidentSessions,omitempty"` + + // MaxRunningPrompts is the maximum number of concurrently running prompts. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=1000 + // +kubebuilder:default=4 + // +optional + MaxRunningPrompts int32 `json:"maxRunningPrompts,omitempty"` +} + +// RuntimePoolSpec defines the desired state of a controller-owned logical pool. +// Trust-domain placement and the runtime image/profile are immutable; rollout +// uses drain-and-replace rather than changing an in-memory instance in place. +// +kubebuilder:validation:XValidation:rule="self.trustDomain == oldSelf.trustDomain",message="trustDomain is immutable" +// +kubebuilder:validation:XValidation:rule="has(self.runtimeNamespace) == has(oldSelf.runtimeNamespace) && (!has(self.runtimeNamespace) || self.runtimeNamespace == oldSelf.runtimeNamespace)",message="runtimeNamespace is immutable" +// +kubebuilder:validation:XValidation:rule="self.runtime == oldSelf.runtime",message="runtime image and profile are immutable" +type RuntimePoolSpec struct { + // TrustDomain is the logical namespace/identity boundary served by this pool. + // +kubebuilder:validation:Required + TrustDomain RuntimePoolTrustDomain `json:"trustDomain"` + + // RuntimeNamespace is the physical namespace for controller-owned runtime + // resources. When omitted, the controller selects its configured runtime namespace. + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + // +optional + RuntimeNamespace string `json:"runtimeNamespace,omitempty"` + + // Runtime pins the immutable supervisor image and behavior profile. + // +kubebuilder:validation:Required + Runtime RuntimePoolRuntimeSpec `json:"runtime"` + + // DesiredReplicas is zero or one. More than one runtime Pod would make + // stateful exact-instance routing ambiguous. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1 + // +kubebuilder:default=0 + // +optional + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + + // Capacity sets resident-session and running-prompt limits. + // +kubebuilder:default={maxResidentSessions:10,maxRunningPrompts:4} + // +optional + Capacity *RuntimePoolCapacitySpec `json:"capacity,omitempty"` + + // ColdStartTimeoutSeconds bounds a 0 -> 1 startup before the pool is marked degraded. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=3600 + // +kubebuilder:default=120 + // +optional + ColdStartTimeoutSeconds int32 `json:"coldStartTimeoutSeconds,omitempty"` +} + +// RuntimePoolActiveInstanceStatus is the exact selected runtime Pod and +// supervisor boot. Every stateful request is fenced to this identity. +type RuntimePoolActiveInstanceStatus struct { + // PodNamespace is the namespace containing the selected runtime Pod. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + PodNamespace string `json:"podNamespace"` + + // PodName is the exact selected runtime Pod name. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + PodName string `json:"podName"` + + // PodAddress is the exact Pod address used for stateful routing, not a + // load-balanced Service endpoint. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + PodAddress string `json:"podAddress"` + + // PodUID is the Kubernetes UID of the selected Pod. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + PodUID string `json:"podUID"` + + // BootID is the immutable supervisor boot identifier inside the selected Pod. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + BootID string `json:"bootID"` + + // RuntimeInstanceID is the portable v2 instance fence derived from PodUID and BootID. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + RuntimeInstanceID string `json:"runtimeInstanceID"` + + // ControllerEpoch is the durable controller epoch to which this instance is bound. + // +kubebuilder:validation:Minimum=1 + ControllerEpoch int64 `json:"controllerEpoch"` + + // ProtocolVersion is the supervisor protocol actually advertised by this instance. + // +kubebuilder:validation:Required + ProtocolVersion RuntimePoolProtocolVersion `json:"protocolVersion"` + + // ProfileDigest is the immutable runtime-profile digest advertised by this instance. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ProfileDigest string `json:"profileDigest"` + + // ProfileDigestSchemaVersion is the digest schema advertised by this instance. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + ProfileDigestSchemaVersion string `json:"profileDigestSchemaVersion"` + + // ProviderTokenGeneration is a non-secret digest generation for the exact + // provider capability mounted into this runtime Pod. It lets the controller + // prove that a selected instance converged on the intended proxy credential + // without exposing the bearer token. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[a-f0-9]{16}$` + ProviderTokenGeneration string `json:"providerTokenGeneration"` + + // LastObservedTime is the last authenticated status observation for this instance. + // +optional + LastObservedTime *metav1.Time `json:"lastObservedTime,omitempty"` +} + +// RuntimePoolCapacityReservationStatus is one durable, exact-instance capacity +// claim. The composite key is the pool UID, Task UID, attempt, and controller +// epoch. A reservation claims resident-session and prompt admission slots until +// the supervisor accepts the corresponding work or the reservation expires. +// +kubebuilder:validation:XValidation:rule="self.residentSlots + self.promptSlots > 0",message="a capacity reservation must claim at least one slot" +type RuntimePoolCapacityReservationStatus struct { + // PoolUID fences the claim to the exact RuntimePool object. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + PoolUID string `json:"poolUID"` + + // TaskUID is the immutable Task identity that owns the claim. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + TaskUID string `json:"taskUID"` + + // Attempt is the Task attempt that owns the claim. + // +kubebuilder:validation:Minimum=1 + Attempt int32 `json:"attempt"` + + // ControllerEpoch fences the claim to one controller leadership epoch. + // +kubebuilder:validation:Minimum=1 + ControllerEpoch int64 `json:"controllerEpoch"` + + // RuntimeInstanceID binds admission to the exact selected Pod/boot pair. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + RuntimeInstanceID string `json:"runtimeInstanceID"` + + // ResidentSlots is zero after a RuntimeSession is admitted and one while a + // new resident-session slot is still reserved. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1 + ResidentSlots int32 `json:"residentSlots"` + + // PromptSlots is one until the prompt is accepted by the supervisor. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1 + PromptSlots int32 `json:"promptSlots"` + + // ReservedAt is the first successful resource-version CAS for this claim. + // +kubebuilder:validation:Required + ReservedAt metav1.Time `json:"reservedAt"` + + // ExpiresAt is renewed while pre-admission work is active. A later + // dispatcher may reclaim the claim after this time. + // +kubebuilder:validation:Required + ExpiresAt metav1.Time `json:"expiresAt"` +} + +// RuntimePoolCapacityStatus reports controller and authenticated-supervisor +// capacity counters. These counters are advisory unless fenced by the current +// ActiveInstance and controller epoch. Reservation records are authoritative for +// coordinator-owned pre-admission capacity. +type RuntimePoolCapacityStatus struct { + // MaxResidentSessions is the effective configured resident-session limit. + // +kubebuilder:validation:Minimum=0 + MaxResidentSessions int32 `json:"maxResidentSessions,omitempty"` + + // MaxRunningPrompts is the effective configured running-prompt limit. + // +kubebuilder:validation:Minimum=0 + MaxRunningPrompts int32 `json:"maxRunningPrompts,omitempty"` + + // ResidentSessions is the authenticated supervisor count of resident sessions. + // +kubebuilder:validation:Minimum=0 + ResidentSessions int32 `json:"residentSessions,omitempty"` + + // RunningPrompts is the authenticated supervisor count of active prompts. + // +kubebuilder:validation:Minimum=0 + RunningPrompts int32 `json:"runningPrompts,omitempty"` + + // QueuedTasks is durable unsatisfied demand assigned to this pool. + // +kubebuilder:validation:Minimum=0 + QueuedTasks int32 `json:"queuedTasks,omitempty"` + + // ReservedSessions is the sum of resident slots in Reservations. + // +kubebuilder:validation:Minimum=0 + ReservedSessions int32 `json:"reservedSessions,omitempty"` + + // ReservedPrompts is the sum of prompt slots in Reservations. + // +kubebuilder:validation:Minimum=0 + ReservedPrompts int32 `json:"reservedPrompts,omitempty"` + + // Reservations is the bounded authoritative set of coordinator-owned + // pre-admission capacity claims. + // +listType=map + // +listMapKey=poolUID + // +listMapKey=taskUID + // +listMapKey=attempt + // +listMapKey=controllerEpoch + // +kubebuilder:validation:MaxItems=1000 + // +optional + Reservations []RuntimePoolCapacityReservationStatus `json:"reservations,omitempty"` + + // PendingPermissions is the authenticated count of unresolved prompt permissions. + // +kubebuilder:validation:Minimum=0 + PendingPermissions int32 `json:"pendingPermissions,omitempty"` + + // FinalizingSessions is the count reserved for validation, publication, or finalization. + // +kubebuilder:validation:Minimum=0 + FinalizingSessions int32 `json:"finalizingSessions,omitempty"` + + // LiveDescendants is the authenticated count of tracked runtime descendants. + // +kubebuilder:validation:Minimum=0 + LiveDescendants int32 `json:"liveDescendants,omitempty"` +} + +// RuntimePoolStatus defines the observed state of a controller-owned pool. +type RuntimePoolStatus struct { + // ObservedGeneration is the latest RuntimePool generation reconciled by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // ControllerEpoch is the durable epoch required for authoritative pool writes. + // +kubebuilder:validation:Minimum=0 + // +optional + ControllerEpoch int64 `json:"controllerEpoch,omitempty"` + + // DesiredReplicas is the desired replica count observed by the controller. + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=1 + // +optional + DesiredReplicas int32 `json:"desiredReplicas,omitempty"` + + // CurrentReplicas is the number of non-terminated runtime Pods owned by the pool. + // +kubebuilder:validation:Minimum=0 + // +optional + CurrentReplicas int32 `json:"currentReplicas,omitempty"` + + // Lifecycle is the explicit pool lifecycle. + // +optional + Lifecycle RuntimePoolLifecycle `json:"lifecycle,omitempty"` + + // AdmissionState is the authoritative admission gate for new RuntimeSessions. + // +optional + AdmissionState RuntimePoolAdmissionState `json:"admissionState,omitempty"` + + // ActiveInstance is the exact selected Pod and supervisor boot. It is empty + // unless one instance has been authoritatively selected. + // +optional + ActiveInstance *RuntimePoolActiveInstanceStatus `json:"activeInstance,omitempty"` + + // Capacity reports effective limits, use, and queued demand. + // +optional + Capacity RuntimePoolCapacityStatus `json:"capacity,omitempty"` + + // Message contains bounded, sanitized reconciliation context. + // +kubebuilder:validation:MaxLength=1024 + // +optional + Message string `json:"message,omitempty"` + + // Conditions report admission, Pod Security, quota, scheduling, rollout, and + // other controller-observed failures. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=rtpool +// +kubebuilder:printcolumn:name="Lifecycle",type=string,JSONPath=`.status.lifecycle` +// +kubebuilder:printcolumn:name="Admission",type=string,JSONPath=`.status.admissionState` +// +kubebuilder:printcolumn:name="Desired",type=integer,JSONPath=`.status.desiredReplicas` +// +kubebuilder:printcolumn:name="Current",type=integer,JSONPath=`.status.currentReplicas` +// +kubebuilder:printcolumn:name="Sessions",type=integer,JSONPath=`.status.capacity.residentSessions` +// +kubebuilder:printcolumn:name="Prompts",type=integer,JSONPath=`.status.capacity.runningPrompts` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// RuntimePool is the Schema for controller-owned ACP runtime pools. +type RuntimePool struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RuntimePoolSpec `json:"spec,omitempty"` + Status RuntimePoolStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// RuntimePoolList contains a list of RuntimePool. +type RuntimePoolList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RuntimePool `json:"items"` +} + +func init() { + SchemeBuilder.Register(&RuntimePool{}, &RuntimePoolList{}) +} diff --git a/api/v1alpha1/runtime_pool_types_test.go b/api/v1alpha1/runtime_pool_types_test.go new file mode 100644 index 000000000..12eac9252 --- /dev/null +++ b/api/v1alpha1/runtime_pool_types_test.go @@ -0,0 +1,183 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + "encoding/json" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestRuntimePoolDefaultConstants(t *testing.T) { + if DefaultRuntimePoolDesiredReplicas != 0 { + t.Fatalf("DefaultRuntimePoolDesiredReplicas = %d, want 0", DefaultRuntimePoolDesiredReplicas) + } + if DefaultRuntimePoolMaxResidentSessions != 10 { + t.Fatalf("DefaultRuntimePoolMaxResidentSessions = %d, want 10", DefaultRuntimePoolMaxResidentSessions) + } + if DefaultRuntimePoolMaxRunningPrompts != 4 { + t.Fatalf("DefaultRuntimePoolMaxRunningPrompts = %d, want 4", DefaultRuntimePoolMaxRunningPrompts) + } + if DefaultRuntimePoolColdStartTimeoutSeconds != 120 { + t.Fatalf("DefaultRuntimePoolColdStartTimeoutSeconds = %d, want 120", DefaultRuntimePoolColdStartTimeoutSeconds) + } +} + +func TestRuntimePoolLifecycleConstants(t *testing.T) { + tests := []struct { + got RuntimePoolLifecycle + want string + }{ + {RuntimePoolLifecycleStopped, "Stopped"}, + {RuntimePoolLifecycleStarting, "Starting"}, + {RuntimePoolLifecycleServing, "Serving"}, + {RuntimePoolLifecycleDraining, "Draining"}, + {RuntimePoolLifecycleQuiescent, "Quiescent"}, + {RuntimePoolLifecycleStopping, "Stopping"}, + {RuntimePoolLifecycleDegraded, "Degraded"}, + {RuntimePoolLifecycleAmbiguous, "Ambiguous"}, + } + for _, tt := range tests { + if string(tt.got) != tt.want { + t.Errorf("RuntimePoolLifecycle = %q, want %q", tt.got, tt.want) + } + } +} + +func TestRuntimePoolFieldsRoundTrip(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + pool := RuntimePool{ + TypeMeta: metav1.TypeMeta{ + APIVersion: GroupVersion.String(), + Kind: "RuntimePool", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "codex-default", + Namespace: "tenant-a", + }, + Spec: RuntimePoolSpec{ + TrustDomain: RuntimePoolTrustDomain{ + Namespace: "tenant-a", + Identity: "tenant-a/default", + }, + RuntimeNamespace: "orka-runtimes", + Runtime: RuntimePoolRuntimeSpec{ + Image: "docker.io/example/acp-runtime@" + digest, + Profile: RuntimePoolProfileSpec{ + ProtocolVersion: RuntimePoolProtocolHarnessV2, + Digest: digest, + DigestSchemaVersion: "v1", + ResourceClass: "standard", + }, + }, + DesiredReplicas: 1, + Capacity: &RuntimePoolCapacitySpec{ + MaxResidentSessions: 10, + MaxRunningPrompts: 4, + }, + ColdStartTimeoutSeconds: 120, + }, + Status: RuntimePoolStatus{ + ObservedGeneration: 3, + ControllerEpoch: 9, + DesiredReplicas: 1, + CurrentReplicas: 1, + Lifecycle: RuntimePoolLifecycleServing, + AdmissionState: RuntimePoolAdmissionAccepting, + ActiveInstance: &RuntimePoolActiveInstanceStatus{ + PodNamespace: "orka-runtimes", + PodName: "codex-default-0", + PodAddress: "10.0.0.42", + PodUID: "11111111-2222-3333-4444-555555555555", + BootID: "boot-1", + RuntimeInstanceID: "runtime-instance-1", + ControllerEpoch: 9, + ProtocolVersion: RuntimePoolProtocolHarnessV2, + ProfileDigest: digest, + ProfileDigestSchemaVersion: "v1", + }, + Capacity: RuntimePoolCapacityStatus{ + MaxResidentSessions: 10, + MaxRunningPrompts: 4, + ResidentSessions: 3, + RunningPrompts: 2, + QueuedTasks: 5, + ReservedSessions: 1, + ReservedPrompts: 1, + Reservations: []RuntimePoolCapacityReservationStatus{{ + PoolUID: "pool-uid", TaskUID: "task-uid", Attempt: 1, ControllerEpoch: 9, + RuntimeInstanceID: "runtime-instance-1", ResidentSlots: 1, PromptSlots: 1, + ReservedAt: metav1.NewTime(time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)), + ExpiresAt: metav1.NewTime(time.Date(2026, 7, 25, 12, 2, 0, 0, time.UTC)), + }}, + }, + Conditions: []metav1.Condition{{ + Type: RuntimePoolConditionAdmissionReady, + Status: "True", + Reason: "Serving", + }}, + }, + } + + encoded, err := json.Marshal(&pool) + if err != nil { + t.Fatalf("json.Marshal(RuntimePool): %v", err) + } + var decoded RuntimePool + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("json.Unmarshal(RuntimePool): %v", err) + } + + if decoded.Spec.TrustDomain.Identity != pool.Spec.TrustDomain.Identity { + t.Errorf("trust domain identity = %q, want %q", decoded.Spec.TrustDomain.Identity, pool.Spec.TrustDomain.Identity) + } + if decoded.Status.ActiveInstance == nil { + t.Fatal("active instance was lost during JSON round trip") + } + if decoded.Status.ActiveInstance.RuntimeInstanceID != "runtime-instance-1" { + t.Errorf("runtime instance ID = %q, want runtime-instance-1", decoded.Status.ActiveInstance.RuntimeInstanceID) + } + if decoded.Status.Capacity.QueuedTasks != 5 { + t.Errorf("queued tasks = %d, want 5", decoded.Status.Capacity.QueuedTasks) + } + if len(decoded.Status.Capacity.Reservations) != 1 || decoded.Status.Capacity.Reservations[0].TaskUID != "task-uid" || decoded.Status.Capacity.ReservedPrompts != 1 { + t.Fatalf("capacity reservations = %#v", decoded.Status.Capacity) + } +} + +func TestRuntimePoolReservationDeepCopy(t *testing.T) { + pool := &RuntimePool{Status: RuntimePoolStatus{Capacity: RuntimePoolCapacityStatus{ + Reservations: []RuntimePoolCapacityReservationStatus{{ + PoolUID: "pool-uid", TaskUID: "task-uid", Attempt: 1, ControllerEpoch: 1, RuntimeInstanceID: "instance", + ResidentSlots: 1, PromptSlots: 1, ReservedAt: metav1.Now(), ExpiresAt: metav1.Now(), + }}, + }}} + copy := pool.DeepCopy() + copy.Status.Capacity.Reservations[0].TaskUID = "changed" + if pool.Status.Capacity.Reservations[0].TaskUID != "task-uid" { + t.Fatal("RuntimePool DeepCopy aliased capacity reservations") + } +} + +func TestRuntimePoolRegisteredWithScheme(t *testing.T) { + scheme := runtime.NewScheme() + if err := AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + + obj, err := scheme.New(GroupVersion.WithKind("RuntimePool")) + if err != nil { + t.Fatalf("scheme.New(RuntimePool): %v", err) + } + if _, ok := obj.(*RuntimePool); !ok { + t.Fatalf("scheme.New(RuntimePool) returned %T", obj) + } +} diff --git a/api/v1alpha1/runtime_session_control_types.go b/api/v1alpha1/runtime_session_control_types.go new file mode 100644 index 000000000..f967260a2 --- /dev/null +++ b/api/v1alpha1/runtime_session_control_types.go @@ -0,0 +1,213 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// RuntimeSessionControlLifecycle is the durable RuntimeSession lifecycle. +// +kubebuilder:validation:Enum=Creating;Idle;PromptRunning;Validating;PreparingPublication;PublicationPrepared;Publishing;Verifying;Finalizing;Cancelling;Poisoned;Deleting;Deleted +type RuntimeSessionControlLifecycle string + +// RuntimeSessionControlAvailability gates the Session mutation lease. +// +kubebuilder:validation:Enum=Available;ReconciliationBlocked +type RuntimeSessionControlAvailability string + +// RuntimeSessionLineageStatus is the Kubernetes-authoritative, append-once +// protocol/runtime identity for one conversation Session. Generation is +// independent from mutation-lease and ACP RuntimeSession generations. +type RuntimeSessionLineageStatus struct { + // NamespaceUID prevents a same-name recreated namespace from attaching to + // durable state owned by the previous namespace identity. + NamespaceUID types.UID `json:"namespaceUID"` + + // SessionUID repeats the immutable control identity at the lineage fence. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + SessionUID string `json:"sessionUid"` + + ContractVersion AgentRuntimeContractVersion `json:"contractVersion"` + + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation"` + + // RuntimeIdentity is the built-in runtime type or AgentRuntime UID. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + RuntimeIdentity string `json:"runtimeIdentity"` + + // ConfigDigest freezes the configuration/execution-snapshot identity used + // when the lineage was established. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + ConfigDigest string `json:"configDigest"` + + EstablishedAt metav1.Time `json:"establishedAt"` +} + +// RuntimeSessionControlSpec contains immutable session identity, ownership, and +// profile bindings. Profile changes create a new session generation in status; +// they do not mutate this immutable record identity. +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="runtime session control spec is immutable" +type RuntimeSessionControlSpec struct { + // SessionName is the immutable user-visible Session key within the object + // namespace. The Kubernetes object name is a digest-derived storage key and + // must not be treated as the Session name. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + SessionName string `json:"sessionName"` + + // SessionUID is the immutable Orka Session identity. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + SessionUID string `json:"sessionUid"` + + // RequestDigest binds creation to exact canonical input. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + // Owner identifies the immutable Task or durable Session owner. + Owner ControlRecordOwner `json:"owner"` + + // RuntimePoolRef is the controller-owned logical pool name when known. + // +optional + // +kubebuilder:validation:MaxLength=253 + RuntimePoolRef string `json:"runtimePoolRef,omitempty"` + + // RuntimePoolUID fences the pool object across delete/recreate. + // +optional + // +kubebuilder:validation:MaxLength=1024 + RuntimePoolUID string `json:"runtimePoolUid,omitempty"` + + // RuntimeProfileDigest binds the session to immutable runtime behavior. + // +optional + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RuntimeProfileDigest string `json:"runtimeProfileDigest,omitempty"` + + // ProfileDigestSchemaVersion identifies how RuntimeProfileDigest was built. + // +optional + // +kubebuilder:validation:MaxLength=64 + ProfileDigestSchemaVersion string `json:"profileDigestSchemaVersion,omitempty"` +} + +// RuntimeSessionMutationLeaseStatus mirrors the namespaced Kubernetes Lease +// that serializes mutation for one immutable SessionUID. +type RuntimeSessionMutationLeaseStatus struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + LeaseName string `json:"leaseName"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + LeaseResourceVersion string `json:"leaseResourceVersion"` + + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + TaskUID string `json:"taskUid"` + + // +kubebuilder:validation:Minimum=1 + Attempt int64 `json:"attempt"` + + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + PromptID string `json:"promptId"` + + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + RequestDigest string `json:"requestDigest"` + + AcquiredAt metav1.Time `json:"acquiredAt"` + + // +optional + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` +} + +// RuntimeSessionControlStatus contains the lifecycle, generation, mutation +// Lease, and independently verified recovery baseline. +// +kubebuilder:validation:XValidation:rule="!has(self.availability) || self.availability != 'Available' || ((!has(self.blockedReason) || size(self.blockedReason) == 0) && (!has(self.relatedPromptAttemptId) || size(self.relatedPromptAttemptId) == 0) && (!has(self.relatedPublicationId) || size(self.relatedPublicationId) == 0))",message="available sessions must clear reconciliation block metadata" +// +kubebuilder:validation:XValidation:rule="!has(self.availability) || self.availability != 'ReconciliationBlocked' || (has(self.blockedReason) && size(self.blockedReason) > 0)",message="reconciliation-blocked sessions require a reason" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.lineage) || (has(self.lineage) && self.lineage == oldSelf.lineage)",message="runtime Session lineage is append-once and immutable" +type RuntimeSessionControlStatus struct { + // Generation is the monotonic ACP RuntimeSession generation. + // +optional + // +kubebuilder:validation:Minimum=1 + Generation int64 `json:"generation,omitempty"` + + // +optional + Lifecycle RuntimeSessionControlLifecycle `json:"lifecycle,omitempty"` + + // +optional + Availability RuntimeSessionControlAvailability `json:"availability,omitempty"` + + // MutationLeaseGeneration is monotonic and never reused for SessionUID. + // +optional + // +kubebuilder:validation:Minimum=0 + MutationLeaseGeneration int64 `json:"mutationLeaseGeneration,omitempty"` + + // +optional + MutationLease *RuntimeSessionMutationLeaseStatus `json:"mutationLease,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=16384 + BlockedReason string `json:"blockedReason,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=1024 + RelatedPromptAttemptID string `json:"relatedPromptAttemptId,omitempty"` + + // +optional + // +kubebuilder:validation:MaxLength=1024 + RelatedPublicationID string `json:"relatedPublicationId,omitempty"` + + // +optional + VerifiedBaseline *ControlVerifiedBranchBaseline `json:"verifiedBaseline,omitempty"` + + // Lineage is established or verified in the same RuntimeSessionControl + // status CAS that mirrors the authoritative mutation Lease. + // +optional + Lineage *RuntimeSessionLineageStatus `json:"lineage,omitempty"` + + ControlRecordMutationStatus `json:",inline"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,shortName=rsctrl +// +kubebuilder:printcolumn:name="Lifecycle",type=string,JSONPath=`.status.lifecycle` +// +kubebuilder:printcolumn:name="Availability",type=string,JSONPath=`.status.availability` +// +kubebuilder:printcolumn:name="Generation",type=integer,JSONPath=`.status.generation` +// +kubebuilder:printcolumn:name="Lease",type=integer,JSONPath=`.status.mutationLeaseGeneration` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:validation:XValidation:rule="!has(self.status) || !has(self.status.lineage) || self.status.lineage.sessionUid == self.spec.sessionUid",message="runtime Session lineage UID must match the immutable control Session UID" + +// RuntimeSessionControl is the Kubernetes-authoritative RuntimeSession control +// record. SessionTurn/transcript/deferred-outbox data remains in one durable +// SQLite transaction; the Kubernetes store completes the authoritative +// SessionControl/BranchClaim CAS before activating the terminal projection. +type RuntimeSessionControl struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RuntimeSessionControlSpec `json:"spec"` + Status RuntimeSessionControlStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// RuntimeSessionControlList contains a list of RuntimeSessionControl. +type RuntimeSessionControlList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RuntimeSessionControl `json:"items"` +} + +func init() { + SchemeBuilder.Register(&RuntimeSessionControl{}, &RuntimeSessionControlList{}) +} diff --git a/api/v1alpha1/task_runtime_types.go b/api/v1alpha1/task_runtime_types.go new file mode 100644 index 000000000..bde4676ca --- /dev/null +++ b/api/v1alpha1/task_runtime_types.go @@ -0,0 +1,432 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// WorkspaceIntent declares whether an agent workspace is immutable read-only +// input or may produce a validated publication artifact. Agent Tasks that omit +// intent are interpreted as read by controller logic; container Task behavior +// is unchanged when intent is omitted. +// +kubebuilder:validation:Enum=read;write +type WorkspaceIntent string + +const ( + WorkspaceIntentRead WorkspaceIntent = "read" + WorkspaceIntentWrite WorkspaceIntent = "write" +) + +// WorkspaceCredentialReference references a Secret in the Task namespace. The +// controller freezes the Secret resourceVersion when it reserves an attempt; +// Secret contents are never copied to Task status. +type WorkspaceCredentialReference struct { + // Name is the name of the Secret in the Task namespace. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + Name string `json:"name"` + + // Key is the Secret data key containing one bearer token or one complete + // Authorization header. It defaults to "token" when omitted. + // +optional + // +kubebuilder:default=token + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9._-]+$` + Key string `json:"key,omitempty"` +} + +// RepositoryIdentity is the canonical identity derived from a credential-free +// repository URL. It is used for ownership, BranchClaim, and publication +// reconciliation decisions without persisting credentials. +type RepositoryIdentity struct { + // Provider identifies the source-control provider or forge. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$` + Provider string `json:"provider"` + + // ID is the canonical credential-free URL identity and must match the + // corresponding repository URL after normalization. For GitHub, use + // "github.com/owner/repo"; GitHub GraphQL node IDs are not accepted. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=512 + ID string `json:"id"` +} + +// TaskExecutionState is the durable execution state of one Task attempt. +// +kubebuilder:validation:Enum=Queued;Reserved;SessionStarting;Planned;Submitting;SubmittedUnknown;Accepted;Running;Settling;Succeeded;Failed;Cancelled;OutcomeUnknown +type TaskExecutionState string + +const ( + TaskExecutionStateQueued TaskExecutionState = "Queued" + TaskExecutionStateReserved TaskExecutionState = "Reserved" + TaskExecutionStateSessionStarting TaskExecutionState = "SessionStarting" + TaskExecutionStatePlanned TaskExecutionState = "Planned" + TaskExecutionStateSubmitting TaskExecutionState = "Submitting" + TaskExecutionStateSubmittedUnknown TaskExecutionState = "SubmittedUnknown" + TaskExecutionStateAccepted TaskExecutionState = "Accepted" + TaskExecutionStateRunning TaskExecutionState = "Running" + TaskExecutionStateSettling TaskExecutionState = "Settling" + TaskExecutionStateSucceeded TaskExecutionState = "Succeeded" + TaskExecutionStateFailed TaskExecutionState = "Failed" + TaskExecutionStateCancelled TaskExecutionState = "Cancelled" + TaskExecutionStateOutcomeUnknown TaskExecutionState = "OutcomeUnknown" +) + +// TaskExecutionOutcome is the terminal classification of one Task attempt. +// OutcomeUnknown is terminal and must never be treated as generic retryable +// failure by controller logic. +// +kubebuilder:validation:Enum=Succeeded;Failed;Cancelled;OutcomeUnknown +type TaskExecutionOutcome string + +const ( + TaskExecutionOutcomeSucceeded TaskExecutionOutcome = "Succeeded" + TaskExecutionOutcomeFailed TaskExecutionOutcome = "Failed" + TaskExecutionOutcomeCancelled TaskExecutionOutcome = "Cancelled" + TaskExecutionOutcomeOutcomeUnknown TaskExecutionOutcome = "OutcomeUnknown" +) + +// TaskExecutionReason is a bounded machine-readable execution reason. +// +kubebuilder:validation:MaxLength=128 +// +kubebuilder:validation:Pattern=`^[A-Za-z][A-Za-z0-9._-]{0,127}$` +type TaskExecutionReason string + +const ( + // TaskExecutionReasonAtCapacity is a scheduling reason, not a terminal Task phase. + TaskExecutionReasonAtCapacity TaskExecutionReason = "AtCapacity" + // TaskExecutionReasonRuntimeLost classifies accepted or running work lost with + // an unprovable terminal result; its outcome must be OutcomeUnknown. + TaskExecutionReasonRuntimeLost TaskExecutionReason = "RuntimeLost" +) + +// TaskExecutionStatus is the structured execution lifecycle for the current +// Task attempt. The existing top-level Task phase remains the compatibility +// projection and is not replaced by this status. +// +kubebuilder:validation:XValidation:rule="!has(self.outcome) || (has(self.state) && self.state == self.outcome)",message="execution outcome requires the matching terminal state" +// +kubebuilder:validation:XValidation:rule="!has(self.state) || !(self.state in ['Succeeded', 'Failed', 'Cancelled', 'OutcomeUnknown']) || has(self.outcome)",message="terminal execution state requires an outcome" +type TaskExecutionStatus struct { + // State is the current durable execution state. + // +optional + State TaskExecutionState `json:"state,omitempty"` + + // Outcome is set only after execution reaches a terminal classification. + // +optional + Outcome TaskExecutionOutcome `json:"outcome,omitempty"` + + // Reason is a stable machine-readable explanation for State or Outcome. + // +optional + Reason TaskExecutionReason `json:"reason,omitempty"` + + // Attempt is the one-based Task execution attempt represented by this status. + // +kubebuilder:validation:Minimum=1 + // +optional + Attempt int32 `json:"attempt,omitempty"` + + // PromptID is the durable prompt identity used for submission and settlement. + // +kubebuilder:validation:MaxLength=253 + // +optional + PromptID string `json:"promptID,omitempty"` + + // RuntimePoolName is the namespaced logical pool selected for this attempt. + // +kubebuilder:validation:MaxLength=253 + // +optional + RuntimePoolName string `json:"runtimePoolName,omitempty"` + + // RuntimePoolUID is the immutable pool UID fenced into runtime requests. + // +kubebuilder:validation:MaxLength=253 + // +optional + RuntimePoolUID string `json:"runtimePoolUID,omitempty"` + + // AgentRuntimeName is the namespaced external orka.harness.v2 registration + // selected for this attempt. It is mutually exclusive with RuntimePoolName. + // +kubebuilder:validation:MaxLength=253 + // +optional + AgentRuntimeName string `json:"agentRuntimeName,omitempty"` + + // AgentRuntimeUID is the immutable external AgentRuntime UID fenced into the + // attempt selection. + // +kubebuilder:validation:MaxLength=253 + // +optional + AgentRuntimeUID string `json:"agentRuntimeUID,omitempty"` + + // RuntimeInstanceID is the exact selected supervisor Pod UID plus boot identity. + // +kubebuilder:validation:MaxLength=512 + // +optional + RuntimeInstanceID string `json:"runtimeInstanceID,omitempty"` + + // RuntimeSessionUID is the stable controller-owned Session execution identity. + // +kubebuilder:validation:MaxLength=253 + // +optional + RuntimeSessionUID string `json:"runtimeSessionUID,omitempty"` + + // RuntimeSessionGeneration is the monotonic profile/session generation. + // +kubebuilder:validation:Minimum=0 + // +optional + RuntimeSessionGeneration int64 `json:"runtimeSessionGeneration,omitempty"` + + // RuntimeSessionSupervisorBootID freezes the supervisor boot that owns a + // pending or reusable Session generation. + // +kubebuilder:validation:MaxLength=512 + // +optional + RuntimeSessionSupervisorBootID string `json:"runtimeSessionSupervisorBootID,omitempty"` + + // RuntimeSessionProfileDigest freezes the immutable runtime behavior bound to + // a pending or reusable Session generation. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + RuntimeSessionProfileDigest string `json:"runtimeSessionProfileDigest,omitempty"` + + // RuntimeSessionMCPDigest binds the complete non-secret effective MCP policy + // and descriptor configuration to a pending or reusable Session generation. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + RuntimeSessionMCPDigest string `json:"runtimeSessionMCPDigest,omitempty"` + + // RuntimeSessionWorkspaceDigest binds a reusable Session generation to the + // exact repository, source ref, verified baseline, intent, and relative root. + // It contains no credential material. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + RuntimeSessionWorkspaceDigest string `json:"runtimeSessionWorkspaceDigest,omitempty"` + + // RuntimeSessionRecreationPending records that the exact generation is being + // created or replaced and must be reconciled before a different request may + // reuse that identity. + // +optional + RuntimeSessionRecreationPending bool `json:"runtimeSessionRecreationPending,omitempty"` + + // RuntimeSessionCleanupDigest is the controller-owned proof that the exact + // RuntimeSession requiring retirement was deleted or its immutable runtime + // instance was replaced. Users may read but cannot mutate the Task status subresource. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + RuntimeSessionCleanupDigest string `json:"runtimeSessionCleanupDigest,omitempty"` + + // RequestDigest is the canonical immutable prompt request digest. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + RequestDigest string `json:"requestDigest,omitempty"` + + // ControllerEpoch is the durable controller epoch fencing this attempt. + // +kubebuilder:validation:Minimum=0 + // +optional + ControllerEpoch int64 `json:"controllerEpoch,omitempty"` + + // ReadCredentialResourceVersion freezes the read credential Secret version + // selected at reservation without exposing credential material. + // +optional + // +kubebuilder:validation:MaxLength=253 + ReadCredentialResourceVersion string `json:"readCredentialResourceVersion,omitempty"` + + // PublicationReadCredentialResourceVersion freezes the target-read Secret + // version used for preflight and independent verification. + // +optional + // +kubebuilder:validation:MaxLength=253 + PublicationReadCredentialResourceVersion string `json:"publicationReadCredentialResourceVersion,omitempty"` + + // PublicationCredentialResourceVersion freezes the target-write Secret + // version selected at reservation without exposing credential material. + // +optional + // +kubebuilder:validation:MaxLength=253 + PublicationCredentialResourceVersion string `json:"publicationCredentialResourceVersion,omitempty"` + + // ForgeCredentialResourceVersion freezes the forge-only Secret version used + // for pull request reconciliation. + // +optional + // +kubebuilder:validation:MaxLength=253 + ForgeCredentialResourceVersion string `json:"forgeCredentialResourceVersion,omitempty"` + + // Message contains bounded, sanitized execution context. + // +kubebuilder:validation:MaxLength=1024 + // +optional + Message string `json:"message,omitempty"` + + // LastTransitionTime is the last durable execution-state transition time. + // +optional + LastTransitionTime *metav1.Time `json:"lastTransitionTime,omitempty"` +} + +// TaskDeliveryState is the durable validation and publication lifecycle. +// +kubebuilder:validation:Enum=NotRequested;Validating;Preparing;Prepared;Publishing;Verifying;VerifiedExact;DeliveredSuperseded;ReadValidated;NoChange;CancelledBeforePublish;ReadOnlyWorkspaceModified;DeliveryConflict;CredentialBlocked;PublicationOutcomeUnknown +type TaskDeliveryState string + +const ( + TaskDeliveryStateNotRequested TaskDeliveryState = "NotRequested" + TaskDeliveryStateValidating TaskDeliveryState = "Validating" + TaskDeliveryStatePreparing TaskDeliveryState = "Preparing" + TaskDeliveryStatePrepared TaskDeliveryState = "Prepared" + TaskDeliveryStatePublishing TaskDeliveryState = "Publishing" + TaskDeliveryStateVerifying TaskDeliveryState = "Verifying" + TaskDeliveryStateVerifiedExact TaskDeliveryState = "VerifiedExact" + TaskDeliveryStateDeliveredSuperseded TaskDeliveryState = "DeliveredSuperseded" + TaskDeliveryStateReadValidated TaskDeliveryState = "ReadValidated" + TaskDeliveryStateNoChange TaskDeliveryState = "NoChange" + TaskDeliveryStateCancelledBeforePublish TaskDeliveryState = "CancelledBeforePublish" + TaskDeliveryStateReadOnlyWorkspaceModified TaskDeliveryState = "ReadOnlyWorkspaceModified" + TaskDeliveryStateDeliveryConflict TaskDeliveryState = "DeliveryConflict" + TaskDeliveryStateCredentialBlocked TaskDeliveryState = "CredentialBlocked" + TaskDeliveryStatePublicationOutcomeUnknown TaskDeliveryState = "PublicationOutcomeUnknown" +) + +// TaskDeliveryOutcome is the terminal delivery classification. +// +kubebuilder:validation:Enum=NotRequested;VerifiedExact;DeliveredSuperseded;ReadValidated;NoChange;CancelledBeforePublish;ReadOnlyWorkspaceModified;DeliveryConflict;CredentialBlocked;PublicationOutcomeUnknown +type TaskDeliveryOutcome string + +const ( + TaskDeliveryOutcomeNotRequested TaskDeliveryOutcome = "NotRequested" + TaskDeliveryOutcomeVerifiedExact TaskDeliveryOutcome = "VerifiedExact" + TaskDeliveryOutcomeDeliveredSuperseded TaskDeliveryOutcome = "DeliveredSuperseded" + TaskDeliveryOutcomeReadValidated TaskDeliveryOutcome = "ReadValidated" + TaskDeliveryOutcomeNoChange TaskDeliveryOutcome = "NoChange" + TaskDeliveryOutcomeCancelledBeforePublish TaskDeliveryOutcome = "CancelledBeforePublish" + TaskDeliveryOutcomeReadOnlyWorkspaceModified TaskDeliveryOutcome = "ReadOnlyWorkspaceModified" + TaskDeliveryOutcomeDeliveryConflict TaskDeliveryOutcome = "DeliveryConflict" + TaskDeliveryOutcomeCredentialBlocked TaskDeliveryOutcome = "CredentialBlocked" + TaskDeliveryOutcomePublicationOutcomeUnknown TaskDeliveryOutcome = "PublicationOutcomeUnknown" +) + +// TaskDeliveryReason is a bounded machine-readable delivery reason. +// +kubebuilder:validation:MaxLength=128 +// +kubebuilder:validation:Pattern=`^[A-Za-z][A-Za-z0-9._-]{0,127}$` +type TaskDeliveryReason string + +const ( + // TaskDeliveryReasonCancellationRequestedAfterPublish records that publication + // won the durable CAS and must continue reconciliation despite cancellation. + TaskDeliveryReasonCancellationRequestedAfterPublish TaskDeliveryReason = "CancellationRequestedAfterPublish" +) + +// TaskPullRequestReceipt is the bounded, non-secret receipt for an explicitly +// requested pull request reconciliation. +type TaskPullRequestReceipt struct { + // ID is the provider's durable pull request identifier. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=512 + ID string `json:"id"` + + // Number is the provider's numeric pull request number when available. + // +kubebuilder:validation:Minimum=1 + // +optional + Number int64 `json:"number,omitempty"` + + // URL is the canonical user-facing pull request URL. It must not contain credentials. + // +kubebuilder:validation:MaxLength=2048 + // +optional + URL string `json:"url,omitempty"` + + // State is the provider-observed pull request state. + // +kubebuilder:validation:MaxLength=64 + // +optional + State string `json:"state,omitempty"` + + // BaseBranch is the reconciled pull request base branch. + // +kubebuilder:validation:MaxLength=255 + // +optional + BaseBranch string `json:"baseBranch,omitempty"` + + // HeadBranch is the reconciled pull request head branch. + // +kubebuilder:validation:MaxLength=255 + // +optional + HeadBranch string `json:"headBranch,omitempty"` + + // HeadSHA is the exact observed pull request head commit. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + HeadSHA string `json:"headSHA,omitempty"` +} + +// TaskDeliveryStatus is the structured validation/publication status and its +// durable, non-secret receipt. +// +kubebuilder:validation:XValidation:rule="!has(self.outcome) || (has(self.state) && self.state == self.outcome)",message="delivery outcome requires the matching terminal state" +// +kubebuilder:validation:XValidation:rule="!has(self.state) || !(self.state in ['NotRequested', 'VerifiedExact', 'DeliveredSuperseded', 'ReadValidated', 'NoChange', 'CancelledBeforePublish', 'ReadOnlyWorkspaceModified', 'DeliveryConflict', 'CredentialBlocked', 'PublicationOutcomeUnknown']) || has(self.outcome)",message="terminal delivery state requires an outcome" +type TaskDeliveryStatus struct { + // State is the current durable delivery state. + // +optional + State TaskDeliveryState `json:"state,omitempty"` + + // Outcome is set only after delivery reaches a terminal classification. + // +optional + Outcome TaskDeliveryOutcome `json:"outcome,omitempty"` + + // Reason is a stable machine-readable explanation for State or Outcome. + // +optional + Reason TaskDeliveryReason `json:"reason,omitempty"` + + // PublicationID is the durable identity reused for reconciliation of the same artifact. + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9][A-Za-z0-9._:-]{0,252}$` + // +optional + PublicationID string `json:"publicationID,omitempty"` + + // SourceRepository is the canonical repository from which the workspace baseline was created. + // +optional + SourceRepository *RepositoryIdentity `json:"sourceRepository,omitempty"` + + // PublicationRepository is the canonical repository whose branch was reconciled. + // +optional + PublicationRepository *RepositoryIdentity `json:"publicationRepository,omitempty"` + + // Branch is the publication branch without a refs/heads/ prefix. + // +kubebuilder:validation:MaxLength=255 + // +optional + Branch string `json:"branch,omitempty"` + + // StartingSHA is the verified source baseline before prompt execution. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + StartingSHA string `json:"startingSHA,omitempty"` + + // RemoteBeforeSHA is the exact publication ref observed before the CAS push. + // Nil means not yet observed; a pointer to the empty string records explicit + // absence; a non-empty value records the observed object ID. + // +kubebuilder:validation:Pattern=`^(|[a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + RemoteBeforeSHA *string `json:"remoteBeforeSHA,omitempty"` + + // TreeSHA is the deterministic clean-room tree written by the publisher. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + TreeSHA string `json:"treeSHA,omitempty"` + + // ExpectedCommitSHA is the exact Orka-owned commit prepared for publication. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + ExpectedCommitSHA string `json:"expectedCommitSHA,omitempty"` + + // VerifiedRemoteSHA is the independently observed remote branch head. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + VerifiedRemoteSHA string `json:"verifiedRemoteSHA,omitempty"` + + // SupersedingRemoteSHA is the verified descendant that superseded ExpectedCommitSHA. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + SupersedingRemoteSHA string `json:"supersedingRemoteSHA,omitempty"` + + // ArtifactDigest is the durable content-addressed workspace delta digest. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + ArtifactDigest string `json:"artifactDigest,omitempty"` + + // PRReceipt is present only when createPR was explicitly requested and reconciled. + // +optional + PRReceipt *TaskPullRequestReceipt `json:"prReceipt,omitempty"` + + // Message contains bounded, sanitized delivery context. + // +kubebuilder:validation:MaxLength=1024 + // +optional + Message string `json:"message,omitempty"` + + // LastTransitionTime is the last durable delivery-state transition time. + // +optional + LastTransitionTime *metav1.Time `json:"lastTransitionTime,omitempty"` +} diff --git a/api/v1alpha1/task_runtime_types_test.go b/api/v1alpha1/task_runtime_types_test.go new file mode 100644 index 000000000..acc1ed946 --- /dev/null +++ b/api/v1alpha1/task_runtime_types_test.go @@ -0,0 +1,187 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestTaskWorkspaceSchemaFields(t *testing.T) { + workspace := WorkspaceConfig{ + Intent: WorkspaceIntentWrite, + GitRepo: "https://github.com/example/source.git", + SourceRepository: &RepositoryIdentity{ + Provider: "github", + ID: "github.com/example/source", + }, + Branch: "main", + Ref: "0123456789abcdef0123456789abcdef01234567", + ReadCredentialRef: &WorkspaceCredentialReference{ + Name: "source-read", + }, + PublicationGitRepo: "https://github.com/example/fork.git", + PublicationRepository: &RepositoryIdentity{ + Provider: "github", + ID: "github.com/example/fork", + }, + PublicationReadCredentialRef: &WorkspaceCredentialReference{Name: "publication-read"}, + PublicationCredentialRef: &WorkspaceCredentialReference{ + Name: "publication-write", + }, + ForgeCredentialRef: &WorkspaceCredentialReference{Name: "forge-token"}, + SubPath: "src/app", + PRBaseBranch: "main", + PushBranch: "orka/task-full-uid", + CreatePR: true, + } + task := TaskSpec{Type: TaskTypeAgent, Workspace: &workspace} + + encoded, err := json.Marshal(task) + if err != nil { + t.Fatalf("json.Marshal(TaskSpec): %v", err) + } + var fields map[string]any + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatalf("json.Unmarshal(TaskSpec): %v", err) + } + workspaceJSON, ok := fields["workspace"].(map[string]any) + if !ok { + t.Fatalf("workspace JSON = %#v, want object", fields["workspace"]) + } + for _, field := range []string{ + "intent", + "gitRepo", + "sourceRepository", + "readCredentialRef", + "publicationGitRepo", + "publicationRepository", + "publicationReadCredentialRef", + "publicationCredentialRef", + "forgeCredentialRef", + "createPR", + } { + if _, ok := workspaceJSON[field]; !ok { + t.Errorf("workspace JSON missing %q: %s", field, encoded) + } + } + if _, ok := fields["agentRuntime"]; ok { + t.Fatalf("new top-level workspace unexpectedly serialized agentRuntime: %s", encoded) + } +} + +func TestTaskStructuredExecutionAndDeliveryStatus(t *testing.T) { + sha := strings.Repeat("a", 40) + remoteBeforeSHA := sha + digest := "sha256:" + strings.Repeat("b", 64) + status := TaskStatus{ + Phase: TaskPhaseSucceeded, + Execution: &TaskExecutionStatus{ + State: TaskExecutionStateSucceeded, + Outcome: TaskExecutionOutcomeSucceeded, + Attempt: 1, + PromptID: "prompt-1", + ControllerEpoch: 7, + }, + Delivery: &TaskDeliveryStatus{ + State: TaskDeliveryStateVerifiedExact, + Outcome: TaskDeliveryOutcomeVerifiedExact, + PublicationID: "publication-1", + SourceRepository: &RepositoryIdentity{ + Provider: "github", + ID: "github.com/example/source", + }, + PublicationRepository: &RepositoryIdentity{ + Provider: "github", + ID: "github.com/example/fork", + }, + Branch: "orka/task-full-uid", + StartingSHA: sha, + RemoteBeforeSHA: &remoteBeforeSHA, + TreeSHA: sha, + ExpectedCommitSHA: sha, + VerifiedRemoteSHA: sha, + ArtifactDigest: digest, + PRReceipt: &TaskPullRequestReceipt{ + ID: "PR_1", + Number: 42, + URL: "https://github.com/example/source/pull/42", + State: "open", + BaseBranch: "main", + HeadBranch: "orka/task-full-uid", + HeadSHA: sha, + }, + }, + } + + encoded, err := json.Marshal(status) + if err != nil { + t.Fatalf("json.Marshal(TaskStatus): %v", err) + } + var decoded TaskStatus + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("json.Unmarshal(TaskStatus): %v", err) + } + if decoded.Execution == nil || decoded.Execution.Outcome != TaskExecutionOutcomeSucceeded { + t.Fatalf("execution status round trip = %#v", decoded.Execution) + } + if decoded.Delivery == nil || decoded.Delivery.Outcome != TaskDeliveryOutcomeVerifiedExact { + t.Fatalf("delivery status round trip = %#v", decoded.Delivery) + } + if decoded.Delivery.PRReceipt == nil || decoded.Delivery.PRReceipt.Number != 42 { + t.Fatalf("PR receipt round trip = %#v", decoded.Delivery.PRReceipt) + } +} + +func TestTaskExecutionAndDeliveryOutcomeConstants(t *testing.T) { + execution := map[TaskExecutionOutcome]string{ + TaskExecutionOutcomeSucceeded: "Succeeded", + TaskExecutionOutcomeFailed: "Failed", + TaskExecutionOutcomeCancelled: "Cancelled", + TaskExecutionOutcomeOutcomeUnknown: "OutcomeUnknown", + } + for got, want := range execution { + if string(got) != want { + t.Errorf("execution outcome = %q, want %q", got, want) + } + } + + delivery := map[TaskDeliveryOutcome]string{ + TaskDeliveryOutcomeNotRequested: "NotRequested", + TaskDeliveryOutcomeVerifiedExact: "VerifiedExact", + TaskDeliveryOutcomeDeliveredSuperseded: "DeliveredSuperseded", + TaskDeliveryOutcomeReadValidated: "ReadValidated", + TaskDeliveryOutcomeNoChange: "NoChange", + TaskDeliveryOutcomeCancelledBeforePublish: "CancelledBeforePublish", + TaskDeliveryOutcomeReadOnlyWorkspaceModified: "ReadOnlyWorkspaceModified", + TaskDeliveryOutcomeDeliveryConflict: "DeliveryConflict", + TaskDeliveryOutcomeCredentialBlocked: "CredentialBlocked", + TaskDeliveryOutcomePublicationOutcomeUnknown: "PublicationOutcomeUnknown", + } + for got, want := range delivery { + if string(got) != want { + t.Errorf("delivery outcome = %q, want %q", got, want) + } + } +} + +func TestTaskWorkspaceHasNoLegacyCredentialOrForkFields(t *testing.T) { + workspace := WorkspaceConfig{ + GitRepo: "https://github.com/example/source.git", + ReadCredentialRef: &WorkspaceCredentialReference{Name: "source-read"}, + PublicationGitRepo: "https://github.com/example/fork.git", + PublicationCredentialRef: &WorkspaceCredentialReference{Name: "fork-write"}, + } + encoded, err := json.Marshal(workspace) + if err != nil { + t.Fatalf("json.Marshal(WorkspaceConfig): %v", err) + } + if strings.Contains(string(encoded), "gitSecretRef") || strings.Contains(string(encoded), "forkRepo") { + t.Fatalf("workspace serialized legacy fields: %s", encoded) + } +} diff --git a/api/v1alpha1/task_session_ref_validation_test.go b/api/v1alpha1/task_session_ref_validation_test.go new file mode 100644 index 000000000..a969a54f7 --- /dev/null +++ b/api/v1alpha1/task_session_ref_validation_test.go @@ -0,0 +1,178 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package v1alpha1 + +import ( + "context" + "maps" + "strings" + "testing" + + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + celconfig "k8s.io/apiserver/pkg/apis/cel" +) + +const ( + agentSessionRefImmutabilityRule = "self.type != 'agent' || (has(self.sessionRef) == has(oldSelf.sessionRef) && (!has(self.sessionRef) || self.sessionRef == oldSelf.sessionRef))" + agentSessionRefImmutabilityMarker = "// +kubebuilder:validation:XValidation:rule=\"" + agentSessionRefImmutabilityRule + "\",message=\"sessionRef is immutable for agent Tasks\"" +) + +func TestAgentSessionRefImmutabilityMarkerAdmission(t *testing.T) { + if source := string(readTaskTypesSource(t)); !strings.Contains(source, agentSessionRefImmutabilityMarker) { + t.Fatalf("TaskSpec is missing the complete agent sessionRef immutability marker: want %q", agentSessionRefImmutabilityMarker) + } + + schema := apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "type": {Type: "string"}, + "sessionRef": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": {Type: "string"}, + "create": {Type: "boolean"}, + "append": {Type: "boolean"}, + "maxMessages": {Type: "integer", Format: "int32"}, + "throughMessageId": {Type: "string"}, + "promptIncluded": {Type: "boolean"}, + }, + }, + }, + XValidations: apiextensions.ValidationRules{{ + Rule: agentSessionRefImmutabilityRule, + Message: "sessionRef is immutable for agent Tasks", + }}, + } + structural, err := structuralschema.NewStructural(&schema) + if err != nil { + t.Fatalf("build structural TaskSpec schema: %v", err) + } + validator := cel.NewValidator(structural, false, celconfig.PerCallLimit) + if validator == nil { + t.Fatal("compile agent sessionRef immutability admission rule: validator is nil") + } + + fullSessionRef := map[string]any{ + "name": "session-a", + "create": true, + "append": true, + "maxMessages": int64(50), + "throughMessageId": "message-42", + "promptIncluded": true, + } + oldAgent := taskSpecForSessionRefAdmission("agent", fullSessionRef) + + tests := []struct { + name string + oldSpec map[string]any + newSpec map[string]any + wantErr bool + }{ + { + name: "unchanged complete reference", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", fullSessionRef), + }, + { + name: "add reference", + oldSpec: taskSpecForSessionRefAdmission("agent", nil), + newSpec: taskSpecForSessionRefAdmission("agent", fullSessionRef), + wantErr: true, + }, + { + name: "remove reference", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", nil), + wantErr: true, + }, + { + name: "change name", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", changedSessionRef(fullSessionRef, "name", "session-b")), + wantErr: true, + }, + { + name: "change create", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", changedSessionRef(fullSessionRef, "create", false)), + wantErr: true, + }, + { + name: "change append", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", changedSessionRef(fullSessionRef, "append", false)), + wantErr: true, + }, + { + name: "change max messages", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", changedSessionRef(fullSessionRef, "maxMessages", int64(10))), + wantErr: true, + }, + { + name: "change transcript cutoff", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", changedSessionRef(fullSessionRef, "throughMessageId", "message-41")), + wantErr: true, + }, + { + name: "change prompt included", + oldSpec: oldAgent, + newSpec: taskSpecForSessionRefAdmission("agent", changedSessionRef(fullSessionRef, "promptIncluded", false)), + wantErr: true, + }, + { + name: "container reference remains mutable", + oldSpec: taskSpecForSessionRefAdmission("container", fullSessionRef), + newSpec: taskSpecForSessionRefAdmission("container", changedSessionRef(fullSessionRef, "name", "session-b")), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs, _ := validator.Validate( + context.Background(), + nil, + structural, + tt.newSpec, + tt.oldSpec, + celconfig.RuntimeCELCostBudget, + ) + if tt.wantErr { + if len(errs) == 0 { + t.Fatal("agent sessionRef mutation unexpectedly passed admission") + } + if got := errs.ToAggregate().Error(); !strings.Contains(got, "sessionRef is immutable for agent Tasks") { + t.Fatalf("admission error = %q, want sessionRef immutability message", got) + } + return + } + if len(errs) != 0 { + t.Fatalf("admission unexpectedly rejected update: %v", errs.ToAggregate()) + } + }) + } +} + +func taskSpecForSessionRefAdmission(taskType string, sessionRef map[string]any) map[string]any { + spec := map[string]any{"type": taskType} + if sessionRef != nil { + spec["sessionRef"] = changedSessionRef(sessionRef, "", nil) + } + return spec +} + +func changedSessionRef(sessionRef map[string]any, field string, value any) map[string]any { + changed := make(map[string]any, len(sessionRef)) + maps.Copy(changed, sessionRef) + if field != "" { + changed[field] = value + } + return changed +} diff --git a/api/v1alpha1/task_types.go b/api/v1alpha1/task_types.go index 06fd7f2e9..f9c94215e 100644 --- a/api/v1alpha1/task_types.go +++ b/api/v1alpha1/task_types.go @@ -7,6 +7,8 @@ MIT License - see LICENSE file for details. package v1alpha1 import ( + "encoding/json" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -126,7 +128,19 @@ type TaskTransaction struct { // TaskSpec defines the desired state of Task // +kubebuilder:validation:XValidation:rule="has(self.requestedBy) == has(oldSelf.requestedBy) && (!has(self.requestedBy) || self.requestedBy == oldSelf.requestedBy)",message="requestedBy is immutable" // +kubebuilder:validation:XValidation:rule="has(self.transaction) == has(oldSelf.transaction) && (!has(self.transaction) || self.transaction == oldSelf.transaction)",message="transaction is immutable" +// +kubebuilder:validation:XValidation:rule="self.type == oldSelf.type",message="type is immutable" +// +kubebuilder:validation:XValidation:rule="(has(self.workspace) && has(self.workspace.intent) ? self.workspace.intent : (self.type == 'agent' ? 'read' : self.type)) == (has(oldSelf.workspace) && has(oldSelf.workspace.intent) ? oldSelf.workspace.intent : (oldSelf.type == 'agent' ? 'read' : oldSelf.type))",message="effective workspace intent is immutable" +// +kubebuilder:validation:XValidation:rule="self.type != 'agent' || (has(self.prompt) == has(oldSelf.prompt) && (!has(self.prompt) || self.prompt == oldSelf.prompt))",message="agent prompt is immutable" +// +kubebuilder:validation:XValidation:rule="self.type != 'agent' || (has(self.agentRef) == has(oldSelf.agentRef) && (!has(self.agentRef) || self.agentRef == oldSelf.agentRef))",message="agentRef is immutable for agent Tasks" +// +kubebuilder:validation:XValidation:rule="self.type != 'agent' || (has(self.agentRuntime) == has(oldSelf.agentRuntime) && (!has(self.agentRuntime) || self.agentRuntime == oldSelf.agentRuntime))",message="agentRuntime is immutable for agent Tasks" +// +kubebuilder:validation:XValidation:rule="self.type != 'agent' || (has(self.sessionRef) == has(oldSelf.sessionRef) && (!has(self.sessionRef) || self.sessionRef == oldSelf.sessionRef))",message="sessionRef is immutable for agent Tasks" +// +kubebuilder:validation:XValidation:rule="self.type != 'agent' || (has(self.workspace) == has(oldSelf.workspace) && (!has(self.workspace) || self.workspace == oldSelf.workspace))",message="workspace is immutable for agent Tasks" +// +kubebuilder:validation:XValidation:rule="self.type != 'agent' || (has(self.timeout) == has(oldSelf.timeout) && (!has(self.timeout) || self.timeout == oldSelf.timeout))",message="timeout is immutable for agent Tasks" // +kubebuilder:validation:XValidation:rule="!has(self.execution) || !has(self.execution.workspace) || self.execution.workspace.reusePolicy != 'session' || has(self.sessionRef)",message="session workspace reuse requires spec.sessionRef" +// +kubebuilder:validation:XValidation:rule="self.type != 'container' || !has(self.workspace) || !has(self.workspace.expectedRemoteSHA)",message="container Tasks do not support workspace.expectedRemoteSHA" +// +kubebuilder:validation:XValidation:rule="self.type != 'container' || !has(self.workspace) || (!has(self.workspace.createPR) || !self.workspace.createPR)",message="container Tasks do not support workspace.createPR" +// +kubebuilder:validation:XValidation:rule="self.type != 'container' || !has(self.workspace) || (!has(self.workspace.maxChangedFiles) && (!has(self.workspace.allowedPaths) || self.workspace.allowedPaths.size() == 0) && (!has(self.workspace.denyRepositoryControlPaths) || !self.workspace.denyRepositoryControlPaths) && (!has(self.workspace.rejectBinaryFiles) || !self.workspace.rejectBinaryFiles) && (!has(self.workspace.rejectSecretLikeContent) || !self.workspace.rejectSecretLikeContent))",message="container Tasks do not support clean-room workspace publication policies" +// +kubebuilder:validation:XValidation:rule="self.type != 'container' || !has(self.workspace) || !has(self.workspace.pushBranch) || self.workspace.pushBranch.size() == 0 || !has(self.image) || self.image.size() == 0",message="custom-image container Tasks do not support workspace.pushBranch publication" type TaskSpec struct { // Type specifies the task type: "container" or "ai" // +kubebuilder:validation:Required @@ -237,9 +251,9 @@ type TaskSpec struct { // +optional AgentRuntime *AgentRuntimeSpec `json:"agentRuntime,omitempty"` - // Workspace defines repository checkout and push settings for tasks that need - // a git workspace. Agent tasks can continue to use agentRuntime.workspace for - // compatibility; this top-level field is used by container tasks as well. + // Workspace defines the canonical repository workspace, intent, credentials, + // and publication request. Agent Tasks that omit intent are interpreted as + // read by controller logic; an omitted intent preserves existing container behavior. // +optional Workspace *WorkspaceConfig `json:"workspace,omitempty"` @@ -412,6 +426,9 @@ type SkillReference struct { // TaskStatus defines the observed state of Task // +kubebuilder:validation:XValidation:rule="!has(oldSelf.executionOutcome) || self.executionOutcome == oldSelf.executionOutcome",message="executionOutcome is immutable once recorded" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.agentExecutionBinding) || (has(self.agentExecutionBinding) && self.agentExecutionBinding == oldSelf.agentExecutionBinding)",message="agentExecutionBinding is write-once and immutable" +// +kubebuilder:validation:XValidation:rule="!has(self.agentExecutionBinding) || self.agentExecutionBinding.contractVersion != 'orka.harness.v1' || ((!has(self.execution) || (has(oldSelf.execution) && self.execution == oldSelf.execution)) && (!has(self.delivery) || (has(oldSelf.delivery) && self.delivery == oldSelf.delivery)))",message="a v1-bound Task cannot acquire new v2 execution or delivery state" +// +kubebuilder:validation:XValidation:rule="!has(self.agentExecutionBinding) || self.agentExecutionBinding.contractVersion != 'orka.harness.v2' || !has(self.harnessRuntime) || (has(oldSelf.harnessRuntime) && self.harnessRuntime == oldSelf.harnessRuntime)",message="a v2-bound Task cannot acquire new v1 harness state" type TaskStatus struct { // Phase is the current phase of the task // +optional @@ -442,10 +459,31 @@ type TaskStatus struct { // +optional ResultRef *ResultReference `json:"resultRef,omitempty"` - // ExecutionOutcome is the immutable outcome recorded when workload execution ends. Workspace - // attachment revocation and cleanup continue independently while the Task is Finalizing. + // Execution reports the durable execution state and terminal outcome for the + // current attempt. Phase remains the compatibility projection. + // +optional + Execution *TaskExecutionStatus `json:"execution,omitempty"` + + // Delivery reports trusted workspace validation and publication reconciliation. // +optional - ExecutionOutcome *TaskExecutionOutcome `json:"executionOutcome,omitempty"` + Delivery *TaskDeliveryStatus `json:"delivery,omitempty"` + + // HarnessRuntime records the controller-resolved harness v1 runtime target + // for an in-flight agent turn. It intentionally stores only non-secret + // routing metadata and Secret references, never bearer values. Compatibility + // surface for harness v1 bindings. + // +optional + HarnessRuntime *HarnessRuntimeStatus `json:"harnessRuntime,omitempty"` + + // AgentExecutionBinding is the authoritative, write-once, immutable + // execution route for this agent Task. + // +optional + AgentExecutionBinding *AgentExecutionBinding `json:"agentExecutionBinding,omitempty"` + + // ExecutionOutcome records the immutable outcome of a non-ACP workload before + // provider-neutral execution-workspace finalization completes. + // +optional + ExecutionOutcome *TaskWorkloadExecutionOutcome `json:"executionOutcome,omitempty"` // ExecutionWorkspace reports the provider-neutral lifecycle state for a // requested execution workspace. Provider-native identifiers and credentials @@ -453,12 +491,6 @@ type TaskStatus struct { // +optional ExecutionWorkspace *ExecutionWorkspaceStatus `json:"executionWorkspace,omitempty"` - // HarnessRuntime records the controller-resolved harness runtime target for an - // in-flight agent turn. It intentionally stores only non-secret routing metadata - // and Secret references, never bearer values. - // +optional - HarnessRuntime *HarnessRuntimeStatus `json:"harnessRuntime,omitempty"` - // WebhookDelivered indicates whether the webhook was successfully called // +optional WebhookDelivered bool `json:"webhookDelivered,omitempty"` @@ -486,43 +518,11 @@ type TaskStatus struct { NextScheduleTime *metav1.Time `json:"nextScheduleTime,omitempty"` } -// HarnessRuntimeStatus records the resolved harness runtime selected by the controller. -type HarnessRuntimeStatus struct { - // RuntimeRefName is the AgentRuntime name for custom runtimeRef turns. Empty means built-in CLI wrapper. - // +optional - RuntimeRefName string `json:"runtimeRefName,omitempty"` - - // RuntimeName is the runtime name advertised by the harness capabilities and sent in turn metadata. - // +optional - RuntimeName string `json:"runtimeName,omitempty"` - - // ContractVersion is the Orka harness contract version used for the turn. - // +optional - ContractVersion string `json:"contractVersion,omitempty"` - - // Endpoint is the non-secret harness base URL selected when the turn started. - // +optional - Endpoint string `json:"endpoint,omitempty"` - - // RuntimeGeneration is the AgentRuntime generation selected when the turn started. - // +optional - RuntimeGeneration int64 `json:"runtimeGeneration,omitempty"` - - // AuthRefName is the Secret name selected when the turn started. - // +optional - AuthRefName string `json:"authRefName,omitempty"` - - // AuthRefField is the Secret data field selected when the turn started. - // +optional - AuthRefField string `json:"authRefField,omitempty"` - - // AuthRefResourceVersion is the auth Secret resourceVersion validated before starting the turn. - // +optional - AuthRefResourceVersion string `json:"authRefResourceVersion,omitempty"` -} - -// TaskExecutionOutcome records the immutable result of workload execution before workspace finalization. -type TaskExecutionOutcome struct { +// TaskWorkloadExecutionOutcome records the immutable result of non-ACP workload +// execution before provider-neutral workspace finalization. ACP agent attempts use +// TaskStatus.Execution, whose stronger fencing and OutcomeUnknown semantics are +// defined in task_runtime_types.go. +type TaskWorkloadExecutionOutcome struct { // Phase is the terminal workload execution phase. // +kubebuilder:validation:Enum=Succeeded;Failed;Cancelled Phase TaskPhase `json:"phase"` @@ -693,6 +693,7 @@ type ChildTaskStatus struct { // +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Priority",type=integer,JSONPath=`.spec.priority` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.status) || (!has(oldSelf.status.agentExecutionBinding) || self.spec == oldSelf.spec)",message="Task spec is immutable after execution authority is recorded" // Task is the Schema for the tasks API type Task struct { @@ -713,26 +714,30 @@ type TaskList struct { } // AgentRuntimeType defines the agent runtime to use -// +kubebuilder:validation:Enum=copilot;claude;codex;opencode +// +kubebuilder:validation:Enum=claude;codex;copilot;opencode type AgentRuntimeType string const ( - // AgentRuntimeCopilot uses GitHub Copilot CLI as the agent runtime + // AgentRuntimeCopilot uses GitHub Copilot CLI as the agent runtime. AgentRuntimeCopilot AgentRuntimeType = "copilot" // AgentRuntimeClaude uses Claude Code CLI as the agent runtime AgentRuntimeClaude AgentRuntimeType = "claude" // AgentRuntimeCodex uses OpenAI Codex CLI as the agent runtime AgentRuntimeCodex AgentRuntimeType = "codex" - // AgentRuntimeOpencode uses OpenCode CLI as the agent runtime + // AgentRuntimeOpencode uses OpenCode CLI's native ACP server as the agent runtime. AgentRuntimeOpencode AgentRuntimeType = "opencode" ) // AgentRuntimeSpec defines task-level overrides for agent runtime configuration. // Runtime type and credentials come from the referenced Agent CRD. +// +kubebuilder:validation:XValidation:rule="!has(self.workspace) || (oldSelf.hasValue() && has(oldSelf.value().workspace) && self.workspace == oldSelf.value().workspace)",optionalOldSelf=true,message="legacy agentRuntime.workspace is a preserved harness v1 compatibility surface; new Tasks must use spec.workspace" type AgentRuntimeSpec struct { - // Workspace defines the working directory configuration + // Workspace is the legacy harness v1 agent workspace configuration at its + // historical JSON path. It is a preserved compatibility read surface for + // stored v1 Tasks only: it can never be introduced or changed, and it is + // not an authority surface for new work. // +optional - Workspace *WorkspaceConfig `json:"workspace,omitempty"` + Workspace *LegacyAgentWorkspaceConfig `json:"workspace,omitempty"` // MaxTurns limits the number of agent loop iterations // +kubebuilder:validation:Minimum=1 @@ -753,40 +758,304 @@ type AgentRuntimeSpec struct { AllowBash *bool `json:"allowBash,omitempty"` } -// WorkspaceConfig defines workspace setup for agent tasks -type WorkspaceConfig struct { - // GitRepo is the repository URL to clone +// MarshalJSON preserves the distinction between an omitted task tool override +// and an explicitly empty deny-all override. +func (in AgentRuntimeSpec) MarshalJSON() ([]byte, error) { + type agentRuntimeSpecJSON struct { + Workspace *LegacyAgentWorkspaceConfig `json:"workspace,omitempty"` + MaxTurns *int32 `json:"maxTurns,omitempty"` + AllowedTools *[]string `json:"allowedTools,omitempty"` + DisallowedTools []string `json:"disallowedTools,omitempty"` + AllowBash *bool `json:"allowBash,omitempty"` + } + var allowedTools *[]string + if in.AllowedTools != nil { + tools := append([]string{}, in.AllowedTools...) + allowedTools = &tools + } + return json.Marshal(agentRuntimeSpecJSON{ + Workspace: in.Workspace, + MaxTurns: in.MaxTurns, + AllowedTools: allowedTools, + DisallowedTools: in.DisallowedTools, + AllowBash: in.AllowBash, + }) +} + +// LegacyAgentWorkspaceConfig is the harness v1 agent workspace shape preserved +// at the historical spec.agentRuntime.workspace JSON path. Historically valid +// stored values round-trip unchanged and are intentionally not subject to the +// v2 WorkspaceConfig URL and credential CEL policy; the shared schema keeps +// only protocol-neutral validation, and protocol-specific rules are enforced +// at binding resolution. These fields never enter new bindings: new v1 +// workspaces are credential-free and public-read-only by policy. +type LegacyAgentWorkspaceConfig struct { + // GitRepo is the repository URL to clone. // +optional GitRepo string `json:"gitRepo,omitempty"` - // Branch is the git branch to checkout + // Branch is the git branch to checkout. // +optional Branch string `json:"branch,omitempty"` - // Ref is a specific git ref (commit SHA, tag) to checkout + // Ref is a specific git ref (commit SHA, tag) to checkout. // +optional Ref string `json:"ref,omitempty"` - // GitSecretRef references a Secret containing git credentials + // GitSecretRef references a Secret containing git credentials. Adopted + // legacy bindings freeze the exact Secret identity; new bindings reject it. // +optional GitSecretRef *corev1.LocalObjectReference `json:"gitSecretRef,omitempty"` - // SubPath is a subdirectory within the repo to use as workspace root + // SubPath is a subdirectory within the repo to use as workspace root. // +optional SubPath string `json:"subPath,omitempty"` - // ForkRepo is the writable fork repository URL for pushing changes + // ForkRepo is the writable fork repository URL for pushing changes. // +optional ForkRepo string `json:"forkRepo,omitempty"` - // PRBaseBranch is the upstream branch to target for pull requests + // PRBaseBranch is the upstream branch to target for pull requests. + // +optional + PRBaseBranch string `json:"prBaseBranch,omitempty"` + + // PushBranch is the remote branch name to push changes to after the agent + // completes. + // +optional + PushBranch string `json:"pushBranch,omitempty"` +} + +// HarnessRuntimeStatus records the resolved harness v1 runtime and its durable +// attempt projection. Harness v1 cannot write the v2-only execution/delivery +// surfaces, so terminal ambiguity is represented here without weakening route +// exclusivity. +// +kubebuilder:validation:XValidation:rule="!has(self.state) || !(self.state in ['Succeeded', 'Failed', 'Cancelled', 'OutcomeUnknown']) || has(self.outcome)",message="terminal harness state requires an outcome" +// +kubebuilder:validation:XValidation:rule="!has(self.outcome) || (has(self.state) && self.state in ['Succeeded', 'Failed', 'Cancelled', 'OutcomeUnknown'])",message="harness outcome requires a terminal state" +// +kubebuilder:validation:XValidation:rule="!has(self.state) || self.state != 'OutcomeUnknown' || (has(self.outcome) && self.outcome == 'OutcomeUnknown')",message="OutcomeUnknown harness state requires OutcomeUnknown outcome" +type HarnessRuntimeStatus struct { + // RuntimeRefName is the AgentRuntime name for custom runtimeRef turns. + // Empty means built-in CLI wrapper. + // +optional + RuntimeRefName string `json:"runtimeRefName,omitempty"` + + // RuntimeName is the runtime name advertised by the harness capabilities + // and sent in turn metadata. + // +optional + RuntimeName string `json:"runtimeName,omitempty"` + + // ContractVersion is the Orka harness contract version used for the turn. + // +optional + ContractVersion string `json:"contractVersion,omitempty"` + + // Endpoint is the non-secret harness base URL selected when the turn started. + // +optional + Endpoint string `json:"endpoint,omitempty"` + + // RuntimeGeneration is the AgentRuntime generation selected when the turn started. + // +optional + RuntimeGeneration int64 `json:"runtimeGeneration,omitempty"` + + // AuthRefName is the Secret name selected when the turn started. + // +optional + AuthRefName string `json:"authRefName,omitempty"` + + // AuthRefField is the Secret data field selected when the turn started. + // +optional + AuthRefField string `json:"authRefField,omitempty"` + + // AuthRefResourceVersion is the auth Secret resourceVersion validated + // before starting the turn. + // +optional + AuthRefResourceVersion string `json:"authRefResourceVersion,omitempty"` + + // Attempt is the durable harness v1 attempt number. + // +kubebuilder:validation:Minimum=1 + // +optional + Attempt int32 `json:"attempt,omitempty"` + + // TurnID is the deterministic, non-secret harness turn identity. + // +optional + TurnID string `json:"turnID,omitempty"` + + // RuntimeSessionID is the deterministic, non-secret v1 runtime-session identity. + // +optional + RuntimeSessionID string `json:"runtimeSessionID,omitempty"` + + // State is the durable harness v1 attempt state projected for operators. + // +optional + State TaskExecutionState `json:"state,omitempty"` + + // Outcome is set only for a terminal harness v1 attempt. + // +optional + Outcome TaskExecutionOutcome `json:"outcome,omitempty"` + + // Reason is a bounded machine-readable terminal reason code. + // +kubebuilder:validation:MaxLength=256 + // +optional + Reason string `json:"reason,omitempty"` + + // TerminalReceiptDigest identifies the authoritative terminal or unknown receipt. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + TerminalReceiptDigest string `json:"terminalReceiptDigest,omitempty"` + + // RequestDigest binds the canonical StartTurn request admitted by the + // durable wrapper ledger. + // +kubebuilder:validation:Pattern=`^sha256:[a-f0-9]{64}$` + // +optional + RequestDigest string `json:"requestDigest,omitempty"` + + // ControllerEpoch records the fenced controller epoch driving the attempt. + // +kubebuilder:validation:Minimum=0 + // +optional + ControllerEpoch int64 `json:"controllerEpoch,omitempty"` + + // LastEventSeq is the highest durably mapped harness frame sequence. + // +kubebuilder:validation:Minimum=0 + // +optional + LastEventSeq int64 `json:"lastEventSeq,omitempty"` + + // CancelRequestedAt records a durable cancellation request. Cancellation + // remains nonterminal until a terminal frame or ledger receipt is observed. + // +optional + CancelRequestedAt *metav1.Time `json:"cancelRequestedAt,omitempty"` + + // Message is bounded, sanitized execution context. + // +kubebuilder:validation:MaxLength=1024 + // +optional + Message string `json:"message,omitempty"` + + // LastTransitionTime is the last durable v1 attempt transition projected to + // the Task. + // +optional + LastTransitionTime *metav1.Time `json:"lastTransitionTime,omitempty"` +} + +// WorkspaceConfig defines repository workspace, validation, and publication intent. +// +kubebuilder:validation:XValidation:rule="!self.createPR || self.intent == 'write'",message="createPR requires write workspace intent" +// +kubebuilder:validation:XValidation:rule="!has(self.gitRepo) || (!self.gitRepo.matches('(?i)^[A-Za-z][A-Za-z0-9+.-]*://[^/]*@') && !self.gitRepo.contains('?') && !self.gitRepo.contains('#'))",message="gitRepo must not contain embedded credentials, query parameters, or fragments" +// +kubebuilder:validation:XValidation:rule="!has(self.publicationGitRepo) || (!self.publicationGitRepo.matches('(?i)^[A-Za-z][A-Za-z0-9+.-]*://[^/]*@') && !self.publicationGitRepo.contains('?') && !self.publicationGitRepo.contains('#'))",message="publicationGitRepo must not contain embedded credentials, query parameters, or fragments" +type WorkspaceConfig struct { + // Intent declares whether the verified workspace must remain unchanged or may + // produce a publication artifact. It is immutable for the lifetime of the Task. + // Agent Tasks that omit intent are interpreted as read by controller logic; + // omitted intent preserves the existing behavior of container Tasks. + // +optional + Intent WorkspaceIntent `json:"intent,omitempty"` + + // GitRepo is the source repository URL cloned by the clean-room workspace boundary. + // Credentials must not be embedded in the URL. + // +kubebuilder:validation:MaxLength=2048 + // +optional + GitRepo string `json:"gitRepo,omitempty"` + + // SourceRepository is the optional URL-derived identity for GitRepo. When set, + // it must match the normalized credential-free URL; for GitHub, use provider + // "github" and ID "github.com/owner/repo". + // +optional + SourceRepository *RepositoryIdentity `json:"sourceRepository,omitempty"` + + // Branch is the source branch to check out. + // +kubebuilder:validation:MaxLength=255 + // +optional + Branch string `json:"branch,omitempty"` + + // Ref is a specific source git ref, commit SHA, or tag to check out. + // +kubebuilder:validation:MaxLength=512 + // +optional + Ref string `json:"ref,omitempty"` + + // ReadCredentialRef references the one-operation clone/read credential Secret. + // The Secret is resolved only by the clean-room workspace boundary. + // +optional + ReadCredentialRef *WorkspaceCredentialReference `json:"readCredentialRef,omitempty"` + + // PublicationGitRepo is the repository URL whose branch receives an exact CAS publication. + // Credentials must not be embedded in the URL. + // +kubebuilder:validation:MaxLength=2048 + // +optional + PublicationGitRepo string `json:"publicationGitRepo,omitempty"` + + // PublicationRepository is the optional URL-derived identity for + // PublicationGitRepo. When set, it must match the normalized credential-free + // URL; for GitHub, use provider "github" and ID "github.com/owner/repo". + // +optional + PublicationRepository *RepositoryIdentity `json:"publicationRepository,omitempty"` + + // PublicationReadCredentialRef references the target-repository read + // credential used only for preflight and independent post-push verification. + // +optional + PublicationReadCredentialRef *WorkspaceCredentialReference `json:"publicationReadCredentialRef,omitempty"` + + // PublicationCredentialRef references the target-repository write credential + // used only for the exact CAS push. It is never used to clone the source. + // +optional + PublicationCredentialRef *WorkspaceCredentialReference `json:"publicationCredentialRef,omitempty"` + + // ForgeCredentialRef references the forge API credential used only for pull + // request reconciliation when createPR=true. + // +optional + ForgeCredentialRef *WorkspaceCredentialReference `json:"forgeCredentialRef,omitempty"` + + // SubPath is a subdirectory within the source repository used as workspace root. + // +kubebuilder:validation:MaxLength=1024 + // +optional + SubPath string `json:"subPath,omitempty"` + + // PRBaseBranch is the upstream branch targeted when CreatePR is true. + // +kubebuilder:validation:MaxLength=255 // +optional PRBaseBranch string `json:"prBaseBranch,omitempty"` - // PushBranch is the remote branch name to push changes to after the agent completes. - // When set, FinalizeResult will commit and push changes to this branch. + // PushBranch is the publication branch. For write Tasks the controller derives + // a full-entropy Task- or Session-owned branch when this is omitted. + // +kubebuilder:validation:MaxLength=255 // +optional PushBranch string `json:"pushBranch,omitempty"` + + // ExpectedRemoteSHA requires the publication branch to exist at this exact + // commit before publication. Empty means the branch must be absent. It is + // supported only for agent Tasks using the trusted ACP publisher boundary. + // +kubebuilder:validation:Pattern=`^([a-f0-9]{40}|[a-f0-9]{64})$` + // +optional + ExpectedRemoteSHA string `json:"expectedRemoteSHA,omitempty"` + + // MaxChangedFiles bounds the total changed, deleted, and symlink paths accepted + // from the trusted supervisor before publication. Zero uses the runtime limit. + // It is not supported for container Tasks. + // +kubebuilder:validation:Minimum=1 + // +optional + MaxChangedFiles *int32 `json:"maxChangedFiles,omitempty"` + + // AllowedPaths restricts publishable workspace changes to these path globs or + // directory prefixes ending in /**. Empty allows every otherwise-safe path. + // It is not supported for container Tasks. + // +kubebuilder:validation:MaxItems=256 + // +optional + AllowedPaths []string `json:"allowedPaths,omitempty"` + + // DenyRepositoryControlPaths rejects workflow, RBAC, and chart-secret paths + // before publication even when AllowedPaths is empty or otherwise matches. + // It is not supported for container Tasks. + // +optional + DenyRepositoryControlPaths bool `json:"denyRepositoryControlPaths,omitempty"` + + // RejectBinaryFiles rejects changed file content that is not valid text. It is + // not supported for container Tasks. + // +optional + RejectBinaryFiles bool `json:"rejectBinaryFiles,omitempty"` + + // RejectSecretLikeContent applies Orka's generic secret detector to changed + // paths and file contents before publication. It is not supported for container Tasks. + // +optional + RejectSecretLikeContent bool `json:"rejectSecretLikeContent,omitempty"` + + // CreatePR explicitly requests pull request reconciliation after branch publication. + // Branch push remains the minimum durable delivery when false. It is supported only + // for agent Tasks using the trusted ACP publisher boundary. + // +kubebuilder:default=false + // +optional + CreatePR bool `json:"createPR,omitempty"` } func init() { diff --git a/api/v1alpha1/tool_types.go b/api/v1alpha1/tool_types.go index f86106916..22ee236b8 100644 --- a/api/v1alpha1/tool_types.go +++ b/api/v1alpha1/tool_types.go @@ -58,6 +58,11 @@ type HTTPExecution struct { Headers map[string]string `json:"headers,omitempty"` // Timeout is the request timeout (default: 30s) + // Consequential (non-read) tools brokered to ACP runtimes execute under a + // fixed external-effect ledger lease; their timeout must stay at or below + // the controller's brokered call bound (currently four minutes). Longer + // timeouts are rejected when the tool is exposed to an ACP prompt, and + // brokered calls are always clamped to that bound at execution time. // +optional Timeout *metav1.Duration `json:"timeout,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2ec71a30b..accf02e5d 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -89,6 +89,11 @@ func (in *Agent) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentCLIRuntime) DeepCopyInto(out *AgentCLIRuntime) { *out = *in + if in.ContractVersion != nil { + in, out := &in.ContractVersion, &out.ContractVersion + *out = new(AgentRuntimeContractVersion) + **out = **in + } if in.RuntimeRef != nil { in, out := &in.RuntimeRef, &out.RuntimeRef *out = new(AgentRuntimeReference) @@ -121,6 +126,94 @@ func (in *AgentCLIRuntime) DeepCopy() *AgentCLIRuntime { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentExecutionAgentRef) DeepCopyInto(out *AgentExecutionAgentRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentExecutionAgentRef. +func (in *AgentExecutionAgentRef) DeepCopy() *AgentExecutionAgentRef { + if in == nil { + return nil + } + out := new(AgentExecutionAgentRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentExecutionBinding) DeepCopyInto(out *AgentExecutionBinding) { + *out = *in + out.Task = in.Task + if in.Agent != nil { + in, out := &in.Agent, &out.Agent + *out = new(AgentExecutionAgentRef) + **out = **in + } + out.Snapshot = in.Snapshot + if in.RuntimeRef != nil { + in, out := &in.RuntimeRef, &out.RuntimeRef + *out = new(AgentExecutionRuntimeRef) + **out = **in + } + in.BoundAt.DeepCopyInto(&out.BoundAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentExecutionBinding. +func (in *AgentExecutionBinding) DeepCopy() *AgentExecutionBinding { + if in == nil { + return nil + } + out := new(AgentExecutionBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentExecutionBindingTaskRef) DeepCopyInto(out *AgentExecutionBindingTaskRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentExecutionBindingTaskRef. +func (in *AgentExecutionBindingTaskRef) DeepCopy() *AgentExecutionBindingTaskRef { + if in == nil { + return nil + } + out := new(AgentExecutionBindingTaskRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentExecutionRuntimeRef) DeepCopyInto(out *AgentExecutionRuntimeRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentExecutionRuntimeRef. +func (in *AgentExecutionRuntimeRef) DeepCopy() *AgentExecutionRuntimeRef { + if in == nil { + return nil + } + out := new(AgentExecutionRuntimeRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentExecutionSnapshotRef) DeepCopyInto(out *AgentExecutionSnapshotRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentExecutionSnapshotRef. +func (in *AgentExecutionSnapshotRef) DeepCopy() *AgentExecutionSnapshotRef { + if in == nil { + return nil + } + out := new(AgentExecutionSnapshotRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentList) DeepCopyInto(out *AgentList) { *out = *in @@ -213,6 +306,21 @@ func (in *AgentRuntimeBearerAuthReference) DeepCopy() *AgentRuntimeBearerAuthRef // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentRuntimeCapabilitiesSpec) DeepCopyInto(out *AgentRuntimeCapabilitiesSpec) { *out = *in + if in.Profile != nil { + in, out := &in.Profile, &out.Profile + *out = new(AgentRuntimeProfileSpec) + (*in).DeepCopyInto(*out) + } + if in.Limits != nil { + in, out := &in.Limits, &out.Limits + *out = new(AgentRuntimeProtocolLimits) + **out = **in + } + if in.WorkspaceGovernance != nil { + in, out := &in.WorkspaceGovernance, &out.WorkspaceGovernance + *out = new(AgentRuntimeWorkspaceGovernanceCapabilities) + **out = **in + } if in.ToolExecutionModes != nil { in, out := &in.ToolExecutionModes, &out.ToolExecutionModes *out = make([]AgentRuntimeToolExecutionMode, len(*in)) @@ -258,7 +366,21 @@ func (in *AgentRuntimeCapabilitiesSpec) DeepCopy() *AgentRuntimeCapabilitiesSpec // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentRuntimeClientAuth) DeepCopyInto(out *AgentRuntimeClientAuth) { *out = *in - out.BearerAuthRef = in.BearerAuthRef + if in.BearerAuthRef != nil { + in, out := &in.BearerAuthRef, &out.BearerAuthRef + *out = new(AgentRuntimeBearerAuthReference) + **out = **in + } + if in.ControllerBearerTokenSecretRef != nil { + in, out := &in.ControllerBearerTokenSecretRef, &out.ControllerBearerTokenSecretRef + *out = new(AgentRuntimeSecretKeyReference) + **out = **in + } + if in.OperationCapabilitySecretRef != nil { + in, out := &in.OperationCapabilitySecretRef, &out.OperationCapabilitySecretRef + *out = new(AgentRuntimeSecretKeyReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRuntimeClientAuth. @@ -321,6 +443,16 @@ func (in *AgentRuntimeList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentRuntimeObservedCapabilities) DeepCopyInto(out *AgentRuntimeObservedCapabilities) { *out = *in + if in.Limits != nil { + in, out := &in.Limits, &out.Limits + *out = new(AgentRuntimeProtocolLimits) + **out = **in + } + if in.WorkspaceGovernance != nil { + in, out := &in.WorkspaceGovernance, &out.WorkspaceGovernance + *out = new(AgentRuntimeWorkspaceGovernanceCapabilities) + **out = **in + } if in.ToolExecutionModes != nil { in, out := &in.ToolExecutionModes, &out.ToolExecutionModes *out = make([]AgentRuntimeToolExecutionMode, len(*in)) @@ -343,6 +475,41 @@ func (in *AgentRuntimeObservedCapabilities) DeepCopy() *AgentRuntimeObservedCapa return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentRuntimeProfileSpec) DeepCopyInto(out *AgentRuntimeProfileSpec) { + *out = *in + if in.ModelLimits != nil { + in, out := &in.ModelLimits, &out.ModelLimits + *out = new(ModelTokenLimits) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRuntimeProfileSpec. +func (in *AgentRuntimeProfileSpec) DeepCopy() *AgentRuntimeProfileSpec { + if in == nil { + return nil + } + out := new(AgentRuntimeProfileSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentRuntimeProtocolLimits) DeepCopyInto(out *AgentRuntimeProtocolLimits) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRuntimeProtocolLimits. +func (in *AgentRuntimeProtocolLimits) DeepCopy() *AgentRuntimeProtocolLimits { + if in == nil { + return nil + } + out := new(AgentRuntimeProtocolLimits) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentRuntimeReference) DeepCopyInto(out *AgentRuntimeReference) { *out = *in @@ -361,8 +528,13 @@ func (in *AgentRuntimeReference) DeepCopy() *AgentRuntimeReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentRuntimeRegistrySpec) DeepCopyInto(out *AgentRuntimeRegistrySpec) { *out = *in + if in.ContractVersion != nil { + in, out := &in.ContractVersion, &out.ContractVersion + *out = new(AgentRuntimeContractVersion) + **out = **in + } out.Deployment = in.Deployment - out.ClientAuth = in.ClientAuth + in.ClientAuth.DeepCopyInto(&out.ClientAuth) if in.Capabilities != nil { in, out := &in.Capabilities, &out.Capabilities *out = new(AgentRuntimeCapabilitiesSpec) @@ -380,12 +552,27 @@ func (in *AgentRuntimeRegistrySpec) DeepCopy() *AgentRuntimeRegistrySpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentRuntimeSecretKeyReference) DeepCopyInto(out *AgentRuntimeSecretKeyReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRuntimeSecretKeyReference. +func (in *AgentRuntimeSecretKeyReference) DeepCopy() *AgentRuntimeSecretKeyReference { + if in == nil { + return nil + } + out := new(AgentRuntimeSecretKeyReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentRuntimeSpec) DeepCopyInto(out *AgentRuntimeSpec) { *out = *in if in.Workspace != nil { in, out := &in.Workspace, &out.Workspace - *out = new(WorkspaceConfig) + *out = new(LegacyAgentWorkspaceConfig) (*in).DeepCopyInto(*out) } if in.MaxTurns != nil { @@ -451,6 +638,21 @@ func (in *AgentRuntimeStatus) DeepCopy() *AgentRuntimeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AgentRuntimeWorkspaceGovernanceCapabilities) DeepCopyInto(out *AgentRuntimeWorkspaceGovernanceCapabilities) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentRuntimeWorkspaceGovernanceCapabilities. +func (in *AgentRuntimeWorkspaceGovernanceCapabilities) DeepCopy() *AgentRuntimeWorkspaceGovernanceCapabilities { + if in == nil { + return nil + } + out := new(AgentRuntimeWorkspaceGovernanceCapabilities) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = *in @@ -588,160 +790,420 @@ func (in *AzureConfig) DeepCopy() *AzureConfig { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ChildTaskStatus) DeepCopyInto(out *ChildTaskStatus) { +func (in *BranchClaim) DeepCopyInto(out *BranchClaim) { *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChildTaskStatus. -func (in *ChildTaskStatus) DeepCopy() *ChildTaskStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BranchClaim. +func (in *BranchClaim) DeepCopy() *BranchClaim { if in == nil { return nil } - out := new(ChildTaskStatus) + out := new(BranchClaim) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BranchClaim) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ConfigMapKeySelector) DeepCopyInto(out *ConfigMapKeySelector) { +func (in *BranchClaimList) DeepCopyInto(out *BranchClaimList) { *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BranchClaim, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeySelector. -func (in *ConfigMapKeySelector) DeepCopy() *ConfigMapKeySelector { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BranchClaimList. +func (in *BranchClaimList) DeepCopy() *BranchClaimList { if in == nil { return nil } - out := new(ConfigMapKeySelector) + out := new(BranchClaimList) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BranchClaimList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *CoordinationConfig) DeepCopyInto(out *CoordinationConfig) { +func (in *BranchClaimSpec) DeepCopyInto(out *BranchClaimSpec) { *out = *in - if in.AllowedAgents != nil { - in, out := &in.AllowedAgents, &out.AllowedAgents - *out = make([]AllowedAgent, len(*in)) - copy(*out, *in) - } - if in.ApprovalRequiredTools != nil { - in, out := &in.ApprovalRequiredTools, &out.ApprovalRequiredTools - *out = make([]string, len(*in)) - copy(*out, *in) - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoordinationConfig. -func (in *CoordinationConfig) DeepCopy() *CoordinationConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BranchClaimSpec. +func (in *BranchClaimSpec) DeepCopy() *BranchClaimSpec { if in == nil { return nil } - out := new(CoordinationConfig) + out := new(BranchClaimSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *DirectOutboundAccess) DeepCopyInto(out *DirectOutboundAccess) { +func (in *BranchClaimStatus) DeepCopyInto(out *BranchClaimStatus) { *out = *in - in.TokenEndpoint.DeepCopyInto(&out.TokenEndpoint) - in.Subject.DeepCopyInto(&out.Subject) - if in.Actor != nil { - in, out := &in.Actor, &out.Actor - *out = new(OutboundTokenSource) - (*in).DeepCopyInto(*out) - } - if in.Audiences != nil { - in, out := &in.Audiences, &out.Audiences - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Scopes != nil { - in, out := &in.Scopes, &out.Scopes - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Resources != nil { - in, out := &in.Resources, &out.Resources - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.AdditionalParameters != nil { - in, out := &in.AdditionalParameters, &out.AdditionalParameters - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.ClientAuthentication != nil { - in, out := &in.ClientAuthentication, &out.ClientAuthentication - *out = new(OutboundClientAuthentication) - (*in).DeepCopyInto(*out) - } - if in.Output != nil { - in, out := &in.Output, &out.Output - *out = new(OutboundCredentialOutput) - (*in).DeepCopyInto(*out) + if in.LastVerified != nil { + in, out := &in.LastVerified, &out.LastVerified + *out = new(ControlRemoteRefState) + **out = **in } + in.ControlRecordMutationStatus.DeepCopyInto(&out.ControlRecordMutationStatus) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DirectOutboundAccess. -func (in *DirectOutboundAccess) DeepCopy() *DirectOutboundAccess { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BranchClaimStatus. +func (in *BranchClaimStatus) DeepCopy() *BranchClaimStatus { if in == nil { return nil } - out := new(DirectOutboundAccess) + out := new(BranchClaimStatus) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExecutionSpec) DeepCopyInto(out *ExecutionSpec) { +func (in *ChildTaskStatus) DeepCopyInto(out *ChildTaskStatus) { *out = *in - if in.NodeSelector != nil { - in, out := &in.NodeSelector, &out.NodeSelector - *out = make(map[string]string, len(*in)) - for key, val := range *in { - (*out)[key] = val - } - } - if in.Tolerations != nil { - in, out := &in.Tolerations, &out.Tolerations - *out = make([]corev1.Toleration, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Affinity != nil { - in, out := &in.Affinity, &out.Affinity - *out = new(corev1.Affinity) - (*in).DeepCopyInto(*out) - } - if in.Workspace != nil { - in, out := &in.Workspace, &out.Workspace - *out = new(ExecutionWorkspaceSpec) - (*in).DeepCopyInto(*out) - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionSpec. -func (in *ExecutionSpec) DeepCopy() *ExecutionSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChildTaskStatus. +func (in *ChildTaskStatus) DeepCopy() *ChildTaskStatus { if in == nil { return nil } - out := new(ExecutionSpec) + out := new(ChildTaskStatus) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ExecutionWorkspaceDensityStatus) DeepCopyInto(out *ExecutionWorkspaceDensityStatus) { +func (in *ConfigMapKeySelector) DeepCopyInto(out *ConfigMapKeySelector) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionWorkspaceDensityStatus. -func (in *ExecutionWorkspaceDensityStatus) DeepCopy() *ExecutionWorkspaceDensityStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeySelector. +func (in *ConfigMapKeySelector) DeepCopy() *ConfigMapKeySelector { + if in == nil { + return nil + } + out := new(ConfigMapKeySelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlRecordMutationStatus) DeepCopyInto(out *ControlRecordMutationStatus) { + *out = *in + if in.CreatedAt != nil { + in, out := &in.CreatedAt, &out.CreatedAt + *out = (*in).DeepCopy() + } + if in.UpdatedAt != nil { + in, out := &in.UpdatedAt, &out.UpdatedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlRecordMutationStatus. +func (in *ControlRecordMutationStatus) DeepCopy() *ControlRecordMutationStatus { + if in == nil { + return nil + } + out := new(ControlRecordMutationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlRecordOwner) DeepCopyInto(out *ControlRecordOwner) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlRecordOwner. +func (in *ControlRecordOwner) DeepCopy() *ControlRecordOwner { + if in == nil { + return nil + } + out := new(ControlRecordOwner) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlRemoteRefState) DeepCopyInto(out *ControlRemoteRefState) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlRemoteRefState. +func (in *ControlRemoteRefState) DeepCopy() *ControlRemoteRefState { + if in == nil { + return nil + } + out := new(ControlRemoteRefState) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControlVerifiedBranchBaseline) DeepCopyInto(out *ControlVerifiedBranchBaseline) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControlVerifiedBranchBaseline. +func (in *ControlVerifiedBranchBaseline) DeepCopy() *ControlVerifiedBranchBaseline { + if in == nil { + return nil + } + out := new(ControlVerifiedBranchBaseline) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControllerEpoch) DeepCopyInto(out *ControllerEpoch) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControllerEpoch. +func (in *ControllerEpoch) DeepCopy() *ControllerEpoch { + if in == nil { + return nil + } + out := new(ControllerEpoch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ControllerEpoch) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControllerEpochList) DeepCopyInto(out *ControllerEpochList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ControllerEpoch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControllerEpochList. +func (in *ControllerEpochList) DeepCopy() *ControllerEpochList { + if in == nil { + return nil + } + out := new(ControllerEpochList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ControllerEpochList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControllerEpochSpec) DeepCopyInto(out *ControllerEpochSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControllerEpochSpec. +func (in *ControllerEpochSpec) DeepCopy() *ControllerEpochSpec { + if in == nil { + return nil + } + out := new(ControllerEpochSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ControllerEpochStatus) DeepCopyInto(out *ControllerEpochStatus) { + *out = *in + if in.AcquiredAt != nil { + in, out := &in.AcquiredAt, &out.AcquiredAt + *out = (*in).DeepCopy() + } + if in.UpdatedAt != nil { + in, out := &in.UpdatedAt, &out.UpdatedAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ControllerEpochStatus. +func (in *ControllerEpochStatus) DeepCopy() *ControllerEpochStatus { + if in == nil { + return nil + } + out := new(ControllerEpochStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CoordinationConfig) DeepCopyInto(out *CoordinationConfig) { + *out = *in + if in.AllowedAgents != nil { + in, out := &in.AllowedAgents, &out.AllowedAgents + *out = make([]AllowedAgent, len(*in)) + copy(*out, *in) + } + if in.ApprovalRequiredTools != nil { + in, out := &in.ApprovalRequiredTools, &out.ApprovalRequiredTools + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CoordinationConfig. +func (in *CoordinationConfig) DeepCopy() *CoordinationConfig { + if in == nil { + return nil + } + out := new(CoordinationConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DirectOutboundAccess) DeepCopyInto(out *DirectOutboundAccess) { + *out = *in + in.TokenEndpoint.DeepCopyInto(&out.TokenEndpoint) + in.Subject.DeepCopyInto(&out.Subject) + if in.Actor != nil { + in, out := &in.Actor, &out.Actor + *out = new(OutboundTokenSource) + (*in).DeepCopyInto(*out) + } + if in.Audiences != nil { + in, out := &in.Audiences, &out.Audiences + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Scopes != nil { + in, out := &in.Scopes, &out.Scopes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AdditionalParameters != nil { + in, out := &in.AdditionalParameters, &out.AdditionalParameters + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ClientAuthentication != nil { + in, out := &in.ClientAuthentication, &out.ClientAuthentication + *out = new(OutboundClientAuthentication) + (*in).DeepCopyInto(*out) + } + if in.Output != nil { + in, out := &in.Output, &out.Output + *out = new(OutboundCredentialOutput) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DirectOutboundAccess. +func (in *DirectOutboundAccess) DeepCopy() *DirectOutboundAccess { + if in == nil { + return nil + } + out := new(DirectOutboundAccess) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecutionSpec) DeepCopyInto(out *ExecutionSpec) { + *out = *in + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } + if in.Workspace != nil { + in, out := &in.Workspace, &out.Workspace + *out = new(ExecutionWorkspaceSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionSpec. +func (in *ExecutionSpec) DeepCopy() *ExecutionSpec { + if in == nil { + return nil + } + out := new(ExecutionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecutionWorkspaceDensityStatus) DeepCopyInto(out *ExecutionWorkspaceDensityStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionWorkspaceDensityStatus. +func (in *ExecutionWorkspaceDensityStatus) DeepCopy() *ExecutionWorkspaceDensityStatus { if in == nil { return nil } @@ -892,11 +1354,51 @@ func (in *ExecutionWorkspaceStatus) DeepCopy() *ExecutionWorkspaceStatus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FindingCountsStatus) DeepCopyInto(out *FindingCountsStatus) { +func (in *ExternalEffectSpec) DeepCopyInto(out *ExternalEffectSpec) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindingCountsStatus. +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalEffectSpec. +func (in *ExternalEffectSpec) DeepCopy() *ExternalEffectSpec { + if in == nil { + return nil + } + out := new(ExternalEffectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalEffectStatus) DeepCopyInto(out *ExternalEffectStatus) { + *out = *in + if in.Response != nil { + in, out := &in.Response, &out.Response + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } + if in.LeaseExpiresAt != nil { + in, out := &in.LeaseExpiresAt, &out.LeaseExpiresAt + *out = (*in).DeepCopy() + } + in.ControlRecordMutationStatus.DeepCopyInto(&out.ControlRecordMutationStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalEffectStatus. +func (in *ExternalEffectStatus) DeepCopy() *ExternalEffectStatus { + if in == nil { + return nil + } + out := new(ExternalEffectStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FindingCountsStatus) DeepCopyInto(out *FindingCountsStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindingCountsStatus. func (in *FindingCountsStatus) DeepCopy() *FindingCountsStatus { if in == nil { return nil @@ -967,6 +1469,14 @@ func (in *HTTPExecution) DeepCopy() *HTTPExecution { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HarnessRuntimeStatus) DeepCopyInto(out *HarnessRuntimeStatus) { *out = *in + if in.CancelRequestedAt != nil { + in, out := &in.CancelRequestedAt, &out.CancelRequestedAt + *out = (*in).DeepCopy() + } + if in.LastTransitionTime != nil { + in, out := &in.LastTransitionTime, &out.LastTransitionTime + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HarnessRuntimeStatus. @@ -979,6 +1489,26 @@ func (in *HarnessRuntimeStatus) DeepCopy() *HarnessRuntimeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LegacyAgentWorkspaceConfig) DeepCopyInto(out *LegacyAgentWorkspaceConfig) { + *out = *in + if in.GitSecretRef != nil { + in, out := &in.GitSecretRef, &out.GitSecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LegacyAgentWorkspaceConfig. +func (in *LegacyAgentWorkspaceConfig) DeepCopy() *LegacyAgentWorkspaceConfig { + if in == nil { + return nil + } + out := new(LegacyAgentWorkspaceConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LocalObjectReference) DeepCopyInto(out *LocalObjectReference) { *out = *in @@ -1043,6 +1573,11 @@ func (in *ModelConfig) DeepCopyInto(out *ModelConfig) { *out = new(float64) **out = **in } + if in.ContextWindow != nil { + in, out := &in.ContextWindow, &out.ContextWindow + *out = new(int32) + **out = **in + } if in.MaxTokens != nil { in, out := &in.MaxTokens, &out.MaxTokens *out = new(int32) @@ -1080,6 +1615,21 @@ func (in *ModelFallback) DeepCopy() *ModelFallback { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModelTokenLimits) DeepCopyInto(out *ModelTokenLimits) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModelTokenLimits. +func (in *ModelTokenLimits) DeepCopy() *ModelTokenLimits { + if in == nil { + return nil + } + out := new(ModelTokenLimits) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespacedSecretKeySelector) DeepCopyInto(out *NamespacedSecretKeySelector) { *out = *in @@ -1371,6 +1921,22 @@ func (in *PolicyConfigMapKeyRef) DeepCopy() *PolicyConfigMapKeyRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PreparedPublicationControlReceipt) DeepCopyInto(out *PreparedPublicationControlReceipt) { + *out = *in + in.PreparedAt.DeepCopyInto(&out.PreparedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreparedPublicationControlReceipt. +func (in *PreparedPublicationControlReceipt) DeepCopy() *PreparedPublicationControlReceipt { + if in == nil { + return nil + } + out := new(PreparedPublicationControlReceipt) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PriorTaskReference) DeepCopyInto(out *PriorTaskReference) { *out = *in @@ -1386,6 +1952,116 @@ func (in *PriorTaskReference) DeepCopy() *PriorTaskReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PromptAttempt) DeepCopyInto(out *PromptAttempt) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PromptAttempt. +func (in *PromptAttempt) DeepCopy() *PromptAttempt { + if in == nil { + return nil + } + out := new(PromptAttempt) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PromptAttempt) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PromptAttemptList) DeepCopyInto(out *PromptAttemptList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PromptAttempt, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PromptAttemptList. +func (in *PromptAttemptList) DeepCopy() *PromptAttemptList { + if in == nil { + return nil + } + out := new(PromptAttemptList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PromptAttemptList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PromptAttemptSpec) DeepCopyInto(out *PromptAttemptSpec) { + *out = *in + if in.CredentialBindings != nil { + in, out := &in.CredentialBindings, &out.CredentialBindings + *out = make([]PromptCredentialBinding, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PromptAttemptSpec. +func (in *PromptAttemptSpec) DeepCopy() *PromptAttemptSpec { + if in == nil { + return nil + } + out := new(PromptAttemptSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PromptAttemptStatus) DeepCopyInto(out *PromptAttemptStatus) { + *out = *in + in.ControlRecordMutationStatus.DeepCopyInto(&out.ControlRecordMutationStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PromptAttemptStatus. +func (in *PromptAttemptStatus) DeepCopy() *PromptAttemptStatus { + if in == nil { + return nil + } + out := new(PromptAttemptStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PromptCredentialBinding) DeepCopyInto(out *PromptCredentialBinding) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PromptCredentialBinding. +func (in *PromptCredentialBinding) DeepCopy() *PromptCredentialBinding { + if in == nil { + return nil + } + out := new(PromptCredentialBinding) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PromptSource) DeepCopyInto(out *PromptSource) { *out = *in @@ -1573,51 +2249,58 @@ func (in *ProviderStatus) DeepCopy() *ProviderStatus { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RateLimitConfig) DeepCopyInto(out *RateLimitConfig) { +func (in *Publication) DeepCopyInto(out *Publication) { *out = *in - if in.RequestsPerMinute != nil { - in, out := &in.RequestsPerMinute, &out.RequestsPerMinute - *out = new(int32) - **out = **in - } - if in.TokensPerMinute != nil { - in, out := &in.TokensPerMinute, &out.TokensPerMinute - *out = new(int64) - **out = **in - } + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitConfig. -func (in *RateLimitConfig) DeepCopy() *RateLimitConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Publication. +func (in *Publication) DeepCopy() *Publication { if in == nil { return nil } - out := new(RateLimitConfig) + out := new(Publication) in.DeepCopyInto(out) return out } +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Publication) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryMonitor) DeepCopyInto(out *RepositoryMonitor) { +func (in *PublicationList) DeepCopyInto(out *PublicationList) { *out = *in out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) - in.Status.DeepCopyInto(&out.Status) + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Publication, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitor. -func (in *RepositoryMonitor) DeepCopy() *RepositoryMonitor { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicationList. +func (in *PublicationList) DeepCopy() *PublicationList { if in == nil { return nil } - out := new(RepositoryMonitor) + out := new(PublicationList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *RepositoryMonitor) DeepCopyObject() runtime.Object { +func (in *PublicationList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -1625,91 +2308,281 @@ func (in *RepositoryMonitor) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryMonitorAdvisoryLabels) DeepCopyInto(out *RepositoryMonitorAdvisoryLabels) { +func (in *PublicationPullRequestIntent) DeepCopyInto(out *PublicationPullRequestIntent) { *out = *in } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorAdvisoryLabels. -func (in *RepositoryMonitorAdvisoryLabels) DeepCopy() *RepositoryMonitorAdvisoryLabels { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicationPullRequestIntent. +func (in *PublicationPullRequestIntent) DeepCopy() *PublicationPullRequestIntent { if in == nil { return nil } - out := new(RepositoryMonitorAdvisoryLabels) + out := new(PublicationPullRequestIntent) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryMonitorAgents) DeepCopyInto(out *RepositoryMonitorAgents) { +func (in *PublicationSpec) DeepCopyInto(out *PublicationSpec) { *out = *in - if in.Reviewer != nil { - in, out := &in.Reviewer, &out.Reviewer - *out = new(AgentReference) - **out = **in - } - if in.Triager != nil { - in, out := &in.Triager, &out.Triager - *out = new(AgentReference) - **out = **in - } - if in.Researcher != nil { - in, out := &in.Researcher, &out.Researcher - *out = new(AgentReference) - **out = **in - } - if in.Planner != nil { - in, out := &in.Planner, &out.Planner - *out = new(AgentReference) - **out = **in - } - if in.Repairer != nil { - in, out := &in.Repairer, &out.Repairer - *out = new(AgentReference) - **out = **in - } - if in.Implementer != nil { - in, out := &in.Implementer, &out.Implementer - *out = new(AgentReference) - **out = **in - } + out.Baseline = in.Baseline + in.CommitTimestamp.DeepCopyInto(&out.CommitTimestamp) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorAgents. -func (in *RepositoryMonitorAgents) DeepCopy() *RepositoryMonitorAgents { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicationSpec. +func (in *PublicationSpec) DeepCopy() *PublicationSpec { if in == nil { return nil } - out := new(RepositoryMonitorAgents) + out := new(PublicationSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryMonitorAutomergeSpec) DeepCopyInto(out *RepositoryMonitorAutomergeSpec) { +func (in *PublicationStatus) DeepCopyInto(out *PublicationStatus) { *out = *in - if in.RequireMaintainerOptIn != nil { - in, out := &in.RequireMaintainerOptIn, &out.RequireMaintainerOptIn - *out = new(bool) + if in.PRIntent != nil { + in, out := &in.PRIntent, &out.PRIntent + *out = new(PublicationPullRequestIntent) **out = **in } - if in.RequireGlobalMergeGate != nil { - in, out := &in.RequireGlobalMergeGate, &out.RequireGlobalMergeGate - *out = new(bool) - **out = **in + if in.PreparedReceipt != nil { + in, out := &in.PreparedReceipt, &out.PreparedReceipt + *out = new(PreparedPublicationControlReceipt) + (*in).DeepCopyInto(*out) } - if in.AllowedMergeMethods != nil { - in, out := &in.AllowedMergeMethods, &out.AllowedMergeMethods - *out = make([]string, len(*in)) - copy(*out, *in) + if in.PublishReceipt != nil { + in, out := &in.PublishReceipt, &out.PublishReceipt + *out = new(PublishOperationControlReceipt) + (*in).DeepCopyInto(*out) + } + if in.VerificationReceipt != nil { + in, out := &in.VerificationReceipt, &out.VerificationReceipt + *out = new(PublicationVerificationControlReceipt) + (*in).DeepCopyInto(*out) + } + if in.PullRequestReceipt != nil { + in, out := &in.PullRequestReceipt, &out.PullRequestReceipt + *out = new(PullRequestOperationControlReceipt) + (*in).DeepCopyInto(*out) } + in.ControlRecordMutationStatus.DeepCopyInto(&out.ControlRecordMutationStatus) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorAutomergeSpec. -func (in *RepositoryMonitorAutomergeSpec) DeepCopy() *RepositoryMonitorAutomergeSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicationStatus. +func (in *PublicationStatus) DeepCopy() *PublicationStatus { if in == nil { return nil } - out := new(RepositoryMonitorAutomergeSpec) + out := new(PublicationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublicationVerificationControlReceipt) DeepCopyInto(out *PublicationVerificationControlReceipt) { + *out = *in + out.ObservedRemote = in.ObservedRemote + in.VerifiedAt.DeepCopyInto(&out.VerifiedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublicationVerificationControlReceipt. +func (in *PublicationVerificationControlReceipt) DeepCopy() *PublicationVerificationControlReceipt { + if in == nil { + return nil + } + out := new(PublicationVerificationControlReceipt) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PublishOperationControlReceipt) DeepCopyInto(out *PublishOperationControlReceipt) { + *out = *in + out.RemoteBefore = in.RemoteBefore + in.PublishedAt.DeepCopyInto(&out.PublishedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublishOperationControlReceipt. +func (in *PublishOperationControlReceipt) DeepCopy() *PublishOperationControlReceipt { + if in == nil { + return nil + } + out := new(PublishOperationControlReceipt) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PullRequestOperationControlReceipt) DeepCopyInto(out *PullRequestOperationControlReceipt) { + *out = *in + in.ReconciledAt.DeepCopyInto(&out.ReconciledAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PullRequestOperationControlReceipt. +func (in *PullRequestOperationControlReceipt) DeepCopy() *PullRequestOperationControlReceipt { + if in == nil { + return nil + } + out := new(PullRequestOperationControlReceipt) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RateLimitConfig) DeepCopyInto(out *RateLimitConfig) { + *out = *in + if in.RequestsPerMinute != nil { + in, out := &in.RequestsPerMinute, &out.RequestsPerMinute + *out = new(int32) + **out = **in + } + if in.TokensPerMinute != nil { + in, out := &in.TokensPerMinute, &out.TokensPerMinute + *out = new(int64) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimitConfig. +func (in *RateLimitConfig) DeepCopy() *RateLimitConfig { + if in == nil { + return nil + } + out := new(RateLimitConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryIdentity) DeepCopyInto(out *RepositoryIdentity) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryIdentity. +func (in *RepositoryIdentity) DeepCopy() *RepositoryIdentity { + if in == nil { + return nil + } + out := new(RepositoryIdentity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryMonitor) DeepCopyInto(out *RepositoryMonitor) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitor. +func (in *RepositoryMonitor) DeepCopy() *RepositoryMonitor { + if in == nil { + return nil + } + out := new(RepositoryMonitor) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryMonitor) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryMonitorAdvisoryLabels) DeepCopyInto(out *RepositoryMonitorAdvisoryLabels) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorAdvisoryLabels. +func (in *RepositoryMonitorAdvisoryLabels) DeepCopy() *RepositoryMonitorAdvisoryLabels { + if in == nil { + return nil + } + out := new(RepositoryMonitorAdvisoryLabels) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryMonitorAgents) DeepCopyInto(out *RepositoryMonitorAgents) { + *out = *in + if in.Reviewer != nil { + in, out := &in.Reviewer, &out.Reviewer + *out = new(AgentReference) + **out = **in + } + if in.Triager != nil { + in, out := &in.Triager, &out.Triager + *out = new(AgentReference) + **out = **in + } + if in.Researcher != nil { + in, out := &in.Researcher, &out.Researcher + *out = new(AgentReference) + **out = **in + } + if in.Planner != nil { + in, out := &in.Planner, &out.Planner + *out = new(AgentReference) + **out = **in + } + if in.Repairer != nil { + in, out := &in.Repairer, &out.Repairer + *out = new(AgentReference) + **out = **in + } + if in.Implementer != nil { + in, out := &in.Implementer, &out.Implementer + *out = new(AgentReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorAgents. +func (in *RepositoryMonitorAgents) DeepCopy() *RepositoryMonitorAgents { + if in == nil { + return nil + } + out := new(RepositoryMonitorAgents) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryMonitorAutomergeSpec) DeepCopyInto(out *RepositoryMonitorAutomergeSpec) { + *out = *in + if in.RequireMaintainerOptIn != nil { + in, out := &in.RequireMaintainerOptIn, &out.RequireMaintainerOptIn + *out = new(bool) + **out = **in + } + if in.RequireGlobalMergeGate != nil { + in, out := &in.RequireGlobalMergeGate, &out.RequireGlobalMergeGate + *out = new(bool) + **out = **in + } + if in.AllowedMergeMethods != nil { + in, out := &in.AllowedMergeMethods, &out.AllowedMergeMethods + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorAutomergeSpec. +func (in *RepositoryMonitorAutomergeSpec) DeepCopy() *RepositoryMonitorAutomergeSpec { + if in == nil { + return nil + } + out := new(RepositoryMonitorAutomergeSpec) in.DeepCopyInto(out) return out } @@ -2165,6 +3038,26 @@ func (in *RepositoryMonitorSpec) DeepCopyInto(out *RepositoryMonitorSpec) { *out = new(corev1.LocalObjectReference) **out = **in } + if in.ReadCredentialRef != nil { + in, out := &in.ReadCredentialRef, &out.ReadCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.PublicationReadCredentialRef != nil { + in, out := &in.PublicationReadCredentialRef, &out.PublicationReadCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.PublicationCredentialRef != nil { + in, out := &in.PublicationCredentialRef, &out.PublicationCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.ForgeCredentialRef != nil { + in, out := &in.ForgeCredentialRef, &out.ForgeCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } if in.TimeZone != nil { in, out := &in.TimeZone, &out.TimeZone *out = new(string) @@ -2234,73 +3127,544 @@ func (in *RepositoryMonitorTargets) DeepCopyInto(out *RepositoryMonitorTargets) in.Commits.DeepCopyInto(&out.Commits) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorTargets. -func (in *RepositoryMonitorTargets) DeepCopy() *RepositoryMonitorTargets { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorTargets. +func (in *RepositoryMonitorTargets) DeepCopy() *RepositoryMonitorTargets { + if in == nil { + return nil + } + out := new(RepositoryMonitorTargets) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryMonitorTriggers) DeepCopyInto(out *RepositoryMonitorTriggers) { + *out = *in + out.GitHub = in.GitHub +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorTriggers. +func (in *RepositoryMonitorTriggers) DeepCopy() *RepositoryMonitorTriggers { + if in == nil { + return nil + } + out := new(RepositoryMonitorTriggers) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryMonitorValidationSpec) DeepCopyInto(out *RepositoryMonitorValidationSpec) { + *out = *in + if in.Commands != nil { + in, out := &in.Commands, &out.Commands + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorValidationSpec. +func (in *RepositoryMonitorValidationSpec) DeepCopy() *RepositoryMonitorValidationSpec { + if in == nil { + return nil + } + out := new(RepositoryMonitorValidationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryScan) DeepCopyInto(out *RepositoryScan) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScan. +func (in *RepositoryScan) DeepCopy() *RepositoryScan { + if in == nil { + return nil + } + out := new(RepositoryScan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryScan) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryScanList) DeepCopyInto(out *RepositoryScanList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RepositoryScan, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScanList. +func (in *RepositoryScanList) DeepCopy() *RepositoryScanList { + if in == nil { + return nil + } + out := new(RepositoryScanList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RepositoryScanList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryScanSpec) DeepCopyInto(out *RepositoryScanSpec) { + *out = *in + if in.GitSecretRef != nil { + in, out := &in.GitSecretRef, &out.GitSecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.ReadCredentialRef != nil { + in, out := &in.ReadCredentialRef, &out.ReadCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.PublicationReadCredentialRef != nil { + in, out := &in.PublicationReadCredentialRef, &out.PublicationReadCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.PublicationCredentialRef != nil { + in, out := &in.PublicationCredentialRef, &out.PublicationCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.ForgeCredentialRef != nil { + in, out := &in.ForgeCredentialRef, &out.ForgeCredentialRef + *out = new(corev1.LocalObjectReference) + **out = **in + } + if in.TimeZone != nil { + in, out := &in.TimeZone, &out.TimeZone + *out = new(string) + **out = **in + } + if in.HistoryDays != nil { + in, out := &in.HistoryDays, &out.HistoryDays + *out = new(int32) + **out = **in + } + if in.ValidationMaxFindingsPerRun != nil { + in, out := &in.ValidationMaxFindingsPerRun, &out.ValidationMaxFindingsPerRun + *out = new(int32) + **out = **in + } + if in.CustomScanInstructionsRef != nil { + in, out := &in.CustomScanInstructionsRef, &out.CustomScanInstructionsRef + *out = new(PolicyConfigMapKeyRef) + **out = **in + } + if in.FalsePositivePolicyRef != nil { + in, out := &in.FalsePositivePolicyRef, &out.FalsePositivePolicyRef + *out = new(PolicyConfigMapKeyRef) + **out = **in + } + out.AnalysisAgentRef = in.AnalysisAgentRef + if in.PatchAgentRef != nil { + in, out := &in.PatchAgentRef, &out.PatchAgentRef + *out = new(AgentReference) + **out = **in + } + if in.MaxFindingsPerRun != nil { + in, out := &in.MaxFindingsPerRun, &out.MaxFindingsPerRun + *out = new(int32) + **out = **in + } + if in.Suspend != nil { + in, out := &in.Suspend, &out.Suspend + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScanSpec. +func (in *RepositoryScanSpec) DeepCopy() *RepositoryScanSpec { + if in == nil { + return nil + } + out := new(RepositoryScanSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RepositoryScanStatus) DeepCopyInto(out *RepositoryScanStatus) { + *out = *in + if in.LastScanAt != nil { + in, out := &in.LastScanAt, &out.LastScanAt + *out = (*in).DeepCopy() + } + if in.LastSuccessfulScanAt != nil { + in, out := &in.LastSuccessfulScanAt, &out.LastSuccessfulScanAt + *out = (*in).DeepCopy() + } + out.FindingCounts = in.FindingCounts + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScanStatus. +func (in *RepositoryScanStatus) DeepCopy() *RepositoryScanStatus { + if in == nil { + return nil + } + out := new(RepositoryScanStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RequestedBy) DeepCopyInto(out *RequestedBy) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RequestedBy. +func (in *RequestedBy) DeepCopy() *RequestedBy { + if in == nil { + return nil + } + out := new(RequestedBy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResultReference) DeepCopyInto(out *ResultReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResultReference. +func (in *ResultReference) DeepCopy() *ResultReference { + if in == nil { + return nil + } + out := new(ResultReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RetryPolicy) DeepCopyInto(out *RetryPolicy) { + *out = *in + if in.InitialDelay != nil { + in, out := &in.InitialDelay, &out.InitialDelay + *out = new(v1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RetryPolicy. +func (in *RetryPolicy) DeepCopy() *RetryPolicy { + if in == nil { + return nil + } + out := new(RetryPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePool) DeepCopyInto(out *RuntimePool) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePool. +func (in *RuntimePool) DeepCopy() *RuntimePool { + if in == nil { + return nil + } + out := new(RuntimePool) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RuntimePool) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolActiveInstanceStatus) DeepCopyInto(out *RuntimePoolActiveInstanceStatus) { + *out = *in + if in.LastObservedTime != nil { + in, out := &in.LastObservedTime, &out.LastObservedTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolActiveInstanceStatus. +func (in *RuntimePoolActiveInstanceStatus) DeepCopy() *RuntimePoolActiveInstanceStatus { + if in == nil { + return nil + } + out := new(RuntimePoolActiveInstanceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolCapacityReservationStatus) DeepCopyInto(out *RuntimePoolCapacityReservationStatus) { + *out = *in + in.ReservedAt.DeepCopyInto(&out.ReservedAt) + in.ExpiresAt.DeepCopyInto(&out.ExpiresAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolCapacityReservationStatus. +func (in *RuntimePoolCapacityReservationStatus) DeepCopy() *RuntimePoolCapacityReservationStatus { + if in == nil { + return nil + } + out := new(RuntimePoolCapacityReservationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolCapacitySpec) DeepCopyInto(out *RuntimePoolCapacitySpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolCapacitySpec. +func (in *RuntimePoolCapacitySpec) DeepCopy() *RuntimePoolCapacitySpec { + if in == nil { + return nil + } + out := new(RuntimePoolCapacitySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolCapacityStatus) DeepCopyInto(out *RuntimePoolCapacityStatus) { + *out = *in + if in.Reservations != nil { + in, out := &in.Reservations, &out.Reservations + *out = make([]RuntimePoolCapacityReservationStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolCapacityStatus. +func (in *RuntimePoolCapacityStatus) DeepCopy() *RuntimePoolCapacityStatus { + if in == nil { + return nil + } + out := new(RuntimePoolCapacityStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolList) DeepCopyInto(out *RuntimePoolList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RuntimePool, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolList. +func (in *RuntimePoolList) DeepCopy() *RuntimePoolList { + if in == nil { + return nil + } + out := new(RuntimePoolList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RuntimePoolList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolProfileSpec) DeepCopyInto(out *RuntimePoolProfileSpec) { + *out = *in + if in.AdapterDigests != nil { + in, out := &in.AdapterDigests, &out.AdapterDigests + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.ModelLimits != nil { + in, out := &in.ModelLimits, &out.ModelLimits + *out = new(ModelTokenLimits) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolProfileSpec. +func (in *RuntimePoolProfileSpec) DeepCopy() *RuntimePoolProfileSpec { + if in == nil { + return nil + } + out := new(RuntimePoolProfileSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolRuntimeSpec) DeepCopyInto(out *RuntimePoolRuntimeSpec) { + *out = *in + in.Profile.DeepCopyInto(&out.Profile) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolRuntimeSpec. +func (in *RuntimePoolRuntimeSpec) DeepCopy() *RuntimePoolRuntimeSpec { + if in == nil { + return nil + } + out := new(RuntimePoolRuntimeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimePoolSpec) DeepCopyInto(out *RuntimePoolSpec) { + *out = *in + out.TrustDomain = in.TrustDomain + in.Runtime.DeepCopyInto(&out.Runtime) + if in.Capacity != nil { + in, out := &in.Capacity, &out.Capacity + *out = new(RuntimePoolCapacitySpec) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolSpec. +func (in *RuntimePoolSpec) DeepCopy() *RuntimePoolSpec { if in == nil { return nil } - out := new(RepositoryMonitorTargets) + out := new(RuntimePoolSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryMonitorTriggers) DeepCopyInto(out *RepositoryMonitorTriggers) { +func (in *RuntimePoolStatus) DeepCopyInto(out *RuntimePoolStatus) { *out = *in - out.GitHub = in.GitHub + if in.ActiveInstance != nil { + in, out := &in.ActiveInstance, &out.ActiveInstance + *out = new(RuntimePoolActiveInstanceStatus) + (*in).DeepCopyInto(*out) + } + in.Capacity.DeepCopyInto(&out.Capacity) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorTriggers. -func (in *RepositoryMonitorTriggers) DeepCopy() *RepositoryMonitorTriggers { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolStatus. +func (in *RuntimePoolStatus) DeepCopy() *RuntimePoolStatus { if in == nil { return nil } - out := new(RepositoryMonitorTriggers) + out := new(RuntimePoolStatus) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryMonitorValidationSpec) DeepCopyInto(out *RepositoryMonitorValidationSpec) { +func (in *RuntimePoolTrustDomain) DeepCopyInto(out *RuntimePoolTrustDomain) { *out = *in - if in.Commands != nil { - in, out := &in.Commands, &out.Commands - *out = make([]string, len(*in)) - copy(*out, *in) - } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryMonitorValidationSpec. -func (in *RepositoryMonitorValidationSpec) DeepCopy() *RepositoryMonitorValidationSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimePoolTrustDomain. +func (in *RuntimePoolTrustDomain) DeepCopy() *RuntimePoolTrustDomain { if in == nil { return nil } - out := new(RepositoryMonitorValidationSpec) + out := new(RuntimePoolTrustDomain) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryScan) DeepCopyInto(out *RepositoryScan) { +func (in *RuntimeSessionControl) DeepCopyInto(out *RuntimeSessionControl) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) + out.Spec = in.Spec in.Status.DeepCopyInto(&out.Status) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScan. -func (in *RepositoryScan) DeepCopy() *RepositoryScan { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeSessionControl. +func (in *RuntimeSessionControl) DeepCopy() *RuntimeSessionControl { if in == nil { return nil } - out := new(RepositoryScan) + out := new(RuntimeSessionControl) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *RepositoryScan) DeepCopyObject() runtime.Object { +func (in *RuntimeSessionControl) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -2308,31 +3672,31 @@ func (in *RepositoryScan) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryScanList) DeepCopyInto(out *RepositoryScanList) { +func (in *RuntimeSessionControlList) DeepCopyInto(out *RuntimeSessionControlList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items - *out = make([]RepositoryScan, len(*in)) + *out = make([]RuntimeSessionControl, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScanList. -func (in *RepositoryScanList) DeepCopy() *RepositoryScanList { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeSessionControlList. +func (in *RuntimeSessionControlList) DeepCopy() *RuntimeSessionControlList { if in == nil { return nil } - out := new(RepositoryScanList) + out := new(RuntimeSessionControlList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *RepositoryScanList) DeepCopyObject() runtime.Object { +func (in *RuntimeSessionControlList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } @@ -2340,153 +3704,84 @@ func (in *RepositoryScanList) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryScanSpec) DeepCopyInto(out *RepositoryScanSpec) { +func (in *RuntimeSessionControlSpec) DeepCopyInto(out *RuntimeSessionControlSpec) { *out = *in - if in.GitSecretRef != nil { - in, out := &in.GitSecretRef, &out.GitSecretRef - *out = new(corev1.LocalObjectReference) - **out = **in - } - if in.TimeZone != nil { - in, out := &in.TimeZone, &out.TimeZone - *out = new(string) - **out = **in - } - if in.HistoryDays != nil { - in, out := &in.HistoryDays, &out.HistoryDays - *out = new(int32) - **out = **in - } - if in.ValidationMaxFindingsPerRun != nil { - in, out := &in.ValidationMaxFindingsPerRun, &out.ValidationMaxFindingsPerRun - *out = new(int32) - **out = **in - } - if in.CustomScanInstructionsRef != nil { - in, out := &in.CustomScanInstructionsRef, &out.CustomScanInstructionsRef - *out = new(PolicyConfigMapKeyRef) - **out = **in - } - if in.FalsePositivePolicyRef != nil { - in, out := &in.FalsePositivePolicyRef, &out.FalsePositivePolicyRef - *out = new(PolicyConfigMapKeyRef) - **out = **in - } - out.AnalysisAgentRef = in.AnalysisAgentRef - if in.PatchAgentRef != nil { - in, out := &in.PatchAgentRef, &out.PatchAgentRef - *out = new(AgentReference) - **out = **in - } - if in.MaxFindingsPerRun != nil { - in, out := &in.MaxFindingsPerRun, &out.MaxFindingsPerRun - *out = new(int32) - **out = **in - } - if in.Suspend != nil { - in, out := &in.Suspend, &out.Suspend - *out = new(bool) - **out = **in - } + out.Owner = in.Owner } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScanSpec. -func (in *RepositoryScanSpec) DeepCopy() *RepositoryScanSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeSessionControlSpec. +func (in *RuntimeSessionControlSpec) DeepCopy() *RuntimeSessionControlSpec { if in == nil { return nil } - out := new(RepositoryScanSpec) + out := new(RuntimeSessionControlSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RepositoryScanStatus) DeepCopyInto(out *RepositoryScanStatus) { +func (in *RuntimeSessionControlStatus) DeepCopyInto(out *RuntimeSessionControlStatus) { *out = *in - if in.LastScanAt != nil { - in, out := &in.LastScanAt, &out.LastScanAt - *out = (*in).DeepCopy() - } - if in.LastSuccessfulScanAt != nil { - in, out := &in.LastSuccessfulScanAt, &out.LastSuccessfulScanAt - *out = (*in).DeepCopy() - } - out.FindingCounts = in.FindingCounts - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RepositoryScanStatus. -func (in *RepositoryScanStatus) DeepCopy() *RepositoryScanStatus { - if in == nil { - return nil + if in.MutationLease != nil { + in, out := &in.MutationLease, &out.MutationLease + *out = new(RuntimeSessionMutationLeaseStatus) + (*in).DeepCopyInto(*out) } - out := new(RepositoryScanStatus) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RequestedBy) DeepCopyInto(out *RequestedBy) { - *out = *in - if in.Groups != nil { - in, out := &in.Groups, &out.Groups - *out = make([]string, len(*in)) - copy(*out, *in) + if in.VerifiedBaseline != nil { + in, out := &in.VerifiedBaseline, &out.VerifiedBaseline + *out = new(ControlVerifiedBranchBaseline) + **out = **in } - if in.Roles != nil { - in, out := &in.Roles, &out.Roles - *out = make([]string, len(*in)) - copy(*out, *in) + if in.Lineage != nil { + in, out := &in.Lineage, &out.Lineage + *out = new(RuntimeSessionLineageStatus) + (*in).DeepCopyInto(*out) } + in.ControlRecordMutationStatus.DeepCopyInto(&out.ControlRecordMutationStatus) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RequestedBy. -func (in *RequestedBy) DeepCopy() *RequestedBy { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeSessionControlStatus. +func (in *RuntimeSessionControlStatus) DeepCopy() *RuntimeSessionControlStatus { if in == nil { return nil } - out := new(RequestedBy) + out := new(RuntimeSessionControlStatus) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResultReference) DeepCopyInto(out *ResultReference) { +func (in *RuntimeSessionLineageStatus) DeepCopyInto(out *RuntimeSessionLineageStatus) { *out = *in + in.EstablishedAt.DeepCopyInto(&out.EstablishedAt) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResultReference. -func (in *ResultReference) DeepCopy() *ResultReference { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeSessionLineageStatus. +func (in *RuntimeSessionLineageStatus) DeepCopy() *RuntimeSessionLineageStatus { if in == nil { return nil } - out := new(ResultReference) + out := new(RuntimeSessionLineageStatus) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RetryPolicy) DeepCopyInto(out *RetryPolicy) { +func (in *RuntimeSessionMutationLeaseStatus) DeepCopyInto(out *RuntimeSessionMutationLeaseStatus) { *out = *in - if in.InitialDelay != nil { - in, out := &in.InitialDelay, &out.InitialDelay - *out = new(v1.Duration) - **out = **in + in.AcquiredAt.DeepCopyInto(&out.AcquiredAt) + if in.ExpiresAt != nil { + in, out := &in.ExpiresAt, &out.ExpiresAt + *out = (*in).DeepCopy() } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RetryPolicy. -func (in *RetryPolicy) DeepCopy() *RetryPolicy { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeSessionMutationLeaseStatus. +func (in *RuntimeSessionMutationLeaseStatus) DeepCopy() *RuntimeSessionMutationLeaseStatus { if in == nil { return nil } - out := new(RetryPolicy) + out := new(RuntimeSessionMutationLeaseStatus) in.DeepCopyInto(out) return out } @@ -2886,22 +4181,59 @@ func (in *Task) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TaskExecutionOutcome) DeepCopyInto(out *TaskExecutionOutcome) { +func (in *TaskDeliveryStatus) DeepCopyInto(out *TaskDeliveryStatus) { *out = *in - if in.ResultRef != nil { - in, out := &in.ResultRef, &out.ResultRef - *out = new(ResultReference) + if in.SourceRepository != nil { + in, out := &in.SourceRepository, &out.SourceRepository + *out = new(RepositoryIdentity) **out = **in } - in.RecordedAt.DeepCopyInto(&out.RecordedAt) + if in.PublicationRepository != nil { + in, out := &in.PublicationRepository, &out.PublicationRepository + *out = new(RepositoryIdentity) + **out = **in + } + if in.RemoteBeforeSHA != nil { + in, out := &in.RemoteBeforeSHA, &out.RemoteBeforeSHA + *out = new(string) + **out = **in + } + if in.PRReceipt != nil { + in, out := &in.PRReceipt, &out.PRReceipt + *out = new(TaskPullRequestReceipt) + **out = **in + } + if in.LastTransitionTime != nil { + in, out := &in.LastTransitionTime, &out.LastTransitionTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskDeliveryStatus. +func (in *TaskDeliveryStatus) DeepCopy() *TaskDeliveryStatus { + if in == nil { + return nil + } + out := new(TaskDeliveryStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskExecutionStatus) DeepCopyInto(out *TaskExecutionStatus) { + *out = *in + if in.LastTransitionTime != nil { + in, out := &in.LastTransitionTime, &out.LastTransitionTime + *out = (*in).DeepCopy() + } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskExecutionOutcome. -func (in *TaskExecutionOutcome) DeepCopy() *TaskExecutionOutcome { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskExecutionStatus. +func (in *TaskExecutionStatus) DeepCopy() *TaskExecutionStatus { if in == nil { return nil } - out := new(TaskExecutionOutcome) + out := new(TaskExecutionStatus) in.DeepCopyInto(out) return out } @@ -2938,6 +4270,21 @@ func (in *TaskList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskPullRequestReceipt) DeepCopyInto(out *TaskPullRequestReceipt) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskPullRequestReceipt. +func (in *TaskPullRequestReceipt) DeepCopy() *TaskPullRequestReceipt { + if in == nil { + return nil + } + out := new(TaskPullRequestReceipt) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TaskSpec) DeepCopyInto(out *TaskSpec) { *out = *in @@ -3077,9 +4424,29 @@ func (in *TaskStatus) DeepCopyInto(out *TaskStatus) { *out = new(ResultReference) **out = **in } + if in.Execution != nil { + in, out := &in.Execution, &out.Execution + *out = new(TaskExecutionStatus) + (*in).DeepCopyInto(*out) + } + if in.Delivery != nil { + in, out := &in.Delivery, &out.Delivery + *out = new(TaskDeliveryStatus) + (*in).DeepCopyInto(*out) + } + if in.HarnessRuntime != nil { + in, out := &in.HarnessRuntime, &out.HarnessRuntime + *out = new(HarnessRuntimeStatus) + (*in).DeepCopyInto(*out) + } + if in.AgentExecutionBinding != nil { + in, out := &in.AgentExecutionBinding, &out.AgentExecutionBinding + *out = new(AgentExecutionBinding) + (*in).DeepCopyInto(*out) + } if in.ExecutionOutcome != nil { in, out := &in.ExecutionOutcome, &out.ExecutionOutcome - *out = new(TaskExecutionOutcome) + *out = new(TaskWorkloadExecutionOutcome) (*in).DeepCopyInto(*out) } if in.ExecutionWorkspace != nil { @@ -3087,11 +4454,6 @@ func (in *TaskStatus) DeepCopyInto(out *TaskStatus) { *out = new(ExecutionWorkspaceStatus) (*in).DeepCopyInto(*out) } - if in.HarnessRuntime != nil { - in, out := &in.HarnessRuntime, &out.HarnessRuntime - *out = new(HarnessRuntimeStatus) - **out = **in - } if in.ChildTasks != nil { in, out := &in.ChildTasks, &out.ChildTasks *out = make([]ChildTaskStatus, len(*in)) @@ -3156,6 +4518,27 @@ func (in *TaskTransaction) DeepCopy() *TaskTransaction { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TaskWorkloadExecutionOutcome) DeepCopyInto(out *TaskWorkloadExecutionOutcome) { + *out = *in + if in.ResultRef != nil { + in, out := &in.ResultRef, &out.ResultRef + *out = new(ResultReference) + **out = **in + } + in.RecordedAt.DeepCopyInto(&out.RecordedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TaskWorkloadExecutionOutcome. +func (in *TaskWorkloadExecutionOutcome) DeepCopy() *TaskWorkloadExecutionOutcome { + if in == nil { + return nil + } + out := new(TaskWorkloadExecutionOutcome) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tool) DeepCopyInto(out *Tool) { *out = *in @@ -3372,11 +4755,46 @@ func (in *WorkspaceClassReference) DeepCopy() *WorkspaceClassReference { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceConfig) DeepCopyInto(out *WorkspaceConfig) { *out = *in - if in.GitSecretRef != nil { - in, out := &in.GitSecretRef, &out.GitSecretRef - *out = new(corev1.LocalObjectReference) + if in.SourceRepository != nil { + in, out := &in.SourceRepository, &out.SourceRepository + *out = new(RepositoryIdentity) + **out = **in + } + if in.ReadCredentialRef != nil { + in, out := &in.ReadCredentialRef, &out.ReadCredentialRef + *out = new(WorkspaceCredentialReference) + **out = **in + } + if in.PublicationRepository != nil { + in, out := &in.PublicationRepository, &out.PublicationRepository + *out = new(RepositoryIdentity) + **out = **in + } + if in.PublicationReadCredentialRef != nil { + in, out := &in.PublicationReadCredentialRef, &out.PublicationReadCredentialRef + *out = new(WorkspaceCredentialReference) + **out = **in + } + if in.PublicationCredentialRef != nil { + in, out := &in.PublicationCredentialRef, &out.PublicationCredentialRef + *out = new(WorkspaceCredentialReference) + **out = **in + } + if in.ForgeCredentialRef != nil { + in, out := &in.ForgeCredentialRef, &out.ForgeCredentialRef + *out = new(WorkspaceCredentialReference) + **out = **in + } + if in.MaxChangedFiles != nil { + in, out := &in.MaxChangedFiles, &out.MaxChangedFiles + *out = new(int32) **out = **in } + if in.AllowedPaths != nil { + in, out := &in.AllowedPaths, &out.AllowedPaths + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceConfig. @@ -3389,6 +4807,21 @@ func (in *WorkspaceConfig) DeepCopy() *WorkspaceConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceCredentialReference) DeepCopyInto(out *WorkspaceCredentialReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceCredentialReference. +func (in *WorkspaceCredentialReference) DeepCopy() *WorkspaceCredentialReference { + if in == nil { + return nil + } + out := new(WorkspaceCredentialReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceObjectReference) DeepCopyInto(out *WorkspaceObjectReference) { *out = *in diff --git a/cmd/build/helmify/README.md b/cmd/build/helmify/README.md index 51acfca81..5aa8cd06f 100644 --- a/cmd/build/helmify/README.md +++ b/cmd/build/helmify/README.md @@ -6,11 +6,12 @@ This directory is derived from Gatekeeper's `cmd/build/helmify` flow at `make manifests` performs the same staged generation pattern used by Gatekeeper: 1. `controller-gen` refreshes the canonical CRDs under `config/crd/bases`. -2. Kustomize renders `config/default`. -3. This generator copies the static chart inputs and writes every rendered CRD - under `manifest_staging/charts/orka/crds`. -4. Kustomize also writes the next-release installer to - `manifest_staging/deploy/orka.yaml`. +2. Kustomize renders `config/acp-production` as the next-release raw installer. +3. The Helmify Kustomize input renders `config/default`; this generator copies + the static chart inputs and writes every rendered CRD under + `manifest_staging/charts/orka/crds`. +4. The raw installer is written to `manifest_staging/deploy/orka.yaml` with + fail-closed digest placeholders and without CRDs. Only CRDs are generated from the Kustomize stream in this adaptation. Orka's existing non-CRD Helm templates remain static inputs under `static/templates`; diff --git a/cmd/build/helmify/admission_networkpolicy_test.go b/cmd/build/helmify/admission_networkpolicy_test.go new file mode 100644 index 000000000..d630b49d6 --- /dev/null +++ b/cmd/build/helmify/admission_networkpolicy_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/yaml" +) + +func TestSharedAdmissionNetworkPolicyAllowsKubernetesAPIServiceAndBackendPorts(t *testing.T) { + manifestPath := filepath.Join("..", "..", "..", "config", "orka-admission", "networkpolicy.yaml") + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read admission NetworkPolicy: %v", err) + } + assertKubernetesAPIEgressPorts(t, manifest) +} + +func assertKubernetesAPIEgressPorts(t *testing.T, manifest []byte) { + t.Helper() + + var policy networkingv1.NetworkPolicy + if err := yaml.Unmarshal(manifest, &policy); err != nil { + t.Fatalf("decode admission NetworkPolicy: %v", err) + } + if policy.Kind != "NetworkPolicy" { + t.Fatalf("kind = %q, want NetworkPolicy", policy.Kind) + } + + ports := make(map[int32]bool) + for _, rule := range policy.Spec.Egress { + for _, port := range rule.Ports { + if port.Port == nil || port.Port.Type != intstr.Int { + continue + } + if port.Protocol != nil && *port.Protocol != corev1.ProtocolTCP { + continue + } + ports[port.Port.IntVal] = true + } + } + for _, required := range []int32{443, 6443} { + if !ports[required] { + t.Errorf("admission NetworkPolicy TCP egress ports = %v, missing %d", ports, required) + } + } +} diff --git a/cmd/build/helmify/admission_validating_webhook_test.go b/cmd/build/helmify/admission_validating_webhook_test.go new file mode 100644 index 000000000..f9f7dbd1c --- /dev/null +++ b/cmd/build/helmify/admission_validating_webhook_test.go @@ -0,0 +1,401 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + "sigs.k8s.io/yaml" +) + +const ( + canonicalProductionControllerUsername = "system:serviceaccount:orka-system:orka-controller-manager" + staticChartTestNamespace = "orka-test" + webhookPortName = "webhook" +) + +func TestControllerWebhooksAreReleaseLocalAndModeScoped(t *testing.T) { + digest := "sha256:" + strings.Repeat("3", 64) + for _, mode := range []string{"harness-v1", "harness-v2"} { + t.Run(mode, func(t *testing.T) { + args := []string{ + "--set-string", "controller.mode=" + mode, + "--show-only", "templates/controller-validating-webhook.yaml", + } + if mode == "harness-v1" { + args = append(args, + "--set-string", "harnessV1.image.digest="+digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + ) + } + + rendered := requireHelmRender(t, args...) + configuration := admissionregistrationv1.ValidatingWebhookConfiguration{} + if err := yaml.Unmarshal([]byte(rendered), &configuration); err != nil { + t.Fatalf("decode controller validating webhook configuration: %v", err) + } + if configuration.Name != "test-orka-controller" { + t.Fatalf("controller webhook name = %q, want test-orka-controller", configuration.Name) + } + + webhooks := make(map[string]admissionregistrationv1.ValidatingWebhook, len(configuration.Webhooks)) + for _, webhook := range configuration.Webhooks { + webhooks[webhook.Name] = webhook + if !strings.HasSuffix(webhook.Name, "."+mode+".orka.ai") { + t.Errorf("webhook name %q is not scoped to mode %q", webhook.Name, mode) + } + if webhook.FailurePolicy == nil || *webhook.FailurePolicy != admissionregistrationv1.Fail { + t.Errorf("%s failurePolicy = %v, want Fail", webhook.Name, webhook.FailurePolicy) + } + service := webhook.ClientConfig.Service + if service == nil || service.Name != "test-orka-webhook" || service.Namespace != staticChartTestNamespace || + service.Port == nil || *service.Port != 443 { + t.Errorf("%s service = %#v, want test-orka-webhook:443 in orka-test", webhook.Name, service) + } + selector := webhook.NamespaceSelector + if strings.HasPrefix(webhook.Name, "namespace-mode.") { + selector = webhook.ObjectSelector + } + if selector == nil || selector.MatchLabels["orka.ai/controller-mode"] != mode { + t.Errorf("%s execution-mode selector = %#v, want %q", webhook.Name, selector, mode) + } + if selector == nil || selector.MatchLabels["kubernetes.io/metadata.name"] != staticChartTestNamespace { + t.Errorf("%s namespace selector = %#v, want orka-test", webhook.Name, selector) + } + } + + _, hasTaskWorkspace := webhooks["task-workspace-class."+mode+".orka.ai"] + _, hasToolWorkspace := webhooks["tool-workspace-class."+mode+".orka.ai"] + wantWorkspace := mode == "harness-v2" + if hasTaskWorkspace != wantWorkspace || hasToolWorkspace != wantWorkspace { + t.Fatalf("workspace webhooks present = task:%t tool:%t, want %t", hasTaskWorkspace, hasToolWorkspace, wantWorkspace) + } + }) + } +} + +func TestControllerWebhooksDoNotOverlapSameModeReleasesInDifferentNamespaces(t *testing.T) { + const mode = "harness-v2" + selectorsByNamespace := make(map[string]map[string]map[string]string) + for _, namespace := range []string{"orka-one", "orka-two"} { + output := requireHelmRender(t, + "--namespace", namespace, + "--set-string", "controller.mode="+mode, + "--set-string", "controller.watchNamespace="+namespace, + "--show-only", "templates/controller-validating-webhook.yaml", + ) + + configuration := admissionregistrationv1.ValidatingWebhookConfiguration{} + if err := yaml.Unmarshal([]byte(output), &configuration); err != nil { + t.Fatalf("decode controller validating webhook configuration in namespace %q: %v", namespace, err) + } + selectorsByNamespace[namespace] = make(map[string]map[string]string, len(configuration.Webhooks)) + for _, webhook := range configuration.Webhooks { + selector := webhook.NamespaceSelector + if strings.HasPrefix(webhook.Name, "namespace-mode.") { + selector = webhook.ObjectSelector + } + if selector == nil { + t.Fatalf("%s selector in namespace %q is nil", webhook.Name, namespace) + } + selectorsByNamespace[namespace][webhook.Name] = selector.MatchLabels + if selector.MatchLabels["orka.ai/controller-mode"] != mode || + selector.MatchLabels["kubernetes.io/metadata.name"] != namespace { + t.Fatalf("%s selector in namespace %q = %#v", webhook.Name, namespace, selector.MatchLabels) + } + } + } + + for webhookName, first := range selectorsByNamespace["orka-one"] { + second := selectorsByNamespace["orka-two"][webhookName] + if reflect.DeepEqual(first, second) { + t.Errorf("%s has overlapping selectors across namespaces: %#v", webhookName, selectorsByNamespace) + } + } +} + +func TestControllerWebhookServiceIsIsolatedFromExternalService(t *testing.T) { + rendered := requireHelmRender(t, + "--set", "service.type=LoadBalancer", + "--show-only", "templates/service.yaml", + ) + controllerService := corev1.Service{} + if err := yaml.Unmarshal([]byte(rendered), &controllerService); err != nil { + t.Fatalf("decode controller Service: %v", err) + } + if controllerService.Spec.Type != corev1.ServiceTypeLoadBalancer { + t.Fatalf("controller Service type = %q, want LoadBalancer", controllerService.Spec.Type) + } + for _, port := range controllerService.Spec.Ports { + if port.Name == webhookPortName || port.TargetPort.String() == webhookPortName || port.Port == 443 { + t.Fatalf("external controller Service exposes webhook port: %#v", port) + } + } + + rendered = requireHelmRender(t, + "--set", "service.type=LoadBalancer", + "--show-only", "templates/controller-webhook-service.yaml", + ) + webhookService := corev1.Service{} + if err := yaml.Unmarshal([]byte(rendered), &webhookService); err != nil { + t.Fatalf("decode controller webhook Service: %v", err) + } + if webhookService.Name != "test-orka-webhook" { + t.Fatalf("controller webhook Service name = %q, want test-orka-webhook", webhookService.Name) + } + if webhookService.Spec.Type != corev1.ServiceTypeClusterIP { + t.Fatalf("controller webhook Service type = %q, want ClusterIP", webhookService.Spec.Type) + } + if len(webhookService.Spec.Ports) != 1 { + t.Fatalf("controller webhook Service ports = %#v, want one", webhookService.Spec.Ports) + } + port := webhookService.Spec.Ports[0] + if port.Name != webhookPortName || port.Port != 443 || port.TargetPort.String() != webhookPortName { + t.Fatalf("controller webhook Service port = %#v, want webhook 443 -> webhook", port) + } + + rendered = requireHelmRender(t, + "--set", "service.type=LoadBalancer", + "--show-only", "templates/controller-validating-webhook.yaml", + ) + configuration := admissionregistrationv1.ValidatingWebhookConfiguration{} + if err := yaml.Unmarshal([]byte(rendered), &configuration); err != nil { + t.Fatalf("decode controller validating webhook configuration: %v", err) + } + for _, webhook := range configuration.Webhooks { + service := webhook.ClientConfig.Service + if service == nil || service.Name != webhookService.Name || service.Namespace != staticChartTestNamespace { + t.Errorf("%s service = %#v, want %s in orka-test", webhook.Name, service, webhookService.Name) + } + } +} + +func TestControllerDeploymentEnablesReleaseLocalAdmission(t *testing.T) { + rendered := requireHelmRender(t, "--show-only", "templates/deployment.yaml") + deployment := appsv1.Deployment{} + if err := yaml.Unmarshal([]byte(rendered), &deployment); err != nil { + t.Fatalf("decode controller Deployment: %v", err) + } + + var args []string + for _, container := range deployment.Spec.Template.Spec.Containers { + if container.Name == "controller" { + args = container.Args + break + } + } + for _, want := range []string{ + "--task-provenance-admission-enabled=true", + "--workspace-class-use-admission-enabled=true", + "--webhook-cert-path=/var/run/orka/webhook/tls", + } { + if !containsString(args, want) { + t.Errorf("controller args do not contain %q: %#v", want, args) + } + } +} + +func TestSharedAdmissionAuthorizesCanonicalProductionController(t *testing.T) { + path := filepath.Join("..", "..", "..", "config", "orka-admission", "deployment.yaml") + manifest, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read standalone admission Deployment: %v", err) + } + deployment := appsv1.Deployment{} + if err := yaml.Unmarshal(manifest, &deployment); err != nil { + t.Fatalf("decode standalone admission Deployment: %v", err) + } + + var args []string + var lifecycle *corev1.Lifecycle + for _, container := range deployment.Spec.Template.Spec.Containers { + if container.Name == "admission" { + args = container.Args + lifecycle = container.Lifecycle + break + } + } + if lifecycle == nil || lifecycle.PreStop == nil || lifecycle.PreStop.Exec == nil || + !slices.Equal(lifecycle.PreStop.Exec.Command, []string{"/orka-admission", "--pre-stop-delay=5s"}) { + t.Fatalf("admission preStop lifecycle = %#v, want bounded endpoint-removal delay", lifecycle) + } + for _, prefix := range []string{"--controller-usernames=", "--task-provenance-trusted-users="} { + if !commaListArgumentContains(args, prefix, canonicalProductionControllerUsername) { + t.Errorf("admission args do not authorize %q in %s: %#v", + canonicalProductionControllerUsername, prefix, args) + } + } + for _, serviceAccount := range []string{"orka-ai-worker", "orka-vendor-worker"} { + if !commaListArgumentContains(args, "--task-provenance-trusted-service-accounts=", serviceAccount) { + t.Errorf("admission args do not authorize canonical worker %q: %#v", serviceAccount, args) + } + } +} + +//nolint:gocyclo // This contract intentionally validates the complete multi-resource HA rollout shape. +func TestSharedAdmissionRolloutPreservesReadyEndpoints(t *testing.T) { + deploymentPath := filepath.Join("..", "..", "..", "config", "orka-admission", "deployment.yaml") + manifest, err := os.ReadFile(deploymentPath) + if err != nil { + t.Fatalf("read standalone admission Deployment: %v", err) + } + deployment := appsv1.Deployment{} + if err := yaml.Unmarshal(manifest, &deployment); err != nil { + t.Fatalf("decode standalone admission Deployment: %v", err) + } + if deployment.Spec.Replicas == nil || *deployment.Spec.Replicas < 2 { + t.Fatalf("admission replicas = %v, want at least two", deployment.Spec.Replicas) + } + rollingUpdate := deployment.Spec.Strategy.RollingUpdate + if deployment.Spec.Strategy.Type != appsv1.RollingUpdateDeploymentStrategyType || rollingUpdate == nil || + rollingUpdate.MaxUnavailable == nil || rollingUpdate.MaxUnavailable.IntValue() != 0 || + rollingUpdate.MaxSurge == nil || rollingUpdate.MaxSurge.IntValue() < 1 { + t.Fatalf( + "admission rollout strategy = %#v, want zero-unavailable rolling update with surge", + deployment.Spec.Strategy, + ) + } + if deployment.Spec.Template.Spec.TerminationGracePeriodSeconds == nil || + *deployment.Spec.Template.Spec.TerminationGracePeriodSeconds <= 5 { + t.Fatalf("admission termination grace = %v, want longer than endpoint-removal delay", + deployment.Spec.Template.Spec.TerminationGracePeriodSeconds) + } + + var admissionContainer *corev1.Container + for i := range deployment.Spec.Template.Spec.Containers { + container := &deployment.Spec.Template.Spec.Containers[i] + if container.Name == "admission" { + admissionContainer = container + break + } + } + if admissionContainer == nil { + t.Fatal("standalone admission container is missing") + } + if admissionContainer.ReadinessProbe == nil || admissionContainer.ReadinessProbe.HTTPGet == nil || + admissionContainer.ReadinessProbe.HTTPGet.Path != "/readyz" { + t.Fatalf("admission readiness probe = %#v, want /readyz", admissionContainer.ReadinessProbe) + } + if admissionContainer.Lifecycle == nil || admissionContainer.Lifecycle.PreStop == nil || + admissionContainer.Lifecycle.PreStop.Exec == nil || + !slices.Equal(admissionContainer.Lifecycle.PreStop.Exec.Command, []string{"/orka-admission", "--pre-stop-delay=5s"}) { + t.Fatalf("admission preStop lifecycle = %#v, want bounded endpoint-removal delay", admissionContainer.Lifecycle) + } + + servicePath := filepath.Join("..", "..", "..", "config", "orka-admission", "service.yaml") + manifest, err = os.ReadFile(servicePath) + if err != nil { + t.Fatalf("read standalone admission Service: %v", err) + } + service := corev1.Service{} + if err := yaml.Unmarshal(manifest, &service); err != nil { + t.Fatalf("decode standalone admission Service: %v", err) + } + if service.Spec.PublishNotReadyAddresses { + t.Fatal("admission Service publishes unready addresses; terminating Pods could remain routable") + } + if !reflect.DeepEqual(service.Spec.Selector, deployment.Spec.Selector.MatchLabels) { + t.Fatalf("admission Service selector = %#v, want Deployment selector %#v", + service.Spec.Selector, deployment.Spec.Selector.MatchLabels) + } + + pdbPath := filepath.Join("..", "..", "..", "config", "orka-admission", "poddisruptionbudget.yaml") + manifest, err = os.ReadFile(pdbPath) + if err != nil { + t.Fatalf("read standalone admission PodDisruptionBudget: %v", err) + } + pdb := policyv1.PodDisruptionBudget{} + if err := yaml.Unmarshal(manifest, &pdb); err != nil { + t.Fatalf("decode standalone admission PodDisruptionBudget: %v", err) + } + if pdb.Spec.MinAvailable == nil || pdb.Spec.MinAvailable.IntValue() < 1 || pdb.Spec.Selector == nil || + !reflect.DeepEqual(pdb.Spec.Selector.MatchLabels, deployment.Spec.Selector.MatchLabels) { + t.Fatalf("admission disruption budget = %#v, want at least one matching Pod available", pdb.Spec) + } +} + +func TestSharedTaskWebhooksBypassExactControllerCleanup(t *testing.T) { + path := filepath.Join("..", "..", "..", "config", "orka-admission-webhooks", "validating_webhook.yaml") + manifest, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read standalone admission webhooks: %v", err) + } + assertTaskWebhooksBypassExactControllerCleanup(t, manifest) +} + +func assertTaskWebhooksBypassExactControllerCleanup(t *testing.T, manifest []byte) { + t.Helper() + + configuration := admissionregistrationv1.ValidatingWebhookConfiguration{} + if err := yaml.Unmarshal(manifest, &configuration); err != nil { + t.Fatalf("decode validating webhook configuration: %v", err) + } + webhooks := make(map[string]admissionregistrationv1.ValidatingWebhook, len(configuration.Webhooks)) + for _, webhook := range configuration.Webhooks { + webhooks[webhook.Name] = webhook + } + + authority, ok := webhooks["taskexecutionauthority.core.orka.ai"] + if !ok { + t.Fatal("task execution authority webhook is missing") + } + if len(authority.MatchConditions) != 1 || + authority.MatchConditions[0].Name != "route-unless-controller-cleanup-safe" { + t.Fatalf("task execution authority cleanup-safe condition = %#v", authority.MatchConditions) + } + condition := authority.MatchConditions[0] + for _, marker := range []string{ + "request.userInfo.username == '" + canonicalProductionControllerUsername + "'", + "request.operation == 'UPDATE'", + "has(oldObject.metadata.deletionTimestamp)", + "oldObject.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup')", + "oldObject.metadata.?finalizers.orValue([]).filter(f, f != 'orka.ai/cleanup')", + "object.spec == oldObject.spec", + "object.?status.orValue({}) == oldObject.?status.orValue({})", + } { + if !strings.Contains(condition.Expression, marker) { + t.Fatalf("cleanup-safe condition is missing %q:\n%s", marker, condition.Expression) + } + } + + for _, name := range []string{ + "taskprovenance.core.orka.ai", + "taskworkspaceclassuse.core.orka.ai", + } { + webhook, ok := webhooks[name] + if !ok { + t.Fatalf("%s webhook is missing", name) + } + if webhook.FailurePolicy == nil || *webhook.FailurePolicy != admissionregistrationv1.Fail { + t.Fatalf("%s failurePolicy = %v, want Fail", name, webhook.FailurePolicy) + } + if !reflect.DeepEqual(webhook.MatchConditions, authority.MatchConditions) { + t.Fatalf("%s cleanup-safe conditions = %#v, want %#v", name, webhook.MatchConditions, authority.MatchConditions) + } + } +} + +func containsString(values []string, want string) bool { + return slices.Contains(values, want) +} + +func commaListArgumentContains(args []string, prefix, want string) bool { + for _, arg := range args { + value, ok := strings.CutPrefix(arg, prefix) + if !ok { + continue + } + if slices.Contains(strings.Split(value, ","), want) { + return true + } + } + return false +} diff --git a/cmd/build/helmify/agent_sandbox_mode_test.go b/cmd/build/helmify/agent_sandbox_mode_test.go new file mode 100644 index 000000000..41ab96877 --- /dev/null +++ b/cmd/build/helmify/agent_sandbox_mode_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "strings" + "testing" +) + +func TestStaticChartValidatesAgentSandboxControllerMode(t *testing.T) { + t.Run("rejects harness v1", func(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + output, err := helmTemplateStaticChart(t, + "--set-string", "controller.mode=harness-v1", + "--set", "controller.agentSandbox.enabled=true", + "--set-string", "harnessV1.image.digest="+digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + ) + const want = "controller.agentSandbox.enabled is unsupported when controller.mode=harness-v1" + if err == nil || !strings.Contains(output, want) { + t.Fatalf("helm render error = %v, want %q:\n%s", err, want, output) + } + }) + + t.Run("allows harness v2", func(t *testing.T) { + output, err := helmTemplateStaticChart(t, + "--set", "controller.agentSandbox.enabled=true", + "--show-only", "templates/deployment.yaml", + ) + if err != nil { + t.Fatalf("helm template rejected harness-v2 agent sandbox: %v\n%s", err, output) + } + if !strings.Contains(output, "--agent-sandbox-enabled=true") { + t.Fatalf("harness-v2 render is missing the agent sandbox flag:\n%s", output) + } + }) +} diff --git a/cmd/build/helmify/coexistence_admission_policy_test.go b/cmd/build/helmify/coexistence_admission_policy_test.go new file mode 100644 index 000000000..8fad4954e --- /dev/null +++ b/cmd/build/helmify/coexistence_admission_policy_test.go @@ -0,0 +1,4 @@ +package main + +// The former coexistence policy render tests were retired with the dynamic +// bridge. Static controller modes are covered by main_test.go. diff --git a/cmd/build/helmify/main_test.go b/cmd/build/helmify/main_test.go index 792b28861..2b8184b15 100644 --- a/cmd/build/helmify/main_test.go +++ b/cmd/build/helmify/main_test.go @@ -2,7 +2,10 @@ package main import ( "os" + "os/exec" "path/filepath" + "regexp" + "strconv" "strings" "testing" ) @@ -81,3 +84,1555 @@ func TestObjectSetRejectsDuplicateCRDFilenames(t *testing.T) { t.Fatalf("write() error = %v, want duplicate filename error", err) } } + +func helmTemplateStaticChart(t *testing.T, args ...string) (string, error) { + return helmTemplateStaticChartForRelease(t, "test", "orka-test", args...) +} + +func helmTemplateStaticChartForRelease( + t *testing.T, + releaseName string, + namespace string, + args ...string, +) (string, error) { + t.Helper() + helm, err := exec.LookPath("helm") + if err != nil { + t.Skip("helm is required for static chart render tests") + } + + commandArgs := []string{"template", releaseName, "static", "--namespace", namespace} + commandArgs = append(commandArgs, staticChartDefaultArgs()...) + commandArgs = append(commandArgs, "--set-string", "controller.watchNamespace="+namespace) + commandArgs = append(commandArgs, args...) + output, err := exec.Command(helm, commandArgs...).CombinedOutput() + return string(output), err +} + +func staticChartDefaultArgs() []string { + digest := "sha256:" + strings.Repeat("0", 64) + return []string{ + "--set-string", "controller.mode=harness-v2", + "--set-string", "controller.watchNamespace=orka-test", + "--set-string", "controller.image.digest=" + digest, + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + "--set-string", "webhooks.tls.existingSecret=controller-webhook-tls", + "--set-string", "webhooks.caBundle=Y2E=", + "--set-string", "publisher.image.digest=" + digest, + "--set", "providerProxy.enabled=true", + } +} + +func forceStaticChartNamespaceMode(t *testing.T, chartDir, mode string) { + t.Helper() + helpersPath := filepath.Join(chartDir, "templates", "_helpers.tpl") + helpers, err := os.ReadFile(helpersPath) + if err != nil { + t.Fatalf("read static chart helpers: %v", err) + } + namespaceLookup := `{{- $existingNamespace := lookup "v1" "Namespace" "" .Release.Namespace -}}` + forcedNamespaceLookup := `{{- $existingNamespace := dict "metadata" (dict "labels" ` + + `(dict "orka.ai/controller-mode" ` + strconv.Quote(mode) + `)) -}}` + forced := strings.Replace(string(helpers), namespaceLookup, forcedNamespaceLookup, 1) + if forced == string(helpers) { + t.Fatalf("controller mode validation is not gated by the exact existing Namespace lookup") + } + if err := os.WriteFile(helpersPath, []byte(forced), 0o600); err != nil { + t.Fatalf("force existing namespace lookup in copied chart: %v", err) + } +} + +func helmTemplateStaticChartWithExistingController( + t *testing.T, + existingControllerArgs []string, + args ...string, +) (string, error) { + t.Helper() + return helmTemplateStaticChartWithExistingControllerSnapshot( + t, + existingControllerArgs, + "snapshot-key", + "encryption-key", + args..., + ) +} + +func helmTemplateStaticChartWithExistingControllerSnapshot( + t *testing.T, + existingControllerArgs []string, + existingSnapshotSecret string, + existingSnapshotKey string, + args ...string, +) (string, error) { + t.Helper() + helm, err := exec.LookPath("helm") + if err != nil { + t.Skip("helm is required for static chart render tests") + } + + chartDir := filepath.Join(t.TempDir(), "static") + if err := os.CopyFS(chartDir, os.DirFS("static")); err != nil { + t.Fatalf("copy static chart: %v", err) + } + forceStaticChartNamespaceMode(t, chartDir, "harness-v2") + helpersPath := filepath.Join(chartDir, "templates", "_helpers.tpl") + helpers, err := os.ReadFile(helpersPath) + if err != nil { + t.Fatalf("read static chart helpers: %v", err) + } + lookup := `{{- $existingControllerList := lookup "apps/v1" "Deployment" .Release.Namespace "" -}}` + forcedLookup := `{{- $existingControllerList := dict "items" (list) -}}` + if existingControllerArgs != nil { + quotedControllerArgs := make([]string, 0, len(existingControllerArgs)) + for _, arg := range existingControllerArgs { + quotedControllerArgs = append(quotedControllerArgs, strconv.Quote(arg)) + } + forcedLookup = `{{- $existingControllerList := dict "items" (list (dict ` + + `"metadata" (dict "name" "test-orka-controller" "labels" (dict ` + + `"app.kubernetes.io/instance" "test" "app.kubernetes.io/component" "controller" ` + + `"app.kubernetes.io/managed-by" "Helm")) ` + + `"spec" (dict "template" (dict "spec" (dict ` + + `"containers" (list (dict "name" "controller" "args" (list ` + + strings.Join(quotedControllerArgs, " ") + `))) ` + + `"volumes" (list (dict "name" "agent-execution-snapshot-key" "secret" (dict ` + + `"secretName" ` + strconv.Quote(existingSnapshotSecret) + ` "items" (list (dict ` + + `"key" ` + strconv.Quote(existingSnapshotKey) + ` "path" "key")))))))))) -}}` + } + withController := strings.Replace(string(helpers), lookup, forcedLookup, 1) + if withController == string(helpers) { + t.Fatalf("controller mode validation is not gated by the release-owned Deployment list lookup") + } + if err := os.WriteFile(helpersPath, []byte(withController), 0o600); err != nil { + t.Fatalf("force existing controller lookup in copied chart: %v", err) + } + + commandArgs := []string{"template", "test", chartDir, "--namespace", "orka-test", "--is-upgrade"} + commandArgs = append(commandArgs, staticChartDefaultArgs()...) + commandArgs = append(commandArgs, args...) + output, err := exec.Command(helm, commandArgs...).CombinedOutput() + return string(output), err +} + +func requireHelmRender(t *testing.T, args ...string) string { + t.Helper() + output, err := helmTemplateStaticChart(t, args...) + if err != nil { + t.Fatalf("helm template failed: %v\n%s", err, output) + } + return output +} + +func requireHarnessV1UpgradeDrainHookRender(t *testing.T, matchesDesiredGeneration bool, args ...string) string { + t.Helper() + output, err := helmTemplateHarnessV1UpgradeDrainHook(t, harnessV1UpgradeState{ + matchesDesiredGeneration: matchesDesiredGeneration, + authSecret: "harness-wrapper-auth", + authKey: "token", + tlsSecret: "harness-wrapper-tls", + }, args...) + if err != nil { + t.Fatalf("helm template forced wrapper upgrade hook failed: %v\n%s", err, output) + } + return output +} + +type harnessV1UpgradeState struct { + matchesDesiredGeneration bool + wrapperMissing bool + controllerState string + authSecret string + authKey string + tlsSecret string +} + +func helmTemplateHarnessV1UpgradeDrainHook( + t *testing.T, + state harnessV1UpgradeState, + args ...string, +) (string, error) { + t.Helper() + helm, err := exec.LookPath("helm") + if err != nil { + t.Skip("helm is required for static chart render tests") + } + + chartDir := filepath.Join(t.TempDir(), "static") + if err := os.CopyFS(chartDir, os.DirFS("static")); err != nil { + t.Fatalf("copy static chart: %v", err) + } + forceStaticChartNamespaceMode(t, chartDir, "harness-v1") + hookPath := filepath.Join(chartDir, "templates", "harness-wrapper-drain-hook.yaml") + hook, err := os.ReadFile(hookPath) + if err != nil { + t.Fatalf("read wrapper drain hook: %v", err) + } + lookup := `{{- $existingWrapper := lookup "apps/v1" "Deployment" .Release.Namespace $wrapperName }}` + existingGeneration := `"current-generation"` + if state.matchesDesiredGeneration { + existingGeneration = `$desiredGeneration` + } + currentImage := "registry.example/current-wrapper@sha256:" + strings.Repeat("2", 64) + tlsSecret := state.tlsSecret + if tlsSecret == "" { + tlsSecret = "harness-wrapper-tls" + } + forcedLookup := `{{- $existingWrapper := dict }}` + if !state.wrapperMissing { + forcedLookup = strings.Join([]string{ + `{{- $existingWrapper := dict`, + `"metadata" (dict "name" $wrapperName)`, + `"spec" (dict "template" (dict "spec" (dict`, + `"containers" (list (dict "name" "wrapper"`, + `"image" "` + currentImage + `" "imagePullPolicy" "Always"`, + `"env" (list (dict "name" "ORKA_HARNESS_WRAPPER_LEDGER_GENERATION"`, + `"value" ` + existingGeneration + `))))`, + `"volumes" (list (dict "name" "auth" "secret"`, + `(dict "secretName" "` + state.authSecret + `" "items"`, + `(list (dict "key" "` + state.authKey + `" "path" "token"))))`, + `(dict "name" "tls" "secret" (dict "secretName" "` + tlsSecret + `")))))) }}`, + }, " ") + } + forced := strings.Replace(string(hook), lookup, forcedLookup, 1) + if forced == string(hook) { + t.Fatalf("wrapper drain hook is not gated by the exact existing Deployment lookup") + } + if state.controllerState != "" { + var controllerArgs []string + switch state.controllerState { + case "enabled": + controllerArgs = []string{ + `"--controller-mode=harness-v1"`, + `"--harness-v1-auth-secret-name=` + state.authSecret + `"`, + `"--harness-v1-auth-secret-key=` + state.authKey + `"`, + } + default: + t.Fatalf("unsupported forced controller state %q", state.controllerState) + } + controllerLookup := `{{- $existingController := lookup "apps/v1" "Deployment" .Release.Namespace $controllerName }}` + forcedControllerLookup := `{{- $existingController := dict "spec" (dict "template" (dict "spec" ` + + `(dict "containers" (list (dict "name" "controller" "args" (list ` + + strings.Join(controllerArgs, " ") + `)))))) }}` + withController := strings.Replace(forced, controllerLookup, forcedControllerLookup, 1) + if withController == forced { + t.Fatalf("wrapper drain hook is not gated by the exact existing controller Deployment lookup") + } + forced = withController + } + if err := os.WriteFile(hookPath, []byte(forced), 0o600); err != nil { + t.Fatalf("force existing wrapper lookup in copied chart: %v", err) + } + + commandArgs := []string{"template", "test", chartDir, "--namespace", "orka-test", "--is-upgrade"} + commandArgs = append(commandArgs, staticChartDefaultArgs()...) + commandArgs = append(commandArgs, args...) + output, err := exec.Command(helm, commandArgs...).CombinedOutput() + return string(output), err +} + +var harnessV1GenerationPattern = regexp.MustCompile( + `(?m)name: ORKA_HARNESS_WRAPPER_LEDGER_GENERATION\n\s+value: "([a-f0-9]{64})"`, +) + +func harnessV1RenderedGeneration(t *testing.T, rendered string) string { + t.Helper() + match := harnessV1GenerationPattern.FindStringSubmatch(rendered) + if len(match) != 2 { + t.Fatalf("rendered harness v1 Deployment is missing a canonical generation:\n%s", rendered) + } + return match[1] +} + +func requireRenderedDocument(t *testing.T, rendered string, markers ...string) string { + t.Helper() + for document := range strings.SplitSeq(rendered, "\n---\n") { + matched := true + for _, marker := range markers { + if !strings.Contains(document, marker) { + matched = false + break + } + } + if matched { + return document + } + } + t.Fatalf("rendered chart has no document containing %q:\n%s", markers, rendered) + return "" +} + +func renderedResourceName(t *testing.T, rendered string, markers ...string) string { + t.Helper() + document := requireRenderedDocument(t, rendered, markers...) + match := regexp.MustCompile(`(?m)^metadata:\n name: ([^\n]+)$`).FindStringSubmatch(document) + if len(match) != 2 { + t.Fatalf("rendered resource has no metadata.name:\n%s", document) + } + return strings.Trim(match[1], `"`) +} + +func TestStaticChartLongReleaseNamesKeepClusterScopedResourcesDistinct(t *testing.T) { + prefix := strings.Repeat("a", 52) + fullNamePrefix := strings.Repeat("b", 62) + renderedA, err := helmTemplateStaticChartForRelease( + t, prefix+"x", "orka-test", "--set-string", "fullnameOverride="+fullNamePrefix+"x", + ) + if err != nil { + t.Fatalf("helm template first long release failed: %v\n%s", err, renderedA) + } + renderedB, err := helmTemplateStaticChartForRelease( + t, prefix+"y", "orka-test", "--set-string", "fullnameOverride="+fullNamePrefix+"y", + ) + if err != nil { + t.Fatalf("helm template second long release failed: %v\n%s", err, renderedB) + } + + resources := []struct { + name string + maxLength int + markers []string + }{ + { + name: "validating webhook", + maxLength: 63, + markers: []string{"kind: ValidatingWebhookConfiguration"}, + }, + { + name: "controller cluster role", + maxLength: 253, + markers: []string{"kind: ClusterRole", `resources: ["tokenreviews"]`}, + }, + } + for _, resource := range resources { + t.Run(resource.name, func(t *testing.T) { + nameA := renderedResourceName(t, renderedA, resource.markers...) + nameB := renderedResourceName(t, renderedB, resource.markers...) + if nameA == nameB { + t.Fatalf("long release names collapsed to cluster-scoped name %q", nameA) + } + if len(nameA) > resource.maxLength || len(nameB) > resource.maxLength { + t.Fatalf("cluster-scoped names exceed %d characters: %q, %q", resource.maxLength, nameA, nameB) + } + }) + } + + clusterRoleA := renderedResourceName(t, renderedA, "kind: ClusterRole", `resources: ["tokenreviews"]`) + bindingA := requireRenderedDocument( + t, + renderedA, + "kind: ClusterRoleBinding", + "kind: ClusterRole\n name: "+clusterRoleA, + ) + if bindingName := renderedResourceName(t, bindingA, "kind: ClusterRoleBinding"); bindingName != clusterRoleA { + t.Fatalf("controller ClusterRoleBinding name %q does not match ClusterRole %q", bindingName, clusterRoleA) + } + + shortRender := requireHelmRender(t) + webhookName := renderedResourceName(t, shortRender, "kind: ValidatingWebhookConfiguration") + if webhookName != "test-orka-controller" { + t.Fatalf("short validating webhook name changed to %q", webhookName) + } + clusterRoleName := renderedResourceName(t, shortRender, "kind: ClusterRole", `resources: ["tokenreviews"]`) + if clusterRoleName != "test-orka-controller-cluster" { + t.Fatalf("short controller ClusterRole name changed to %q", clusterRoleName) + } + + otherNamespaceRender, err := helmTemplateStaticChartForRelease( + t, prefix+"x", "orka-other", "--set-string", "fullnameOverride="+fullNamePrefix+"x", + ) + if err != nil { + t.Fatalf("helm template other namespace failed: %v\n%s", err, otherNamespaceRender) + } + otherNamespaceWebhook := renderedResourceName(t, otherNamespaceRender, "kind: ValidatingWebhookConfiguration") + webhookA := renderedResourceName(t, renderedA, "kind: ValidatingWebhookConfiguration") + if webhookA == otherNamespaceWebhook { + t.Fatalf("namespaces collapsed to cluster-scoped validating webhook name %q", webhookA) + } +} + +func TestStaticChartUsesServicePortForInClusterControllerURLs(t *testing.T) { + rendered := requireHelmRender(t, + "--set", "service.port=18080", + "--set", "controller.apiPort=8080", + ) + + if got := strings.Count(rendered, "--controller-url="); got != 1 { + t.Fatalf("controller URL argument count = %d, want 1", got) + } + if !strings.Contains(rendered, "--controller-url=http://test-orka.orka-test.svc:18080") { + t.Fatalf("controller URL does not use service.port:\n%s", rendered) + } + for _, variable := range []string{ + "ORKA_PUBLISHER_ARTIFACT_AUTHORIZATION_BROKER_URL", + "ORKA_PUBLISHER_ARTIFACT_API_URL", + "ORKA_PUBLISHER_CREDENTIAL_BROKER_URL", + } { + marker := "name: " + variable + "\n value: http://test-orka:18080" + if !strings.Contains(rendered, marker) { + t.Fatalf("%s does not use service.port", variable) + } + } + + service := requireHelmRender(t, + "--set", "service.port=18080", + "--set", "controller.apiPort=8080", + "--show-only", "templates/service.yaml", + ) + if !strings.Contains(service, "port: 18080") || !strings.Contains(service, "targetPort: api") { + t.Fatalf("controller Service does not preserve service port to named API target:\n%s", service) + } +} + +func TestStaticChartProviderProxyConfigurationIsFixedToSupportedBoundary(t *testing.T) { + digest := "sha256:" + strings.Repeat("0", 64) + args := []string{ + "--set", "providerProxy.enabled=true", + "--set", "controller.acpRuntime.enabled=true", + "--set", "store.persistence.enabled=true", + "--set-string", "controller.image.digest=" + digest, + "--set-string", "publisher.image.digest=" + digest, + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + "--set-string", "controller.acpRuntime.providerProxyNamespace=orka-test", + "--set-string", "providerProxy.upstreamBaseURL=http://vekil.vekil-system.svc:1337/", + } + rendered := requireHelmRender(t, args...) + + for _, marker := range []string{ + "--acp-provider-proxy-base-url=http://test-orka-provider-auth-proxy.orka-test.svc:8080", + "--acp-provider-proxy-namespace=orka-test", + "--upstream-base-url=http://vekil.vekil-system.svc:1337", + } { + if !strings.Contains(rendered, marker) { + t.Fatalf("rendered provider proxy configuration is missing %q", marker) + } + } + if strings.Contains(rendered, "--upstream-base-url=http://vekil.vekil-system.svc:1337/") { + t.Fatalf("provider upstream trailing slash was not normalized") + } + + providerPolicy := requireHelmRender(t, + "--set", "providerProxy.enabled=true", + "--show-only", "templates/provider-proxy-networkpolicy.yaml", + ) + for _, marker := range []string{ + "kubernetes.io/metadata.name: vekil-system", + "app.kubernetes.io/name: vekil", + "ports: [{protocol: TCP, port: 1337}]", + } { + if !strings.Contains(providerPolicy, marker) { + t.Fatalf("provider proxy NetworkPolicy lost fixed Vekil boundary %q:\n%s", marker, providerPolicy) + } + } + + vekilPolicy := requireHelmRender(t, + "--set", "providerProxy.enabled=true", + "--show-only", "templates/vekil-ingress-networkpolicy.yaml", + ) + for _, marker := range []string{ + "namespace: vekil-system", + "kubernetes.io/metadata.name: orka-test", + "ports: [{protocol: TCP, port: 1337}]", + } { + if !strings.Contains(vekilPolicy, marker) { + t.Fatalf("Vekil ingress NetworkPolicy lost fixed boundary %q:\n%s", marker, vekilPolicy) + } + } +} + +func TestStaticChartEnforcesSQLiteControllerSafety(t *testing.T) { + tests := []struct { + name string + args []string + wantError string + }{ + { + name: "zero replicas", + args: []string{"--set", "controller.replicas=0"}, + wantError: "controller.replicas must be exactly 1 when using the SQLite store backend", + }, + { + name: "multiple replicas", + args: []string{"--set", "controller.replicas=2"}, + wantError: "controller.replicas must be exactly 1 when using the SQLite store backend", + }, + { + name: "leader election disabled", + args: []string{"--set", "controller.leaderElect=false"}, + wantError: "controller.leaderElect must be true for an isolated controller installation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChart(t, tt.args...) + if err == nil { + t.Fatalf("helm template unexpectedly accepted unsafe SQLite controller configuration:\n%s", output) + } + if !strings.Contains(output, tt.wantError) { + t.Fatalf("helm template error does not contain %q:\n%s", tt.wantError, output) + } + }) + } + + rendered := requireHelmRender(t, "--show-only", "templates/deployment.yaml") + for _, marker := range []string{ + "replicas: 1", + "strategy:\n type: Recreate", + "--leader-elect=true", + } { + if !strings.Contains(rendered, marker) { + t.Fatalf("controller deployment is missing SQLite safety marker %q:\n%s", marker, rendered) + } + } +} + +func TestStaticChartRequiresAgentExecutionSnapshotSecret(t *testing.T) { + tests := []struct { + name string + args []string + wantError string + }{ + { + name: "missing Secret name", + args: []string{ + "--set-string", "controller.agentExecutionSnapshot.existingSecret=", + }, + wantError: "controller.agentExecutionSnapshot.existingSecret is required when agent execution is enabled", + }, + { + name: "missing Secret key", + args: []string{ + "--set-string", "controller.agentExecutionSnapshot.key=", + }, + wantError: "controller.agentExecutionSnapshot.key is required when agent execution is enabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChart(t, tt.args...) + if err == nil { + t.Fatalf("helm template unexpectedly accepted incomplete snapshot key configuration:\n%s", output) + } + if !strings.Contains(output, tt.wantError) { + t.Fatalf("helm template error does not contain %q:\n%s", tt.wantError, output) + } + }) + } +} + +func TestStaticChartMountsAgentExecutionSnapshotKey(t *testing.T) { + args := []string{ + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + "--show-only", "templates/deployment.yaml", + } + rendered := requireHelmRender(t, args...) + + for _, marker := range []string{ + "--agent-execution-snapshot-key-file=/var/run/orka/agent-execution-snapshot/key", + "mountPath: /var/run/orka/agent-execution-snapshot", + "readOnly: true", + "secretName: \"snapshot-key\"", + "key: \"encryption-key\"", + "path: key", + } { + if !strings.Contains(rendered, marker) { + t.Fatalf("controller deployment is missing snapshot key marker %q:\n%s", marker, rendered) + } + } +} + +func TestStaticChartRendersOneStaticHarnessMode(t *testing.T) { + v2 := requireHelmRender(t) + for _, marker := range []string{ + "--controller-mode=harness-v2", + "app.kubernetes.io/component: acp-runtime", + "app.kubernetes.io/component: provider-auth-proxy", + } { + if !strings.Contains(v2, marker) { + t.Fatalf("harness-v2 render is missing %q:\n%s", marker, v2) + } + } + if strings.Contains(v2, "app.kubernetes.io/component: agent-harness-wrapper") { + t.Fatalf("harness-v2 render contains the harness-v1 data plane:\n%s", v2) + } + + digest := "sha256:" + strings.Repeat("1", 64) + v1 := requireHelmRender(t, + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest="+digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + ) + for _, marker := range []string{ + "--controller-mode=harness-v1", + "app.kubernetes.io/component: agent-harness-wrapper", + "--harness-v1-endpoint=https://test-orka-agent-harness-wrapper.orka-test.svc:8080", + } { + if !strings.Contains(v1, marker) { + t.Fatalf("harness-v1 render is missing %q:\n%s", marker, v1) + } + } + for _, forbidden := range []string{ + "app.kubernetes.io/component: acp-runtime", + "app.kubernetes.io/component: provider-auth-proxy", + "app.kubernetes.io/component: workspace-publisher", + } { + if strings.Contains(v1, forbidden) { + t.Fatalf("harness-v1 render contains harness-v2 component %q:\n%s", forbidden, v1) + } + } +} + +func TestStaticChartRejectsNonStaticControllerModes(t *testing.T) { + for _, mode := range []string{"", "dual", "auto", "harness-v1-drain", "unknown"} { + t.Run(mode, func(t *testing.T) { + output, err := helmTemplateStaticChart(t, "--set-string", "controller.mode="+mode) + if err == nil || !strings.Contains(output, "controller.mode must be harness-v1 or harness-v2") { + t.Fatalf("helm render error = %v, want static-mode rejection:\n%s", err, output) + } + }) + } +} + +func TestStaticChartRejectsControllerWatchScopeChangesOnUpgrade(t *testing.T) { + const watchScopeError = `controller.watchNamespace is immutable; ` + + `the existing controller must already watch namespace "orka-test"` + tests := []struct { + name string + existingControllerArgs []string + wantError string + }{ + { + name: "legacy cluster-wide controller", + existingControllerArgs: []string{"--acp-runtime-enabled=true"}, + wantError: watchScopeError, + }, + { + name: "legacy controller in another namespace", + existingControllerArgs: []string{ + "--watch-namespace=other-namespace", + "--acp-runtime-enabled=true", + }, + wantError: watchScopeError, + }, + { + name: "legacy controller in the release namespace", + existingControllerArgs: []string{ + "--watch-namespace=orka-test", + "--acp-runtime-enabled=true", + }, + wantError: "implicit or legacy harness-v2 installations cannot upgrade in place", + }, + { + name: "static harness v2 controller in the release namespace", + existingControllerArgs: []string{ + "--controller-mode=harness-v2", + "--watch-namespace=orka-test", + "--controller-url=http://test-orka.orka-test.svc:8080", + "--acp-runtime-namespace=orka-runtimes", + }, + }, + { + name: "static harness v2 namespace with a deleted controller", + existingControllerArgs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChartWithExistingController(t, tt.existingControllerArgs) + if tt.wantError == "" { + if err != nil { + t.Fatalf("same-mode static controller upgrade failed: %v\n%s", err, output) + } + return + } + if err == nil || !strings.Contains(output, tt.wantError) { + t.Fatalf("helm render error = %v, want watch-scope rejection:\n%s", err, output) + } + }) + } +} + +func TestStaticChartRejectsHarnessV2IdentityChangesOnUpgrade(t *testing.T) { + staticControllerArgs := []string{ + "--controller-mode=harness-v2", + "--watch-namespace=orka-test", + "--controller-url=http://test-orka.orka-test.svc:8080", + "--acp-runtime-namespace=orka-runtimes", + } + tests := []struct { + name string + args []string + wantError string + }{ + { + name: "fullname override", + args: []string{"--set-string", "fullnameOverride=renamed"}, + wantError: "the effective chart fullname is immutable for harness-v2 upgrades", + }, + { + name: "effective name override", + args: []string{"--set-string", "nameOverride=renamed"}, + wantError: "the effective chart fullname is immutable for harness-v2 upgrades", + }, + { + name: "ACP runtime namespace", + args: []string{"--set-string", "controller.acpRuntime.namespace=other-runtimes"}, + wantError: "controller.acpRuntime.namespace is immutable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChartWithExistingController(t, staticControllerArgs, tt.args...) + if err == nil || !strings.Contains(output, tt.wantError) { + t.Fatalf("helm render error = %v, want immutable identity rejection %q:\n%s", err, tt.wantError, output) + } + }) + } +} + +func TestStaticChartRejectsAgentExecutionSnapshotIdentityChangesOnUpgrade(t *testing.T) { + staticControllerArgs := []string{ + "--controller-mode=harness-v2", + "--watch-namespace=orka-test", + "--controller-url=http://test-orka.orka-test.svc:8080", + "--acp-runtime-namespace=orka-runtimes", + } + tests := []struct { + name string + existingSecret string + existingKey string + args []string + wantError string + }{ + { + name: "Secret name changed", + existingSecret: "old-snapshot-key", + existingKey: "encryption-key", + args: []string{"--set-string", "controller.agentExecutionSnapshot.existingSecret=new-snapshot-key"}, + wantError: "controller.agentExecutionSnapshot.existingSecret is immutable for in-place upgrades", + }, + { + name: "Secret item key changed", + existingSecret: "snapshot-key", + existingKey: "old-encryption-key", + args: []string{"--set-string", "controller.agentExecutionSnapshot.key=new-encryption-key"}, + wantError: "controller.agentExecutionSnapshot.key is immutable for in-place upgrades", + }, + { + name: "live Secret name missing", + existingSecret: "", + existingKey: "encryption-key", + wantError: "cannot determine the existing agent execution snapshot Secret name", + }, + { + name: "live Secret item key missing", + existingSecret: "snapshot-key", + existingKey: "", + wantError: "cannot determine the existing agent execution snapshot Secret key", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChartWithExistingControllerSnapshot( + t, + staticControllerArgs, + tt.existingSecret, + tt.existingKey, + tt.args..., + ) + if err == nil || !strings.Contains(output, tt.wantError) { + t.Fatalf("helm render error = %v, want snapshot identity rejection %q:\n%s", err, tt.wantError, output) + } + }) + } +} + +func TestStaticChartRejectsUpgradeWithoutStaticNamespaceIdentity(t *testing.T) { + output, err := helmTemplateStaticChart(t, "--is-upgrade") + if err == nil || !strings.Contains(output, "controller mode identity is missing or incompatible") { + t.Fatalf("helm render error = %v, want missing static namespace identity rejection:\n%s", err, output) + } +} + +//nolint:gocyclo // One render matrix verifies the coupled rollout, rollback, and uninstall invariants. +func TestStaticChartHarnessV1UpgradeDrainHookIsExistingDeploymentGated(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + args := []string{ + "--set-string", "controller.mode=harness-v1", + "--set", "store.persistence.enabled=true", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.upgradeDrain.timeout=9m", + "--set-string", "harnessV1.upgradeDrain.pollInterval=3s", + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + } + // A fresh installation has no live Deployment and must not emit a drain + // hook. The enabled revision must still persist its post-rollback abort hook because + // Helm executes hooks recorded in the historical rollback target. + fresh := requireHelmRender(t, args...) + if strings.Contains(fresh, "app.kubernetes.io/component: agent-harness-wrapper-drain") || + strings.Contains(fresh, "helm.sh/hook: pre-upgrade") { + t.Fatalf("fresh harness v1 render unexpectedly contains an upgrade drain hook:\n%s", fresh) + } + for _, marker := range []string{ + "app.kubernetes.io/component: agent-harness-wrapper-rollover-abort", + "helm.sh/hook: post-rollback", + "- abort-rollover", + `image: "ghcr.io/orka-agents/orka/agent-harness-wrapper@sha256:` + strings.Repeat("1", 64) + `"`, + `secretName: "harness-wrapper-auth"`, + `key: "token"`, + } { + if !strings.Contains(fresh, marker) { + t.Fatalf("fresh enabled revision rollback hook is missing %q:\n%s", marker, fresh) + } + } + if got := strings.Count(fresh, "helm.sh/hook: post-rollback"); got != 3 { + t.Fatalf("fresh enabled rollback hook annotation count = %d, want 3:\n%s", got, fresh) + } + + unknown, err := helmTemplateStaticChart(t, append(append([]string{}, args...), "--is-upgrade")...) + if err == nil { + t.Fatalf("upgrade without live controller or wrapper state rendered successfully:\n%s", unknown) + } + if !strings.Contains(unknown, "cannot determine the previously deployed harness v1 state during upgrade") { + t.Fatalf("unknown-state upgrade did not fail closed:\n%s", unknown) + } + + // Render the unchanged hook body from a copied chart with only lookup's + // result replaced, so the existing-Deployment branch remains Helm-validated. + hook := requireHarnessV1UpgradeDrainHookRender(t, false, args...) + for _, marker := range []string{ + "kind: NetworkPolicy", + "kind: Job", + "app.kubernetes.io/component: agent-harness-wrapper-rollover-drain", + "app.kubernetes.io/component: agent-harness-wrapper-delete-drain", + "helm.sh/hook: pre-upgrade,pre-rollback", + "helm.sh/hook: pre-delete", + `helm.sh/hook-weight: "-20"`, + `helm.sh/hook-weight: "-10"`, + "helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded", + "backoffLimit: 0", + "serviceAccountName: test-orka-agent-harness-wrapper", + "automountServiceAccountToken: false", + "runAsNonRoot: true", + "readOnlyRootFilesystem: true", + "drop: [ALL]", + `image: "registry.example/current-wrapper@sha256:` + strings.Repeat("2", 64) + `"`, + "imagePullPolicy: Always", + `command: ["/orka-agent-harness-wrapper"]`, + "- drain", + `- "--endpoint=https://test-orka-agent-harness-wrapper.orka-test.svc:8080"`, + "- --bearer-token-file=/var/run/orka/harness-wrapper-auth/token", + "- --ca-file=/var/run/orka/harness-wrapper-tls/ca.crt", + `- "--timeout=9m"`, + `- "--poll-interval=3s"`, + `secretName: "harness-wrapper-auth"`, + `key: "token"`, + "defaultMode: 0440", + } { + if !strings.Contains(hook, marker) { + t.Fatalf("harness v1 drain hook is missing %q:\n%s", marker, hook) + } + } + if got := strings.Count(hook, "helm.sh/hook: pre-upgrade,pre-rollback"); got != 3 { + t.Fatalf("rollover hook annotation count = %d, want 3:\n%s", got, hook) + } + if got := strings.Count(hook, "helm.sh/hook: pre-delete"); got != 3 { + t.Fatalf("pre-delete hook annotation count = %d, want 3:\n%s", got, hook) + } + if !regexp.MustCompile(`--next-generation=[a-f0-9]{64}`).MatchString(hook) { + t.Fatalf("rollover hook is missing its canonical replacement generation:\n%s", hook) + } + rolloverJob := requireRenderedDocument(t, hook, + "kind: Job", + "app.kubernetes.io/component: agent-harness-wrapper-rollover-drain", + ) + if strings.Contains(rolloverJob, "--controller-endpoint=") || + strings.Contains(rolloverJob, "--controller-token-file=") || + strings.Contains(rolloverJob, "serviceAccountToken:") { + t.Fatalf("ordinary wrapper rollover unexpectedly retired controller-side v1 admission:\n%s", rolloverJob) + } + deleteJob := requireRenderedDocument(t, hook, + "kind: Job", + "app.kubernetes.io/component: agent-harness-wrapper-delete-drain", + ) + if strings.Contains(deleteJob, "--controller-endpoint=") || + strings.Contains(deleteJob, "--controller-token-file=") || + strings.Contains(deleteJob, "serviceAccountToken:") { + t.Fatalf("uninstall drain unexpectedly coordinates a cross-mode controller retirement:\n%s", deleteJob) + } + for _, marker := range []string{ + "app.kubernetes.io/component: agent-harness-wrapper-rollover-abort", + "helm.sh/hook: post-rollback", + "- abort-rollover", + `image: "ghcr.io/orka-agents/orka/agent-harness-wrapper@sha256:` + strings.Repeat("1", 64) + `"`, + `secretName: "harness-wrapper-auth"`, + `key: "token"`, + } { + if !strings.Contains(hook, marker) { + t.Fatalf("changed-generation rollback hook is missing %q:\n%s", marker, hook) + } + } + if strings.Contains(hook, "/usr/local/bin/node") { + t.Fatalf("delete drain hook assumes an unavailable Node runtime:\n%s", hook) + } + if strings.Contains(hook, strings.Repeat("x", 32)) { + t.Fatalf("harness v1 drain hook rendered a raw bearer token:\n%s", hook) + } + + unchanged := requireHarnessV1UpgradeDrainHookRender(t, true, args...) + if strings.Contains(unchanged, "helm.sh/hook: pre-upgrade,pre-rollback") || + strings.Contains(unchanged, "agent-harness-wrapper-rollover-drain") { + t.Fatalf("unchanged wrapper Pod template unexpectedly triggered a rollover drain:\n%s", unchanged) + } + for _, marker := range []string{ + "app.kubernetes.io/component: agent-harness-wrapper-rollover-abort", + "helm.sh/hook: post-rollback", + "- abort-rollover", + `- "--endpoint=https://test-orka-agent-harness-wrapper.orka-test.svc:8080"`, + "- --bearer-token-file=/var/run/orka/harness-wrapper-auth/token", + "- --ca-file=/var/run/orka/harness-wrapper-tls/ca.crt", + `secretName: "harness-wrapper-auth"`, + `secretName: "harness-wrapper-tls"`, + `key: "token"`, + } { + if !strings.Contains(unchanged, marker) { + t.Fatalf("same-generation rollback hook is missing %q:\n%s", marker, unchanged) + } + } + if got := strings.Count(unchanged, "helm.sh/hook: post-rollback"); got != 3 { + t.Fatalf("rollback abort hook annotation count = %d, want 3:\n%s", got, unchanged) + } + if !regexp.MustCompile(`--expected-generation=[a-f0-9]{64}`).MatchString(unchanged) { + t.Fatalf("rollback abort hook is missing its exact live generation:\n%s", unchanged) + } + if !strings.Contains(unchanged, "helm.sh/hook: pre-delete") { + t.Fatalf("enabled release lost its uninstall drain hook:\n%s", unchanged) + } +} + +func TestStaticChartHarnessV1RejectsLiveAuthRotation(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + baseArgs := []string{ + "--set-string", "controller.mode=harness-v1", + "--set", "store.persistence.enabled=true", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + } + tests := []struct { + name string + state harnessV1UpgradeState + args []string + wantError string + }{ + { + name: "Secret source", + state: harnessV1UpgradeState{ + authSecret: "current-wrapper-auth", + authKey: "token", + }, + args: []string{ + "--set-string", "harnessV1.auth.existingSecret=next-wrapper-auth", + }, + wantError: "harnessV1.auth.existingSecret cannot change while the previously deployed " + + "harness v1 route remains enabled", + }, + { + name: "Secret key", + state: harnessV1UpgradeState{ + authSecret: "harness-wrapper-auth", + authKey: "current-token", + }, + args: []string{ + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.auth.tokenKey=next-token", + }, + wantError: "harnessV1.auth.tokenKey cannot change while the previously deployed harness v1 route remains enabled", + }, + { + name: "missing wrapper Secret source", + state: harnessV1UpgradeState{ + wrapperMissing: true, + controllerState: "enabled", + authSecret: "current-wrapper-auth", + authKey: "token", + }, + args: []string{ + "--set-string", "harnessV1.auth.existingSecret=next-wrapper-auth", + }, + wantError: "harnessV1.auth.existingSecret cannot change while the previously deployed " + + "harness v1 route remains enabled", + }, + { + name: "missing wrapper Secret key", + state: harnessV1UpgradeState{ + wrapperMissing: true, + controllerState: "enabled", + authSecret: "harness-wrapper-auth", + authKey: "current-token", + }, + args: []string{ + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.auth.tokenKey=next-token", + }, + wantError: "harnessV1.auth.tokenKey cannot change while the previously deployed harness v1 route remains enabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append(append([]string{}, baseArgs...), tt.args...) + output, err := helmTemplateHarnessV1UpgradeDrainHook(t, tt.state, args...) + if err == nil { + t.Fatalf("unsafe live auth rotation rendered successfully:\n%s", output) + } + if !strings.Contains(output, tt.wantError) { + t.Fatalf("helm template error is missing %q:\n%s", tt.wantError, output) + } + }) + } +} + +func TestStaticChartHarnessV1TLSRotationUsesDrainedRollover(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + args := []string{ + "--set-string", "controller.mode=harness-v1", + "--set", "store.persistence.enabled=true", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=next-wrapper-tls", + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + } + rendered, err := helmTemplateHarnessV1UpgradeDrainHook(t, harnessV1UpgradeState{ + authSecret: "harness-wrapper-auth", + authKey: "token", + tlsSecret: "current-wrapper-tls", + }, args...) + if err != nil { + t.Fatalf("TLS Secret rotation failed to render drained rollover: %v\n%s", err, rendered) + } + rollover := requireRenderedDocument(t, rendered, + "kind: Job", + "app.kubernetes.io/component: agent-harness-wrapper-rollover-drain", + ) + if !strings.Contains(rollover, `secretName: "current-wrapper-tls"`) || + strings.Contains(rollover, `secretName: "next-wrapper-tls"`) { + t.Fatalf("rollover drain did not retain the live wrapper TLS authority:\n%s", rollover) + } + abort := requireRenderedDocument(t, rendered, + "kind: Job", + "app.kubernetes.io/component: agent-harness-wrapper-rollover-abort", + ) + if !strings.Contains(abort, `secretName: "next-wrapper-tls"`) { + t.Fatalf("rollback abort did not use the target revision TLS authority:\n%s", abort) + } +} + +func TestStaticChartHarnessV1GenerationTracksOnlyPodTemplate(t *testing.T) { + digest := "sha256:" + strings.Repeat("3", 64) + args := []string{ + "--set-string", "controller.mode=harness-v1", + "--set", "store.persistence.enabled=true", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + "--show-only", "templates/harness-wrapper-deployment.yaml", + } + first := requireHelmRender(t, append(append([]string{}, args...), "--set", "controller.apiPort=8080")...) + second := requireHelmRender(t, append(append([]string{}, args...), "--set", "controller.apiPort=9090")...) + firstGeneration := harnessV1RenderedGeneration(t, first) + if secondGeneration := harnessV1RenderedGeneration(t, second); secondGeneration != firstGeneration { + t.Fatalf("unrelated controller value changed wrapper generation: %s != %s", secondGeneration, firstGeneration) + } + changedArgs := append(append([]string{}, args...), "--set", "harnessV1.codexSandboxMode=read-only") + changed := requireHelmRender(t, changedArgs...) + if changedGeneration := harnessV1RenderedGeneration(t, changed); changedGeneration == firstGeneration { + t.Fatalf("wrapper Pod-template change preserved generation %s", changedGeneration) + } + rotated := requireHelmRender(t, append(append([]string{}, args...), + "--set-string", "harnessV1.tls.rolloutNonce=certificate-2")...) + if rotatedGeneration := harnessV1RenderedGeneration(t, rotated); rotatedGeneration == firstGeneration { + t.Fatalf("TLS rollout nonce preserved wrapper generation %s", rotatedGeneration) + } +} + +func TestStaticChartHarnessV1UsesOnlyExistingSecretReferences(t *testing.T) { + digest := "sha256:" + strings.Repeat("4", 64) + args := []string{ + "--set-string", "controller.mode=harness-v1", + "--set", "store.persistence.enabled=true", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + } + rendered := requireHelmRender(t, args...) + if !strings.Contains(rendered, "secretName: \"harness-wrapper-auth\"") { + t.Fatalf("wrapper did not mount the configured existing Secret:\n%s", rendered) + } + if strings.Contains(rendered, "# Source: orka/templates/harness-wrapper-secret.yaml") { + t.Fatalf("chart rendered a managed harness wrapper Secret:\n%s", rendered) + } +} + +func TestStaticChartRejectsUnsafeHarnessV1Values(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + tests := []struct { + name string + args []string + wantError string + }{ + { + name: "missing digest", + args: []string{ + "--set-string", "controller.mode=harness-v1", + }, + wantError: "harnessV1.image.digest must be a sha256 digest when controller.mode=harness-v1", + }, + { + name: "mutable tag-shaped digest", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=latest", + }, + wantError: "harnessV1.image.digest must be a sha256 digest when controller.mode=harness-v1", + }, + { + name: "Substrate workspace provider", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set", "controller.substrate.enabled=true", + }, + wantError: "controller.substrate.enabled is unsupported when controller.mode=harness-v1", + }, + { + name: "inline bearer token", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.auth.token=" + strings.Repeat("x", 32), + }, + wantError: "harnessV1.auth.token is unsupported", + }, + { + name: "short bearer token", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.token=too-short", + }, + wantError: "harnessV1.auth.token is unsupported", + }, + { + name: "missing existing auth Secret", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + }, + wantError: "harnessV1.auth.existingSecret is required when controller.mode=harness-v1", + }, + { + name: "missing existing TLS Secret", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + }, + wantError: "harnessV1.tls.existingSecret is required when controller.mode=harness-v1", + }, + { + name: "shared auth and TLS Secret", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-auth", + }, + wantError: "harnessV1.tls.existingSecret must differ from harnessV1.auth.existingSecret", + }, + { + name: "missing ledger capacity", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.ledger.size=", + }, + wantError: "harnessV1.ledger.size is required when controller.mode=harness-v1", + }, + { + name: "missing ledger retention", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.ledger.retention=", + }, + wantError: "harnessV1.ledger.retention is required when controller.mode=harness-v1", + }, + { + name: "zero ledger retention", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.ledger.retention=0s", + }, + wantError: "harnessV1.ledger.retention must be a positive Go duration", + }, + { + name: "malformed ledger retention", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.ledger.retention=immediate", + }, + wantError: "harnessV1.ledger.retention must be a positive Go duration", + }, + { + name: "negative ledger retention", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.ledger.retention=-1h", + }, + wantError: "harnessV1.ledger.retention must be a positive Go duration", + }, + { + name: "parallel dispatch workers", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set", "harnessV1.dispatch.workers=2", + }, + wantError: "harnessV1.dispatch.workers must be exactly 1 when controller.mode=harness-v1", + }, + { + name: "unsupported Codex sandbox", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.codexSandboxMode=unrestricted", + }, + wantError: "harnessV1.codexSandboxMode must be read-only, workspace-write, or danger-full-access", + }, + { + name: "invalid upgrade drain timeout", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.upgradeDrain.timeout=0s", + }, + wantError: "harnessV1.upgradeDrain.timeout must be a positive Go duration", + }, + { + name: "invalid upgrade drain poll interval", + args: []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.upgradeDrain.pollInterval=immediate", + }, + wantError: "harnessV1.upgradeDrain.pollInterval must be a positive Go duration", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChart(t, tt.args...) + if err == nil { + t.Fatalf("helm template unexpectedly accepted unsafe harness v1 values") + } + if !strings.Contains(output, tt.wantError) { + t.Fatalf("helm template error does not contain %q:\n%s", tt.wantError, output) + } + }) + } +} + +func TestStaticChartHarnessV1EnabledRenderIsIsolatedAndDurable(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + args := []string{ + "--set-string", "controller.mode=harness-v1", + "--set-string", "harnessV1.image.digest=" + digest, + "--set-string", "harnessV1.auth.existingSecret=harness-wrapper-auth", + "--set-string", "harnessV1.tls.existingSecret=harness-wrapper-tls", + "--set-string", "harnessV1.tls.rolloutNonce=certificate-1", + "--set-string", "harnessV1.ledger.retention=168h", + "--set-string", "service.port=18080", + "--set-string", "controller.apiPort=18081", + "--set-string", "controller.agentExecutionSnapshot.existingSecret=snapshot-key", + "--set-string", "controller.agentExecutionSnapshot.key=encryption-key", + } + controllerDeployment := requireHelmRender(t, append(args, "--show-only", "templates/deployment.yaml")...) + for _, marker := range []string{ + "--harness-v1-dispatch-workers=1", + "--harness-v1-endpoint=https://test-orka-agent-harness-wrapper.orka-test.svc:8080", + "--harness-v1-ca-file=/var/run/orka/harness-v1-tls/ca.crt", + "mountPath: /var/run/orka/harness-v1-tls", + `secretName: "harness-wrapper-tls"`, + "key: ca.crt", + `orka.ai/harness-v1-tls-rollout-nonce: "certificate-1"`, + } { + if !strings.Contains(controllerDeployment, marker) { + t.Fatalf("controller Deployment is missing harness v1 TLS marker %q:\n%s", marker, controllerDeployment) + } + } + + deployment := requireHelmRender(t, append(args, "--show-only", "templates/harness-wrapper-deployment.yaml")...) + for _, marker := range []string{ + "replicas: 1", + "strategy:\n type: Recreate", + `image: "ghcr.io/orka-agents/orka/agent-harness-wrapper@` + digest + `"`, + "serviceAccountName: test-orka-agent-harness-wrapper", + "automountServiceAccountToken: false", + "name: https", + "name: ORKA_CONTROLLER_URL", + "value: http://test-orka.orka-test.svc:18080", + "name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE", + "value: /var/run/orka/harness-wrapper-auth/token", + "name: ORKA_HARNESS_WRAPPER_TLS_CERT_FILE", + "value: /var/run/orka/harness-wrapper-tls/tls.crt", + "name: ORKA_HARNESS_WRAPPER_TLS_KEY_FILE", + "value: /var/run/orka/harness-wrapper-tls/tls.key", + "scheme: HTTPS", + "name: ORKA_HARNESS_WRAPPER_ADMISSION_LEDGER_PATH", + "value: /var/lib/orka/harness-v1/admission-ledger.db", + "name: ORKA_HARNESS_WRAPPER_LEDGER_GENERATION", + "name: ORKA_HARNESS_WRAPPER_LEDGER_RETENTION", + `value: "168h"`, + "mountPath: /var/lib/orka/harness-v1", + "claimName: test-orka-harness-v1-ledger", + `secretName: "harness-wrapper-auth"`, + `secretName: "harness-wrapper-tls"`, + "name: controller-api-token", + "mountPath: /var/run/secrets/kubernetes.io/serviceaccount", + "projected:", + "defaultMode: 0400", + "serviceAccountToken:", + "path: token", + "expirationSeconds: 3600", + `orka.ai/harness-v1-tls-rollout-nonce: "certificate-1"`, + "key: tls.crt", + "key: tls.key", + "key: ca.crt", + } { + if !strings.Contains(deployment, marker) { + t.Fatalf("harness v1 Deployment is missing %q:\n%s", marker, deployment) + } + } + for _, forbidden := range []string{ + "ORKA_SA_TOKEN_PATH", + "upload-token", + "GIT_TOKEN", + "GITHUB_TOKEN", + "ORKA_WORKSPACE_PUBLISHER", + "provider-auth", + } { + if strings.Contains(deployment, forbidden) { + t.Fatalf("harness v1 Deployment contains forbidden ambient credential surface %q:\n%s", forbidden, deployment) + } + } + + for template, markers := range map[string][]string{ + "templates/harness-wrapper-service.yaml": { + "kind: Service", + "name: test-orka-agent-harness-wrapper", + "name: https", + "port: 8080", + "targetPort: https", + }, + "templates/harness-wrapper-serviceaccount.yaml": { + "kind: ServiceAccount", + "automountServiceAccountToken: false", + }, + "templates/harness-wrapper-pvc.yaml": { + "kind: PersistentVolumeClaim", + "name: test-orka-harness-v1-ledger", + "helm.sh/resource-policy: keep", + "storage: 1Gi", + }, + "templates/harness-wrapper-networkpolicy.yaml": { + "kind: NetworkPolicy", + "policyTypes: [Ingress, Egress]", + "egress:\n" + + " - to:\n" + + " - podSelector:\n" + + " matchLabels:\n" + + " app.kubernetes.io/name: orka\n" + + " app.kubernetes.io/instance: test\n" + + " app.kubernetes.io/component: controller\n" + + " ports:\n" + + " - protocol: TCP\n" + + " port: 18081", + "kubernetes.io/metadata.name: kube-system", + "cidr: 0.0.0.0/0", + "cidr: ::/0", + "port: 443", + }, + } { + rendered := requireHelmRender(t, append(args, "--show-only", template)...) + for _, marker := range markers { + if !strings.Contains(rendered, marker) { + t.Fatalf("%s is missing %q:\n%s", template, marker, rendered) + } + } + } + harnessV1RenderedGeneration(t, deployment) +} + +func TestStaticChartRejectsUnsupportedProviderProxyOverrides(t *testing.T) { + tests := []struct { + name string + args []string + wantError string + }{ + { + name: "different namespace", + args: []string{ + "--set", "providerProxy.enabled=true", + "--set-string", "controller.acpRuntime.providerProxyNamespace=other-system", + }, + wantError: "controller.acpRuntime.providerProxyNamespace must be empty or match the Helm release namespace", + }, + { + name: "different upstream host", + args: []string{ + "--set", "providerProxy.enabled=true", + "--set-string", "providerProxy.upstreamBaseURL=http://other.vekil-system.svc:1337", + }, + wantError: "providerProxy.upstreamBaseURL must be http://vekil.vekil-system.svc:1337", + }, + { + name: "different upstream port", + args: []string{ + "--set", "providerProxy.enabled=true", + "--set-string", "providerProxy.upstreamBaseURL=http://vekil.vekil-system.svc:8080", + }, + wantError: "providerProxy.upstreamBaseURL must be http://vekil.vekil-system.svc:1337", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := helmTemplateStaticChart(t, tt.args...) + if err == nil { + t.Fatalf("helm template unexpectedly accepted unsupported provider proxy override:\n%s", output) + } + if !strings.Contains(output, tt.wantError) { + t.Fatalf("helm template error does not contain %q:\n%s", tt.wantError, output) + } + }) + } +} + +func TestStaticChartUsesRegisteredContextTokenTTSEndpointFlag(t *testing.T) { + rendered := requireHelmRender(t, + "--set-string", "controller.contextToken.tts.endpoint=https://tts.example.test/oauth/token", + "--show-only", "templates/deployment.yaml", + ) + + if !strings.Contains(rendered, "--context-token-tts-endpoint=https://tts.example.test/oauth/token") { + t.Fatalf("controller deployment is missing the registered TTS endpoint flag:\n%s", rendered) + } + if strings.Contains(rendered, "--context-token-tts-url=") { + t.Fatalf("controller deployment rendered the unregistered TTS URL flag:\n%s", rendered) + } +} + +func TestStaticChartRendersByteLimitsAsDecimalIntegers(t *testing.T) { + publisher := requireHelmRender(t, "--show-only", "templates/publisher-deployment.yaml") + scmProxy := requireHelmRender(t, "--show-only", "templates/scm-egress-proxy-deployment.yaml") + + if !strings.Contains(publisher, `value: "4194304"`) { + t.Fatalf("publisher deployment did not render max response bytes as a decimal integer:\n%s", publisher) + } + for _, want := range []string{ + `--max-request-header-bytes=32768`, + `--max-response-header-bytes=65536`, + `--max-request-bytes=4194304`, + `--max-response-bytes=8388608`, + `--max-tunnel-bytes=1073741824`, + } { + if !strings.Contains(scmProxy, want) { + t.Fatalf("SCM proxy deployment is missing decimal byte limit %q:\n%s", want, scmProxy) + } + } + if strings.Contains(publisher, "e+") || strings.Contains(scmProxy, "e+") { + t.Fatalf("byte limit render used scientific notation:\npublisher:\n%s\nSCM proxy:\n%s", publisher, scmProxy) + } +} + +func TestStaticChartAuthRolloutNoncesTargetOnlyCredentialConsumers(t *testing.T) { + args := []string{ + "--set-string", "publisher.auth.rolloutNonce=publisher-v2", + "--set-string", "scmEgressProxy.auth.rolloutNonce=scm-v3", + "--set-string", "publisher.auth.controllerToken=publisher-secret-material", + "--set-string", "scmEgressProxy.auth.token=scm-secret-material-0123456789abcd", + } + + controller := requireHelmRender(t, append(args, "--show-only", "templates/deployment.yaml")...) + publisher := requireHelmRender(t, append(args, "--show-only", "templates/publisher-deployment.yaml")...) + scmProxy := requireHelmRender(t, append(args, "--show-only", "templates/scm-egress-proxy-deployment.yaml")...) + providerProxy := requireHelmRender(t, + "--set", "providerProxy.enabled=true", + "--set-string", "publisher.auth.rolloutNonce=publisher-v2", + "--set-string", "scmEgressProxy.auth.rolloutNonce=scm-v3", + "--show-only", "templates/provider-proxy-deployment.yaml", + ) + + publisherNonce := `orka.ai/publisher-auth-rollout-nonce: "publisher-v2"` + scmNonce := `orka.ai/scm-egress-proxy-auth-rollout-nonce: "scm-v3"` + if !strings.Contains(controller, publisherNonce) || strings.Contains(controller, scmNonce) { + t.Fatalf("controller rollout annotations are incorrect:\n%s", controller) + } + if !strings.Contains(publisher, publisherNonce) || !strings.Contains(publisher, scmNonce) { + t.Fatalf("publisher rollout annotations are incorrect:\n%s", publisher) + } + if strings.Contains(scmProxy, publisherNonce) || !strings.Contains(scmProxy, scmNonce) { + t.Fatalf("SCM proxy rollout annotations are incorrect:\n%s", scmProxy) + } + if strings.Contains(providerProxy, publisherNonce) || strings.Contains(providerProxy, scmNonce) { + t.Fatalf("provider proxy received unrelated auth rollout annotations:\n%s", providerProxy) + } + for name, rendered := range map[string]string{ + "controller": controller, + "publisher": publisher, + "SCM proxy": scmProxy, + } { + if strings.Contains(rendered, "secret-material") { + t.Fatalf("%s Pod template annotation render exposed Secret material", name) + } + } +} diff --git a/cmd/build/helmify/scm_egress_proxy_security_test.go b/cmd/build/helmify/scm_egress_proxy_security_test.go new file mode 100644 index 000000000..db38a4db9 --- /dev/null +++ b/cmd/build/helmify/scm_egress_proxy_security_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "sigs.k8s.io/yaml" +) + +const serviceAccountAutomountDisabledMarker = "automountServiceAccountToken: false" + +func TestSCMEgressProxyStandaloneWorkloadHasNoKubernetesCredential(t *testing.T) { + root := filepath.Join("..", "..", "..", "config", "scm-egress-proxy") + deploymentManifest, err := os.ReadFile(filepath.Join(root, "deployment.yaml")) + if err != nil { + t.Fatalf("read SCM proxy Deployment: %v", err) + } + serviceAccountManifest, err := os.ReadFile(filepath.Join(root, "serviceaccount.yaml")) + if err != nil { + t.Fatalf("read SCM proxy ServiceAccount: %v", err) + } + + var deployment appsv1.Deployment + if err := yaml.Unmarshal(deploymentManifest, &deployment); err != nil { + t.Fatalf("decode SCM proxy Deployment: %v", err) + } + assertSCMProxyPodCredentialIsolation(t, deployment.Spec.Template.Spec) + assertSCMProxyPodSecurityContext(t, deployment.Spec.Template.Spec) + + var serviceAccount corev1.ServiceAccount + if err := yaml.Unmarshal(serviceAccountManifest, &serviceAccount); err != nil { + t.Fatalf("decode SCM proxy ServiceAccount: %v", err) + } + if serviceAccount.AutomountServiceAccountToken == nil || *serviceAccount.AutomountServiceAccountToken { + t.Fatal("SCM proxy ServiceAccount must disable token mounting") + } +} + +func assertSCMProxyPodCredentialIsolation(t *testing.T, pod corev1.PodSpec) { + t.Helper() + if pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken { + t.Fatal("SCM proxy Pod must disable service-account token mounting") + } + if pod.EnableServiceLinks == nil || *pod.EnableServiceLinks { + t.Fatal("SCM proxy Pod must disable unnecessary service links") + } + for _, volume := range pod.Volumes { + if volume.Projected == nil { + continue + } + for _, source := range volume.Projected.Sources { + if source.ServiceAccountToken != nil { + t.Fatalf("SCM proxy Pod contains a projected Kubernetes credential volume %q", volume.Name) + } + } + } +} + +func assertSCMProxyPodSecurityContext(t *testing.T, pod corev1.PodSpec) { + t.Helper() + if pod.SecurityContext == nil || pod.SecurityContext.RunAsNonRoot == nil || + !*pod.SecurityContext.RunAsNonRoot || pod.SecurityContext.RunAsUser == nil || + *pod.SecurityContext.RunAsUser != 65532 || pod.SecurityContext.SeccompProfile == nil || + pod.SecurityContext.SeccompProfile.Type != corev1.SeccompProfileTypeRuntimeDefault { + t.Fatal("SCM proxy Pod must run as the non-root distroless user with runtime-default seccomp") + } + if len(pod.Containers) != 1 { + t.Fatalf("SCM proxy container count = %d, want 1", len(pod.Containers)) + } + container := pod.Containers[0] + if container.SecurityContext == nil || + container.SecurityContext.AllowPrivilegeEscalation == nil || + *container.SecurityContext.AllowPrivilegeEscalation || + container.SecurityContext.ReadOnlyRootFilesystem == nil || + !*container.SecurityContext.ReadOnlyRootFilesystem { + t.Fatal("SCM proxy container must forbid privilege escalation and use a read-only root filesystem") + } + if container.SecurityContext.Capabilities == nil || + len(container.SecurityContext.Capabilities.Drop) != 1 || + container.SecurityContext.Capabilities.Drop[0] != corev1.Capability("ALL") { + t.Fatalf("SCM proxy dropped capabilities = %v, want [ALL]", container.SecurityContext.Capabilities) + } +} + +func TestSCMEgressProxyStandaloneNetworkPolicyRetainsDirectPrivateAddressDefense(t *testing.T) { + manifestPath := filepath.Join("..", "..", "..", "config", "scm-egress-proxy", "networkpolicy.yaml") + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read SCM proxy NetworkPolicy: %v", err) + } + var policy networkingv1.NetworkPolicy + if err := yaml.Unmarshal(manifest, &policy); err != nil { + t.Fatalf("decode SCM proxy NetworkPolicy: %v", err) + } + + for _, rule := range policy.Spec.Egress { + for _, peer := range rule.To { + if peer.IPBlock == nil || peer.IPBlock.CIDR != "0.0.0.0/0" { + continue + } + exclusions := make(map[string]bool, len(peer.IPBlock.Except)) + for _, exclusion := range peer.IPBlock.Except { + exclusions[exclusion] = true + } + for _, required := range []string{ + "10.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + } { + if !exclusions[required] { + t.Errorf("SCM proxy IPv4 egress exclusions are missing %s", required) + } + } + return + } + } + t.Fatal("SCM proxy NetworkPolicy is missing its public IPv4 egress block") +} + +func TestStaticChartSCMEgressProxyWorkloadHasNoKubernetesCredential(t *testing.T) { + rendered := requireHelmRender(t, "--show-only", "templates/scm-egress-proxy-deployment.yaml") + for _, required := range []string{ + serviceAccountAutomountDisabledMarker, + "enableServiceLinks: false", + "runAsNonRoot: true", + "readOnlyRootFilesystem: true", + "capabilities: {drop: [ALL]}", + } { + if !strings.Contains(rendered, required) { + t.Fatalf("SCM proxy Deployment is missing hardening marker %q:\n%s", required, rendered) + } + } + for _, forbidden := range []string{ + "serviceAccountToken:", + "/var/run/secrets/kubernetes.io/serviceaccount", + } { + if strings.Contains(rendered, forbidden) { + t.Fatalf("SCM proxy Deployment contains Kubernetes credential surface %q:\n%s", forbidden, rendered) + } + } + serviceAccount := requireHelmRender(t, "--show-only", "templates/scm-egress-proxy-serviceaccount.yaml") + if !strings.Contains(serviceAccount, serviceAccountAutomountDisabledMarker) { + t.Fatalf("SCM proxy ServiceAccount permits token mounting:\n%s", serviceAccount) + } +} diff --git a/cmd/build/helmify/static/README.md b/cmd/build/helmify/static/README.md index ef3c5a383..270dc54fd 100644 --- a/cmd/build/helmify/static/README.md +++ b/cmd/build/helmify/static/README.md @@ -2,33 +2,171 @@ This chart is generated from `cmd/build/helmify`; edit the generator inputs and run `make manifests` rather than editing generated chart copies directly. It -packages all thirteen canonical Orka CRDs under `crds/`. +packages all 26 canonical Orka CRDs under `crds/`. ## Fresh install -A normal install creates the CRDs before the templated release resources: +A normal `harness-v2` install requires Vekil to be running in `vekil-system`, +immutable controller and Publisher image digests, and two operator-managed +Secrets. Prepare: + +- a snapshot key file containing exactly 32 random bytes; +- a webhook serving certificate and private key whose certificate is valid for + `orka-webhook.orka-system.svc`, plus its PEM CA certificate; and +- `CONTROLLER_DIGEST` and `PUBLISHER_DIGEST` values in + `sha256:<64 lowercase hexadecimal characters>` form. + +The following creates the namespace and required Secrets without putting key +material in Helm values or command-line arguments, then installs the CRDs and +release resources. Replace the file paths and digest placeholders first: ```bash +set -euo pipefail + +: "${SNAPSHOT_KEY_FILE:?set SNAPSHOT_KEY_FILE to the 32-byte key file}" +: "${WEBHOOK_CERT_FILE:?set WEBHOOK_CERT_FILE to the serving certificate}" +: "${WEBHOOK_PRIVATE_KEY_FILE:?set WEBHOOK_PRIVATE_KEY_FILE to the private key}" +: "${WEBHOOK_CA_FILE:?set WEBHOOK_CA_FILE to the CA certificate}" +: "${CONTROLLER_DIGEST:?set CONTROLLER_DIGEST to sha256:<64 lowercase hex>}" +: "${PUBLISHER_DIGEST:?set PUBLISHER_DIGEST to sha256:<64 lowercase hex>}" + +kubectl create -f - <<'EOF' +apiVersion: v1 +kind: Namespace +metadata: + name: orka-system + labels: + orka.ai/controller-mode: harness-v2 +EOF +kubectl -n orka-system create secret generic agent-execution-snapshot-key \ + --from-file=snapshot-key="${SNAPSHOT_KEY_FILE}" +kubectl -n orka-system create secret generic orka-webhook-tls \ + --type=kubernetes.io/tls \ + --from-file=tls.crt="${WEBHOOK_CERT_FILE}" \ + --from-file=tls.key="${WEBHOOK_PRIVATE_KEY_FILE}" \ + --from-file=ca.crt="${WEBHOOK_CA_FILE}" + +WEBHOOK_CA_BUNDLE="$(kubectl -n orka-system get secret orka-webhook-tls \ + -o jsonpath='{.data.ca\.crt}')" + helm install orka charts/orka \ --namespace orka-system \ - --create-namespace \ + --set controller.mode=harness-v2 \ + --set controller.watchNamespace=orka-system \ + --set-string controller.image.digest="${CONTROLLER_DIGEST}" \ + --set-string publisher.image.digest="${PUBLISHER_DIGEST}" \ + --set-string controller.agentExecutionSnapshot.existingSecret=agent-execution-snapshot-key \ + --set-string controller.agentExecutionSnapshot.key=snapshot-key \ + --set-string webhooks.tls.existingSecret=orka-webhook-tls \ + --set-string webhooks.caBundle="${WEBHOOK_CA_BUNDLE}" \ + --set providerProxy.enabled=true \ --wait ``` +The chart installs the exact cross-namespace ingress policy for Vekil. The +chart-managed provider proxy itself always runs in the Helm release namespace. Leave +`controller.acpRuntime.providerProxyNamespace` empty or set it to that release +namespace. The only supported upstream is +`http://vekil.vekil-system.svc:1337` (an optional trailing slash is normalized); +alternate hosts, namespaces, and ports are rejected because the chart does not +create matching NetworkPolicies. + +`service.port` is the controller Service port used by controller and Publisher Service URLs. `controller.apiPort` is only the controller container listener and Service target port. + +### SCM proxy NetworkPolicy portability boundary + +The SCM proxy NetworkPolicy excludes RFC 1918 and reserved address ranges, but +Kubernetes does not define whether Service destination NAT runs before or +after `ipBlock` evaluation. Some CNI and cloud combinations can therefore +reach the `kubernetes.default` transport through its ClusterIP despite those +exclusions. This does not grant API authorization: the SCM proxy Pod and +ServiceAccount do not mount a service-account token, service links are disabled, +Orka grants that identity no API RBAC, and the proxy refuses to start if the +conventional Kubernetes service-account token path exists. The proxy itself +accepts only exact configured SCM hostnames and rejects non-public DNS answers +and connected peers. Clusters requiring TCP-level API denial must add and +validate a CNI- or cloud-native pre-DNAT or Service-aware egress control; +standard NetworkPolicy cannot guarantee this portably. + +### Coordinated authentication Secret rotation + +The Publisher and SCM egress proxy read their authentication material at process startup. Rotate each Secret and its non-secret rollout marker in the same Helm upgrade: + +- When rotating `publisher.auth.existingSecret` (or the chart-managed publisher auth values), bump `publisher.auth.rolloutNonce`. The marker is added only to the controller and Publisher Pod templates so both restart onto the same credential generation. +- When rotating `scmEgressProxy.auth.existingSecret` (or the chart-managed SCM proxy token), bump `scmEgressProxy.auth.rolloutNonce`. The marker is added only to the Publisher and SCM proxy Pod templates. + +The nonce is a revision label, not a credential. Never put Secret content in it. A coordinated upgrade may briefly fail closed while Pods roll, but it avoids an indefinite split generation. + +The harness-v1 wrapper likewise keeps execution authority and transport +material separate. `harnessV1.auth.existingSecret` contains only the bearer +token and is immutable while v1 work exists. `harnessV1.tls.existingSecret` +contains `tls.crt`, `tls.key`, and `ca.crt`. A TLS Secret name change is a +wrapper Pod-template change and automatically uses the existing drained +rollover. For same-name certificate renewal, update the TLS Secret and bump +`harnessV1.tls.rolloutNonce` in the Helm upgrade; the hook drains the live +wrapper before both wrapper and controller restart. Keep the updated `ca.crt` +able to verify the certificate currently being served during that drain, or +rotate to a versioned TLS Secret so the hook can mount the prior CA. + CRDs are cluster-scoped and shared by every Orka release. Use `--skip-crds` only when a designated platform or GitOps workflow already manages compatible Orka CRDs for the cluster. +## Static harness mode + +Every release selects exactly one controller mode: `harness-v1` or +`harness-v2`. `dual`, `auto`, and `harness-v1-drain` are rejected. Each release +also requires a distinct, non-empty `controller.watchNamespace` labeled with +the matching mode: + +```bash +kubectl create -f - <<'EOF' +apiVersion: v1 +kind: Namespace +metadata: + name: orka-v2-system + labels: + orka.ai/controller-mode: harness-v2 +EOF + +helm install orka-v2 charts/orka \ + --namespace orka-v2-system \ + --set controller.mode=harness-v2 \ + --set controller.watchNamespace=orka-v2-system +``` + +The mode is an installation identity, not an upgrade toggle. Never change a +release from v1 to v2 in place or reuse its PVC, SQLite store, ledger, Session, +or Task identities under the other mode. + +A v1 and v2 release may share a cluster only when their release/watch +namespaces, Services, ServiceAccounts/RBAC, Leases, stores, Secrets, and +data-plane resources are disjoint. The chart intentionally requires +`controller.watchNamespace` to equal the Helm release namespace. The v2 +release must also have its own runtime namespace. Install the shared compatible +CRDs and common admission resources through one designated owner; install the +second release with `--skip-crds`. + Controller Services, worker ServiceAccounts, and worker RBAC are scoped to the Helm release name. Run only one Orka controller release per namespace. If a cluster has multiple releases, every release (including the first) must use a cluster-unique release name or `fullnameOverride`, a separate controller -namespace, and a distinct, non-empty `controller.watchNamespace`. Do not mix a -cluster-wide watcher with namespace-scoped releases: gateway admission policies -would overlap. All releases share the same cluster-scoped CRDs. +namespace, and a distinct, non-empty `controller.watchNamespace`. Cluster-wide +watchers are rejected. All releases share the same cluster-scoped CRDs, and +cluster-scoped gateway/workspace ownership belongs only to the v2 release. ## Upgrade +An in-place controller upgrade is supported only when the release namespace +already carries the same static mode claim and any live controller declares +that mode and watch namespace. A deleted controller can be recreated only +under that retained same-mode namespace claim. A pre-static controller that +implicitly enabled ACP is not a supported `harness-v2` upgrade source because +its accepted attempts may lack the immutable execution authority required for +recovery. Settle or retire that installation and install static `harness-v2` +as a new release and namespace. The chart rejects missing, opposite-mode, and +legacy identity before rendering upgrade resources. + Helm installs files from `crds/` only during installation. It does not create or update them during `helm upgrade`, including when upgrading from an older Orka chart that installed no CRDs. @@ -76,7 +214,7 @@ A matching Orka source checkout provides the same guarded flow as competing CRD apply workflows for the same cluster. If another system owns the CRDs, perform the CRD-first step through that system, -wait for all thirteen CRDs to become `Established`, and then upgrade Orka. +wait for all 26 CRDs to become `Established`, and then upgrade Orka. If a previous release was uninstalled, update its retained CRDs first and install the replacement release with `--skip-crds`. diff --git a/cmd/build/helmify/static/templates/NOTES.txt b/cmd/build/helmify/static/templates/NOTES.txt index 46f8b71ee..8bb052154 100644 --- a/cmd/build/helmify/static/templates/NOTES.txt +++ b/cmd/build/helmify/static/templates/NOTES.txt @@ -1,10 +1,10 @@ Orka has been installed. -Controller: {{ include "orka.fullname" . }}-controller +Controller: {{ include "orka.controllerName" . }} Namespace: {{ .Release.Namespace }} CRD lifecycle: - - A fresh install creates Orka's thirteen cluster-scoped CRDs unless --skip-crds is used. + - A fresh install creates Orka's 26 cluster-scoped CRDs unless --skip-crds is used. - Helm does not update CRDs during helm upgrade. Apply the CRDs from the exact target chart before upgrading the release. - helm uninstall retains the CRDs and all Orka custom resources. @@ -15,7 +15,7 @@ Upgrade guidance: helm show readme ⚠️ STORAGE WARNING: SQLite store is using ephemeral storage (emptyDir). Task results and session data will be LOST on pod restart. -{{- if .Values.controller.gateway.enabled }} +{{- if and (eq .Values.controller.mode "harness-v2") .Values.controller.gateway.enabled }} Gateway acknowledgements, deduplication, and queued deliveries are also NOT durable. {{- end }} For production use, enable persistent storage: @@ -25,7 +25,7 @@ Upgrade guidance: helm show readme {{- else }} -✅ SQLite store is using persistent storage (PVC: {{ include "orka.fullname" . }}-store). +✅ SQLite store is using persistent storage (PVC: {{ include "orka.storeName" . }}). Data will survive pod restarts. {{- end }} diff --git a/cmd/build/helmify/static/templates/_helpers.tpl b/cmd/build/helmify/static/templates/_helpers.tpl index 7764f35bf..361e20266 100644 --- a/cmd/build/helmify/static/templates/_helpers.tpl +++ b/cmd/build/helmify/static/templates/_helpers.tpl @@ -79,30 +79,793 @@ suffix so long release names cannot collapse all trust tiers to one name. {{- end }} {{/* -Create release-scoped harness-wrapper names while reserving room for suffixes -that must remain valid DNS labels (notably the Service name). +Create release-scoped harness v1 wrapper names while reserving room for the +longest suffix so names remain valid DNS labels for long Helm release names. */}} -{{- define "orka.harnessWrapperName" -}} +{{- define "orka.harnessV1Name" -}} {{- printf "%s-agent-harness-wrapper" (include "orka.fullname" . | trunc 41 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} {{- end }} -{{- define "orka.harnessWrapperAuthSecretName" -}} -{{- printf "%s-harness-wrapper-auth" (include "orka.fullname" . | trunc 42 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- define "orka.harnessV1LedgerName" -}} +{{- printf "%s-harness-v1-ledger" (include "orka.fullname" . | trunc 45 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} {{- end }} +{{- define "orka.harnessV1DrainName" -}} +{{- printf "%s-drain" (include "orka.harnessV1Name" . | trunc 57 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.harnessV1DrainEgressName" -}} +{{- printf "%s-egress" (include "orka.harnessV1DrainName" . | trunc 56 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.harnessV1AbortName" -}} +{{- printf "%s-abort" (include "orka.harnessV1Name" . | trunc 57 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.harnessV1AbortEgressName" -}} +{{- printf "%s-egress" (include "orka.harnessV1AbortName" . | trunc 56 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.harnessV1DeleteDrainName" -}} +{{- printf "%s-delete" (include "orka.harnessV1Name" . | trunc 56 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.harnessV1DeleteDrainEgressName" -}} +{{- printf "%s-egress" (include "orka.harnessV1DeleteDrainName" . | trunc 56 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Render the complete harness v1 Pod template from one canonical helper. The +ledger generation hashes this structure with a fixed sentinel in place of the +generation itself, so only a real Pod-template change advances the generation. +*/}} +{{- define "orka.harnessV1PodTemplate" -}} +{{- $root := .root -}} +{{- $generation := .generation -}} +metadata: + labels: + {{- include "orka.labels" $root | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper + orka.ai/network-role: harness-v1 + {{- with $root.Values.harnessV1.tls.rolloutNonce }} + annotations: + orka.ai/harness-v1-tls-rollout-nonce: {{ . | quote }} + {{- end }} +spec: + serviceAccountName: {{ include "orka.harnessV1Name" $root }} + automountServiceAccountToken: false + securityContext: + runAsUser: 0 + runAsGroup: 0 + seccompProfile: + type: RuntimeDefault + containers: + - name: wrapper + image: {{ include "orka.imageRef" $root.Values.harnessV1.image | quote }} + imagePullPolicy: {{ $root.Values.harnessV1.image.pullPolicy }} + ports: + - name: https + containerPort: 8080 + protocol: TCP + env: + - name: ORKA_HARNESS_WRAPPER_RUNTIME + value: multi + - name: ORKA_HARNESS_WRAPPER_LISTEN_ADDR + value: :8080 + - name: ORKA_CONTROLLER_URL + value: http://{{ include "orka.fullname" $root }}.{{ $root.Release.Namespace }}.svc:{{ $root.Values.service.port }} + - name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE + value: /var/run/orka/harness-wrapper-auth/token + - name: ORKA_HARNESS_WRAPPER_TLS_CERT_FILE + value: /var/run/orka/harness-wrapper-tls/tls.crt + - name: ORKA_HARNESS_WRAPPER_TLS_KEY_FILE + value: /var/run/orka/harness-wrapper-tls/tls.key + - name: ORKA_HARNESS_WRAPPER_ADMISSION_LEDGER_PATH + value: /var/lib/orka/harness-v1/admission-ledger.db + - name: ORKA_HARNESS_WRAPPER_LEDGER_GENERATION + value: {{ $generation | quote }} + - name: ORKA_HARNESS_WRAPPER_LEDGER_RETENTION + value: {{ $root.Values.harnessV1.ledger.retention | quote }} + - name: ORKA_ALLOW_BASH + value: "true" + - name: ORKA_HARNESS_WRAPPER_CHILD_UID + value: "1000" + - name: ORKA_HARNESS_WRAPPER_CHILD_GID + value: "1000" + - name: ORKA_CODEX_SANDBOX_MODE + value: {{ $root.Values.harnessV1.codexSandboxMode | quote }} + volumeMounts: + - name: auth + mountPath: /var/run/orka/harness-wrapper-auth + readOnly: true + - name: tls + mountPath: /var/run/orka/harness-wrapper-tls + readOnly: true + - name: controller-api-token + mountPath: /var/run/secrets/kubernetes.io/serviceaccount + readOnly: true + - name: ledger + mountPath: /var/lib/orka/harness-v1 + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsUser: 0 + runAsGroup: 0 + capabilities: + drop: + - ALL + add: + - SETUID + - SETGID + - CHOWN + - KILL + - FOWNER + livenessProbe: + httpGet: + path: /v1/health + port: https + scheme: HTTPS + initialDelaySeconds: 10 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /v1/ready + port: https + scheme: HTTPS + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with $root.Values.harnessV1.resources }} + resources: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: auth + secret: + secretName: {{ $root.Values.harnessV1.auth.existingSecret | quote }} + defaultMode: 0400 + items: + - key: {{ $root.Values.harnessV1.auth.tokenKey | quote }} + path: token + - name: tls + secret: + secretName: {{ $root.Values.harnessV1.tls.existingSecret | quote }} + defaultMode: 0400 + items: + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key + - key: ca.crt + path: ca.crt + - name: controller-api-token + projected: + defaultMode: 0400 + sources: + - serviceAccountToken: + path: token + expirationSeconds: 3600 + - name: ledger + persistentVolumeClaim: + claimName: {{ include "orka.harnessV1LedgerName" $root }} + - name: tmp + emptyDir: {} +{{- end }} + +{{- define "orka.harnessV1PodTemplateGeneration" -}} +{{- $template := include "orka.harnessV1PodTemplate" (dict "root" . "generation" "ORKA_HARNESS_V1_TEMPLATE_GENERATION") | fromYaml -}} +{{- toJson $template | sha256sum -}} +{{- end }} + +{{/* Read the live wrapper inputs used by rollover hooks. */}} +{{- define "orka.harnessV1ExistingImage" -}} +{{- $image := "" -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "wrapper" -}} +{{- $image = default "" .image -}} +{{- end -}} +{{- end -}} +{{- required "existing harness v1 wrapper Deployment is missing the wrapper image" $image -}} +{{- end }} + +{{- define "orka.harnessV1ExistingImagePullPolicy" -}} +{{- $pullPolicy := "IfNotPresent" -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "wrapper" -}} +{{- $pullPolicy = default "IfNotPresent" .imagePullPolicy -}} +{{- end -}} +{{- end -}} +{{- $pullPolicy -}} +{{- end }} + +{{- define "orka.harnessV1ExistingGeneration" -}} +{{- $generation := "" -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "wrapper" -}} +{{- range (default (list) .env) -}} +{{- if eq (default "" .name) "ORKA_HARNESS_WRAPPER_LEDGER_GENERATION" -}} +{{- $generation = default "" .value -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- $generation -}} +{{- end }} + +{{- define "orka.harnessV1ExistingAuthSecretName" -}} +{{- $secretName := "" -}} +{{- range (dig "spec" "template" "spec" "volumes" (list) .) -}} +{{- if eq (default "" .name) "auth" -}} +{{- $secretName = dig "secret" "secretName" "" . -}} +{{- end -}} +{{- end -}} +{{- required "existing harness v1 wrapper Deployment is missing the auth Secret name" $secretName -}} +{{- end }} + +{{- define "orka.harnessV1ExistingAuthSecretKey" -}} +{{- $secretKey := "" -}} +{{- range (dig "spec" "template" "spec" "volumes" (list) .) -}} +{{- if eq (default "" .name) "auth" -}} +{{- range (dig "secret" "items" (list) .) -}} +{{- if eq (default "" .path) "token" -}} +{{- $secretKey = default "" .key -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- required "existing harness v1 wrapper Deployment is missing the auth Secret token key" $secretKey -}} +{{- end }} + +{{- define "orka.harnessV1ExistingTLSSecretName" -}} +{{- $secretName := "" -}} +{{- $legacyAuthSecretName := "" -}} +{{- range (dig "spec" "template" "spec" "volumes" (list) .) -}} +{{- if eq (default "" .name) "tls" -}} +{{- $secretName = dig "secret" "secretName" "" . -}} +{{- else if eq (default "" .name) "auth" -}} +{{- $legacyAuthSecretName = dig "secret" "secretName" "" . -}} +{{- end -}} +{{- end -}} +{{- if not $secretName -}} +{{- $secretName = $legacyAuthSecretName -}} +{{- end -}} +{{- required "existing harness v1 wrapper Deployment is missing the TLS Secret name" $secretName -}} +{{- end }} + +{{/* Read the live controller's exact namespace watch scope. */}} +{{- define "orka.existingControllerWatchNamespace" -}} +{{- $watchNamespaces := list -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix "--watch-namespace=" $arg -}} +{{- $watchNamespaces = append $watchNamespaces (trimPrefix "--watch-namespace=" $arg) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if eq (len $watchNamespaces) 1 -}} +{{- index $watchNamespaces 0 -}} +{{- end -}} +{{- end }} + +{{/* Read the exact chart fullname from the live controller's in-cluster URL. */}} +{{- define "orka.existingControllerFullname" -}} +{{- $fullnames := list -}} +{{- $namespaceSuffix := printf ".%s.svc" .namespace -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .controller) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix "--controller-url=http://" $arg -}} +{{- $endpoint := trimPrefix "--controller-url=http://" $arg -}} +{{- $hostPort := first (splitList "/" $endpoint) -}} +{{- $host := first (splitList ":" $hostPort) -}} +{{- if hasSuffix $namespaceSuffix $host -}} +{{- $fullname := trimSuffix $namespaceSuffix $host -}} +{{- if and $fullname (not (contains "." $fullname)) -}} +{{- $fullnames = append $fullnames $fullname -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if eq (len $fullnames) 1 -}} +{{- index $fullnames 0 -}} +{{- end -}} +{{- end }} + +{{/* Read the live controller's exact ACP runtime namespace. */}} +{{- define "orka.existingControllerACPRuntimeNamespace" -}} +{{- $runtimeNamespaces := list -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix "--acp-runtime-namespace=" $arg -}} +{{- $runtimeNamespaces = append $runtimeNamespaces (trimPrefix "--acp-runtime-namespace=" $arg) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if eq (len $runtimeNamespaces) 1 -}} +{{- index $runtimeNamespaces 0 -}} +{{- end -}} +{{- end }} + +{{/* Read the live controller's exact static mode. Legacy controllers return empty. */}} +{{- define "orka.existingControllerMode" -}} +{{- $modes := list -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix "--controller-mode=" $arg -}} +{{- $modes = append $modes (trimPrefix "--controller-mode=" $arg) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if eq (len $modes) 1 -}} +{{- $mode := index $modes 0 -}} +{{- if has $mode (list "harness-v1" "harness-v2") -}} +{{- $mode -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* Read the live controller's exact agent-execution snapshot Secret name. */}} +{{- define "orka.existingControllerAgentExecutionSnapshotSecretName" -}} +{{- $volumes := list -}} +{{- range (dig "spec" "template" "spec" "volumes" (list) .) -}} +{{- if eq (default "" .name) "agent-execution-snapshot-key" -}} +{{- $volumes = append $volumes . -}} +{{- end -}} +{{- end -}} +{{- if eq (len $volumes) 1 -}} +{{- dig "secret" "secretName" "" (index $volumes 0) -}} +{{- end -}} +{{- end }} + +{{/* Read the live controller's exact snapshot Secret item mounted as key. */}} +{{- define "orka.existingControllerAgentExecutionSnapshotSecretKey" -}} +{{- $volumes := list -}} +{{- range (dig "spec" "template" "spec" "volumes" (list) .) -}} +{{- if eq (default "" .name) "agent-execution-snapshot-key" -}} +{{- $volumes = append $volumes . -}} +{{- end -}} +{{- end -}} +{{- if eq (len $volumes) 1 -}} +{{- $keys := list -}} +{{- range (dig "secret" "items" (list) (index $volumes 0)) -}} +{{- if eq (default "" .path) "key" -}} +{{- $keys = append $keys (default "" .key) -}} +{{- end -}} +{{- end -}} +{{- if eq (len $keys) 1 -}} +{{- index $keys 0 -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* Read the live controller inputs used when the wrapper Deployment is absent. */}} +{{- define "orka.harnessV1ExistingControllerState" -}} +{{- $state := "" -}} +{{- $mode := "" -}} +{{- $harnessMarker := false -}} +{{- $harnessEnabled := false -}} +{{- $harnessDisabled := false -}} +{{- $acpEnabled := false -}} +{{- $acpDisabled := false -}} +{{- $dualMarker := false -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix "--controller-mode=" $arg -}} +{{- $mode = trimPrefix "--controller-mode=" $arg -}} +{{- end -}} +{{- if hasPrefix "--harness-v1-enabled=" $arg -}} +{{- $harnessMarker = true -}} +{{- end -}} +{{- if eq $arg "--harness-v1-enabled=true" -}} +{{- $harnessEnabled = true -}} +{{- else if eq $arg "--harness-v1-enabled=false" -}} +{{- $harnessDisabled = true -}} +{{- else if eq $arg "--acp-runtime-enabled=true" -}} +{{- $acpEnabled = true -}} +{{- else if eq $arg "--acp-runtime-enabled=false" -}} +{{- $acpDisabled = true -}} +{{- else if hasPrefix "--agent-execution-" $arg -}} +{{- $dualMarker = true -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- if eq $mode "harness-v1" -}} +{{- $state = "enabled" -}} +{{- else if eq $mode "harness-v2" -}} +{{- $state = "disabled" -}} +{{- else if and $harnessEnabled (not $harnessDisabled) -}} +{{- $state = "enabled" -}} +{{- else if and $harnessDisabled (not $harnessEnabled) -}} +{{- $state = "disabled" -}} +{{- else if and (not $harnessMarker) (not $dualMarker) (ne $acpEnabled $acpDisabled) -}} +{{- $state = "legacy-v2-disabled" -}} +{{- end -}} +{{- $state -}} +{{- end }} + +{{- define "orka.harnessV1ExistingControllerAuthSecretName" -}} +{{- $secretName := "" -}} +{{- $prefix := "--harness-v1-auth-secret-name=" -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix $prefix $arg -}} +{{- $secretName = trimPrefix $prefix $arg -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- required "existing harness v1 controller Deployment is missing the auth Secret name" $secretName -}} +{{- end }} + +{{- define "orka.harnessV1ExistingControllerAuthSecretKey" -}} +{{- $secretKey := "" -}} +{{- $prefix := "--harness-v1-auth-secret-key=" -}} +{{- range (dig "spec" "template" "spec" "containers" (list) .) -}} +{{- if eq (default "" .name) "controller" -}} +{{- range (default (list) .args) -}} +{{- $arg := toString . -}} +{{- if hasPrefix $prefix $arg -}} +{{- $secretKey = trimPrefix $prefix $arg -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- required "existing harness v1 controller Deployment is missing the auth Secret token key" $secretKey -}} +{{- end }} + +{{- define "orka.controllerName" -}} +{{- printf "%s-controller" (include "orka.fullname" . | trunc 52 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.controllerWebhookServiceName" -}} +{{- printf "%s-webhook" (include "orka.fullname" . | trunc 55 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* Keep legacy short names, but hash any identity controllerName truncates. */}} +{{- define "orka.controllerWebhookName" -}} +{{- $fullname := include "orka.fullname" . -}} +{{- if le (len $fullname) 52 -}} +{{- include "orka.controllerName" . -}} +{{- else -}} +{{- $identity := printf "%s/%s/%s/%s/%s" .Release.Namespace .Release.Name (default "" .Values.fullnameOverride) (default "" .Values.nameOverride) .Chart.Name -}} +{{- printf "%s-controller-%s" ($fullname | trunc 39 | trimSuffix "-") (sha256sum $identity | trunc 12) | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end }} + +{{- define "orka.controllerClusterRoleName" -}} +{{- printf "%s-cluster" (include "orka.controllerWebhookName" .) -}} +{{- end }} + +{{- define "orka.controllerUsername" -}} +{{- printf "system:serviceaccount:%s:%s" .Release.Namespace (include "orka.serviceAccountName" .) -}} +{{- end }} + +{{- define "orka.publisherName" -}} +{{- printf "%s-workspace-publisher" (include "orka.fullname" . | trunc 43 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.publisherAuthSecretName" -}} +{{- printf "%s-workspace-publisher-auth" (include "orka.fullname" . | trunc 38 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.acpArtifactSecretName" -}} +{{- printf "%s-acp-artifact-capability" (include "orka.fullname" . | trunc 39 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.providerProxyName" -}} +{{- printf "%s-provider-auth-proxy" (include "orka.fullname" . | trunc 43 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.scmEgressProxyName" -}} +{{- printf "%s-scm-egress-proxy" (include "orka.fullname" . | trunc 46 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.scmEgressProxyAuthSecretName" -}} +{{- printf "%s-scm-egress-proxy-auth" (include "orka.fullname" . | trunc 41 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.storeName" -}} +{{- printf "%s-store" (include "orka.fullname" . | trunc 57 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "orka.vekilIngressPolicyName" -}} +{{- printf "%s-vekil-ingress" (include "orka.fullname" . | trunc 49 | trimSuffix "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create the name of the workspace publisher ServiceAccount to use. +*/}} +{{- define "orka.publisherServiceAccountName" -}} +{{- if .Values.publisher.serviceAccount.create }} +{{- default (include "orka.publisherName" .) .Values.publisher.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.publisher.serviceAccount.name }} +{{- end }} +{{- end }} {{/* -Create the namespace for the chart-managed client ServiceAccount. -When namespace isolation is enforced and the controller watches one namespace, -place the default client in that namespace so its token remains usable. +Reject mutable ACP runtime image references when a provider image is configured. +An empty provider image leaves that provider unavailable; Tasks still fail closed +because the ACP runtime remains enabled and has no legacy fallback. +*/}} +{{- define "orka.validateACPRuntimeImage" -}} +{{- $name := .name -}} +{{- $ref := default "" .ref -}} +{{- if and $ref (not (regexMatch "^.+@sha256:[0-9a-f]{64}$" $ref)) -}} +{{- fail (printf "%s must be an immutable image reference ending in @sha256:<64 lowercase hex characters>; got %q" $name $ref) -}} +{{- end -}} +{{- end }} + +{{/* +The chart-managed provider proxy is release-namespaced and its NetworkPolicies +are intentionally pinned to the chart-supported Vekil Service. +*/}} +{{- define "orka.validateProviderProxyConfig" -}} +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled -}} +{{- $configuredNamespace := trim (default "" .Values.controller.acpRuntime.providerProxyNamespace) -}} +{{- if and $configuredNamespace (ne $configuredNamespace .Release.Namespace) -}} +{{- fail (printf "controller.acpRuntime.providerProxyNamespace must be empty or match the Helm release namespace %q when providerProxy.enabled=true" .Release.Namespace) -}} +{{- end -}} +{{- $upstream := trimSuffix "/" (trim (default "" .Values.providerProxy.upstreamBaseURL)) -}} +{{- if ne $upstream "http://vekil.vekil-system.svc:1337" -}} +{{- fail "providerProxy.upstreamBaseURL must be http://vekil.vekil-system.svc:1337 (an optional trailing slash is accepted)" -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* +The controller uses a process-local SQLite store, so production deployments +must have exactly one elected writer and must not overlap Pods during rollout. +*/}} +{{- define "orka.validateSQLiteController" -}} +{{- if ne (int .Values.controller.replicas) 1 -}} +{{- fail "controller.replicas must be exactly 1 when using the SQLite store backend" -}} +{{- end -}} +{{- if not .Values.controller.leaderElect -}} +{{- fail "controller.leaderElect must be true when using the SQLite store backend" -}} +{{- end -}} +{{- end }} + +{{/* +Every release owns exactly one immutable execution contract and one tenant +namespace. There is no dual, automatic, or drain controller mode. +*/}} +{{- define "orka.validateControllerMode" -}} +{{- if not (has .Values.controller.mode (list "harness-v1" "harness-v2")) -}} +{{- fail "controller.mode must be harness-v1 or harness-v2" -}} +{{- end -}} +{{- if not (trim (default "" .Values.controller.watchNamespace)) -}} +{{- fail "controller.watchNamespace is required for an isolated controller installation" -}} +{{- end -}} +{{- if ne .Values.controller.watchNamespace .Release.Namespace -}} +{{- fail (printf "controller.watchNamespace must equal the Helm release namespace %q" .Release.Namespace) -}} +{{- end -}} +{{- if not .Values.controller.leaderElect -}} +{{- fail "controller.leaderElect must be true for an isolated controller installation" -}} +{{- end -}} +{{- if .Release.IsUpgrade -}} +{{- $existingNamespace := lookup "v1" "Namespace" "" .Release.Namespace -}} +{{- $existingNamespaceMode := "" -}} +{{- if $existingNamespace -}} +{{- $existingNamespaceMode = dig "metadata" "labels" "orka.ai/controller-mode" "" $existingNamespace -}} +{{- end -}} +{{- if ne $existingNamespaceMode .Values.controller.mode -}} +{{- fail (printf "controller mode identity is missing or incompatible; namespace %q must already claim orka.ai/controller-mode=%s before this release can be upgraded" .Release.Namespace .Values.controller.mode) -}} +{{- end -}} +{{- $root := . -}} +{{- $existingControllerList := lookup "apps/v1" "Deployment" .Release.Namespace "" -}} +{{- $existingControllers := list -}} +{{- range (dig "items" (list) (default (dict) $existingControllerList)) -}} +{{- $labels := dig "metadata" "labels" (dict) . -}} +{{- if and (eq (get $labels "app.kubernetes.io/instance") $root.Release.Name) (eq (get $labels "app.kubernetes.io/component") "controller") (eq (get $labels "app.kubernetes.io/managed-by") $root.Release.Service) -}} +{{- $existingControllers = append $existingControllers . -}} +{{- end -}} +{{- end -}} +{{- if gt (len $existingControllers) 1 -}} +{{- fail (printf "multiple controller Deployments are owned by Helm release %q in namespace %q; restore a single controller before upgrading" .Release.Name .Release.Namespace) -}} +{{- end -}} +{{- $existingController := dict -}} +{{- if eq (len $existingControllers) 1 -}} +{{- $existingController = index $existingControllers 0 -}} +{{- end -}} +{{- if $existingController -}} +{{- $existingWatchNamespace := include "orka.existingControllerWatchNamespace" $existingController | trim -}} +{{- if ne $existingWatchNamespace .Values.controller.watchNamespace -}} +{{- fail (printf "controller.watchNamespace is immutable; the existing controller must already watch namespace %q; install cluster-wide or differently scoped controllers as a new release and namespace" .Values.controller.watchNamespace) -}} +{{- end -}} +{{- $existingMode := include "orka.existingControllerMode" $existingController | trim -}} +{{- $existingState := include "orka.harnessV1ExistingControllerState" $existingController | trim -}} +{{- if $existingMode -}} +{{- if ne $existingMode .Values.controller.mode -}} +{{- fail (printf "controller.mode is immutable; install %s as a new release and namespace" .Values.controller.mode) -}} +{{- end -}} +{{- else if eq .Values.controller.mode "harness-v2" -}} +{{- fail "implicit or legacy harness-v2 installations cannot upgrade in place; settle or retire the existing installation and install harness-v2 as a new release and namespace" -}} +{{- else if ne $existingState "enabled" -}} +{{- fail "controller.mode is immutable; install harness-v1 as a new release and namespace" -}} +{{- end -}} +{{- $existingSnapshotSecret := include "orka.existingControllerAgentExecutionSnapshotSecretName" $existingController | trim -}} +{{- if not $existingSnapshotSecret -}} +{{- fail "cannot determine the existing agent execution snapshot Secret name from the live controller; restore its exact agent-execution-snapshot-key volume before upgrading" -}} +{{- end -}} +{{- $desiredSnapshotSecret := trim (default "" .Values.controller.agentExecutionSnapshot.existingSecret) -}} +{{- if ne $existingSnapshotSecret $desiredSnapshotSecret -}} +{{- fail (printf "controller.agentExecutionSnapshot.existingSecret is immutable for in-place upgrades; preserve %q so retained encrypted execution snapshots remain decryptable" $existingSnapshotSecret) -}} +{{- end -}} +{{- $existingSnapshotKey := include "orka.existingControllerAgentExecutionSnapshotSecretKey" $existingController | trim -}} +{{- if not $existingSnapshotKey -}} +{{- fail "cannot determine the existing agent execution snapshot Secret key from the live controller; restore its exact item mounted at path key before upgrading" -}} +{{- end -}} +{{- $desiredSnapshotKey := trim (default "" .Values.controller.agentExecutionSnapshot.key) -}} +{{- if ne $existingSnapshotKey $desiredSnapshotKey -}} +{{- fail (printf "controller.agentExecutionSnapshot.key is immutable for in-place upgrades; preserve %q so retained encrypted execution snapshots remain decryptable" $existingSnapshotKey) -}} +{{- end -}} +{{- if eq .Values.controller.mode "harness-v2" -}} +{{- $existingFullname := include "orka.existingControllerFullname" (dict "controller" $existingController "namespace" .Release.Namespace) | trim -}} +{{- if not $existingFullname -}} +{{- fail "cannot determine the existing harness-v2 chart fullname from the live controller; restore its exact --controller-url argument before upgrading" -}} +{{- end -}} +{{- $desiredFullname := include "orka.fullname" . -}} +{{- if ne $existingFullname $desiredFullname -}} +{{- fail (printf "the effective chart fullname is immutable for harness-v2 upgrades; the existing controller uses %q, but this upgrade would use %q" $existingFullname $desiredFullname) -}} +{{- end -}} +{{- $existingRuntimeNamespace := include "orka.existingControllerACPRuntimeNamespace" $existingController | trim -}} +{{- if not $existingRuntimeNamespace -}} +{{- fail "cannot determine the existing harness-v2 ACP runtime namespace; restore its exact --acp-runtime-namespace argument before upgrading" -}} +{{- end -}} +{{- if ne $existingRuntimeNamespace .Values.controller.acpRuntime.namespace -}} +{{- fail (printf "controller.acpRuntime.namespace is immutable; the existing controller uses namespace %q" $existingRuntimeNamespace) -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- $clientNamespace := trim (default "" .Values.client.namespace) -}} +{{- if and $clientNamespace (ne $clientNamespace .Values.controller.watchNamespace) -}} +{{- fail "client.namespace must be empty or match controller.watchNamespace" -}} +{{- end -}} +{{- if eq .Values.controller.mode "harness-v2" -}} +{{- if not (trim (default "" .Values.controller.acpRuntime.namespace)) -}} +{{- fail "controller.acpRuntime.namespace is required when controller.mode=harness-v2" -}} +{{- end -}} +{{- if eq .Values.controller.acpRuntime.namespace .Release.Namespace -}} +{{- fail "controller.acpRuntime.namespace must differ from the release namespace" -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* +Agent execution snapshots contain sensitive resolved inputs. When either +agent protocol is enabled, require an operator-managed Secret for their +encryption key rather than generating or storing the key in Helm values. +*/}} +{{- define "orka.validateAgentExecutionSnapshot" -}} +{{- if not (trim (default "" .Values.controller.agentExecutionSnapshot.existingSecret)) -}} +{{- fail "controller.agentExecutionSnapshot.existingSecret is required when agent execution is enabled" -}} +{{- end -}} +{{- if not (trim (default "" .Values.controller.agentExecutionSnapshot.key)) -}} +{{- fail "controller.agentExecutionSnapshot.key is required when agent execution is enabled" -}} +{{- end -}} +{{- end }} + +{{/* +The release-local controller serves its own fail-closed webhooks. Its +certificate and CA trust are always operator-managed. +*/}} +{{- define "orka.validateWebhooks" -}} +{{- if not (trim (default "" .Values.webhooks.tls.existingSecret)) -}} +{{- fail "webhooks.tls.existingSecret is required" -}} +{{- end -}} +{{- if not (trim (default "" .Values.webhooks.tls.certKey)) -}} +{{- fail "webhooks.tls.certKey is required" -}} +{{- end -}} +{{- if not (trim (default "" .Values.webhooks.tls.privateKeyKey)) -}} +{{- fail "webhooks.tls.privateKeyKey is required" -}} +{{- end -}} +{{- if and (not (trim (default "" .Values.webhooks.caBundle))) (empty .Values.webhooks.caInjectionAnnotations) -}} +{{- fail "webhooks requires a nonempty caBundle or caInjectionAnnotations" -}} +{{- end -}} +{{- if or (lt (int .Values.webhooks.timeoutSeconds) 1) (gt (int .Values.webhooks.timeoutSeconds) 30) -}} +{{- fail "webhooks.timeoutSeconds must be between 1 and 30" -}} +{{- end -}} +{{- end }} + +{{/* +Harness v1 is an explicitly selected compatibility data plane. Its image must +be immutable, its admission ledger durable, and its bearer credential must +remain outside rendered Helm manifests. +*/}} +{{- define "orka.validateHarnessV1" -}} +{{- if eq .Values.controller.mode "harness-v1" -}} +{{- if .Values.controller.agentSandbox.enabled -}} +{{- fail "controller.agentSandbox.enabled is unsupported when controller.mode=harness-v1; Agent Sandbox requires harness-v2" -}} +{{- end -}} +{{- if .Values.controller.substrate.enabled -}} +{{- fail "controller.substrate.enabled is unsupported when controller.mode=harness-v1; Substrate requires harness-v2" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.image.repository)) -}} +{{- fail "harnessV1.image.repository is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (regexMatch "^sha256:[0-9a-f]{64}$" (.Values.harnessV1.image.digest | default "")) -}} +{{- fail "harnessV1.image.digest must be a sha256 digest when controller.mode=harness-v1" -}} +{{- end -}} +{{- if trim (default "" .Values.harnessV1.auth.token) -}} +{{- fail "harnessV1.auth.token is unsupported; create a Kubernetes Secret and set harnessV1.auth.existingSecret" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.auth.existingSecret)) -}} +{{- fail "harnessV1.auth.existingSecret is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.auth.tokenKey)) -}} +{{- fail "harnessV1.auth.tokenKey is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.tls.existingSecret)) -}} +{{- fail "harnessV1.tls.existingSecret is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if eq (trim .Values.harnessV1.auth.existingSecret) (trim .Values.harnessV1.tls.existingSecret) -}} +{{- fail "harnessV1.tls.existingSecret must differ from harnessV1.auth.existingSecret" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.ledger.size)) -}} +{{- fail "harnessV1.ledger.size is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.ledger.retention)) -}} +{{- fail "harnessV1.ledger.retention is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (regexMatch "^([1-9][0-9]*(ns|us|µs|ms|s|m|h))+$" (trim (default "" .Values.harnessV1.ledger.retention))) -}} +{{- fail "harnessV1.ledger.retention must be a positive Go duration when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not .Values.store.persistence.enabled -}} +{{- fail "store.persistence.enabled must be true when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (trim (default "" .Values.harnessV1.dispatch.interval)) -}} +{{- fail "harnessV1.dispatch.interval is required when controller.mode=harness-v1" -}} +{{- end -}} +{{- if ne (int .Values.harnessV1.dispatch.workers) 1 -}} +{{- fail "harnessV1.dispatch.workers must be exactly 1 when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (regexMatch "^([1-9][0-9]*(ns|us|µs|ms|s|m|h))+$" (trim (default "" .Values.harnessV1.upgradeDrain.timeout))) -}} +{{- fail "harnessV1.upgradeDrain.timeout must be a positive Go duration when controller.mode=harness-v1" -}} +{{- end -}} +{{- if not (regexMatch "^([1-9][0-9]*(ns|us|µs|ms|s|m|h))+$" (trim (default "" .Values.harnessV1.upgradeDrain.pollInterval))) -}} +{{- fail "harnessV1.upgradeDrain.pollInterval must be a positive Go duration when controller.mode=harness-v1" -}} +{{- end -}} +{{- $sandboxMode := trim (default "" .Values.harnessV1.codexSandboxMode) -}} +{{- if and $sandboxMode (not (has $sandboxMode (list "read-only" "workspace-write" "danger-full-access"))) -}} +{{- fail "harnessV1.codexSandboxMode must be read-only, workspace-write, or danger-full-access" -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{- define "orka.providerProxyUpstreamBaseURL" -}} +{{- trimSuffix "/" (trim (default "" .Values.providerProxy.upstreamBaseURL)) -}} +{{- end }} + + +{{/* +Create the namespace for the chart-managed client ServiceAccount. Static +installations always place the client in the watched namespace. */}} {{- define "orka.clientNamespace" -}} {{- if .Values.client.namespace }} {{- .Values.client.namespace }} -{{- else if and .Values.controller.enforceNamespaceIsolation .Values.controller.watchNamespace }} -{{- .Values.controller.watchNamespace }} {{- else }} -{{- .Release.Namespace }} +{{- .Values.controller.watchNamespace }} {{- end }} {{- end }} @@ -122,16 +885,25 @@ Create release-scoped worker ClusterRole names. {{- end }} {{/* -Create release-scoped static worker ClusterRoleBinding names. +Create release-scoped static worker RoleBinding names. */}} -{{- define "orka.aiWorkerClusterRoleBindingName" -}} +{{- define "orka.aiWorkerRoleBindingName" -}} {{- printf "%s-ai-worker-rolebinding" (include "orka.fullname" .) | trunc 253 | trimSuffix "-" }} {{- end }} -{{- define "orka.vendorWorkerClusterRoleBindingName" -}} +{{- define "orka.vendorWorkerRoleBindingName" -}} {{- printf "%s-vendor-worker-rolebinding" (include "orka.fullname" .) | trunc 253 | trimSuffix "-" }} {{- end }} -{{- define "orka.containerWorkerClusterRoleBindingName" -}} +{{- define "orka.containerWorkerRoleBindingName" -}} {{- printf "%s-container-worker-rolebinding" (include "orka.fullname" .) | trunc 253 | trimSuffix "-" }} {{- end }} + +{{/* Render repository@digest when an immutable digest is configured. */}} +{{- define "orka.imageRef" -}} +{{- if .digest -}} +{{ printf "%s@%s" .repository .digest }} +{{- else -}} +{{ printf "%s:%s" .repository .tag }} +{{- end -}} +{{- end }} diff --git a/cmd/build/helmify/static/templates/acp-artifact-secret.yaml b/cmd/build/helmify/static/templates/acp-artifact-secret.yaml new file mode 100644 index 000000000..49343eb1a --- /dev/null +++ b/cmd/build/helmify/static/templates/acp-artifact-secret.yaml @@ -0,0 +1,19 @@ +{{- if and (eq .Values.controller.mode "harness-v2") (not .Values.controller.acpArtifact.existingSecret) }} +{{- $secretName := include "orka.acpArtifactSecretName" . }} +{{- $secretKey := .Values.controller.acpArtifact.secretKey | default "capability-secret" }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} +{{- $existingValue := "" }} +{{- if and $existing (hasKey $existing.data $secretKey) }} +{{- $existingValue = (index $existing.data $secretKey | b64dec) }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $secretName }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: acp-artifact-api +type: Opaque +stringData: + {{ $secretKey }}: {{ default (default (randAlphaNum 64) $existingValue) .Values.controller.acpArtifact.secret | quote }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/admission-deployment.yaml b/cmd/build/helmify/static/templates/admission-deployment.yaml new file mode 100644 index 000000000..1d81ba4fd --- /dev/null +++ b/cmd/build/helmify/static/templates/admission-deployment.yaml @@ -0,0 +1,4 @@ +{{/* +The dedicated chart deployment was retired. Static releases serve their +mode-scoped validation endpoints from the release-local controller. +*/}} diff --git a/cmd/build/helmify/static/templates/controller-validating-webhook.yaml b/cmd/build/helmify/static/templates/controller-validating-webhook.yaml new file mode 100644 index 000000000..13472d206 --- /dev/null +++ b/cmd/build/helmify/static/templates/controller-validating-webhook.yaml @@ -0,0 +1,191 @@ +{{- include "orka.validateControllerMode" . }} +{{- include "orka.validateWebhooks" . }} +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: {{ include "orka.controllerWebhookName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: controller + {{- with .Values.webhooks.caInjectionAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +webhooks: + - name: namespace-mode.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-v1-namespace-execution-mode + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [""] + apiVersions: [v1] + resources: [namespaces] + scope: Cluster + objectSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} + - name: task-provenance.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-core-orka-ai-v1alpha1-task-provenance + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [core.orka.ai] + apiVersions: [v1alpha1] + resources: [tasks] + scope: Namespaced + namespaceSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} + {{- if eq .Values.controller.mode "harness-v2" }} + - name: task-workspace-class.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-core-orka-ai-v1alpha1-task-workspace-class-use + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [core.orka.ai] + apiVersions: [v1alpha1] + resources: [tasks] + scope: Namespaced + namespaceSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} + - name: tool-workspace-class.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-core-orka-ai-v1alpha1-tool-workspace-class-use + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [core.orka.ai] + apiVersions: [v1alpha1] + resources: [tools] + scope: Namespaced + namespaceSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} + {{- end }} + - name: agent-contract.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-core-orka-ai-v1alpha1-agent-contract + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [core.orka.ai] + apiVersions: [v1alpha1] + resources: [agents] + scope: Namespaced + namespaceSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} + - name: agentruntime-contract.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-core-orka-ai-v1alpha1-agentruntime-contract + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [core.orka.ai] + apiVersions: [v1alpha1] + resources: [agentruntimes] + scope: Namespaced + namespaceSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} + - name: task-execution-authority.{{ .Values.controller.mode }}.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ .Values.webhooks.timeoutSeconds }} + clientConfig: + service: + name: {{ include "orka.controllerWebhookServiceName" . }} + namespace: {{ .Release.Namespace }} + path: /validate-core-orka-ai-v1alpha1-task-execution-authority + port: 443 + {{- with .Values.webhooks.caBundle }} + caBundle: {{ . | quote }} + {{- end }} + rules: + - operations: [CREATE, UPDATE] + apiGroups: [core.orka.ai] + apiVersions: [v1alpha1] + resources: [tasks, tasks/status] + scope: Namespaced + namespaceSelector: + matchLabels: + orka.ai/controller-mode: {{ .Values.controller.mode | quote }} + kubernetes.io/metadata.name: {{ .Release.Namespace | quote }} diff --git a/cmd/build/helmify/static/templates/controller-webhook-service.yaml b/cmd/build/helmify/static/templates/controller-webhook-service.yaml new file mode 100644 index 000000000..19f96695e --- /dev/null +++ b/cmd/build/helmify/static/templates/controller-webhook-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "orka.controllerWebhookServiceName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: controller +spec: + type: ClusterIP + ports: + - port: 443 + targetPort: webhook + protocol: TCP + name: webhook + selector: + {{- include "orka.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: controller diff --git a/cmd/build/helmify/static/templates/deployment.yaml b/cmd/build/helmify/static/templates/deployment.yaml index 31ef45c6e..c535e6021 100644 --- a/cmd/build/helmify/static/templates/deployment.yaml +++ b/cmd/build/helmify/static/templates/deployment.yaml @@ -1,15 +1,33 @@ -{{- if and .Values.controller.workspaceProvider.fakeProviderEnabled (not .Values.controller.workspaceProvider.apiEnabled) -}} -{{- fail "controller.workspaceProvider.fakeProviderEnabled requires controller.workspaceProvider.apiEnabled" -}} -{{- end -}} -{{- if and .Values.controller.workspaceProvider.apiEnabled (not .Values.controller.workspaceProvider.classUseAdmission.enabled) -}} -{{- fail "controller.workspaceProvider.apiEnabled requires controller.workspaceProvider.classUseAdmission.enabled" -}} -{{- end -}} -{{- if .Values.controller.workspaceProvider.classUseAdmission.enabled -}} -{{- $workspaceWebhookSecret := required "controller.workspaceProvider.classUseAdmission.existingSecret is required when class-use admission is enabled" .Values.controller.workspaceProvider.classUseAdmission.existingSecret -}} -{{- $workspaceWebhookCA := required "controller.workspaceProvider.classUseAdmission.caBundle is required when class-use admission is enabled" .Values.controller.workspaceProvider.classUseAdmission.caBundle -}} -{{- end -}} +{{- $harnessV1 := eq .Values.controller.mode "harness-v1" -}} +{{- $harnessV2 := eq .Values.controller.mode "harness-v2" -}} +{{- include "orka.validateControllerMode" . }} +{{- include "orka.validateProviderProxyConfig" . }} +{{- include "orka.validateSQLiteController" . }} +{{- include "orka.validateAgentExecutionSnapshot" . }} +{{- include "orka.validateWebhooks" . }} +{{- if not (regexMatch "^sha256:[0-9a-f]{64}$" (.Values.controller.image.digest | default "")) }} +{{- fail "controller.image.digest must be a sha256 digest" }} +{{- end }} +{{- if $harnessV2 }} +{{- if not (regexMatch "^sha256:[0-9a-f]{64}$" (.Values.controller.image.digest | default "")) }} +{{- fail "controller.image.digest must be a sha256 digest when controller.mode=harness-v2" }} +{{- end }} +{{- if or (not .Values.publisher.enabled) (not (regexMatch "^sha256:[0-9a-f]{64}$" (.Values.publisher.image.digest | default ""))) }} +{{- fail "publisher must be enabled with publisher.image.digest set when controller.mode=harness-v2" }} +{{- end }} +{{- if not .Values.store.persistence.enabled }} +{{- fail "store.persistence.enabled must be true when controller.mode=harness-v2" }} +{{- end }} +{{- include "orka.validateACPRuntimeImage" (dict "name" "controller.acpRuntime.codexImage" "ref" .Values.controller.acpRuntime.codexImage) }} +{{- include "orka.validateACPRuntimeImage" (dict "name" "controller.acpRuntime.claudeImage" "ref" .Values.controller.acpRuntime.claudeImage) }} +{{- include "orka.validateACPRuntimeImage" (dict "name" "controller.acpRuntime.copilotImage" "ref" .Values.controller.acpRuntime.copilotImage) }} +{{- include "orka.validateACPRuntimeImage" (dict "name" "controller.acpRuntime.opencodeImage" "ref" .Values.controller.acpRuntime.opencodeImage) }} +{{- if not .Values.providerProxy.enabled }} +{{- fail "providerProxy.enabled must be true when controller.mode=harness-v2" }} +{{- end }} +{{- end }} {{- $gatewayCRDsReady := false -}} -{{- if and .Values.controller.gateway.enabled (not .Values.controller.gateway.crdsReadyOverride) -}} +{{- if and $harnessV2 .Values.controller.gateway.enabled (not .Values.controller.gateway.crdsReadyOverride) -}} {{- $gatewayClassCRD := lookup "apiextensions.k8s.io/v1" "CustomResourceDefinition" "" "gatewayclasses.gateway.orka.ai" -}} {{- $gatewayCRD := lookup "apiextensions.k8s.io/v1" "CustomResourceDefinition" "" "gateways.gateway.orka.ai" -}} {{- $gatewayBindingCRD := lookup "apiextensions.k8s.io/v1" "CustomResourceDefinition" "" "gatewaybindings.gateway.orka.ai" -}} @@ -20,23 +38,21 @@ {{- end -}} {{- $gatewayCRDsReady = and (not (empty $gatewayClassCRD)) (not (empty $gatewayCRD)) (not (empty $gatewayBindingCRD)) $taskSchemaReady -}} {{- end -}} -{{- $gatewayRuntimeEnabled := and .Values.controller.gateway.enabled (or .Values.controller.gateway.crdsReadyOverride $gatewayCRDsReady) -}} +{{- $gatewayRuntimeEnabled := and $harnessV2 .Values.controller.gateway.enabled (or .Values.controller.gateway.crdsReadyOverride $gatewayCRDsReady) -}} {{- if and $gatewayRuntimeEnabled (not .Values.store.persistence.enabled) (not .Values.controller.gateway.allowEphemeralStore) -}} {{- fail "controller.gateway.enabled requires store.persistence.enabled=true; set controller.gateway.allowEphemeralStore=true only for disposable development" -}} {{- end -}} apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "orka.fullname" . }}-controller + name: {{ include "orka.controllerName" . }} labels: {{- include "orka.labels" . | nindent 4 }} app.kubernetes.io/component: controller spec: replicas: {{ .Values.controller.replicas }} - {{- if .Values.store.persistence.enabled }} strategy: type: Recreate - {{- end }} selector: matchLabels: {{- include "orka.selectorLabels" . | nindent 6 }} @@ -46,9 +62,18 @@ spec: labels: {{- include "orka.selectorLabels" . | nindent 8 }} app.kubernetes.io/component: controller - {{- with .Values.annotations }} + orka.ai/network-role: controller + {{- if or .Values.annotations (and $harnessV2 .Values.publisher.auth.rolloutNonce) (and $harnessV1 .Values.harnessV1.tls.rolloutNonce) }} annotations: + {{- with .Values.annotations }} {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.publisher.auth.rolloutNonce }} + orka.ai/publisher-auth-rollout-nonce: {{ . | quote }} + {{- end }} + {{- with .Values.harnessV1.tls.rolloutNonce }} + orka.ai/harness-v1-tls-rollout-nonce: {{ . | quote }} + {{- end }} {{- end }} spec: serviceAccountName: {{ include "orka.serviceAccountName" . }} @@ -56,9 +81,12 @@ spec: securityContext: {{- toYaml . | nindent 8 }} {{- end }} + {{- if and $harnessV2 .Values.controller.acpRuntime.upgradeDrain.enabled }} + terminationGracePeriodSeconds: {{ .Values.controller.acpRuntime.upgradeDrain.terminationGracePeriodSeconds }} + {{- end }} containers: - name: controller - image: "{{ .Values.controller.image.repository }}:{{ .Values.controller.image.tag }}" + image: {{ include "orka.imageRef" .Values.controller.image | quote }} imagePullPolicy: {{ .Values.controller.image.pullPolicy }} args: - --api-port={{ .Values.controller.apiPort }} @@ -66,10 +94,56 @@ spec: - --metrics-bind-address=:{{ .Values.controller.metricsPort }} - --health-probe-bind-address=:{{ .Values.controller.healthPort }} - --metrics-secure=false - {{- if .Values.controller.watchNamespace }} - --watch-namespace={{ .Values.controller.watchNamespace }} - {{- end }} - --leader-elect={{ .Values.controller.leaderElect }} + - --controller-mode={{ .Values.controller.mode }} + - {{ printf "--execution-mode-controller-usernames=%s" (include "orka.controllerUsername" .) | quote }} + - --webhook-cert-path=/var/run/orka/webhook/tls + - {{ printf "--webhook-cert-name=%s" .Values.webhooks.tls.certKey | quote }} + - {{ printf "--webhook-cert-key=%s" .Values.webhooks.tls.privateKeyKey | quote }} + - --task-provenance-admission-enabled=true + - {{ printf "--task-provenance-admission-trusted-users=%s" (include "orka.controllerUsername" .) | quote }} + - --workspace-class-use-admission-enabled={{ $harnessV2 }} + {{- if $harnessV1 }} + - --harness-v1-endpoint=https://{{ include "orka.harnessV1Name" . }}.{{ .Release.Namespace }}.svc:8080 + - --harness-v1-ca-file=/var/run/orka/harness-v1-tls/ca.crt + - --harness-v1-auth-secret-namespace={{ .Release.Namespace }} + - --harness-v1-auth-secret-name={{ .Values.harnessV1.auth.existingSecret }} + - --harness-v1-auth-secret-key={{ .Values.harnessV1.auth.tokenKey }} + - --harness-v1-dispatch-interval={{ .Values.harnessV1.dispatch.interval }} + - --harness-v1-dispatch-workers={{ .Values.harnessV1.dispatch.workers }} + {{- end }} + - --agent-execution-snapshot-key-file=/var/run/orka/agent-execution-snapshot/key + - --agent-execution-snapshot-retention={{ .Values.controller.agentExecutionSnapshot.retention }} + - --agent-execution-snapshot-retention-interval={{ .Values.controller.agentExecutionSnapshot.retentionInterval }} + {{- if $harnessV2 }} + - --acp-runtime-namespace={{ .Values.controller.acpRuntime.namespace }} + {{- end }} + {{- if and $harnessV2 .Values.controller.acpRuntime.upgradeDrain.enabled }} + - --acp-upgrade-drain-bind-address=127.0.0.1:{{ .Values.controller.acpRuntime.upgradeDrain.port }} + - --acp-upgrade-drain-timeout={{ .Values.controller.acpRuntime.upgradeDrain.timeout }} + - --acp-upgrade-drain-poll-interval={{ .Values.controller.acpRuntime.upgradeDrain.pollInterval }} + - --acp-upgrade-drain-trigger-timeout={{ .Values.controller.acpRuntime.upgradeDrain.triggerTimeout }} + - --acp-upgrade-drain-marker-namespace={{ .Release.Namespace }} + {{- end }} + {{- if and $harnessV2 .Values.providerProxy.enabled }} + - --acp-provider-proxy-base-url=http://{{ include "orka.providerProxyName" . }}.{{ .Release.Namespace }}.svc:8080 + - --acp-provider-proxy-namespace={{ .Release.Namespace }} + - --acp-provider-proxy-pod-labels=orka.ai/network-role=provider-auth-proxy + - --acp-provider-proxy-token-file=/var/run/orka/provider-auth/token + {{- end }} + {{- if .Values.controller.acpRuntime.codexImage }} + - {{ printf "--acp-codex-runtime-image=%s" .Values.controller.acpRuntime.codexImage | quote }} + {{- end }} + {{- if .Values.controller.acpRuntime.claudeImage }} + - {{ printf "--acp-claude-runtime-image=%s" .Values.controller.acpRuntime.claudeImage | quote }} + {{- end }} + {{- if .Values.controller.acpRuntime.copilotImage }} + - {{ printf "--acp-copilot-runtime-image=%s" .Values.controller.acpRuntime.copilotImage | quote }} + {{- end }} + {{- if .Values.controller.acpRuntime.opencodeImage }} + - {{ printf "--acp-opencode-runtime-image=%s" .Values.controller.acpRuntime.opencodeImage | quote }} + {{- end }} - --zap-log-level={{ .Values.controller.logLevel }} - --gateway-enabled={{ $gatewayRuntimeEnabled }} - --gateway-pending-per-session={{ .Values.controller.gateway.pendingPerSession }} @@ -243,25 +317,11 @@ spec: - --ai-worker-cluster-role-name={{ include "orka.aiWorkerClusterRoleName" . }} - --vendor-worker-cluster-role-name={{ include "orka.vendorWorkerClusterRoleName" . }} - --container-worker-cluster-role-name={{ include "orka.containerWorkerClusterRoleName" . }} - - --worker-cluster-role-binding-prefix={{ include "orka.fullname" . }} - {{- if .Values.controller.enforceNamespaceIsolation }} + - --worker-role-binding-prefix={{ include "orka.fullname" . }} - --enforce-namespace-isolation=true - {{- end }} {{- if gt (int .Values.controller.maxTasksPerNamespace) 0 }} - --max-tasks-per-namespace={{ .Values.controller.maxTasksPerNamespace }} {{- end }} - {{- with .Values.controller.workspaceProvider }} - {{- if .apiEnabled }} - - --enable-workspace-provider-api=true - {{- end }} - {{- if .fakeProviderEnabled }} - - --enable-fake-workspace-provider=true - {{- end }} - {{- if .classUseAdmission.enabled }} - - --workspace-class-use-admission-enabled=true - - --webhook-cert-path=/var/run/orka/workspace-webhook - {{- end }} - {{- end }} {{- with .Values.controller.executionWorkspace }} - --execution-workspace-default-provider={{ .defaultProvider | default "agent-sandbox" }} {{- end }} @@ -315,12 +375,22 @@ spec: value: {{ include "orka.vendorWorkerClusterRoleName" . | quote }} - name: ORKA_CONTAINER_WORKER_CLUSTER_ROLE_NAME value: {{ include "orka.containerWorkerClusterRoleName" . | quote }} - - name: ORKA_HARNESS_WRAPPER_ENDPOINT - value: http://{{ include "orka.harnessWrapperName" . }}:8080 - - name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE - value: /var/run/orka/harness-wrapper/token - - name: ORKA_HARNESS_WRAPPER_SERVICE_ACCOUNT_NAME - value: {{ include "orka.harnessWrapperName" . | quote }} + {{- if $harnessV2 }} + - name: ORKA_ACP_ARTIFACT_CAPABILITY_SECRET_FILE + value: /var/run/orka/acp-artifacts/capability-secret + - name: ORKA_ACP_ARTIFACT_ROOT + value: /data/acp-artifacts + - name: ORKA_ACP_ARTIFACT_MAX_BYTES + value: {{ .Values.controller.acpArtifact.maxBytes | quote }} + {{- end }} + {{- if and $harnessV2 .Values.publisher.enabled }} + - name: ORKA_WORKSPACE_PUBLISHER_URL + value: http://{{ include "orka.publisherName" . }}:8080 + - name: ORKA_WORKSPACE_PUBLISHER_CONTROLLER_TOKEN_FILE + value: /var/run/orka/publisher-auth/controller-token + - name: ORKA_WORKSPACE_PUBLISHER_CAPABILITY_SECRET_FILE + value: /var/run/orka/publisher-auth/operation-capability-secret + {{- end }} {{- if .Values.github.webhook.secretName }} - name: ORKA_GITHUB_WEBHOOK_SECRET valueFrom: @@ -368,11 +438,18 @@ spec: - name: health containerPort: {{ .Values.controller.healthPort }} protocol: TCP - {{- if .Values.controller.workspaceProvider.classUseAdmission.enabled }} - - name: webhook-server + - name: webhook containerPort: 9443 protocol: TCP - {{- end }} + {{- if and $harnessV2 .Values.controller.acpRuntime.upgradeDrain.enabled }} + lifecycle: + preStop: + exec: + command: + - /manager + - {{ printf "--acp-upgrade-drain-trigger-url=http://127.0.0.1:%v%s" .Values.controller.acpRuntime.upgradeDrain.port "/acp/upgrade-drain" | quote }} + - {{ printf "--acp-upgrade-drain-trigger-timeout=%s" .Values.controller.acpRuntime.upgradeDrain.triggerTimeout | quote }} + {{- end }} livenessProbe: httpGet: path: /healthz @@ -398,41 +475,96 @@ spec: mountPath: /tmp - name: store mountPath: /data - - name: harness-wrapper-auth - mountPath: /var/run/orka/harness-wrapper + {{- if $harnessV2 }} + - name: acp-artifact-capability + mountPath: /var/run/orka/acp-artifacts + readOnly: true + {{- end }} + {{- if and $harnessV2 .Values.publisher.enabled }} + - name: workspace-publisher-auth + mountPath: /var/run/orka/publisher-auth + readOnly: true + {{- end }} + {{- if and $harnessV2 .Values.providerProxy.enabled }} + - name: provider-auth-proxy + mountPath: /var/run/orka/provider-auth + readOnly: true + {{- end }} + - name: agent-execution-snapshot-key + mountPath: /var/run/orka/agent-execution-snapshot readOnly: true - {{- if .Values.controller.workspaceProvider.classUseAdmission.enabled }} - - name: workspace-webhook-certs - mountPath: /var/run/orka/workspace-webhook + - name: webhook-tls + mountPath: /var/run/orka/webhook/tls + readOnly: true + {{- if $harnessV1 }} + - name: harness-v1-tls + mountPath: /var/run/orka/harness-v1-tls readOnly: true {{- end }} volumes: - name: tmp emptyDir: {} - - name: harness-wrapper-auth - secret: - secretName: {{ .Values.workers.harnessWrapper.auth.existingSecret | default (include "orka.harnessWrapperAuthSecretName" .) }} - items: - - key: {{ .Values.workers.harnessWrapper.auth.tokenKey | default "token" }} - path: token - {{- if .Values.controller.workspaceProvider.classUseAdmission.enabled }} - - name: workspace-webhook-certs + {{- if $harnessV2 }} + - name: acp-artifact-capability secret: - secretName: {{ .Values.controller.workspaceProvider.classUseAdmission.existingSecret | quote }} + secretName: {{ .Values.controller.acpArtifact.existingSecret | default (include "orka.acpArtifactSecretName" .) }} defaultMode: 0400 items: - - key: tls.crt - path: tls.crt - - key: tls.key - path: tls.key + - key: {{ .Values.controller.acpArtifact.secretKey | default "capability-secret" }} + path: capability-secret {{- end }} - name: store {{- if .Values.store.persistence.enabled }} persistentVolumeClaim: - claimName: {{ include "orka.fullname" . }}-store + claimName: {{ include "orka.storeName" . }} {{- else }} emptyDir: {} {{- end }} + {{- if and $harnessV2 .Values.publisher.enabled }} + - name: workspace-publisher-auth + secret: + secretName: {{ .Values.publisher.auth.existingSecret | default (include "orka.publisherAuthSecretName" .) }} + defaultMode: 0400 + items: + - key: {{ .Values.publisher.auth.controllerTokenKey | default "controller-token" }} + path: controller-token + - key: {{ .Values.publisher.auth.capabilitySecretKey | default "operation-capability-secret" }} + path: operation-capability-secret + {{- end }} + {{- if and $harnessV2 .Values.providerProxy.enabled }} + - name: provider-auth-proxy + secret: + secretName: {{ .Values.providerProxy.auth.existingSecret | default (include "orka.providerProxyName" .) }} + defaultMode: 0400 + items: + - key: {{ .Values.providerProxy.auth.tokenKey | default "token" }} + path: token + {{- end }} + - name: agent-execution-snapshot-key + secret: + secretName: {{ .Values.controller.agentExecutionSnapshot.existingSecret | quote }} + defaultMode: 0400 + items: + - key: {{ .Values.controller.agentExecutionSnapshot.key | quote }} + path: key + - name: webhook-tls + secret: + secretName: {{ .Values.webhooks.tls.existingSecret | quote }} + defaultMode: 0400 + items: + - key: {{ .Values.webhooks.tls.certKey | quote }} + path: {{ .Values.webhooks.tls.certKey | quote }} + - key: {{ .Values.webhooks.tls.privateKeyKey | quote }} + path: {{ .Values.webhooks.tls.privateKeyKey | quote }} + {{- if $harnessV1 }} + - name: harness-v1-tls + secret: + secretName: {{ .Values.harnessV1.tls.existingSecret | quote }} + defaultMode: 0400 + items: + - key: ca.crt + path: ca.crt + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/cmd/build/helmify/static/templates/gateway-task-admission-policy.yaml b/cmd/build/helmify/static/templates/gateway-task-admission-policy.yaml index 12a1b1976..940409346 100644 --- a/cmd/build/helmify/static/templates/gateway-task-admission-policy.yaml +++ b/cmd/build/helmify/static/templates/gateway-task-admission-policy.yaml @@ -1,3 +1,4 @@ +{{- if eq .Values.controller.mode "harness-v2" }} {{- $issuerPrefix := "gateway.orka.ai/" -}} {{- if .Values.controller.watchNamespace -}} {{- $issuerPrefix = printf "gateway.orka.ai/%s/" .Values.controller.watchNamespace -}} @@ -43,3 +44,4 @@ metadata: spec: policyName: {{ include "orka.fullname" . }}-gateway-task-protection validationActions: [Deny] +{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-deployment.yaml b/cmd/build/helmify/static/templates/harness-wrapper-deployment.yaml index 7d42a6b64..ce943f73c 100644 --- a/cmd/build/helmify/static/templates/harness-wrapper-deployment.yaml +++ b/cmd/build/helmify/static/templates/harness-wrapper-deployment.yaml @@ -1,7 +1,9 @@ +{{- include "orka.validateHarnessV1" . }} +{{- if eq .Values.controller.mode "harness-v1" }} apiVersion: apps/v1 kind: Deployment metadata: - name: {{ include "orka.harnessWrapperName" . }} + name: {{ include "orka.harnessV1Name" . }} labels: {{- include "orka.labels" . | nindent 4 }} app.kubernetes.io/component: agent-harness-wrapper @@ -14,97 +16,5 @@ spec: {{- include "orka.selectorLabels" . | nindent 6 }} app.kubernetes.io/component: agent-harness-wrapper template: - metadata: - labels: - {{- include "orka.selectorLabels" . | nindent 8 }} - app.kubernetes.io/component: agent-harness-wrapper - spec: - serviceAccountName: {{ include "orka.harnessWrapperName" . }} - automountServiceAccountToken: false - securityContext: - runAsUser: 0 - runAsGroup: 0 - seccompProfile: - type: RuntimeDefault - containers: - - name: wrapper - image: {{ .Values.workers.harnessWrapper.image.repository }}:{{ .Values.workers.harnessWrapper.image.tag }} - imagePullPolicy: {{ .Values.workers.harnessWrapper.image.pullPolicy }} - ports: - - name: http - containerPort: 8080 - protocol: TCP - env: - - name: ORKA_HARNESS_WRAPPER_RUNTIME - value: multi - - name: ORKA_HARNESS_WRAPPER_LISTEN_ADDR - value: :8080 - - name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE - value: /var/run/orka/harness-wrapper/token - - name: ORKA_ALLOW_BASH - value: "true" - - name: ORKA_HARNESS_WRAPPER_CHILD_UID - value: "1000" - - name: ORKA_HARNESS_WRAPPER_CHILD_GID - value: "1000" - {{- if .Values.workers.harnessWrapper.codexSandboxMode }} - - name: ORKA_CODEX_SANDBOX_MODE - value: {{ .Values.workers.harnessWrapper.codexSandboxMode | quote }} - {{- end }} - - name: ORKA_SA_TOKEN_PATH - value: /var/run/orka/upload-token/token - volumeMounts: - - name: auth - mountPath: /var/run/orka/harness-wrapper - readOnly: true - - name: upload-token - mountPath: /var/run/orka/upload-token - readOnly: true - - name: tmp - mountPath: /tmp - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsUser: 0 - runAsGroup: 0 - capabilities: - drop: - - ALL - add: - - SETUID - - SETGID - - CHOWN - - KILL - - FOWNER - livenessProbe: - httpGet: - path: /v1/health - port: http - initialDelaySeconds: 10 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /v1/health - port: http - initialDelaySeconds: 5 - periodSeconds: 10 - {{- with .Values.workers.harnessWrapper.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - volumes: - - name: upload-token - projected: - defaultMode: 0400 - sources: - - serviceAccountToken: - path: token - - name: auth - secret: - secretName: {{ .Values.workers.harnessWrapper.auth.existingSecret | default (include "orka.harnessWrapperAuthSecretName" .) }} - defaultMode: 0400 - items: - - key: {{ .Values.workers.harnessWrapper.auth.tokenKey | default "token" }} - path: token - - name: tmp - emptyDir: {} + {{- include "orka.harnessV1PodTemplate" (dict "root" . "generation" (include "orka.harnessV1PodTemplateGeneration" .)) | nindent 4 }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-drain-hook.yaml b/cmd/build/helmify/static/templates/harness-wrapper-drain-hook.yaml new file mode 100644 index 000000000..f43747028 --- /dev/null +++ b/cmd/build/helmify/static/templates/harness-wrapper-drain-hook.yaml @@ -0,0 +1,490 @@ +{{- $harnessV1 := eq .Values.controller.mode "harness-v1" -}} +{{- if $harnessV1 }} +{{- include "orka.validateHarnessV1" . }} +{{- $wrapperName := include "orka.harnessV1Name" . }} +{{- $desiredGeneration := include "orka.harnessV1PodTemplateGeneration" . }} +{{- $rolloverGeneration := $desiredGeneration }} +{{- $existingWrapper := lookup "apps/v1" "Deployment" .Release.Namespace $wrapperName }} +{{- $controllerName := include "orka.controllerName" . }} +{{- $existingController := lookup "apps/v1" "Deployment" .Release.Namespace $controllerName }} +{{- $existingControllerState := "" }} +{{- if $existingController }} +{{- $existingControllerState = include "orka.harnessV1ExistingControllerState" $existingController | trim }} +{{- end }} +{{- if and .Release.IsUpgrade (not $existingWrapper) (empty $existingControllerState) }} +{{- fail "cannot determine the previously deployed harness v1 state during upgrade; restore the release controller or wrapper Deployment before upgrading" }} +{{- end }} +{{- $needsRollover := false }} +{{- $existingRouteEnabled := false }} +{{- $existingImage := "" }} +{{- $existingPullPolicy := "" }} +{{- $existingAuthSecret := "" }} +{{- $existingAuthKey := "" }} +{{- $existingTLSSecret := "" }} +{{- $desiredAuthSecret := .Values.harnessV1.auth.existingSecret }} +{{- $desiredAuthKey := .Values.harnessV1.auth.tokenKey }} +{{- if $existingWrapper }} +{{- $existingRouteEnabled = true }} +{{- $existingGeneration := include "orka.harnessV1ExistingGeneration" $existingWrapper | trim }} +{{- $needsRollover = ne $existingGeneration $desiredGeneration }} +{{- $existingImage = include "orka.harnessV1ExistingImage" $existingWrapper }} +{{- $existingPullPolicy = include "orka.harnessV1ExistingImagePullPolicy" $existingWrapper }} +{{- $existingAuthSecret = include "orka.harnessV1ExistingAuthSecretName" $existingWrapper }} +{{- $existingAuthKey = include "orka.harnessV1ExistingAuthSecretKey" $existingWrapper }} +{{- $existingTLSSecret = include "orka.harnessV1ExistingTLSSecretName" $existingWrapper }} +{{- else if eq $existingControllerState "enabled" }} +{{- $existingRouteEnabled = true }} +{{- $existingAuthSecret = include "orka.harnessV1ExistingControllerAuthSecretName" $existingController }} +{{- $existingAuthKey = include "orka.harnessV1ExistingControllerAuthSecretKey" $existingController }} +{{- end }} +{{- if $existingRouteEnabled }} +{{- if ne $existingAuthSecret $desiredAuthSecret }} +{{- fail "harnessV1.auth.existingSecret cannot change while the previously deployed harness v1 route remains enabled; retire harness v1 before rotating wrapper auth" }} +{{- end }} +{{- if ne $existingAuthKey $desiredAuthKey }} +{{- fail "harnessV1.auth.tokenKey cannot change while the previously deployed harness v1 route remains enabled; retire harness v1 before rotating wrapper auth" }} +{{- end }} +{{- end }} +{{- if $needsRollover }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1DrainName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-drain + annotations: + helm.sh/hook: pre-upgrade,pre-rollback + helm.sh/hook-weight: "-20" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-drain + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1DrainEgressName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-drain + annotations: + helm.sh/hook: pre-upgrade,pre-rollback + helm.sh/hook-weight: "-20" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-drain + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: agent-harness-wrapper + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "orka.harnessV1DrainName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-drain + annotations: + helm.sh/hook: pre-upgrade,pre-rollback + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "orka.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-drain + spec: + serviceAccountName: {{ include "orka.harnessV1Name" . }} + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: drain + image: {{ $existingImage | quote }} + imagePullPolicy: {{ $existingPullPolicy }} + command: ["/orka-agent-harness-wrapper"] + args: + - drain + - {{ printf "--endpoint=https://%s.%s.svc:8080" $wrapperName .Release.Namespace | quote }} + - --bearer-token-file=/var/run/orka/harness-wrapper-auth/token + - --ca-file=/var/run/orka/harness-wrapper-tls/ca.crt + - {{ printf "--timeout=%s" .Values.harnessV1.upgradeDrain.timeout | quote }} + - {{ printf "--poll-interval=%s" .Values.harnessV1.upgradeDrain.pollInterval | quote }} + - {{ printf "--next-generation=%s" $rolloverGeneration | quote }} + volumeMounts: + - name: auth + mountPath: /var/run/orka/harness-wrapper-auth + readOnly: true + - name: tls + mountPath: /var/run/orka/harness-wrapper-tls + readOnly: true + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: [ALL] + {{- with .Values.harnessV1.upgradeDrain.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: auth + secret: + secretName: {{ $existingAuthSecret | quote }} + defaultMode: 0440 + items: + - key: {{ $existingAuthKey | quote }} + path: token + - name: tls + secret: + secretName: {{ $existingTLSSecret | quote }} + defaultMode: 0440 + items: + - key: ca.crt + path: ca.crt + - name: tmp + emptyDir: {} +{{- end }} +{{- if $harnessV1 }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1AbortName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-abort + annotations: + helm.sh/hook: post-rollback + helm.sh/hook-weight: "-20" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-abort + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1AbortEgressName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-abort + annotations: + helm.sh/hook: post-rollback + helm.sh/hook-weight: "-20" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-abort + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: agent-harness-wrapper + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "orka.harnessV1AbortName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-abort + annotations: + helm.sh/hook: post-rollback + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "orka.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: agent-harness-wrapper-rollover-abort + spec: + serviceAccountName: {{ include "orka.harnessV1Name" . }} + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: abort-rollover + image: {{ include "orka.imageRef" .Values.harnessV1.image | quote }} + imagePullPolicy: {{ .Values.harnessV1.image.pullPolicy }} + command: ["/orka-agent-harness-wrapper"] + args: + - abort-rollover + - {{ printf "--endpoint=https://%s.%s.svc:8080" $wrapperName .Release.Namespace | quote }} + - --bearer-token-file=/var/run/orka/harness-wrapper-auth/token + - --ca-file=/var/run/orka/harness-wrapper-tls/ca.crt + - {{ printf "--expected-generation=%s" $desiredGeneration | quote }} + - {{ printf "--timeout=%s" .Values.harnessV1.upgradeDrain.timeout | quote }} + volumeMounts: + - name: auth + mountPath: /var/run/orka/harness-wrapper-auth + readOnly: true + - name: tls + mountPath: /var/run/orka/harness-wrapper-tls + readOnly: true + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: [ALL] + {{- with .Values.harnessV1.upgradeDrain.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: auth + secret: + secretName: {{ .Values.harnessV1.auth.existingSecret | quote }} + defaultMode: 0440 + items: + - key: {{ .Values.harnessV1.auth.tokenKey | quote }} + path: token + - name: tls + secret: + secretName: {{ .Values.harnessV1.tls.existingSecret | quote }} + defaultMode: 0440 + items: + - key: ca.crt + path: ca.crt + - name: tmp + emptyDir: {} +{{- end }} +{{- if $harnessV1 }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1DeleteDrainName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-delete-drain + annotations: + helm.sh/hook: pre-delete + helm.sh/hook-weight: "-20" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: agent-harness-wrapper-delete-drain + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1DeleteDrainEgressName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-delete-drain + annotations: + helm.sh/hook: pre-delete + helm.sh/hook-weight: "-20" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper-delete-drain + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: agent-harness-wrapper + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "orka.harnessV1DeleteDrainName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper-delete-drain + annotations: + helm.sh/hook: pre-delete + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 0 + template: + metadata: + labels: + {{- include "orka.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: agent-harness-wrapper-delete-drain + spec: + serviceAccountName: {{ include "orka.harnessV1Name" . }} + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: + type: RuntimeDefault + containers: + - name: drain + image: {{ include "orka.imageRef" .Values.harnessV1.image | quote }} + imagePullPolicy: {{ .Values.harnessV1.image.pullPolicy }} + command: ["/orka-agent-harness-wrapper"] + args: + - drain + - {{ printf "--endpoint=https://%s.%s.svc:8080" $wrapperName .Release.Namespace | quote }} + - --bearer-token-file=/var/run/orka/harness-wrapper-auth/token + - --ca-file=/var/run/orka/harness-wrapper-tls/ca.crt + - {{ printf "--timeout=%s" .Values.harnessV1.upgradeDrain.timeout | quote }} + - {{ printf "--poll-interval=%s" .Values.harnessV1.upgradeDrain.pollInterval | quote }} + - {{ printf "--next-generation=retired:%s" $desiredGeneration | quote }} + volumeMounts: + - name: auth + mountPath: /var/run/orka/harness-wrapper-auth + readOnly: true + - name: tls + mountPath: /var/run/orka/harness-wrapper-tls + readOnly: true + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + capabilities: + drop: [ALL] + {{- with .Values.harnessV1.upgradeDrain.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: auth + secret: + secretName: {{ .Values.harnessV1.auth.existingSecret | quote }} + defaultMode: 0440 + items: + - key: {{ .Values.harnessV1.auth.tokenKey | quote }} + path: token + - name: tls + secret: + secretName: {{ .Values.harnessV1.tls.existingSecret | quote }} + defaultMode: 0440 + items: + - key: ca.crt + path: ca.crt + - name: tmp + emptyDir: {} +{{- end }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-networkpolicy.yaml b/cmd/build/helmify/static/templates/harness-wrapper-networkpolicy.yaml new file mode 100644 index 000000000..d07344804 --- /dev/null +++ b/cmd/build/helmify/static/templates/harness-wrapper-networkpolicy.yaml @@ -0,0 +1,87 @@ +{{- include "orka.validateHarnessV1" . }} +{{- if eq .Values.controller.mode "harness-v1" }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.harnessV1Name" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: agent-harness-wrapper + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: controller + ports: + - protocol: TCP + port: 8080 + # V1 compatibility workloads may reach their release-local controller plus + # public HTTPS provider and read-only SCM endpoints. Private, local, + # link-local, multicast, and documentation networks remain denied, and no + # in-cluster publisher/proxy egress is granted. + egress: + - to: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: controller + ports: + - protocol: TCP + port: {{ .Values.controller.apiPort }} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 0.0.0.0/8 + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.0.0.0/24 + - 192.0.2.0/24 + - 192.168.0.0/16 + - 198.18.0.0/15 + - 198.51.100.0/24 + - 203.0.113.0/24 + - 224.0.0.0/4 + - 240.0.0.0/4 + ports: + - protocol: TCP + port: 443 + - to: + - ipBlock: + cidr: ::/0 + except: + - ::/128 + - ::1/128 + - 64:ff9b::/96 + - 64:ff9b:1::/48 + - 100::/64 + - 2001::/32 + - 2001:db8::/32 + - 2002::/16 + - fc00::/7 + - fe80::/10 + - ff00::/8 + ports: + - protocol: TCP + port: 443 +{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-pvc.yaml b/cmd/build/helmify/static/templates/harness-wrapper-pvc.yaml new file mode 100644 index 000000000..70f413323 --- /dev/null +++ b/cmd/build/helmify/static/templates/harness-wrapper-pvc.yaml @@ -0,0 +1,21 @@ +{{- include "orka.validateHarnessV1" . }} +{{- if eq .Values.controller.mode "harness-v1" }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "orka.harnessV1LedgerName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper + annotations: + helm.sh/resource-policy: keep +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.harnessV1.ledger.size }} + {{- with .Values.harnessV1.ledger.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-secret.yaml b/cmd/build/helmify/static/templates/harness-wrapper-secret.yaml deleted file mode 100644 index 4a3a5c968..000000000 --- a/cmd/build/helmify/static/templates/harness-wrapper-secret.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- if not .Values.workers.harnessWrapper.auth.existingSecret }} -{{- $secretName := include "orka.harnessWrapperAuthSecretName" . }} -{{- $tokenKey := .Values.workers.harnessWrapper.auth.tokenKey | default "token" }} -{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }} -{{- $existingToken := "" }} -{{- if and $existing (hasKey $existing.data $tokenKey) }} -{{- $existingToken = (index $existing.data $tokenKey | b64dec) }} -{{- end }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ $secretName }} - labels: - {{- include "orka.labels" . | nindent 4 }} - app.kubernetes.io/component: agent-harness-wrapper -type: Opaque -stringData: - {{ $tokenKey }}: {{ default (default (randAlphaNum 32) $existingToken) .Values.workers.harnessWrapper.auth.token | quote }} -{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-service.yaml b/cmd/build/helmify/static/templates/harness-wrapper-service.yaml index e6d5ddf10..cbbdbd3d3 100644 --- a/cmd/build/helmify/static/templates/harness-wrapper-service.yaml +++ b/cmd/build/helmify/static/templates/harness-wrapper-service.yaml @@ -1,7 +1,9 @@ +{{- include "orka.validateHarnessV1" . }} +{{- if eq .Values.controller.mode "harness-v1" }} apiVersion: v1 kind: Service metadata: - name: {{ include "orka.harnessWrapperName" . }} + name: {{ include "orka.harnessV1Name" . }} labels: {{- include "orka.labels" . | nindent 4 }} app.kubernetes.io/component: agent-harness-wrapper @@ -10,7 +12,8 @@ spec: {{- include "orka.selectorLabels" . | nindent 4 }} app.kubernetes.io/component: agent-harness-wrapper ports: - - name: http + - name: https port: 8080 - targetPort: http + targetPort: https protocol: TCP +{{- end }} diff --git a/cmd/build/helmify/static/templates/harness-wrapper-serviceaccount.yaml b/cmd/build/helmify/static/templates/harness-wrapper-serviceaccount.yaml new file mode 100644 index 000000000..24a7b8014 --- /dev/null +++ b/cmd/build/helmify/static/templates/harness-wrapper-serviceaccount.yaml @@ -0,0 +1,11 @@ +{{- include "orka.validateHarnessV1" . }} +{{- if eq .Values.controller.mode "harness-v1" }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "orka.harnessV1Name" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: agent-harness-wrapper +automountServiceAccountToken: false +{{- end }} diff --git a/cmd/build/helmify/static/templates/provider-proxy-deployment.yaml b/cmd/build/helmify/static/templates/provider-proxy-deployment.yaml new file mode 100644 index 000000000..cba838b6a --- /dev/null +++ b/cmd/build/helmify/static/templates/provider-proxy-deployment.yaml @@ -0,0 +1,82 @@ +{{- include "orka.validateProviderProxyConfig" . }} +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled }} +{{- $secretName := .Values.providerProxy.auth.existingSecret | default (include "orka.providerProxyName" .) }} +{{- $currentKey := .Values.providerProxy.auth.tokenKey | default "token" }} +{{- $previousKey := .Values.providerProxy.auth.previousTokenKey | default "previous-token" }} +{{- $previousValidUntilKey := .Values.providerProxy.auth.previousTokenValidUntilKey | default "previous-token-valid-until" }} +{{- if or (eq $currentKey $previousKey) (eq $currentKey $previousValidUntilKey) (eq $previousKey $previousValidUntilKey) }} +{{- fail "providerProxy auth token keys must differ" }} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "orka.providerProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: provider-auth-proxy +spec: + replicas: 1 + strategy: {type: Recreate} + selector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: provider-auth-proxy + template: + metadata: + {{- with .Values.providerProxy.rolloutNonce }} + annotations: + orka.ai/provider-auth-rollout-nonce: {{ . | quote }} + {{- end }} + labels: + {{- include "orka.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: provider-auth-proxy + orka.ai/network-role: provider-auth-proxy + spec: + serviceAccountName: {{ include "orka.providerProxyName" . }} + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: {type: RuntimeDefault} + containers: + - name: proxy + image: {{ include "orka.imageRef" .Values.controller.image | quote }} + imagePullPolicy: {{ .Values.controller.image.pullPolicy }} + command: [/provider-auth-proxy] + args: + - --listen-address=:8080 + - {{ printf "--upstream-base-url=%s" (include "orka.providerProxyUpstreamBaseURL" .) | quote }} + - --token-file=/var/run/secrets/orka/provider-auth/token + - --previous-token-file=/var/run/secrets/orka/provider-auth/previous-token + - --previous-token-valid-until-file=/var/run/secrets/orka/provider-auth/previous-token-valid-until + - {{ printf "--token-reload-interval=%s" (.Values.providerProxy.tokenReloadInterval | default "5s") | quote }} + - {{ printf "--previous-token-overlap=%s" (.Values.providerProxy.previousTokenOverlap | default "10m") | quote }} + ports: [{name: http, containerPort: 8080}] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: + {{- toYaml .Values.providerProxy.resources | nindent 12 }} + readinessProbe: {httpGet: {path: /readyz, port: http}} + livenessProbe: {httpGet: {path: /healthz, port: http}} + volumeMounts: + - {name: provider-auth, mountPath: /var/run/secrets/orka/provider-auth, readOnly: true} + volumes: + - name: provider-auth + projected: + defaultMode: 0440 + sources: + - secret: + name: {{ $secretName }} + optional: true + items: + - key: {{ $currentKey }} + path: token + - key: {{ $previousKey }} + path: previous-token + - key: {{ $previousValidUntilKey }} + path: previous-token-valid-until +{{- end }} diff --git a/cmd/build/helmify/static/templates/provider-proxy-networkpolicy.yaml b/cmd/build/helmify/static/templates/provider-proxy-networkpolicy.yaml new file mode 100644 index 000000000..3e7d953f9 --- /dev/null +++ b/cmd/build/helmify/static/templates/provider-proxy-networkpolicy.yaml @@ -0,0 +1,36 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.providerProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: provider-auth-proxy + policyTypes: [Ingress, Egress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.controller.acpRuntime.namespace }} + podSelector: + matchLabels: + orka.ai/network-role: provider-client + ports: [{protocol: TCP, port: 8080}] + egress: + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: kube-system} + podSelector: + matchLabels: {k8s-app: kube-dns} + ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: vekil-system} + podSelector: + matchLabels: {app.kubernetes.io/name: vekil} + ports: [{protocol: TCP, port: 1337}] +{{- end }} diff --git a/cmd/build/helmify/static/templates/provider-proxy-secret.yaml b/cmd/build/helmify/static/templates/provider-proxy-secret.yaml new file mode 100644 index 000000000..4c49d9ce9 --- /dev/null +++ b/cmd/build/helmify/static/templates/provider-proxy-secret.yaml @@ -0,0 +1,31 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled (not .Values.providerProxy.auth.existingSecret) }} +{{- $name := include "orka.providerProxyName" . }} +{{- $key := .Values.providerProxy.auth.tokenKey | default "token" }} +{{- $previousKey := .Values.providerProxy.auth.previousTokenKey | default "previous-token" }} +{{- $previousValidUntilKey := .Values.providerProxy.auth.previousTokenValidUntilKey | default "previous-token-valid-until" }} +{{- $previousToken := .Values.providerProxy.auth.previousToken | default "" }} +{{- $previousValidUntil := .Values.providerProxy.auth.previousTokenValidUntil | default "" }} +{{- if or (eq $key $previousKey) (eq $key $previousValidUntilKey) (eq $previousKey $previousValidUntilKey) }} +{{- fail "providerProxy auth token keys must differ" }} +{{- end }} +{{- if ne (empty $previousToken) (empty $previousValidUntil) }} +{{- fail "providerProxy.auth.previousToken and previousTokenValidUntil must be set together" }} +{{- end }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }} +{{- $existingValue := "" }} +{{- if and $existing (hasKey $existing.data $key) }}{{- $existingValue = (index $existing.data $key | b64dec) }}{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: provider-auth-proxy +type: Opaque +data: + {{ $key }}: {{ default (default (randAlphaNum 64) $existingValue) .Values.providerProxy.auth.token | toString | b64enc | quote }} + {{- if $previousToken }} + {{ $previousKey }}: {{ $previousToken | toString | b64enc | quote }} + {{ $previousValidUntilKey }}: {{ $previousValidUntil | toString | b64enc | quote }} + {{- end }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/provider-proxy-service.yaml b/cmd/build/helmify/static/templates/provider-proxy-service.yaml new file mode 100644 index 000000000..ac79d3248 --- /dev/null +++ b/cmd/build/helmify/static/templates/provider-proxy-service.yaml @@ -0,0 +1,15 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "orka.providerProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: provider-auth-proxy +spec: + selector: + {{- include "orka.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: provider-auth-proxy + ports: + - {name: http, port: 8080, targetPort: http} +{{- end }} diff --git a/cmd/build/helmify/static/templates/provider-proxy-serviceaccount.yaml b/cmd/build/helmify/static/templates/provider-proxy-serviceaccount.yaml new file mode 100644 index 000000000..c1785784f --- /dev/null +++ b/cmd/build/helmify/static/templates/provider-proxy-serviceaccount.yaml @@ -0,0 +1,10 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "orka.providerProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: provider-auth-proxy +automountServiceAccountToken: false +{{- end }} diff --git a/cmd/build/helmify/static/templates/publisher-deployment.yaml b/cmd/build/helmify/static/templates/publisher-deployment.yaml new file mode 100644 index 000000000..e28838faa --- /dev/null +++ b/cmd/build/helmify/static/templates/publisher-deployment.yaml @@ -0,0 +1,113 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled (not .Values.scmEgressProxy.enabled) }} +{{- fail "publisher.enabled requires scmEgressProxy.enabled so public HTTPS egress remains proxy-only" }} +{{- end }} +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled }} +{{- $scmProxySecretName := .Values.scmEgressProxy.auth.existingSecret | default (include "orka.scmEgressProxyAuthSecretName" .) }} +{{- $scmProxyTokenKey := .Values.scmEgressProxy.auth.tokenKey | default "token" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "orka.publisherName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: workspace-publisher +spec: + replicas: 1 + strategy: {type: Recreate} + selector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: workspace-publisher + template: + metadata: + {{- if or .Values.publisher.auth.rolloutNonce .Values.scmEgressProxy.auth.rolloutNonce }} + annotations: + {{- with .Values.publisher.auth.rolloutNonce }} + orka.ai/publisher-auth-rollout-nonce: {{ . | quote }} + {{- end }} + {{- with .Values.scmEgressProxy.auth.rolloutNonce }} + orka.ai/scm-egress-proxy-auth-rollout-nonce: {{ . | quote }} + {{- end }} + {{- end }} + labels: + {{- include "orka.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: workspace-publisher + orka.ai/network-role: workspace-publisher + spec: + serviceAccountName: {{ include "orka.publisherServiceAccountName" . }} + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: {type: RuntimeDefault} + containers: + - name: publisher + image: {{ include "orka.imageRef" .Values.publisher.image | quote }} + imagePullPolicy: {{ .Values.publisher.image.pullPolicy }} + ports: + - {name: http, containerPort: 8080} + env: + - name: ORKA_SCM_EGRESS_PROXY_TOKEN + valueFrom: + secretKeyRef: + name: {{ $scmProxySecretName }} + key: {{ $scmProxyTokenKey }} + - name: HTTPS_PROXY + value: {{ printf "http://orka-publisher:$(ORKA_SCM_EGRESS_PROXY_TOKEN)@%s.%s.svc:%v" (include "orka.scmEgressProxyName" .) .Release.Namespace 8080 | quote }} + - name: https_proxy + value: {{ printf "http://orka-publisher:$(ORKA_SCM_EGRESS_PROXY_TOKEN)@%s.%s.svc:%v" (include "orka.scmEgressProxyName" .) .Release.Namespace 8080 | quote }} + - {name: NO_PROXY, value: {{ .Values.scmEgressProxy.noProxy | quote }}} + - {name: no_proxy, value: {{ .Values.scmEgressProxy.noProxy | quote }}} + - {name: ORKA_PUBLISHER_SCM_EGRESS_PROXY_REQUIRED, value: "true"} + - {name: ORKA_PUBLISHER_LISTEN_ADDRESS, value: ":8080"} + - {name: ORKA_PUBLISHER_TEMP_ROOT, value: /tmp/orka-workspace-publisher/runtime} + - {name: ORKA_PUBLISHER_CONTROLLER_TOKEN_FILE, value: /var/run/orka/publisher-auth/controller-token} + - {name: ORKA_PUBLISHER_OPERATION_CAPABILITY_SECRET_FILE, value: /var/run/orka/publisher-auth/operation-capability-secret} + - name: ORKA_PUBLISHER_ARTIFACT_AUTHORIZATION_BROKER_URL + value: http://{{ include "orka.fullname" . }}:{{ .Values.service.port }} + - name: ORKA_PUBLISHER_ARTIFACT_API_URL + value: http://{{ include "orka.fullname" . }}:{{ .Values.service.port }} + - name: ORKA_PUBLISHER_CREDENTIAL_BROKER_URL + value: http://{{ include "orka.fullname" . }}:{{ .Values.service.port }} + - {name: ORKA_PUBLISHER_ALLOWED_SCM_HOSTS, value: {{ .Values.publisher.allowedSCMHosts | quote }}} + - {name: ORKA_PUBLISHER_GITHUB_PR_ENABLED, value: {{ .Values.publisher.githubPR.enabled | quote }}} + {{- if .Values.publisher.githubPR.enabled }} + - {name: ORKA_PUBLISHER_GITHUB_API_BASE_URL, value: {{ .Values.publisher.githubPR.apiBaseURL | quote }}} + - {name: ORKA_PUBLISHER_GITHUB_REQUEST_TIMEOUT, value: {{ .Values.publisher.githubPR.requestTimeout | quote }}} + - {name: ORKA_PUBLISHER_GITHUB_MAX_RESPONSE_BYTES, value: {{ printf "%d" (int64 .Values.publisher.githubPR.maxResponseBytes) | quote }}} + {{- end }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: + {{- toYaml .Values.publisher.resources | nindent 12 }} + volumeMounts: + - {name: data, mountPath: /data} + - {name: tmp, mountPath: /tmp/orka-workspace-publisher} + - {name: publisher-auth, mountPath: /var/run/orka/publisher-auth/controller-token, subPath: controller-token, readOnly: true} + - {name: publisher-auth, mountPath: /var/run/orka/publisher-auth/operation-capability-secret, subPath: operation-capability-secret, readOnly: true} + readinessProbe: + httpGet: {path: /v1/health, port: http} + livenessProbe: + httpGet: {path: /v1/health, port: http} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "orka.publisherName" . }} + - name: tmp + emptyDir: {sizeLimit: 1Gi} + - name: publisher-auth + secret: + secretName: {{ .Values.publisher.auth.existingSecret | default (include "orka.publisherAuthSecretName" .) }} + # subPath bind mounts expose regular files to the fail-closed + # publisher loader; fsGroup grants only the Pod group read access. + defaultMode: 0440 + items: + - key: {{ .Values.publisher.auth.controllerTokenKey | default "controller-token" }} + path: controller-token + - key: {{ .Values.publisher.auth.capabilitySecretKey | default "operation-capability-secret" }} + path: operation-capability-secret +{{- end }} diff --git a/cmd/build/helmify/static/templates/publisher-networkpolicy.yaml b/cmd/build/helmify/static/templates/publisher-networkpolicy.yaml new file mode 100644 index 000000000..7833f4c23 --- /dev/null +++ b/cmd/build/helmify/static/templates/publisher-networkpolicy.yaml @@ -0,0 +1,40 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.publisherName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: workspace-publisher + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: controller + ports: [{protocol: TCP, port: 8080}] + egress: + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: kube-system} + podSelector: + matchLabels: {k8s-app: kube-dns} + ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] + - to: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: controller + ports: [{protocol: TCP, port: {{ .Values.controller.apiPort }}}] + - to: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: scm-egress-proxy + ports: [{protocol: TCP, port: 8080}] +{{- end }} diff --git a/cmd/build/helmify/static/templates/publisher-pvc.yaml b/cmd/build/helmify/static/templates/publisher-pvc.yaml new file mode 100644 index 000000000..4f503ec33 --- /dev/null +++ b/cmd/build/helmify/static/templates/publisher-pvc.yaml @@ -0,0 +1,17 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "orka.publisherName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: workspace-publisher +spec: + accessModes: [ReadWriteOnce] + {{- if .Values.publisher.persistence.storageClass }} + storageClassName: {{ .Values.publisher.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.publisher.persistence.size }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/publisher-secret.yaml b/cmd/build/helmify/static/templates/publisher-secret.yaml new file mode 100644 index 000000000..1b36da859 --- /dev/null +++ b/cmd/build/helmify/static/templates/publisher-secret.yaml @@ -0,0 +1,23 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled (not .Values.publisher.auth.existingSecret) }} +{{- $name := include "orka.publisherAuthSecretName" . }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }} +{{- $controllerKey := .Values.publisher.auth.controllerTokenKey | default "controller-token" }} +{{- $capabilityKey := .Values.publisher.auth.capabilitySecretKey | default "operation-capability-secret" }} +{{- $existingController := "" }} +{{- $existingCapability := "" }} +{{- if $existing }} +{{- if hasKey $existing.data $controllerKey }}{{- $existingController = (index $existing.data $controllerKey | b64dec) }}{{- end }} +{{- if hasKey $existing.data $capabilityKey }}{{- $existingCapability = (index $existing.data $capabilityKey | b64dec) }}{{- end }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: workspace-publisher +type: Opaque +stringData: + {{ $controllerKey }}: {{ default (default (randAlphaNum 64) $existingController) .Values.publisher.auth.controllerToken | quote }} + {{ $capabilityKey }}: {{ default (default (randAlphaNum 64) $existingCapability) .Values.publisher.auth.capabilitySecret | quote }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/publisher-service.yaml b/cmd/build/helmify/static/templates/publisher-service.yaml new file mode 100644 index 000000000..9b561dbfc --- /dev/null +++ b/cmd/build/helmify/static/templates/publisher-service.yaml @@ -0,0 +1,17 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "orka.publisherName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: workspace-publisher +spec: + selector: + {{- include "orka.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: workspace-publisher + ports: + - name: http + port: 8080 + targetPort: http +{{- end }} diff --git a/cmd/build/helmify/static/templates/rbac.yaml b/cmd/build/helmify/static/templates/rbac.yaml index be674d840..c4ffe2fa8 100644 --- a/cmd/build/helmify/static/templates/rbac.yaml +++ b/cmd/build/helmify/static/templates/rbac.yaml @@ -1,198 +1,263 @@ {{- if .Values.rbac.create -}} -# Controller ClusterRole +{{- $harnessV2 := eq .Values.controller.mode "harness-v2" -}} +# Controller tenant Role. The release namespace is also the immutable watch namespace. apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: Role metadata: - name: {{ include "orka.fullname" . }}-controller + name: {{ include "orka.controllerName" . }} + namespace: {{ .Release.Namespace }} labels: {{- include "orka.labels" . | nindent 4 }} rules: - # Custom Resource permissions - apiGroups: ["core.orka.ai"] - resources: ["tasks", "tools", "agents", "agentruntimes", "providers", "skills", "repositorymonitors", "repositoryscans", "substrateactorpools", "outboundaccesspolicies"] + resources: ["tasks", "tools", "agents", "agentruntimes", "runtimepools", "promptattempts", "runtimesessioncontrols", "publications", "controllerepochs", "externaleffects", "providers", "skills", "repositorymonitors", "repositoryscans", "substrateactorpools", "outboundaccesspolicies"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["core.orka.ai"] - resources: ["tasks/status", "tools/status", "agents/status", "agentruntimes/status", "providers/status", "skills/status", "repositorymonitors/status", "repositoryscans/status", "substrateactorpools/status", "outboundaccesspolicies/status"] + resources: ["tasks/status", "tools/status", "agents/status", "agentruntimes/status", "runtimepools/status", "promptattempts/status", "runtimesessioncontrols/status", "publications/status", "controllerepochs/status", "externaleffects/status", "providers/status", "skills/status", "repositorymonitors/status", "repositoryscans/status", "substrateactorpools/status", "outboundaccesspolicies/status"] verbs: ["get", "update", "patch"] - apiGroups: ["core.orka.ai"] - resources: ["tasks/finalizers", "tools/finalizers", "agents/finalizers", "agentruntimes/finalizers", "providers/finalizers", "skills/finalizers", "repositorymonitors/finalizers", "repositoryscans/finalizers", "substrateactorpools/finalizers", "outboundaccesspolicies/finalizers"] + resources: ["tasks/finalizers", "tools/finalizers", "agents/finalizers", "agentruntimes/finalizers", "runtimepools/finalizers", "promptattempts/finalizers", "runtimesessioncontrols/finalizers", "publications/finalizers", "controllerepochs/finalizers", "externaleffects/finalizers", "providers/finalizers", "skills/finalizers", "repositorymonitors/finalizers", "repositoryscans/finalizers", "substrateactorpools/finalizers", "outboundaccesspolicies/finalizers"] verbs: ["update"] + {{- if $harnessV2 }} - apiGroups: ["gateway.orka.ai"] - resources: ["gatewayclasses", "gateways", "gatewaybindings"] + resources: ["gateways", "gatewaybindings"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["gateway.orka.ai"] - resources: ["gatewayclasses/status", "gateways/status", "gatewaybindings/status"] + resources: ["gateways/status", "gatewaybindings/status"] verbs: ["get", "update", "patch"] - apiGroups: ["gateway.orka.ai"] - resources: ["gatewayclasses/finalizers", "gateways/finalizers", "gatewaybindings/finalizers"] + resources: ["gateways/finalizers", "gatewaybindings/finalizers"] verbs: ["update"] - - apiGroups: ["apiextensions.k8s.io"] - resources: ["customresourcedefinitions"] - resourceNames: ["tasks.core.orka.ai", "gatewayclasses.gateway.orka.ai", "gateways.gateway.orka.ai", "gatewaybindings.gateway.orka.ai"] - verbs: ["get"] - - # Provider-neutral Execution Workspace control plane - apiGroups: ["workspace.orka.ai"] - resources: ["executionworkspaceclasses"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "use"] + resources: ["executionworkspaces", "executionworkspaceclasses", "executionworkspacepools"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # The workspace admission policies gate spec.coreAdmission writes on the + # "admit" verb and class-backed Task/Tool creation on the "use" verb, so the + # controller needs both or fail-closed admission rejects its own writes. - apiGroups: ["workspace.orka.ai"] resources: ["executionworkspaces"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "admit"] - - apiGroups: ["workspace.orka.ai"] - resources: ["executionworkspacepools"] - verbs: ["get", "list", "watch"] + verbs: ["admit"] - apiGroups: ["workspace.orka.ai"] - resources: ["executionworkspaceproviders"] - verbs: ["get", "list", "watch", "update", "patch"] + resources: ["executionworkspaceclasses"] + verbs: ["use"] - apiGroups: ["workspace.orka.ai"] - resources: ["executionworkspaceproviders/status", "executionworkspaceclasses/status", "executionworkspacepools/status", "executionworkspaces/status"] + resources: ["executionworkspaces/status", "executionworkspaceclasses/status", "executionworkspacepools/status"] verbs: ["get", "update", "patch"] - apiGroups: ["workspace.orka.ai"] - resources: ["executionworkspaceproviders/finalizers", "executionworkspaceclasses/finalizers", "executionworkspaces/finalizers"] + resources: ["executionworkspaces/finalizers", "executionworkspaceclasses/finalizers"] verbs: ["update"] - - # Job permissions + - apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] + - apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxtemplates", "sandboxwarmpools"] + verbs: ["get", "list", "watch"] + - apiGroups: ["agents.x-k8s.io"] + resources: ["sandboxes"] + verbs: ["get", "list", "watch"] + - apiGroups: ["ate.dev"] + resources: ["actortemplates"] + verbs: ["get", "list", "watch"] + {{- end }} - apiGroups: ["batch"] resources: ["jobs"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["batch"] resources: ["cronjobs"] verbs: ["get", "list", "watch"] - - # Core resource permissions - apiGroups: [""] - resources: ["configmaps"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["secrets"] + resources: ["configmaps", "secrets", "pods", "services"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["pods/log"] verbs: ["get"] - apiGroups: [""] - resources: ["pods/status"] + resources: ["pods/status", "endpoints", "persistentvolumeclaims", "replicationcontrollers"] verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["serviceaccounts"] verbs: ["get", "list", "watch", "create", "update"] - apiGroups: [""] - resources: ["serviceaccounts/token"] + resources: ["serviceaccounts/token", "pods/portforward"] verbs: ["create"] - - apiGroups: [""] - resources: ["namespaces"] - verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["events"] verbs: ["get", "list", "watch", "create", "patch"] - - apiGroups: [""] - resources: ["nodes", "services", "endpoints"] - verbs: ["get", "list", "watch"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["persistentvolumes", "persistentvolumeclaims", "replicationcontrollers"] - verbs: ["get", "list", "watch"] - - # Workload resource permissions (read-only, for chat K8s tools) - apiGroups: ["apps"] - resources: ["deployments", "replicasets", "statefulsets", "daemonsets"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["statefulsets", "daemonsets"] verbs: ["get", "list", "watch"] - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "list", "watch"] - apiGroups: ["networking.k8s.io"] resources: ["networkpolicies"] - verbs: ["get", "list", "watch", "create", "delete"] - # Agent sandbox workspace backend: create/reattach/delete claims and connect to sandbox pods - - apiGroups: ["extensions.agents.x-k8s.io"] - resources: ["sandboxclaims"] - verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] - - apiGroups: ["extensions.agents.x-k8s.io"] - resources: ["sandboxtemplates", "sandboxwarmpools"] - verbs: ["get", "list", "watch"] - - apiGroups: ["agents.x-k8s.io"] - resources: ["sandboxes"] - verbs: ["get", "list", "watch"] - # Substrate workspace backend: validate approved ActorTemplates before job creation - - apiGroups: ["ate.dev"] - resources: ["actortemplates"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["pods/portforward"] - verbs: ["create"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["autoscaling"] resources: ["horizontalpodautoscalers"] verbs: ["get", "list", "watch"] - apiGroups: ["policy"] resources: ["poddisruptionbudgets"] - verbs: ["get", "list", "watch"] - - apiGroups: ["storage.k8s.io"] - resources: ["storageclasses"] - verbs: ["get", "list", "watch"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["events.k8s.io"] resources: ["events"] verbs: ["get", "list"] - apiGroups: ["metrics.k8s.io"] - resources: ["pods", "nodes"] + resources: ["pods"] verbs: ["get", "list"] - - # RBAC permissions (for chat K8s tools and managed worker bindings) - - apiGroups: ["rbac.authorization.k8s.io"] - resources: ["clusterroles", "roles", "rolebindings"] - verbs: ["get", "list", "watch"] - apiGroups: ["rbac.authorization.k8s.io"] resources: ["roles", "rolebindings"] - verbs: ["create", "update", "delete"] - - apiGroups: ["rbac.authorization.k8s.io"] - resources: ["clusterrolebindings"] verbs: ["get", "list", "watch", "create", "update", "delete"] - - apiGroups: ["rbac.authorization.k8s.io"] - resources: ["clusterroles"] + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "orka.controllerName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "orka.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "orka.controllerName" . }} +subjects: + - kind: ServiceAccount + name: {{ include "orka.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- if $harnessV2 }} +--- +# Runtime child resources are confined to the v2 installation's runtime namespace. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "orka.controllerName" . }}-runtime + namespace: {{ .Values.controller.acpRuntime.namespace }} + labels: + {{- include "orka.labels" . | nindent 4 }} +rules: + - apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["pods", "services", "secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "orka.controllerName" . }}-runtime + namespace: {{ .Values.controller.acpRuntime.namespace }} + labels: + {{- include "orka.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "orka.controllerName" . }}-runtime +subjects: + - kind: ServiceAccount + name: {{ include "orka.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} +--- +# Minimal cluster-scoped controller authority. Namespaced workload access is never bound here. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "orka.controllerClusterRoleName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["namespaces"] resourceNames: - - {{ include "orka.aiWorkerClusterRoleName" . }} - - {{ include "orka.vendorWorkerClusterRoleName" . }} - - {{ include "orka.containerWorkerClusterRoleName" . }} - verbs: ["bind"] - - # TokenReview authenticates caller tokens; SubjectAccessReview authorizes Kubernetes callers + - {{ .Release.Namespace }} + {{- if $harnessV2 }} + - {{ .Values.controller.acpRuntime.namespace }} + {{- end }} + verbs: ["get"] - apiGroups: ["authentication.k8s.io"] resources: ["tokenreviews"] verbs: ["create"] - apiGroups: ["authorization.k8s.io"] resources: ["subjectaccessreviews"] verbs: ["create"] - - # Leader election - - apiGroups: ["coordination.k8s.io"] - resources: ["leases"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: ["clusterroles"] + resourceNames: + - {{ include "orka.aiWorkerClusterRoleName" . }} + - {{ include "orka.vendorWorkerClusterRoleName" . }} + - {{ include "orka.containerWorkerClusterRoleName" . }} + verbs: ["bind"] + {{- if $harnessV2 }} + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + resourceNames: ["tasks.core.orka.ai", "gatewayclasses.gateway.orka.ai", "gateways.gateway.orka.ai", "gatewaybindings.gateway.orka.ai"] + verbs: ["get"] + - apiGroups: ["core.orka.ai"] + resources: ["branchclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["core.orka.ai"] + resources: ["branchclaims/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["core.orka.ai"] + resources: ["branchclaims/finalizers"] + verbs: ["update"] + - apiGroups: ["gateway.orka.ai"] + resources: ["gatewayclasses"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["gateway.orka.ai"] + resources: ["gatewayclasses/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["gateway.orka.ai"] + resources: ["gatewayclasses/finalizers"] + verbs: ["update"] + - apiGroups: ["workspace.orka.ai"] + resources: ["executionworkspaceproviders"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["workspace.orka.ai"] + resources: ["executionworkspaceproviders/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["workspace.orka.ai"] + resources: ["executionworkspaceproviders/finalizers"] + verbs: ["update"] + {{- end }} --- -# Controller ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: {{ include "orka.fullname" . }}-controller + name: {{ include "orka.controllerClusterRoleName" . }} labels: {{- include "orka.labels" . | nindent 4 }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: {{ include "orka.fullname" . }}-controller + name: {{ include "orka.controllerClusterRoleName" . }} subjects: - kind: ServiceAccount name: {{ include "orka.serviceAccountName" . }} namespace: {{ .Release.Namespace }} --- -# Client ClusterRole (for API access) +# Client Role (for API access in this installation's namespace) {{- if .Values.client.create }} apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: Role metadata: name: {{ include "orka.fullname" . }}-client + namespace: {{ include "orka.clientNamespace" . }} labels: {{- include "orka.labels" . | nindent 4 }} rules: @@ -227,10 +292,12 @@ rules: resources: ["skills"] verbs: ["get", "list", "watch"] + {{- if $harnessV2 }} # Generic gateway resource permissions (read-only; durable ledgers use the Orka REST API). - apiGroups: ["gateway.orka.ai"] resources: ["gateways", "gatewaybindings"] verbs: ["get", "list", "watch"] + {{- end }} # ConfigMap permissions (for results and sessions) - apiGroups: [""] @@ -238,28 +305,21 @@ rules: verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 -{{- if .Values.controller.enforceNamespaceIsolation }} kind: RoleBinding metadata: name: {{ include "orka.fullname" . }}-client namespace: {{ include "orka.clientNamespace" . }} labels: {{- include "orka.labels" . | nindent 4 }} -{{- else }} -kind: ClusterRoleBinding -metadata: - name: {{ include "orka.fullname" . }}-client - labels: - {{- include "orka.labels" . | nindent 4 }} -{{- end }} roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: {{ include "orka.fullname" . }}-client subjects: - kind: ServiceAccount name: {{ .Values.client.name }} namespace: {{ include "orka.clientNamespace" . }} +{{- if $harnessV2 }} --- # GatewayClass is cluster-scoped, so isolated clients need a separate ClusterRoleBinding for read access. apiVersion: rbac.authorization.k8s.io/v1 @@ -288,6 +348,7 @@ subjects: name: {{ .Values.client.name }} namespace: {{ include "orka.clientNamespace" . }} {{- end }} +{{- end }} --- # AI Worker Role (trusted worker with Kubernetes tool and code_exec permissions) apiVersion: rbac.authorization.k8s.io/v1 @@ -304,7 +365,7 @@ rules: resources: ["secrets"] verbs: ["get", "create", "update", "delete"] - apiGroups: ["core.orka.ai"] - resources: ["tools", "outboundaccesspolicies"] + resources: ["tools"] verbs: ["get", "list"] - apiGroups: ["core.orka.ai"] resources: ["agents"] @@ -372,20 +433,12 @@ rules: verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 -{{- if .Values.controller.enforceNamespaceIsolation }} kind: RoleBinding metadata: - name: {{ include "orka.aiWorkerClusterRoleBindingName" . }} + name: {{ include "orka.aiWorkerRoleBindingName" . }} namespace: {{ .Release.Namespace }} labels: {{- include "orka.labels" . | nindent 4 }} -{{- else }} -kind: ClusterRoleBinding -metadata: - name: {{ include "orka.aiWorkerClusterRoleBindingName" . }} - labels: - {{- include "orka.labels" . | nindent 4 }} -{{- end }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole @@ -407,7 +460,7 @@ rules: resources: ["configmaps"] verbs: ["get", "list", "watch"] - apiGroups: ["core.orka.ai"] - resources: ["tools", "outboundaccesspolicies"] + resources: ["tools"] verbs: ["get", "list"] - apiGroups: ["core.orka.ai"] resources: ["agents"] @@ -466,20 +519,12 @@ rules: verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 -{{- if .Values.controller.enforceNamespaceIsolation }} kind: RoleBinding metadata: - name: {{ include "orka.vendorWorkerClusterRoleBindingName" . }} + name: {{ include "orka.vendorWorkerRoleBindingName" . }} namespace: {{ .Release.Namespace }} labels: {{- include "orka.labels" . | nindent 4 }} -{{- else }} -kind: ClusterRoleBinding -metadata: - name: {{ include "orka.vendorWorkerClusterRoleBindingName" . }} - labels: - {{- include "orka.labels" . | nindent 4 }} -{{- end }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole @@ -506,20 +551,12 @@ rules: verbs: ["get"] --- apiVersion: rbac.authorization.k8s.io/v1 -{{- if .Values.controller.enforceNamespaceIsolation }} kind: RoleBinding metadata: - name: {{ include "orka.containerWorkerClusterRoleBindingName" . }} + name: {{ include "orka.containerWorkerRoleBindingName" . }} namespace: {{ .Release.Namespace }} labels: {{- include "orka.labels" . | nindent 4 }} -{{- else }} -kind: ClusterRoleBinding -metadata: - name: {{ include "orka.containerWorkerClusterRoleBindingName" . }} - labels: - {{- include "orka.labels" . | nindent 4 }} -{{- end }} roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole @@ -528,48 +565,4 @@ subjects: - kind: ServiceAccount name: {{ include "orka.containerWorkerServiceAccountName" . }} namespace: {{ .Release.Namespace }} - ---- -# Adapter charts aggregate read-only access to their provider-specific parameter CRDs here. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: {{ include "orka.fullname" . }}-workspace-parameter-reader - labels: - {{- include "orka.labels" . | nindent 4 }} -aggregationRule: - clusterRoleSelectors: - - matchLabels: - workspace.orka.ai/aggregate-to-parameter-reader: "true" -rules: [] -{{- if .Values.controller.workspaceProvider.fakeProviderEnabled }} ---- -# Development-only fake provider parameters contribute to the aggregate reader. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: {{ include "orka.fullname" . }}-fake-workspace-parameter-reader - labels: - {{- include "orka.labels" . | nindent 4 }} - workspace.orka.ai/aggregate-to-parameter-reader: "true" -rules: - - apiGroups: ["fake.workspace.orka.ai"] - resources: ["fakeproviderconfigs", "fakepoolparameters"] - verbs: ["get", "list", "watch"] -{{- end }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: {{ include "orka.fullname" . }}-workspace-parameter-reader - labels: - {{- include "orka.labels" . | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: {{ include "orka.fullname" . }}-workspace-parameter-reader -subjects: - - kind: ServiceAccount - name: {{ include "orka.serviceAccountName" . }} - namespace: {{ .Release.Namespace }} {{- end }} diff --git a/cmd/build/helmify/static/templates/runtime-namespace.yaml b/cmd/build/helmify/static/templates/runtime-namespace.yaml new file mode 100644 index 000000000..94295f497 --- /dev/null +++ b/cmd/build/helmify/static/templates/runtime-namespace.yaml @@ -0,0 +1,13 @@ +{{- if eq .Values.controller.mode "harness-v2" }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.controller.acpRuntime.namespace }} + labels: + app.kubernetes.io/name: {{ include "orka.name" . }} + app.kubernetes.io/component: acp-runtime + app.kubernetes.io/managed-by: {{ .Release.Service }} + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/audit: restricted +{{- end }} diff --git a/cmd/build/helmify/static/templates/scm-egress-proxy-deployment.yaml b/cmd/build/helmify/static/templates/scm-egress-proxy-deployment.yaml new file mode 100644 index 000000000..434969dc6 --- /dev/null +++ b/cmd/build/helmify/static/templates/scm-egress-proxy-deployment.yaml @@ -0,0 +1,86 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled .Values.scmEgressProxy.enabled }} +{{- $secretName := .Values.scmEgressProxy.auth.existingSecret | default (include "orka.scmEgressProxyAuthSecretName" .) }} +{{- $tokenKey := .Values.scmEgressProxy.auth.tokenKey | default "token" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "orka.scmEgressProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: scm-egress-proxy +spec: + replicas: 1 + strategy: {type: Recreate} + selector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: scm-egress-proxy + template: + metadata: + {{- with .Values.scmEgressProxy.auth.rolloutNonce }} + annotations: + orka.ai/scm-egress-proxy-auth-rollout-nonce: {{ . | quote }} + {{- end }} + labels: + {{- include "orka.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: scm-egress-proxy + orka.ai/network-role: scm-egress-proxy + spec: + serviceAccountName: {{ include "orka.scmEgressProxyName" . }} + automountServiceAccountToken: false + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: {type: RuntimeDefault} + containers: + - name: proxy + image: {{ include "orka.imageRef" .Values.controller.image | quote }} + imagePullPolicy: {{ .Values.controller.image.pullPolicy }} + command: [/scm-egress-proxy] + args: + - --listen-address=:8080 + - {{ printf "--allowed-hosts=%s" .Values.publisher.allowedSCMHosts | quote }} + {{- if .Values.publisher.githubPR.enabled }} + - {{ printf "--forge-api-base-url=%s" .Values.publisher.githubPR.apiBaseURL | quote }} + {{- else }} + - --forge-api-base-url= + {{- end }} + - --token-file=/var/run/secrets/orka/scm-egress/token + - {{ printf "--max-request-header-bytes=%d" (int64 .Values.scmEgressProxy.maxRequestHeaderBytes) | quote }} + - {{ printf "--max-response-header-bytes=%d" (int64 .Values.scmEgressProxy.maxResponseHeaderBytes) | quote }} + - {{ printf "--max-request-bytes=%d" (int64 .Values.scmEgressProxy.maxRequestBytes) | quote }} + - {{ printf "--max-response-bytes=%d" (int64 .Values.scmEgressProxy.maxResponseBytes) | quote }} + - {{ printf "--max-tunnel-bytes=%d" (int64 .Values.scmEgressProxy.maxTunnelBytes) | quote }} + - {{ printf "--max-concurrent=%v" .Values.scmEgressProxy.maxConcurrent | quote }} + - {{ printf "--resolution-timeout=%s" .Values.scmEgressProxy.resolutionTimeout | quote }} + - {{ printf "--connect-timeout=%s" .Values.scmEgressProxy.connectTimeout | quote }} + - {{ printf "--response-header-timeout=%s" .Values.scmEgressProxy.responseHeaderTimeout | quote }} + - {{ printf "--forward-timeout=%s" .Values.scmEgressProxy.forwardTimeout | quote }} + - {{ printf "--idle-timeout=%s" .Values.scmEgressProxy.idleTimeout | quote }} + - {{ printf "--tunnel-timeout=%s" .Values.scmEgressProxy.tunnelTimeout | quote }} + ports: + - {name: http-proxy, containerPort: 8080} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: + {{- toYaml .Values.scmEgressProxy.resources | nindent 12 }} + readinessProbe: + httpGet: {path: /readyz, port: http-proxy} + livenessProbe: + httpGet: {path: /healthz, port: http-proxy} + volumeMounts: + - {name: auth, mountPath: /var/run/secrets/orka/scm-egress/token, subPath: token, readOnly: true} + volumes: + - name: auth + secret: + secretName: {{ $secretName }} + defaultMode: 0440 + items: + - key: {{ $tokenKey }} + path: token +{{- end }} diff --git a/cmd/build/helmify/static/templates/scm-egress-proxy-networkpolicy.yaml b/cmd/build/helmify/static/templates/scm-egress-proxy-networkpolicy.yaml new file mode 100644 index 000000000..0687aa907 --- /dev/null +++ b/cmd/build/helmify/static/templates/scm-egress-proxy-networkpolicy.yaml @@ -0,0 +1,63 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled .Values.scmEgressProxy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.scmEgressProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: scm-egress-proxy + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: + matchLabels: + {{- include "orka.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: workspace-publisher + ports: [{protocol: TCP, port: 8080}] + egress: + - to: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: kube-system} + podSelector: + matchLabels: {k8s-app: kube-dns} + ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 0.0.0.0/8 + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.0.0.0/24 + - 192.0.2.0/24 + - 192.168.0.0/16 + - 198.18.0.0/15 + - 198.51.100.0/24 + - 203.0.113.0/24 + - 224.0.0.0/4 + - 240.0.0.0/4 + ports: [{protocol: TCP, port: 443}] + - to: + - ipBlock: + cidr: ::/0 + except: + - ::/128 + - ::1/128 + - 64:ff9b::/96 + - 64:ff9b:1::/48 + - 100::/64 + - 2001::/32 + - 2001:db8::/32 + - 2002::/16 + - fc00::/7 + - fe80::/10 + - ff00::/8 + ports: [{protocol: TCP, port: 443}] +{{- end }} diff --git a/cmd/build/helmify/static/templates/scm-egress-proxy-secret.yaml b/cmd/build/helmify/static/templates/scm-egress-proxy-secret.yaml new file mode 100644 index 000000000..609dd6cf9 --- /dev/null +++ b/cmd/build/helmify/static/templates/scm-egress-proxy-secret.yaml @@ -0,0 +1,21 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled .Values.scmEgressProxy.enabled (not .Values.scmEgressProxy.auth.existingSecret) }} +{{- $name := include "orka.scmEgressProxyAuthSecretName" . }} +{{- $key := .Values.scmEgressProxy.auth.tokenKey | default "token" }} +{{- $explicit := .Values.scmEgressProxy.auth.token | default "" | toString }} +{{- if and $explicit (not (regexMatch "^[A-Za-z0-9._~-]{32,256}$" $explicit)) }} +{{- fail "scmEgressProxy.auth.token must contain 32-256 RFC 3986 unreserved characters" }} +{{- end }} +{{- $existing := lookup "v1" "Secret" .Release.Namespace $name }} +{{- $existingValue := "" }} +{{- if and $existing (hasKey $existing.data $key) }}{{- $existingValue = (index $existing.data $key | b64dec) }}{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $name }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: scm-egress-proxy +type: Opaque +stringData: + {{ $key }}: {{ default (default (randAlphaNum 64) $existingValue) $explicit | quote }} +{{- end }} diff --git a/cmd/build/helmify/static/templates/scm-egress-proxy-service.yaml b/cmd/build/helmify/static/templates/scm-egress-proxy-service.yaml new file mode 100644 index 000000000..f57291c70 --- /dev/null +++ b/cmd/build/helmify/static/templates/scm-egress-proxy-service.yaml @@ -0,0 +1,15 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled .Values.scmEgressProxy.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "orka.scmEgressProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: scm-egress-proxy +spec: + selector: + {{- include "orka.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: scm-egress-proxy + ports: + - {name: http-proxy, port: 8080, targetPort: http-proxy} +{{- end }} diff --git a/cmd/build/helmify/static/templates/scm-egress-proxy-serviceaccount.yaml b/cmd/build/helmify/static/templates/scm-egress-proxy-serviceaccount.yaml new file mode 100644 index 000000000..11fc06334 --- /dev/null +++ b/cmd/build/helmify/static/templates/scm-egress-proxy-serviceaccount.yaml @@ -0,0 +1,10 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled .Values.scmEgressProxy.enabled }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "orka.scmEgressProxyName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: scm-egress-proxy +automountServiceAccountToken: false +{{- end }} diff --git a/cmd/build/helmify/static/templates/serviceaccount.yaml b/cmd/build/helmify/static/templates/serviceaccount.yaml index e7c65e6b9..0bea13d7d 100644 --- a/cmd/build/helmify/static/templates/serviceaccount.yaml +++ b/cmd/build/helmify/static/templates/serviceaccount.yaml @@ -11,6 +11,21 @@ metadata: {{- end }} {{- end }} --- +{{- if and (eq .Values.controller.mode "harness-v2") .Values.publisher.enabled .Values.publisher.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "orka.publisherServiceAccountName" . }} + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: workspace-publisher + {{- with .Values.publisher.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: false +{{- end }} +--- {{- if .Values.client.create }} apiVersion: v1 kind: ServiceAccount @@ -48,11 +63,3 @@ metadata: {{- include "orka.labels" . | nindent 4 }} orka.ai/worker: "true" orka.ai/worker-trust: "container" ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "orka.harnessWrapperName" . }} - labels: - {{- include "orka.labels" . | nindent 4 }} - app.kubernetes.io/component: agent-harness-wrapper diff --git a/cmd/build/helmify/static/templates/store-pvc.yaml b/cmd/build/helmify/static/templates/store-pvc.yaml index b13ba6e81..9463d9224 100644 --- a/cmd/build/helmify/static/templates/store-pvc.yaml +++ b/cmd/build/helmify/static/templates/store-pvc.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: {{ include "orka.fullname" . }}-store + name: {{ include "orka.storeName" . }} labels: {{- include "orka.labels" . | nindent 4 }} app.kubernetes.io/component: store diff --git a/cmd/build/helmify/static/templates/vekil-ingress-networkpolicy.yaml b/cmd/build/helmify/static/templates/vekil-ingress-networkpolicy.yaml new file mode 100644 index 000000000..b5549c1e9 --- /dev/null +++ b/cmd/build/helmify/static/templates/vekil-ingress-networkpolicy.yaml @@ -0,0 +1,24 @@ +{{- if and (eq .Values.controller.mode "harness-v2") .Values.providerProxy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "orka.vekilIngressPolicyName" . }} + namespace: vekil-system + labels: + {{- include "orka.labels" . | nindent 4 }} + app.kubernetes.io/component: provider-auth-proxy +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: vekil + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + podSelector: + matchLabels: + orka.ai/network-role: provider-auth-proxy + ports: [{protocol: TCP, port: 1337}] +{{- end }} diff --git a/cmd/build/helmify/static/templates/workspace-class-use-webhook.yaml b/cmd/build/helmify/static/templates/workspace-class-use-webhook.yaml deleted file mode 100644 index 216d9678f..000000000 --- a/cmd/build/helmify/static/templates/workspace-class-use-webhook.yaml +++ /dev/null @@ -1,66 +0,0 @@ -{{- $workspaceProvider := .Values.controller.workspaceProvider | default dict -}} -{{- $classUseAdmission := $workspaceProvider.classUseAdmission | default dict -}} -{{- if $classUseAdmission.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "orka.fullname" . }}-workspace-webhook - labels: - {{- include "orka.labels" . | nindent 4 }} - app.kubernetes.io/component: controller -spec: - ports: - - name: https - port: 443 - targetPort: webhook-server - protocol: TCP - selector: - {{- include "orka.selectorLabels" . | nindent 4 }} - app.kubernetes.io/component: controller ---- -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingWebhookConfiguration -metadata: - name: {{ include "orka.fullname" . }}-workspace-class-use - labels: - {{- include "orka.labels" . | nindent 4 }} -webhooks: - - name: taskworkspaceclassuse.core.orka.ai - admissionReviewVersions: ["v1"] - sideEffects: None - failurePolicy: Fail - matchPolicy: Equivalent - timeoutSeconds: 10 - clientConfig: - service: - name: {{ include "orka.fullname" . }}-workspace-webhook - namespace: {{ .Release.Namespace }} - path: /validate-core-orka-ai-v1alpha1-task-workspace-class-use - port: 443 - caBundle: {{ $classUseAdmission.caBundle | quote }} - rules: - - operations: ["CREATE", "UPDATE"] - apiGroups: ["core.orka.ai"] - apiVersions: ["v1alpha1"] - resources: ["tasks"] - scope: Namespaced - - name: toolworkspaceclassuse.core.orka.ai - admissionReviewVersions: ["v1"] - sideEffects: None - failurePolicy: Fail - matchPolicy: Equivalent - timeoutSeconds: 10 - clientConfig: - service: - name: {{ include "orka.fullname" . }}-workspace-webhook - namespace: {{ .Release.Namespace }} - path: /validate-core-orka-ai-v1alpha1-tool-workspace-class-use - port: 443 - caBundle: {{ $classUseAdmission.caBundle | quote }} - rules: - - operations: ["CREATE", "UPDATE"] - apiGroups: ["core.orka.ai"] - apiVersions: ["v1alpha1"] - resources: ["tools"] - scope: Namespaced -{{- end }} diff --git a/cmd/build/helmify/static/templates/workspace-crd-upgrade-guard.yaml b/cmd/build/helmify/static/templates/workspace-crd-upgrade-guard.yaml deleted file mode 100644 index 561ba7849..000000000 --- a/cmd/build/helmify/static/templates/workspace-crd-upgrade-guard.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- $workspaceProvider := .Values.controller.workspaceProvider | default dict -}} -{{- if $workspaceProvider.apiEnabled -}} -{{- $workspaceCRD := lookup "apiextensions.k8s.io/v1" "CustomResourceDefinition" "" "executionworkspaces.workspace.orka.ai" -}} -{{- $schemaReady := false -}} -{{- if $workspaceCRD -}} - {{- range $version := $workspaceCRD.spec.versions -}} - {{- if eq $version.name "v1alpha1" -}} - {{- $properties := $version.schema.openAPIV3Schema.properties.spec.properties -}} - {{- if hasKey $properties "coreAdmission" -}} - {{- $schemaReady = true -}} - {{- end -}} - {{- end -}} - {{- end -}} -{{- end -}} -{{- $schemaCheckOverridden := $workspaceProvider.crdUpgradeSchemaVerified | default false -}} -{{- if and (or .Release.IsUpgrade $workspaceCRD) (not $schemaReady) (not $schemaCheckOverridden) -}} - {{- fail "controller.workspaceProvider.apiEnabled requires the current workspace CRDs; apply them with `helm show crds | kubectl apply --server-side -f -`, or set controller.workspaceProvider.crdUpgradeSchemaVerified=true only for offline rendering after independent verification" -}} -{{- end -}} -{{- end -}} diff --git a/cmd/build/helmify/static/values.yaml b/cmd/build/helmify/static/values.yaml index b3aa75189..2b470dba3 100644 --- a/cmd/build/helmify/static/values.yaml +++ b/cmd/build/helmify/static/values.yaml @@ -2,6 +2,10 @@ # Controller configuration controller: + # Required immutable execution contract for this installation. Install v1 + # and v2 as separate releases; one release never serves both contracts. + mode: "" + # Number of controller replicas replicas: 1 @@ -9,6 +13,7 @@ controller: image: repository: ghcr.io/orka-agents/orka tag: "0.1.1" + digest: "" pullPolicy: IfNotPresent # Resource limits @@ -20,7 +25,8 @@ controller: cpu: 100m memory: 256Mi - # Watch namespace (empty for cluster-scoped) + # Required exclusive tenant namespace. Label it with + # orka.ai/controller-mode matching controller.mode before startup. watchNamespace: "" # API server port @@ -32,37 +38,60 @@ controller: # Health probe port healthPort: 8082 - # Enable leader election for HA + # Leader election is mandatory for the singleton SQLite controller. leaderElect: true # Log level (debug, info, warn, error) logLevel: info - # Enforce namespace isolation (restrict users to their SA namespace) - enforceNamespaceIsolation: true - # Max active tasks per namespace (0 = unlimited) maxTasksPerNamespace: 0 - # Provider-neutral workspace.orka.ai control plane (disabled by default during rollout). - # Before enabling it during a Helm upgrade, apply the current chart CRDs explicitly; - # Helm does not upgrade CRDs from the crds/ directory and the chart fails closed otherwise. - workspaceProvider: - # Enable generic provider/class/pool/workspace coordination controllers. - apiEnabled: false - # Offline helm template --is-upgrade cannot use lookup. Set this only after - # independently verifying/applying the current workspace CRD schema. - crdUpgradeSchemaVerified: false - # Enable the development-only fake provider adapter. - fakeProviderEnabled: false - # Fail-closed Kubernetes admission required whenever apiEnabled is true. - classUseAdmission: - enabled: false - # Existing TLS Secret with tls.crt and tls.key for the webhook Service DNS name. - existingSecret: "" - # Base64-encoded PEM CA bundle trusted by the API server. - caBundle: "" - + # Operation-scoped ACP artifact transport stored on the controller PVC. + acpArtifact: + existingSecret: "" + secretKey: capability-secret + secret: "" + maxBytes: 536870912 + + # Encryption key for immutable agent execution snapshots. When either agent + # execution protocol is enabled, both fields must reference an existing + # Secret. The selected value must contain exactly 32 raw bytes or its base64 + # encoding. The Secret name, item key, and selected key material must remain + # unchanged for the lifetime of the release so retained snapshots stay + # decryptable. + agentExecutionSnapshot: + existingSecret: "" + key: "" + # A snapshot must remain unreferenced for this full period before GC. + retention: 720h + # Reference-aware GC scan cadence. + retentionInterval: 1h + + # Managed ACP core RuntimePools for built-in agent Tasks. + acpRuntime: + # Used only when controller.mode is harness-v2; there is no v1 fallback. + namespace: orka-runtimes + # The chart-managed provider proxy always runs in the Helm release namespace. + # Leave empty or set exactly to .Release.Namespace; other values are rejected. + providerProxyNamespace: "" + # Configure each provider that should be available with an immutable + # repository@sha256: reference. Empty providers remain unavailable + # and Tasks selecting them fail closed. + codexImage: "" + claudeImage: "" + copilotImage: "" + opencodeImage: "" + # Planned controller upgrades use a same-binary preStop child to call a + # loopback-only coordinator. Keep terminationGracePeriodSeconds greater + # than timeout and triggerTimeout so SIGTERM shutdown still has headroom. + upgradeDrain: + enabled: true + port: 8083 + timeout: 5m + pollInterval: 1s + triggerTimeout: 5m15s + terminationGracePeriodSeconds: 360 # Provider-neutral external gateway plane. gateway: enabled: true @@ -199,6 +228,206 @@ controller: # Exact namespace/name:port tuples trusted for cross-namespace token endpoint refs. trustedTokenEndpointServices: [] +# Fail-closed admission served by this release's internal webhook Service. The +# certificate must authenticate -orka-webhook..svc. +webhooks: + tls: + # Required. The chart never generates webhook certificates. + existingSecret: "" + certKey: tls.crt + privateKeyKey: tls.key + # Base64-encoded PEM CA bundle. Leave empty when an injector annotation is + # configured below. + caBundle: "" + # For example: cert-manager.io/inject-ca-from-secret: orka-v2/orka-webhook-tls + caInjectionAnnotations: {} + timeoutSeconds: 10 + +# Clean-room Workspace/Publisher service. +publisher: + enabled: true + image: + repository: ghcr.io/orka-agents/orka/workspace-publisher + tag: "0.1.1" + digest: "" + pullPolicy: IfNotPresent + allowedSCMHosts: github.com + githubPR: + enabled: true + apiBaseURL: https://api.github.com + requestTimeout: 15s + maxResponseBytes: 4194304 + auth: + existingSecret: "" + controllerTokenKey: controller-token + capabilitySecretKey: operation-capability-secret + controllerToken: "" + capabilitySecret: "" + # Non-secret revision marker. When rotating the publisher auth Secret, bump + # this value in the same Helm upgrade to restart controller and publisher. + rolloutNonce: "" + serviceAccount: + create: true + name: "" + annotations: {} + persistence: + size: 2Gi + storageClass: "" + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 512Mi + limits: + cpu: "2" + memory: 2Gi + ephemeral-storage: 2Gi + +# Authenticated, exact-host HTTPS egress boundary for the Workspace/Publisher. +# The Publisher has no direct public 443 egress; Git and forge API traffic must +# use this proxy, which re-resolves and validates every outbound connection. +scmEgressProxy: + enabled: true + noProxy: localhost,127.0.0.1,::1,.svc,.cluster.local + maxRequestHeaderBytes: 32768 + maxResponseHeaderBytes: 65536 + maxRequestBytes: 4194304 + maxResponseBytes: 8388608 + maxTunnelBytes: 1073741824 + maxConcurrent: 8 + resolutionTimeout: 5s + connectTimeout: 10s + responseHeaderTimeout: 30s + forwardTimeout: 2m + idleTimeout: 30s + tunnelTimeout: 10m + auth: + existingSecret: "" + tokenKey: token + # Existing Secret tokens must use only RFC 3986 unreserved characters and + # be 32-256 bytes so they can be carried in authenticated proxy userinfo. + token: "" + # Non-secret revision marker. When rotating the SCM proxy auth Secret, bump + # this value in the same Helm upgrade to restart publisher and SCM proxy. + rolloutNonce: "" + resources: + requests: + cpu: 25m + memory: 32Mi + ephemeral-storage: 32Mi + limits: + cpu: 500m + memory: 256Mi + ephemeral-storage: 128Mi + +# Authenticated boundary in front of the otherwise unauthenticated Vekil +# service. ACP RuntimePods can reach only this Service, never Vekil directly. +providerProxy: + enabled: false + # Only this chart-supported Vekil Service endpoint is accepted. One trailing + # slash is normalized; alternate hosts, namespaces, and ports are rejected. + upstreamBaseURL: http://vekil.vekil-system.svc:1337 + # Mounted Secret files are reloaded atomically; no proxy Pod restart is + # required for token changes. Reload failures make readiness fail and disable + # all authenticated forwarding until both files are valid again. + tokenReloadInterval: 5s + # Maximum remaining lifetime accepted from the overlap token's absolute + # deadline. Periodic reloads and Pod restarts cannot extend it. Maximum: 24h. + previousTokenOverlap: 10m + # Optional operator-controlled restart input for binary/flag changes or + # recovery. Change it to force only the provider-auth-proxy Pod to roll. + rolloutNonce: "" + auth: + existingSecret: "" + tokenKey: token + token: "" + # Optional overlap key/value. Existing single-token Secrets remain valid + # when this key is absent. For proxy-first rotation, publish new as current + # and old here, wait for proxy reload, then roll controller/runtime pools. + # For controller-first use, first pre-stage new here while current remains + # old and verify it through the proxy; only then publish new as current and + # old here and roll controller/runtime pools. The pre-staged token covers a + # controller request that arrives before the proxy observes the role swap. + # Remove the old token after all workloads advertise the new generation. + # previousTokenValidUntil must be an absolute RFC3339/RFC3339Nano time no + # later than previousTokenOverlap from when the proxy loads it. + previousTokenKey: previous-token + previousToken: "" + previousTokenValidUntilKey: previous-token-valid-until + previousTokenValidUntil: "" + resources: + requests: + cpu: 25m + memory: 32Mi + ephemeral-storage: 32Mi + limits: + cpu: 250m + memory: 128Mi + ephemeral-storage: 128Mi + +# orka.harness.v1 data plane configuration. It is rendered only when +# controller.mode is harness-v1 and is never an ACP fallback. +harnessV1: + dispatch: + interval: 1s + workers: 1 + upgradeDrain: + # Before an upgrade, rollback, or deletion mutates an existing wrapper, a + # Helm hook durably closes admission and proves that no unsettled turns + # remain. Keep this below Helm's default five-minute operation timeout. + timeout: 4m + pollInterval: 2s + resources: + requests: + cpu: 10m + memory: 32Mi + ephemeral-storage: 16Mi + limits: + cpu: 100m + memory: 64Mi + ephemeral-storage: 64Mi + image: + repository: ghcr.io/orka-agents/orka/agent-harness-wrapper + tag: "0.1.1" + # Required when controller.mode is harness-v1. Tags are never accepted for + # a running wrapper. + digest: "" + pullPolicy: IfNotPresent + auth: + # Use a dedicated operator-created Secret. Bearer credentials are never + # accepted through Helm values or emitted into rendered release manifests. + # The Secret source and key are immutable while a harness-v1 wrapper + # Deployment exists because controller attempts pin its resourceVersion. + existingSecret: "" + tokenKey: token + tls: + # Use a different operator-created Secret containing tls.crt, tls.key, and + # ca.crt. Its serving certificate must authenticate the release-scoped + # Service DNS name: -orka-agent-harness-wrapper..svc. + existingSecret: "" + # Bump this non-secret revision whenever certificate data changes without + # changing existingSecret. The chart drains the live wrapper before it + # restarts both the wrapper and controller onto the new TLS generation. + rolloutNonce: "" + ledger: + # The admission ledger is always PVC-backed when harness v1 is enabled. + size: 1Gi + storageClass: "" + # Controller-acknowledged terminal records remain replay-safe for this + # audit/backup window before bounded wrapper GC may reclaim them. + retention: 720h + # Codex defaults to workspace-write. Broader modes require explicit policy. + codexSandboxMode: workspace-write + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 512Mi + limits: + cpu: "2" + memory: 2Gi + ephemeral-storage: 2Gi + # Worker configuration workers: ai: @@ -212,21 +441,6 @@ workers: repository: ghcr.io/orka-agents/orka/general-worker tag: "0.1.1" - harnessWrapper: - image: - repository: ghcr.io/orka-agents/orka/agent-harness-wrapper - tag: "0.1.1" - pullPolicy: IfNotPresent - auth: - # Existing Secret containing the shared wrapper bearer token. - # When existingSecret and token are both empty, Helm generates a release-local token. - existingSecret: "" - tokenKey: token - token: "" - # Optional Codex CLI sandbox mode for the Codex adapter. - codexSandboxMode: "" - resources: {} - # GitHub webhook and label-trigger configuration. # Configure a repository webhook to POST to /webhooks/github with the secret below. github: @@ -307,7 +521,7 @@ annotations: {} store: path: /data/orka.db persistence: - enabled: true # durable gateway inbox/outbox and Session history require a PVC + enabled: true # ACP control records, gateway history, and Session state require durable RWO storage size: 1Gi storageClass: "" # use cluster default accessMode: ReadWriteOnce @@ -318,6 +532,7 @@ client: create: true # Name of the client service account name: orka-client - # Namespace for the client service account. Empty defaults to controller.watchNamespace - # when namespace isolation is enforced and watchNamespace is set, otherwise the release namespace. + # Namespace for the client service account. Empty defaults to + # controller.watchNamespace. A different namespace is not supported by an + # isolated installation. namespace: "" diff --git a/cmd/cli/helpers_test.go b/cmd/cli/helpers_test.go index 4971b9035..3bf903a96 100644 --- a/cmd/cli/helpers_test.go +++ b/cmd/cli/helpers_test.go @@ -611,7 +611,7 @@ func TestRootCmdIncludesCoverageCommands(t *testing.T) { cmd := newRootCmd() want := []string{ "provider", "tool", "session", "secret", "security", "monitor", - "memory", "auth", "models", "workspace", "substrate", + "memory", "auth", "models", "workspace", "runtime-pool", "agent-runtime", "substrate", } seen := map[string]bool{} for _, sub := range cmd.Commands() { diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 36a4c902e..28ea28813 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -74,6 +74,8 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newAuthCmd()) cmd.AddCommand(newModelsCmd()) cmd.AddCommand(newWorkspaceCmd()) + cmd.AddCommand(newRuntimePoolCmd()) + cmd.AddCommand(newAgentRuntimeCmd()) cmd.AddCommand(newSubstrateCmd()) cmd.AddCommand(newGatewayCmd()) diff --git a/cmd/cli/misc_commands.go b/cmd/cli/misc_commands.go index 1656b95a1..d3ae7581f 100644 --- a/cmd/cli/misc_commands.go +++ b/cmd/cli/misc_commands.go @@ -91,36 +91,34 @@ func safeWorkspaceStatus(task client.TaskDetail) map[string]any { "task": client.StringField(task, "metadata", "name"), "namespace": client.StringField(task, "metadata", "namespace"), } - status, _ := task["status"].(map[string]any) - if status == nil { - return out - } + status := nestedMap(task, "status") out["phase"] = status["phase"] - if ew, ok := status["executionWorkspace"].(map[string]any); ok { - safe := map[string]any{} - for _, key := range []string{ - "phase", - "provider", - "reason", - "message", - "url", - "lastTransitionTime", - "observedGeneration", - } { - if v, ok := ew[key]; ok { - safe[key] = v - } - } - out["executionWorkspace"] = safe - } - if ws, ok := status["workspace"].(map[string]any); ok { - safe := map[string]any{} - for _, key := range []string{"phase", "provider", "reason", "message", "url", "lastTransitionTime"} { - if v, ok := ws[key]; ok { - safe[key] = v - } - } + + spec := nestedMap(task, "spec") + workspace := nestedMap(spec, "workspace") + if len(workspace) > 0 { + safe := copyKeys(workspace, + "intent", "gitRepo", "sourceRepository", "branch", "ref", "subPath", + "publicationGitRepo", "publicationRepository", "pushBranch", "prBaseBranch", "createPR") + safe["readCredentialConfigured"] = len(nestedMap(workspace, "readCredentialRef")) > 0 + safe["publicationReadCredentialConfigured"] = len(nestedMap(workspace, "publicationReadCredentialRef")) > 0 + publicationWriteConfigured := len(nestedMap(workspace, "publicationCredentialRef")) > 0 + // Preserve the existing summary field while making the write-only role explicit. + safe["publicationCredentialConfigured"] = publicationWriteConfigured + safe["publicationWriteCredentialConfigured"] = publicationWriteConfigured + safe["forgeCredentialConfigured"] = len(nestedMap(workspace, "forgeCredentialRef")) > 0 out["workspace"] = safe } + if executionWorkspace := nestedMap(status, "executionWorkspace"); len(executionWorkspace) > 0 { + out["executionWorkspace"] = copyKeys(executionWorkspace, + "phase", "provider", "reason", "message", "reusePolicy", "cleanupPolicy", "reused", + "placement", "density", "resumeLatency", "lastUpdateTime") + } + if delivery := nestedMap(status, "delivery"); len(delivery) > 0 { + out["delivery"] = copyKeys(delivery, + "state", "outcome", "reason", "publicationID", "sourceRepository", "publicationRepository", + "branch", "expectedCommitSHA", "verifiedRemoteSHA", "supersedingRemoteSHA", "artifactDigest", + "prReceipt", "message", "lastTransitionTime") + } return out } diff --git a/cmd/cli/resource_commands.go b/cmd/cli/resource_commands.go index 560b2ffe9..9771918e4 100644 --- a/cmd/cli/resource_commands.go +++ b/cmd/cli/resource_commands.go @@ -11,17 +11,18 @@ import ( ) type crudResourceSpec struct { - Use string - Short string - BasePath string - Name string - ReadOnly bool - NoGet bool - NoCreate bool - NoUpdate bool - NoDelete bool - ListFlags func(*cobra.Command) - ListQuery func(*cobra.Command) map[string]string + Use string + Short string + BasePath string + Name string + ReadOnly bool + NoGet bool + NoCreate bool + NoUpdate bool + NoDelete bool + ListFlags func(*cobra.Command) + ListQuery func(*cobra.Command) map[string]string + TablePrinter func(*cobra.Command, any) error } func newCRUDResourceCmd(spec crudResourceSpec) *cobra.Command { @@ -69,6 +70,13 @@ func newCRUDListCmd(spec crudResourceSpec) *cobra.Command { if err != nil { return err } + format, err := outputFormat(cmd) + if err != nil { + return err + } + if format == outputTable && spec.TablePrinter != nil { + return spec.TablePrinter(cmd, result) + } return printStructured(cmd, result) }, } @@ -93,6 +101,13 @@ func newCRUDGetCmd(spec crudResourceSpec) *cobra.Command { if err != nil { return err } + format, err := outputFormat(cmd) + if err != nil { + return err + } + if format == outputTable && spec.TablePrinter != nil { + return spec.TablePrinter(cmd, result) + } return printStructured(cmd, result) }, } diff --git a/cmd/cli/runtime.go b/cmd/cli/runtime.go new file mode 100644 index 000000000..bb5585ffc --- /dev/null +++ b/cmd/cli/runtime.go @@ -0,0 +1,132 @@ +package main + +import ( + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +func newRuntimePoolCmd() *cobra.Command { + return newCRUDResourceCmd(crudResourceSpec{ + Use: "runtime-pool", + Short: "Manage controller-owned ACP runtime pools", + BasePath: "/api/v1/runtime-pools", + Name: "runtime pool", + ReadOnly: true, + TablePrinter: printRuntimePoolTable, + }) +} + +func newAgentRuntimeCmd() *cobra.Command { + return newCRUDResourceCmd(crudResourceSpec{ + Use: "agent-runtime", + Short: "Manage external orka.harness.v2 AgentRuntime registrations", + BasePath: "/api/v1/agent-runtimes", + Name: "agent runtime", + TablePrinter: printAgentRuntimeTable, + }) +} + +func printRuntimePoolTable(cmd *cobra.Command, value any) error { + items := listItems(value) + if len(items) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No runtime pools found.") //nolint:errcheck + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tNAMESPACE\tLIFECYCLE\tADMISSION\tPODS\tSESSIONS\tPROMPTS\tQUEUED\tAGE") //nolint:errcheck + for _, item := range items { + status := nestedMap(item, "status") + capacity := nestedMap(status, "capacity") + pods := fmt.Sprintf("%s/%s", dash(anyString(status["currentReplicas"])), dash(anyString(status["desiredReplicas"]))) + sessions := fmt.Sprintf("%s/%s", dash(anyString(capacity["residentSessions"])), dash(anyString(capacity["maxResidentSessions"]))) + prompts := fmt.Sprintf("%s/%s", dash(anyString(capacity["runningPrompts"])), dash(anyString(capacity["maxRunningPrompts"]))) + fmt.Fprintf( //nolint:errcheck + w, + "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + dash(nestedString(item, "metadata", "name")), + dash(nestedString(item, "metadata", "namespace")), + dash(anyString(status["lifecycle"])), + dash(anyString(status["admissionState"])), + pods, + sessions, + prompts, + dash(anyString(capacity["queuedTasks"])), + dash(formatAge(nestedString(item, "metadata", "creationTimestamp"))), + ) //nolint:errcheck + } + return w.Flush() +} + +func printAgentRuntimeTable(cmd *cobra.Command, value any) error { + items := listItems(value) + if len(items) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No agent runtimes found.") //nolint:errcheck + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tNAMESPACE\tREADY\tCONTRACT\tINTENT\tPROVIDER/MODEL\tINSTANCE\tAGE") //nolint:errcheck + for _, item := range items { + spec := nestedMap(item, "spec") + status := nestedMap(item, "status") + capabilities := nestedMap(spec, "capabilities") + profile := nestedMap(capabilities, "profile") + observed := nestedMap(status, "observedCapabilities") + provider := firstString(observed, "providerKind") + if provider == "" { + provider = firstString(profile, "providerKind") + } + model := firstString(observed, "model") + if model == "" { + model = firstString(profile, "model") + } + instance := firstString(observed, "runtimeInstanceID") + if instance == "" { + instance = firstString(capabilities, "runtimeInstanceID") + } + fmt.Fprintf( //nolint:errcheck + w, + "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + dash(nestedString(item, "metadata", "name")), + dash(nestedString(item, "metadata", "namespace")), + dash(anyString(status["ready"])), + dash(anyString(spec["contractVersion"])), + dash(anyString(profile["workspaceIntent"])), + dash(joinNonEmpty(provider, model, "/")), + dash(compactCLIValue(instance)), + dash(formatAge(nestedString(item, "metadata", "creationTimestamp"))), + ) //nolint:errcheck + } + return w.Flush() +} + +func nestedMap(m map[string]any, keys ...string) map[string]any { + current := m + for _, key := range keys { + next, ok := current[key].(map[string]any) + if !ok { + return map[string]any{} + } + current = next + } + return current +} + +func joinNonEmpty(left, right, separator string) string { + if left == "" { + return right + } + if right == "" { + return left + } + return left + separator + right +} + +func compactCLIValue(value string) string { + const max = 30 + if len(value) <= max { + return value + } + return value[:17] + "…" + value[len(value)-8:] +} diff --git a/cmd/cli/runtime_test.go b/cmd/cli/runtime_test.go new file mode 100644 index 000000000..7a7b5928e --- /dev/null +++ b/cmd/cli/runtime_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRuntimePoolListRendersCapacityAndAdmission(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/runtime-pools" { + t.Fatalf("path = %s", r.URL.Path) + } + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "items": []map[string]any{{ + "metadata": map[string]any{"name": "codex-read", "namespace": "default"}, + "status": map[string]any{ + "lifecycle": "Serving", "admissionState": "Accepting", "currentReplicas": 1, "desiredReplicas": 1, + "capacity": map[string]any{"residentSessions": 3, "maxResidentSessions": 10, "runningPrompts": 2, "maxRunningPrompts": 4, "queuedTasks": 1}, + }, + }}, + }) + })) + defer server.Close() + + var out bytes.Buffer + root := newRootCmd() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--server", server.URL, "--token", "test-token", "runtime-pool", "list"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, want := range []string{"codex-read", "Serving", "Accepting", "3/10", "2/4"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestAgentRuntimeListRendersOnlyV2Identity(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "items": []map[string]any{{ + "metadata": map[string]any{"name": "external-codex", "namespace": "default"}, + "spec": map[string]any{ + "contractVersion": "orka.harness.v2", + "capabilities": map[string]any{ + "runtimeInstanceID": "external-instance-1", + "profile": map[string]any{"workspaceIntent": "read", "providerKind": "openai", "model": "gpt-5"}, + }, + }, + "status": map[string]any{"ready": true}, + }}, + }) + })) + defer server.Close() + + var out bytes.Buffer + root := newRootCmd() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--server", server.URL, "--token", "test-token", "agent-runtime", "list"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, want := range []string{"external-codex", "true", "orka.harness.v2", "read", "openai/gpt-5"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } + } + for _, legacy := range []string{"supportsContinuation", "toolExecutionModes", "brokeredToolClasses"} { + if strings.Contains(out.String(), legacy) { + t.Fatalf("output contains legacy field %q: %s", legacy, out.String()) + } + } +} + +func TestRuntimePoolCommandIsReadOnly(t *testing.T) { + cmd := newRuntimePoolCmd() + for _, child := range cmd.Commands() { + switch child.Name() { + case "list", "get": + default: + t.Fatalf("unexpected mutating RuntimePool command %q", child.Name()) + } + } +} diff --git a/cmd/cli/task.go b/cmd/cli/task.go index 4a3edbb78..f350f62e9 100644 --- a/cmd/cli/task.go +++ b/cmd/cli/task.go @@ -39,6 +39,7 @@ func newTaskCmd() *cobra.Command { cmd.AddCommand(newTaskCreateCmd()) cmd.AddCommand(newTaskListCmd()) cmd.AddCommand(newTaskGetCmd()) + cmd.AddCommand(newTaskRuntimeStatusCmd()) cmd.AddCommand(newTaskLogsCmd()) cmd.AddCommand(newTaskEventsCmd()) cmd.AddCommand(newTaskFollowCmd()) @@ -62,6 +63,7 @@ func newTaskCreateCmd() *cobra.Command { var commandVals, argVals, envVals []string var priority int32 var suspend bool + var workspaceOptions taskWorkspaceCreateOptions cmd := &cobra.Command{ Use: "create ", @@ -155,7 +157,26 @@ func newTaskCreateCmd() *cobra.Command { } } - result, err := c.CreateTask(context.Background(), req) + workspace, err := workspaceOptions.build(cmd, taskType) + if err != nil { + return err + } + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("marshal task request: %w", err) + } + if workspace != nil { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return fmt.Errorf("prepare task workspace request: %w", err) + } + payload["workspace"] = workspace + body, err = json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal task workspace request: %w", err) + } + } + result, err := c.CreateTaskRaw(context.Background(), body) if err != nil { return err } @@ -186,6 +207,7 @@ func newTaskCreateCmd() *cobra.Command { cmd.Flags().StringVar(&schedule, "schedule", "", "Cron schedule for recurring tasks") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA time zone for scheduled tasks") cmd.Flags().BoolVar(&suspend, "suspend", false, "Suspend scheduled task runs") + workspaceOptions.bindFlags(cmd) return cmd } diff --git a/cmd/cli/task_test.go b/cmd/cli/task_test.go index 7afe36cf7..b50ca2b08 100644 --- a/cmd/cli/task_test.go +++ b/cmd/cli/task_test.go @@ -62,7 +62,7 @@ func TestNewTaskCmd(t *testing.T) { for _, sub := range cmd.Commands() { subNames[sub.Use] = true } - for _, want := range []string{"create ", "list", "get ", "logs ", "delete "} { + for _, want := range []string{"create ", "list", "get ", "status ", "logs ", "delete "} { if !subNames[want] { t.Errorf("missing subcommand %q", want) } @@ -73,7 +73,13 @@ func TestNewTaskCreateCmdFlags(t *testing.T) { cmd := newTaskCreateCmd() // Verify flags - for _, flagName := range []string{"type", "agent", "provider", "timeout"} { + for _, flagName := range []string{ + "type", "agent", "provider", "timeout", "workspace-intent", "git-repo", + "read-credential", "read-credential-key", "publication-git-repo", + "publication-read-credential", "publication-read-credential-key", + "publication-credential", "publication-credential-key", "forge-credential", "forge-credential-key", + "push-branch", "create-pr", + } { if cmd.Flags().Lookup(flagName) == nil { t.Errorf("missing flag %q", flagName) } diff --git a/cmd/cli/task_workspace.go b/cmd/cli/task_workspace.go new file mode 100644 index 000000000..3145794af --- /dev/null +++ b/cmd/cli/task_workspace.go @@ -0,0 +1,269 @@ +package main + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + corev1alpha1 "github.com/orka-agents/orka/api/v1alpha1" + "github.com/orka-agents/orka/internal/cli/client" +) + +type taskWorkspaceCreateOptions struct { + intent string + gitRepo string + sourceRepositoryProvider string + sourceRepositoryID string + branch string + ref string + subPath string + readCredential string + readCredentialKey string + publicationGitRepo string + publicationRepositoryProvider string + publicationRepositoryID string + publicationReadCredential string + publicationReadCredentialKey string + publicationCredential string + publicationCredentialKey string + forgeCredential string + forgeCredentialKey string + pushBranch string + prBaseBranch string + createPR bool +} + +func (o *taskWorkspaceCreateOptions) bindFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&o.intent, "workspace-intent", "read", "Agent workspace intent: read or write") + cmd.Flags().StringVar(&o.gitRepo, "git-repo", "", "Source repository URL (credentials must not be embedded)") + cmd.Flags().StringVar(&o.sourceRepositoryProvider, "source-repository-provider", "", "Canonical source repository provider") + cmd.Flags().StringVar(&o.sourceRepositoryID, "source-repository-id", "", "Canonical source repository ID") + cmd.Flags().StringVar(&o.branch, "branch", "", "Source branch") + cmd.Flags().StringVar(&o.ref, "ref", "", "Source commit, tag, or ref") + cmd.Flags().StringVar(&o.subPath, "sub-path", "", "Subdirectory within the source repository") + cmd.Flags().StringVar(&o.readCredential, "read-credential", "", "Secret name for source clone/read credentials") + cmd.Flags().StringVar(&o.readCredentialKey, "read-credential-key", "", "Secret key for source clone/read credentials (default: token)") + cmd.Flags().StringVar(&o.publicationGitRepo, "publication-git-repo", "", "Publication repository URL") + cmd.Flags().StringVar(&o.publicationRepositoryProvider, "publication-repository-provider", "", "Canonical publication repository provider") + cmd.Flags().StringVar(&o.publicationRepositoryID, "publication-repository-id", "", "Canonical publication repository ID") + cmd.Flags().StringVar(&o.publicationReadCredential, "publication-read-credential", "", "Secret name for publication preflight and verification credentials") + cmd.Flags().StringVar(&o.publicationReadCredentialKey, "publication-read-credential-key", "", "Secret key for publication preflight and verification credentials (default: token)") + cmd.Flags().StringVar(&o.publicationCredential, "publication-credential", "", "Secret name for publication write credentials") + cmd.Flags().StringVar(&o.publicationCredentialKey, "publication-credential-key", "", "Secret key for publication write credentials (default: token)") + cmd.Flags().StringVar(&o.forgeCredential, "forge-credential", "", "Secret name for forge API credentials used to reconcile pull requests") + cmd.Flags().StringVar(&o.forgeCredentialKey, "forge-credential-key", "", "Secret key for forge API credentials (default: token)") + cmd.Flags().StringVar(&o.pushBranch, "push-branch", "", "Publication branch (default: controller-derived full-entropy branch)") + cmd.Flags().StringVar(&o.prBaseBranch, "pr-base-branch", "", "Pull request base branch") + cmd.Flags().BoolVar(&o.createPR, "create-pr", false, "Reconcile a pull request after verified publication") +} + +func (o taskWorkspaceCreateOptions) build(cmd *cobra.Command, taskType string) (map[string]any, error) { + intent := strings.ToLower(strings.TrimSpace(o.intent)) + if intent != string(corev1alpha1.WorkspaceIntentRead) && intent != string(corev1alpha1.WorkspaceIntentWrite) { + return nil, fmt.Errorf("--workspace-intent must be read or write") + } + workspaceFlagsUsed := false + for _, name := range []string{ + "workspace-intent", "git-repo", "source-repository-provider", "source-repository-id", "branch", "ref", + "sub-path", "read-credential", "read-credential-key", "publication-git-repo", "publication-repository-provider", + "publication-repository-id", "publication-read-credential", "publication-read-credential-key", + "publication-credential", "publication-credential-key", "forge-credential", "forge-credential-key", + "push-branch", "pr-base-branch", "create-pr", + } { + workspaceFlagsUsed = workspaceFlagsUsed || cmd.Flags().Changed(name) + } + if taskType != cliTaskTypeAgent { + if workspaceFlagsUsed { + return nil, fmt.Errorf("workspace flags are supported only for agent tasks") + } + return nil, nil + } + if (strings.TrimSpace(o.sourceRepositoryProvider) == "") != (strings.TrimSpace(o.sourceRepositoryID) == "") { + return nil, fmt.Errorf("--source-repository-provider and --source-repository-id must be set together") + } + if (strings.TrimSpace(o.publicationRepositoryProvider) == "") != (strings.TrimSpace(o.publicationRepositoryID) == "") { + return nil, fmt.Errorf("--publication-repository-provider and --publication-repository-id must be set together") + } + for _, credential := range []struct { + nameFlag string + name string + keyFlag string + key string + }{ + {nameFlag: "--read-credential", name: o.readCredential, keyFlag: "--read-credential-key", key: o.readCredentialKey}, + {nameFlag: "--publication-read-credential", name: o.publicationReadCredential, keyFlag: "--publication-read-credential-key", key: o.publicationReadCredentialKey}, + {nameFlag: "--publication-credential", name: o.publicationCredential, keyFlag: "--publication-credential-key", key: o.publicationCredentialKey}, + {nameFlag: "--forge-credential", name: o.forgeCredential, keyFlag: "--forge-credential-key", key: o.forgeCredentialKey}, + } { + if strings.TrimSpace(credential.key) != "" && strings.TrimSpace(credential.name) == "" { + return nil, fmt.Errorf("%s requires %s", credential.keyFlag, credential.nameFlag) + } + } + publicationRequested := o.createPR || strings.TrimSpace(o.publicationGitRepo) != "" || + strings.TrimSpace(o.publicationRepositoryProvider) != "" || strings.TrimSpace(o.publicationReadCredential) != "" || + strings.TrimSpace(o.publicationReadCredentialKey) != "" || strings.TrimSpace(o.publicationCredential) != "" || + strings.TrimSpace(o.publicationCredentialKey) != "" || strings.TrimSpace(o.forgeCredential) != "" || + strings.TrimSpace(o.forgeCredentialKey) != "" || strings.TrimSpace(o.pushBranch) != "" || strings.TrimSpace(o.prBaseBranch) != "" + if err := o.validatePublicationOptions(intent, publicationRequested); err != nil { + return nil, err + } + + workspace := map[string]any{"intent": intent} + addTrimmed(workspace, "gitRepo", o.gitRepo) + addRepositoryIdentity(workspace, "sourceRepository", o.sourceRepositoryProvider, o.sourceRepositoryID) + addTrimmed(workspace, "branch", o.branch) + addTrimmed(workspace, "ref", o.ref) + addTrimmed(workspace, "subPath", o.subPath) + addCredentialRef(workspace, "readCredentialRef", o.readCredential, o.readCredentialKey) + if intent == string(corev1alpha1.WorkspaceIntentWrite) { + addTrimmed(workspace, "publicationGitRepo", o.publicationGitRepo) + addRepositoryIdentity(workspace, "publicationRepository", o.publicationRepositoryProvider, o.publicationRepositoryID) + addCredentialRef(workspace, "publicationReadCredentialRef", o.publicationReadCredential, o.publicationReadCredentialKey) + addCredentialRef(workspace, "publicationCredentialRef", o.publicationCredential, o.publicationCredentialKey) + addCredentialRef(workspace, "forgeCredentialRef", o.forgeCredential, o.forgeCredentialKey) + addTrimmed(workspace, "pushBranch", o.pushBranch) + addTrimmed(workspace, "prBaseBranch", o.prBaseBranch) + if o.createPR { + workspace["createPR"] = true + } + } + return workspace, nil +} + +func (o taskWorkspaceCreateOptions) validatePublicationOptions(intent string, publicationRequested bool) error { + if intent != string(corev1alpha1.WorkspaceIntentWrite) { + if publicationRequested { + return fmt.Errorf("publication flags require --workspace-intent write") + } + return nil + } + if strings.TrimSpace(o.gitRepo) == "" { + return fmt.Errorf("--workspace-intent write requires --git-repo") + } + if strings.TrimSpace(o.publicationCredential) == "" { + return fmt.Errorf("--workspace-intent write requires --publication-credential") + } + if o.createPR && strings.TrimSpace(o.prBaseBranch) == "" { + return fmt.Errorf("--create-pr requires --pr-base-branch") + } + if o.createPR && strings.TrimSpace(o.forgeCredential) == "" { + return fmt.Errorf("--create-pr requires --forge-credential") + } + return nil +} + +func addTrimmed(target map[string]any, key, value string) { + if value = strings.TrimSpace(value); value != "" { + target[key] = value + } +} + +func addRepositoryIdentity(target map[string]any, key, provider, id string) { + provider = strings.TrimSpace(provider) + id = strings.TrimSpace(id) + if provider != "" && id != "" { + target[key] = map[string]any{"provider": provider, "id": id} + } +} + +func addCredentialRef(target map[string]any, field, name, secretKey string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + ref := map[string]any{"name": name} + if secretKey = strings.TrimSpace(secretKey); secretKey != "" { + ref["key"] = secretKey + } + target[field] = ref +} + +func newTaskRuntimeStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status ", + Short: "Show durable execution, delivery, and runtime-pool status", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := newClientFromCmd(cmd) + detail, err := c.GetTask(cmd.Context(), args[0], client.GetOptions{Namespace: c.Namespace}) + if err != nil { + return err + } + status := safeTaskRuntimeStatus(*detail) + format, err := outputFormat(cmd) + if err != nil { + return err + } + if format != outputTable { + return printStructured(cmd, status) + } + return printTaskRuntimeStatusTable(cmd, status) + }, + } + addOutputFlag(cmd, outputTable) + return cmd +} + +func safeTaskRuntimeStatus(task client.TaskDetail) map[string]any { + out := map[string]any{ + "task": client.StringField(task, "metadata", "name"), + "namespace": client.StringField(task, "metadata", "namespace"), + } + status := nestedMap(task, "status") + out["phase"] = status["phase"] + if execution := nestedMap(status, "execution"); len(execution) > 0 { + out["execution"] = copyKeys(execution, + "state", "outcome", "reason", "attempt", "promptID", "runtimePoolName", "runtimePoolUID", + "runtimeInstanceID", "runtimeSessionUID", "runtimeSessionGeneration", "requestDigest", "controllerEpoch", + "message", "lastTransitionTime") + } + if delivery := nestedMap(status, "delivery"); len(delivery) > 0 { + out["delivery"] = copyKeys(delivery, + "state", "outcome", "reason", "publicationID", "sourceRepository", "publicationRepository", "branch", + "startingSHA", "remoteBeforeSHA", "treeSHA", "expectedCommitSHA", "verifiedRemoteSHA", "supersedingRemoteSHA", + "artifactDigest", "prReceipt", "message", "lastTransitionTime") + } + return out +} + +func printTaskRuntimeStatusTable(cmd *cobra.Command, status map[string]any) error { + execution := nestedMap(status, "execution") + delivery := nestedMap(status, "delivery") + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "FIELD\tVALUE") //nolint:errcheck + rows := [][2]string{ + {"Task", anyString(status["task"])}, + {"Namespace", anyString(status["namespace"])}, + {"Phase", anyString(status["phase"])}, + {"Execution", anyString(execution["state"])}, + {"Execution outcome", anyString(execution["outcome"])}, + {"Execution reason", anyString(execution["reason"])}, + {"Attempt", anyString(execution["attempt"])}, + {"RuntimePool", anyString(execution["runtimePoolName"])}, + {"Runtime instance", compactCLIValue(anyString(execution["runtimeInstanceID"]))}, + {"Runtime session generation", anyString(execution["runtimeSessionGeneration"])}, + {"Delivery", anyString(delivery["state"])}, + {"Delivery outcome", anyString(delivery["outcome"])}, + {"Publication branch", anyString(delivery["branch"])}, + {"Verified remote", compactCLIValue(anyString(delivery["verifiedRemoteSHA"]))}, + } + for _, row := range rows { + fmt.Fprintf(w, "%s\t%s\n", row[0], dash(row[1])) //nolint:errcheck + } + if execution["state"] == "OutcomeUnknown" || execution["outcome"] == "OutcomeUnknown" { + fmt.Fprintln(w, "Replay policy\tTerminal; create a new Task explicitly. No automatic replay.") //nolint:errcheck + } + return w.Flush() +} + +func copyKeys(source map[string]any, keys ...string) map[string]any { + out := map[string]any{} + for _, key := range keys { + if value, ok := source[key]; ok { + out[key] = value + } + } + return out +} diff --git a/cmd/cli/task_workspace_test.go b/cmd/cli/task_workspace_test.go new file mode 100644 index 000000000..69b140587 --- /dev/null +++ b/cmd/cli/task_workspace_test.go @@ -0,0 +1,249 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTaskCreateWritesCanonicalWorkspaceCredentialRoles(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"metadata": map[string]any{"name": "write-task"}}) //nolint:errcheck + })) + defer server.Close() + + root := newRootCmd() + root.SetArgs([]string{ + "--server", server.URL, "--token", "test-token", "--namespace", "default", + "task", "create", "Update the repository", "--type", "agent", "--agent", "codex-agent", "--name", "write-task", + "--workspace-intent", "write", "--git-repo", "https://github.com/source/repo", + "--source-repository-provider", "github", "--source-repository-id", "source-id", + "--read-credential", "repo-read", "--read-credential-key", "source-token", + "--publication-git-repo", "https://github.com/publish/repo", "--publication-repository-provider", "github", + "--publication-repository-id", "publish-id", + "--publication-read-credential", "repo-verify", "--publication-read-credential-key", "verify-token", + "--publication-credential", "repo-write", "--publication-credential-key", "write-token", + "--forge-credential", "repo-forge", "--forge-credential-key", "forge-token", + "--push-branch", "orka/change", "--pr-base-branch", "main", "--create-pr", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + workspace := nestedMap(body, "workspace") + if got := anyString(workspace["intent"]); got != "write" { + t.Fatalf("intent = %q", got) + } + for _, want := range []struct { + field string + name string + key string + }{ + {field: "readCredentialRef", name: "repo-read", key: "source-token"}, + {field: "publicationReadCredentialRef", name: "repo-verify", key: "verify-token"}, + {field: "publicationCredentialRef", name: "repo-write", key: "write-token"}, + {field: "forgeCredentialRef", name: "repo-forge", key: "forge-token"}, + } { + if got := nestedString(workspace, want.field, "name"); got != want.name { + t.Errorf("%s name = %q, want %q", want.field, got, want.name) + } + if got := nestedString(workspace, want.field, "key"); got != want.key { + t.Errorf("%s key = %q, want %q", want.field, got, want.key) + } + } + if got := anyString(workspace["createPR"]); got != "true" { + t.Fatalf("createPR = %q", got) + } + if _, legacy := nestedMap(body, "agentRuntime")["workspace"]; legacy { + t.Fatal("request contains deprecated agentRuntime.workspace") + } + if _, legacy := workspace["gitSecretRef"]; legacy { + t.Fatal("request contains deprecated gitSecretRef") + } +} + +func TestTaskCreateLeavesCredentialKeyOmittedForAPIDefault(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"metadata": map[string]any{"name": "read-task"}}) //nolint:errcheck + })) + defer server.Close() + + root := newRootCmd() + root.SetArgs([]string{ + "--server", server.URL, "--token", "test-token", + "task", "create", "Inspect", "--type", "agent", "--agent", "a", "--read-credential", "repo-read", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + ref := nestedMap(nestedMap(body, "workspace"), "readCredentialRef") + if ref["name"] != "repo-read" { + t.Fatalf("readCredentialRef = %#v", ref) + } + if _, ok := ref["key"]; ok { + t.Fatalf("readCredentialRef key should be omitted for the API token default: %#v", ref) + } +} + +func TestTaskCreateRejectsPublicationForReadIntent(t *testing.T) { + root := newRootCmd() + root.SetArgs([]string{"task", "create", "Inspect", "--type", "agent", "--agent", "a", "--publication-credential", "repo-write"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "publication flags require --workspace-intent write") { + t.Fatalf("error = %v", err) + } +} + +func TestTaskCreateRejectsIncompleteWriteWorkspace(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "source repository", + args: []string{"--publication-credential", "repo-write"}, + want: "--workspace-intent write requires --git-repo", + }, + { + name: "publication credential", + args: []string{"--git-repo", "https://github.com/source/repo"}, + want: "--workspace-intent write requires --publication-credential", + }, + { + name: "pull request base branch", + args: []string{ + "--git-repo", "https://github.com/source/repo", "--publication-credential", "repo-write", + "--forge-credential", "repo-forge", "--create-pr", + }, + want: "--create-pr requires --pr-base-branch", + }, + { + name: "forge credential", + args: []string{ + "--git-repo", "https://github.com/source/repo", "--publication-credential", "repo-write", + "--pr-base-branch", "main", "--create-pr", + }, + want: "--create-pr requires --forge-credential", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newRootCmd() + args := []string{ + "task", "create", "Publish", "--type", "agent", "--agent", "a", "--workspace-intent", "write", + } + root.SetArgs(append(args, tt.args...)) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} + +func TestTaskCreateRejectsCredentialKeyWithoutSecretName(t *testing.T) { + root := newRootCmd() + root.SetArgs([]string{ + "task", "create", "Inspect", "--type", "agent", "--agent", "a", "--read-credential-key", "custom-token", + }) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--read-credential-key requires --read-credential") { + t.Fatalf("error = %v", err) + } +} + +func TestSafeWorkspaceStatusDoesNotTreatWriteCredentialAsForgeCredential(t *testing.T) { + status := safeWorkspaceStatus(map[string]any{ + "spec": map[string]any{"workspace": map[string]any{ + "intent": "write", "createPR": true, + "publicationCredentialRef": map[string]any{"name": "legacy-combined-credential"}, + }}, + }) + workspace := nestedMap(status, "workspace") + if workspace["publicationCredentialConfigured"] != true || workspace["publicationWriteCredentialConfigured"] != true { + t.Fatalf("write credential summary = %#v", workspace) + } + if workspace["forgeCredentialConfigured"] != false { + t.Fatalf("write credential was silently treated as a forge credential: %#v", workspace) + } +} + +func TestTaskStatusRendersPoolDeliveryAndUnknownReplayPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "metadata": map[string]any{"name": "uncertain", "namespace": "default"}, + "spec": map[string]any{"type": "agent", "workspace": map[string]any{"intent": "write"}}, + "status": map[string]any{ + "phase": "Failed", + "execution": map[string]any{"state": "OutcomeUnknown", "outcome": "OutcomeUnknown", "runtimePoolName": "codex-write", "runtimeInstanceID": "pod:boot", "runtimeSessionGeneration": 2}, + "delivery": map[string]any{"state": "PublicationOutcomeUnknown", "branch": "orka/change"}, + }, + }) + })) + defer server.Close() + + var out bytes.Buffer + root := newRootCmd() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--server", server.URL, "--token", "test-token", "task", "status", "uncertain"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, want := range []string{"OutcomeUnknown", "codex-write", "PublicationOutcomeUnknown", "No automatic replay"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestSafeWorkspaceStatusUsesCanonicalSpecAndDelivery(t *testing.T) { + const secretValue = "must-never-render" + status := safeWorkspaceStatus(map[string]any{ + "metadata": map[string]any{"name": "write", "namespace": "default"}, + "spec": map[string]any{"workspace": map[string]any{ + "intent": "write", "gitRepo": "https://github.com/source/repo", "createPR": true, + "readCredentialRef": map[string]any{"name": "repo-read", "key": "source-token", "value": secretValue}, + "publicationReadCredentialRef": map[string]any{"name": "repo-verify", "key": "verify-token", "value": secretValue}, + "publicationCredentialRef": map[string]any{"name": "repo-write", "key": "write-token", "value": secretValue}, + "forgeCredentialRef": map[string]any{"name": "repo-forge", "key": "forge-token", "value": secretValue}, + }}, + "status": map[string]any{"phase": "Running", "delivery": map[string]any{"state": "Publishing"}}, + }) + workspace := nestedMap(status, "workspace") + for _, field := range []string{ + "readCredentialConfigured", "publicationReadCredentialConfigured", "publicationCredentialConfigured", + "publicationWriteCredentialConfigured", "forgeCredentialConfigured", + } { + if workspace[field] != true { + t.Errorf("%s = %#v, want true", field, workspace[field]) + } + } + for _, field := range []string{ + "readCredentialRef", "publicationReadCredentialRef", "publicationCredentialRef", "forgeCredentialRef", + } { + if _, leaked := workspace[field]; leaked { + t.Errorf("safe workspace status leaked %s", field) + } + } + if strings.Contains(fmt.Sprint(status), secretValue) { + t.Fatal("safe workspace status leaked a Secret value") + } + if got := nestedString(status, "delivery", "state"); got != "Publishing" { + t.Fatalf("delivery state = %q", got) + } +} diff --git a/cmd/harness_v1_tls.go b/cmd/harness_v1_tls.go new file mode 100644 index 000000000..83db7070b --- /dev/null +++ b/cmd/harness_v1_tls.go @@ -0,0 +1,41 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/url" + "os" + "strings" +) + +func validateHarnessV1TLSEndpoint(endpoint string) error { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return fmt.Errorf("harness v1 endpoint must be an HTTPS URL without user info") + } + return nil +} + +func newHarnessV1TLSHTTPClient(caFile string) (*http.Client, error) { + caFile = strings.TrimSpace(caFile) + if caFile == "" { + return nil, fmt.Errorf("harness v1 CA file is required") + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read harness v1 CA file: %w", err) + } + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("harness v1 CA file contains no valid certificates") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots} + return &http.Client{Transport: transport}, nil +} diff --git a/cmd/harness_v1_tls_test.go b/cmd/harness_v1_tls_test.go new file mode 100644 index 000000000..7926b4179 --- /dev/null +++ b/cmd/harness_v1_tls_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHarnessV1TLSHTTPClientAuthenticatesConfiguredCA(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + caFile := filepath.Join(t.TempDir(), "ca.crt") + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(caFile, caPEM, 0o600); err != nil { + t.Fatal(err) + } + client, err := newHarnessV1TLSHTTPClient(caFile) + if err != nil { + t.Fatal(err) + } + response, err := client.Get(server.URL) + if err != nil { + t.Fatalf("GET with configured harness v1 CA: %v", err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent) + } +} + +func TestValidateHarnessV1TLSEndpointRejectsPlaintext(t *testing.T) { + if err := validateHarnessV1TLSEndpoint("http://wrapper.default.svc:8080"); err == nil { + t.Fatal("plaintext harness v1 endpoint was accepted") + } +} + +func TestValidateHarnessV1DispatchOptionsRejectsParallelWorkers(t *testing.T) { + if err := validateHarnessV1DispatchOptions(time.Second, 1); err != nil { + t.Fatalf("default dispatch options: %v", err) + } + if err := validateHarnessV1DispatchOptions(time.Second, 2); err == nil || + !strings.Contains(err.Error(), "harness v1 dispatch workers must be exactly 1") { + t.Fatalf("parallel dispatch options error = %v, want exact-one rejection", err) + } +} diff --git a/cmd/main.go b/cmd/main.go index 1b6233a00..159f7f03b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,21 +7,35 @@ MIT License - see LICENSE file for details. package main import ( + "bytes" "context" "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" "flag" "fmt" + "math" + "net/http" "os" + "slices" "strconv" "strings" "time" + "github.com/google/uuid" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + policyv1 "k8s.io/api/policy/v1" + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -29,6 +43,7 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" + crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -43,13 +58,22 @@ import ( workspacev1alpha1 "github.com/orka-agents/orka/api/workspace/v1alpha1" orkaadmission "github.com/orka-agents/orka/internal/admission" "github.com/orka-agents/orka/internal/api" + "github.com/orka-agents/orka/internal/artifactcap" "github.com/orka-agents/orka/internal/contexttoken" "github.com/orka-agents/orka/internal/controller" + "github.com/orka-agents/orka/internal/executionmode" gatewayruntime "github.com/orka-agents/orka/internal/gateway" + "github.com/orka-agents/orka/internal/harness" + harnessv2 "github.com/orka-agents/orka/internal/harness/v2" + "github.com/orka-agents/orka/internal/labels" _ "github.com/orka-agents/orka/internal/llm/anthropic" _ "github.com/orka-agents/orka/internal/llm/openai" _ "github.com/orka-agents/orka/internal/metrics" "github.com/orka-agents/orka/internal/outboundaccess" + publisherservice "github.com/orka-agents/orka/internal/publisher/service" + "github.com/orka-agents/orka/internal/store" + storekube "github.com/orka-agents/orka/internal/store/kube" + "github.com/orka-agents/orka/internal/store/sqlite" "github.com/orka-agents/orka/internal/tokenexchange" "github.com/orka-agents/orka/internal/tools" @@ -59,9 +83,16 @@ import ( // +kubebuilder:scaffold:imports ) +const ( + taskResourceKind = "Task" + serviceAccountNamespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + controllerLeaderElectionID = "03b49a10.orka.ai" +) + var ( - scheme = runtime.NewScheme() - setupLog = ctrl.Log.WithName("setup") + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") + controllerProcessIncarnation = uuid.NewString() ) func init() { @@ -98,6 +129,25 @@ func validateWorkspaceProviderSecurityConfig(apiEnabled, classUseAdmissionEnable return nil } +func validateStaticTrustedServiceReferences( + watchNamespace string, + trust outboundaccess.TrustConfig, +) error { + watchNamespace = strings.TrimSpace(watchNamespace) + for _, ref := range append(trust.Gateways.References(), trust.TokenEndpoints.References()...) { + if ref.Namespace != watchNamespace { + return fmt.Errorf( + "trusted Service %s/%s:%d must be in controller watch namespace %q", + ref.Namespace, + ref.Name, + ref.Port, + watchNamespace, + ) + } + } + return nil +} + func workspaceCleanupAPIsInstalled(mapper meta.RESTMapper) (bool, error) { for _, gvk := range []schema.GroupVersionKind{ workspacev1alpha1.GroupVersion.WithKind("ExecutionWorkspaceProvider"), @@ -115,8 +165,13 @@ func workspaceCleanupAPIsInstalled(mapper meta.RESTMapper) (bool, error) { return true, nil } +func managerWebhookAdmissionEnabled(taskProvenanceEnabled, workspaceClassUseEnabled bool) bool { + return taskProvenanceEnabled || workspaceClassUseEnabled +} + // nolint:gocyclo func main() { + acpUpgradeDrainOptions := controller.DefaultACPUpgradeDrainOptions() var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string var webhookCertPath, webhookCertName, webhookCertKey string @@ -137,7 +192,7 @@ func main() { var aiWorkerClusterRoleName string var vendorWorkerClusterRoleName string var containerWorkerClusterRoleName string - var workerClusterRoleBindingNamePrefix string + var workerRoleBindingNamePrefix string var chatEnabled bool var chatProvider string var chatModel string @@ -162,9 +217,31 @@ func main() { var aiWorkerImage string var storeBackend string var storePath string + var agentExecutionSnapshotKeyFile string + var agentExecutionSnapshotRetention time.Duration + var agentExecutionSnapshotRetentionInterval time.Duration var controllerURL string var enforceNamespaceIsolation bool var maxTasksPerNamespace int + var controllerModeValue string + var executionModeControllerUsernames string + var harnessV1Endpoint string + var harnessV1CAFile string + var harnessV1AuthSecretNamespace string + var harnessV1AuthSecretName string + var harnessV1AuthSecretKey string + var harnessV1DispatchInterval time.Duration + var harnessV1DispatchWorkers int + var acpIdlePoolTTL time.Duration + var acpCodexRuntimeImage string + var acpClaudeRuntimeImage string + var acpCopilotRuntimeImage string + var acpOpencodeRuntimeImage string + var acpRuntimeNamespace string + var acpProviderProxyNamespace string + var acpProviderProxyBaseURL string + var acpProviderProxyPodLabels string + var acpProviderProxyTokenFile string var agentSandboxEnabled bool var agentSandboxCleanupPolicy string var oidcIssuer string @@ -235,6 +312,11 @@ func main() { flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") + flag.StringVar(&controllerModeValue, "controller-mode", os.Getenv("ORKA_CONTROLLER_MODE"), + "Required controller mode: harness-v1 or harness-v2. An installation never serves both modes.") + flag.StringVar(&executionModeControllerUsernames, "execution-mode-controller-usernames", + os.Getenv("ORKA_EXECUTION_MODE_CONTROLLER_USERNAMES"), + "Comma-separated exact Kubernetes usernames authorized to write controller-owned Task execution authority.") flag.BoolVar(&secureMetrics, "metrics-secure", true, "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") @@ -260,7 +342,7 @@ func main() { flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") flag.IntVar(&apiPort, "api-port", 8080, "The port the REST API server binds to.") - flag.StringVar(&watchNamespace, "watch-namespace", "", "Namespace to watch for resources. Empty for all namespaces.") + flag.StringVar(&watchNamespace, "watch-namespace", "", "Required single namespace to watch for resources.") flag.BoolVar(&workspaceProviderAPIEnabled, "enable-workspace-provider-api", envBool("ORKA_ENABLE_WORKSPACE_PROVIDER_API"), "Enable workspace.orka.ai provider/class/pool/workspace coordination controllers.") @@ -286,9 +368,9 @@ func main() { controller.DefaultVendorWorkerClusterRoleName, "ClusterRole name for vendor worker tasks.") flag.StringVar(&containerWorkerClusterRoleName, "container-worker-cluster-role-name", controller.DefaultContainerWorkerClusterRoleName, "ClusterRole name for container worker tasks.") - flag.StringVar(&workerClusterRoleBindingNamePrefix, "worker-cluster-role-binding-prefix", - os.Getenv("ORKA_WORKER_CLUSTER_ROLE_BINDING_PREFIX"), - "Prefix for per-namespace worker ClusterRoleBinding names. Empty uses the legacy 'orka' prefix.") + flag.StringVar(&workerRoleBindingNamePrefix, "worker-role-binding-prefix", + os.Getenv("ORKA_WORKER_ROLE_BINDING_PREFIX"), + "Prefix for per-namespace worker RoleBinding names. Empty uses the 'orka' prefix.") flag.BoolVar(&chatEnabled, "chat-enabled", true, "Enable the chat endpoint.") flag.StringVar(&chatProvider, "chat-provider", "", "Default Provider CRD name for chat.") flag.StringVar(&chatModel, "chat-model", "", "Default model for chat.") @@ -326,12 +408,60 @@ func main() { "Maximum gateway events and deliveries processed per iteration.") flag.StringVar(&storeBackend, "store-backend", "sqlite", "Storage backend (sqlite)") flag.StringVar(&storePath, "store-path", "/data/orka.db", "Path to SQLite database file") + flag.StringVar(&agentExecutionSnapshotKeyFile, "agent-execution-snapshot-key-file", "", + "Path to the 32-byte (raw or base64) AES-256 key encrypting immutable agent execution snapshots. "+ + "When set, executable agent Tasks freeze a write-once binding and encrypted snapshot before dispatch.") + flag.DurationVar(&agentExecutionSnapshotRetention, "agent-execution-snapshot-retention", + envDurationDefault("ORKA_AGENT_EXECUTION_SNAPSHOT_RETENTION", controller.DefaultAgentExecutionSnapshotRetention), + "Minimum audit/backup retention period for encrypted execution snapshots after all references disappear.") + flag.DurationVar(&agentExecutionSnapshotRetentionInterval, "agent-execution-snapshot-retention-interval", + envDurationDefault("ORKA_AGENT_EXECUTION_SNAPSHOT_RETENTION_INTERVAL", controller.DefaultAgentExecutionSnapshotRetentionInterval), + "Interval between reference-aware encrypted execution snapshot retention scans.") flag.StringVar(&controllerURL, "controller-url", "", "Base URL for the controller API, used by workers. E.g. http://orka-controller.orka-system.svc:8080") flag.BoolVar(&enforceNamespaceIsolation, "enforce-namespace-isolation", false, "When true, restrict users to their ServiceAccount's namespace for all operations.") flag.IntVar(&maxTasksPerNamespace, "max-tasks-per-namespace", 0, "Maximum active tasks per namespace (0 = unlimited).") + flag.StringVar(&harnessV1Endpoint, "harness-v1-endpoint", os.Getenv("ORKA_HARNESS_V1_ENDPOINT"), + "Base URL of the built-in harness v1 wrapper Service.") + flag.StringVar(&harnessV1CAFile, "harness-v1-ca-file", os.Getenv("ORKA_HARNESS_V1_CA_FILE"), + "CA bundle used to authenticate built-in and registered harness v1 Services.") + flag.StringVar(&harnessV1AuthSecretNamespace, "harness-v1-auth-secret-namespace", + os.Getenv("ORKA_HARNESS_V1_AUTH_SECRET_NAMESPACE"), + "Namespace of the dedicated harness v1 wrapper bearer-token Secret.") + flag.StringVar(&harnessV1AuthSecretName, "harness-v1-auth-secret-name", + os.Getenv("ORKA_HARNESS_V1_AUTH_SECRET_NAME"), + "Name of the dedicated harness v1 wrapper bearer-token Secret.") + flag.StringVar(&harnessV1AuthSecretKey, "harness-v1-auth-secret-key", + envStringDefault("ORKA_HARNESS_V1_AUTH_SECRET_KEY", "token"), + "Key in the dedicated harness v1 wrapper bearer-token Secret.") + flag.DurationVar(&harnessV1DispatchInterval, "harness-v1-dispatch-interval", + envDurationDefault("ORKA_HARNESS_V1_DISPATCH_INTERVAL", controller.DefaultHarnessV1DispatchInterval), + "Interval between durable harness v1 attempt recovery scans.") + flag.IntVar(&harnessV1DispatchWorkers, "harness-v1-dispatch-workers", + controller.DefaultHarnessV1DispatchWorkers, + "Maximum concurrent harness v1 attempt workers.") + flag.DurationVar(&acpIdlePoolTTL, "acp-idle-pool-ttl", envDurationDefault("ORKA_ACP_IDLE_POOL_TTL", controller.DefaultACPIdlePoolTTL), + "Scale an idle ACP RuntimePool to zero after this duration.") + flag.StringVar(&acpCodexRuntimeImage, "acp-codex-runtime-image", os.Getenv("ORKA_ACP_CODEX_RUNTIME_IMAGE"), + "Digest-pinned Codex ACP runtime image.") + flag.StringVar(&acpClaudeRuntimeImage, "acp-claude-runtime-image", os.Getenv("ORKA_ACP_CLAUDE_RUNTIME_IMAGE"), + "Digest-pinned Claude ACP runtime image.") + flag.StringVar(&acpCopilotRuntimeImage, "acp-copilot-runtime-image", os.Getenv("ORKA_ACP_COPILOT_RUNTIME_IMAGE"), + "Digest-pinned Copilot ACP runtime image.") + flag.StringVar(&acpOpencodeRuntimeImage, "acp-opencode-runtime-image", os.Getenv("ORKA_ACP_OPENCODE_RUNTIME_IMAGE"), + "Digest-pinned OpenCode ACP runtime image.") + flag.StringVar(&acpRuntimeNamespace, "acp-runtime-namespace", envStringDefault("ORKA_ACP_RUNTIME_NAMESPACE", "orka-runtimes"), + "Physical namespace for managed ACP runtime Pods.") + flag.StringVar(&acpProviderProxyNamespace, "acp-provider-proxy-namespace", envStringDefault("ORKA_ACP_PROVIDER_PROXY_NAMESPACE", "vekil-system"), + "Namespace containing the approved credential-injecting provider proxy.") + flag.StringVar(&acpProviderProxyBaseURL, "acp-provider-proxy-base-url", os.Getenv("ORKA_ACP_PROVIDER_PROXY_BASE_URL"), + "Cluster-local base URL of the authenticated provider proxy boundary.") + flag.StringVar(&acpProviderProxyPodLabels, "acp-provider-proxy-pod-labels", envStringDefault("ORKA_ACP_PROVIDER_PROXY_POD_LABELS", "orka.ai/network-role=provider-auth-proxy"), + "Comma-separated exact Pod labels selected by RuntimePool provider-proxy egress policy.") + flag.StringVar(&acpProviderProxyTokenFile, "acp-provider-proxy-token-file", os.Getenv("ORKA_ACP_PROVIDER_PROXY_TOKEN_FILE"), + "Mounted file containing the authenticated provider proxy bearer token.") flag.StringVar(&executionWorkspaceDefaultProviderFlag, "execution-workspace-default-provider", executionWorkspaceDefaultProviderFlag, "Default execution workspace provider when Task execution.workspace.provider is omitted (agent-sandbox, substrate).") @@ -550,7 +680,84 @@ func main() { Development: true, } opts.BindFlags(flag.CommandLine) + acpUpgradeDrainOptions.BindFlags(flag.CommandLine) flag.Parse() + if handled, err := controller.RunACPUpgradeDrainTriggerMode(context.Background(), acpUpgradeDrainOptions); handled { + if err != nil { + fmt.Fprintln(os.Stderr, "ACP planned-upgrade drain trigger failed") + os.Exit(1) + } + return + } + mode, err := executionmode.Parse(controllerModeValue) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + watchNamespace = strings.TrimSpace(watchNamespace) + if watchNamespace == "" { + fmt.Fprintln(os.Stderr, "--watch-namespace is required; controller modes cannot use a cluster-wide watch") + os.Exit(1) + } + acpUpgradeDrainOptions.WatchNamespace = watchNamespace + if !enforceNamespaceIsolation { + fmt.Fprintln(os.Stderr, "--enforce-namespace-isolation=true is required for a static controller installation") + os.Exit(1) + } + harnessV1Enabled := mode == executionmode.HarnessV1 + acpRuntimeEnabled := mode == executionmode.HarnessV2 + if harnessV1Enabled { + // Cluster-scoped gateway/workspace infrastructure is owned only by the + // harness-v2 installation. A v1 installation must remain namespaced. + gatewayEnabled = false + workspaceProviderAPIEnabled = false + workspaceClassUseAdmissionEnabled = false + fakeWorkspaceProviderEnabled = false + } + if !enableLeaderElection { + fmt.Fprintln(os.Stderr, "--leader-elect=true is required for an isolated controller installation") + os.Exit(1) + } + if len(splitCommaList(executionModeControllerUsernames)) == 0 { + fmt.Fprintln(os.Stderr, "--execution-mode-controller-usernames must contain at least one exact username") + os.Exit(1) + } + if err := validateAgentExecutionSnapshotOptions( + mode, + agentExecutionSnapshotKeyFile, + agentExecutionSnapshotRetention, + agentExecutionSnapshotRetentionInterval, + ); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if harnessV1Enabled { + missing := make([]string, 0, 5) + for name, value := range map[string]string{ + "--harness-v1-endpoint": harnessV1Endpoint, + "--harness-v1-ca-file": harnessV1CAFile, + "--harness-v1-auth-secret-namespace": harnessV1AuthSecretNamespace, + "--harness-v1-auth-secret-name": harnessV1AuthSecretName, + "--harness-v1-auth-secret-key": harnessV1AuthSecretKey, + } { + if strings.TrimSpace(value) == "" { + missing = append(missing, name) + } + } + if len(missing) != 0 { + slices.Sort(missing) + fmt.Fprintf(os.Stderr, "harness v1 requires %s\n", strings.Join(missing, ", ")) + os.Exit(1) + } + if err := validateHarnessV1TLSEndpoint(harnessV1Endpoint); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := validateHarnessV1DispatchOptions(harnessV1DispatchInterval, harnessV1DispatchWorkers); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } // Empty worker ServiceAccount flags retain the package defaults for callers that // explicitly clear a flag, matching the zero-value fallback in the controller. @@ -574,6 +781,7 @@ func main() { } ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + setupLog.Info("configured isolated controller mode", "mode", mode, "namespace", watchNamespace) executionWorkspaceDefaultProvider = corev1alpha1.WorkspaceProvider(executionWorkspaceDefaultProviderFlag) if !controller.WorkspaceProviderSupported(executionWorkspaceDefaultProvider) { @@ -673,6 +881,10 @@ func main() { os.Exit(1) } outboundAccessTrust := outboundaccess.TrustConfig{Gateways: trustedGateways, TokenEndpoints: trustedTokenEndpoints} + if err := validateStaticTrustedServiceReferences(watchNamespace, outboundAccessTrust); err != nil { + setupLog.Error(err, "invalid static-controller outbound trust configuration") + os.Exit(1) + } if err := validateWorkspaceProviderSecurityConfig( workspaceProviderAPIEnabled, @@ -681,6 +893,10 @@ func main() { setupLog.Error(err, "invalid workspace provider security configuration") os.Exit(1) } + managerAdmissionEnabled := managerWebhookAdmissionEnabled( + taskProvenanceAdmissionEnabled, + workspaceClassUseAdmissionEnabled, + ) // Initialize OpenTelemetry tracing (noop when disabled) tracingShutdown, err := tracing.Init("orka-controller", enableTracing) @@ -748,27 +964,53 @@ func main() { metricsServerOptions.KeyName = metricsCertKey } + processCtx, stopProcess := context.WithCancel(ctrl.SetupSignalHandler()) + defer stopProcess() + restConfig := ctrl.GetConfigOrDie() mgrOptions := ctrl.Options{ - Scheme: scheme, - Metrics: metricsServerOptions, - WebhookServer: webhookServer, - HealthProbeBindAddress: probeAddr, - LeaderElection: enableLeaderElection, - LeaderElectionID: "03b49a10.orka.ai", + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: controllerLeaderElectionID, + LeaderElectionNamespace: watchNamespace, + LeaderElectionReleaseOnCancel: true, } - // Set namespace scope if specified - if watchNamespace != "" { - mgrOptions.Cache.DefaultNamespaces = map[string]cache.Config{ - watchNamespace: {}, - } - } + // Tenant resources are always namespace-scoped. Only harness v2 may also + // cache RuntimePool child kinds from its separately owned runtime namespace. + runtimeCacheNamespace := "" + if acpRuntimeEnabled { + runtimeCacheNamespace = acpRuntimeNamespace + } + mgrOptions.Cache = managerCacheOptions( + watchNamespace, + runtimeCacheNamespace, + ) - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), mgrOptions) + mgr, err := ctrl.NewManager(restConfig, mgrOptions) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) } + kubeClient, err := kubernetes.NewForConfig(restConfig) + if err != nil { + setupLog.Error(err, "unable to create Kubernetes clientset") + os.Exit(1) + } + modeNamespace, err := kubeClient.CoreV1().Namespaces().Get( + context.Background(), watchNamespace, metav1.GetOptions{}, + ) + if err != nil { + setupLog.Error(err, "unable to read controller-mode namespace", "namespace", watchNamespace) + os.Exit(1) + } + if err := executionmode.ValidateNamespace(modeNamespace, mode); err != nil { + setupLog.Error(err, "controller-mode namespace claim failed") + os.Exit(1) + } + controllerHolderID := currentControllerHolderID() if gatewayEnabled { checkCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) err := gatewayruntime.WaitForGatewayPrerequisites(checkCtx, mgr.GetAPIReader(), 500*time.Millisecond) @@ -805,13 +1047,19 @@ func main() { "trustedServiceAccounts", strings.Join(admissionConfig.TrustedServiceAccountNames, ","), ) } - - // Create Kubernetes clientset for pod log reading - kubeClient, err := kubernetes.NewForConfig(mgr.GetConfig()) - if err != nil { - setupLog.Error(err, "unable to create kubernetes clientset") - os.Exit(1) + if managerAdmissionEnabled { + orkaadmission.RegisterExecutionModeWebhooks( + mgr.GetWebhookServer(), + mgr.GetScheme(), + mgr.GetAPIReader(), + orkaadmission.ExecutionModeConfig{ + ControllerUsernames: splitCommaList(executionModeControllerUsernames), + }, + ) + setupLog.Info("registered immutable namespace mode and execution-authority admission") } + + // The clientset is reused for pod log and broker operations. outboundAccessResolver := &outboundaccess.KubernetesResolver{ Reader: mgr.GetAPIReader(), KubeClient: kubeClient, @@ -819,12 +1067,14 @@ func main() { Exchanger: tokenexchange.NewClient(tokenexchange.ClientOptions{}), } var brokeredTransactionExchange *worker.TransactionExchangeConfig + var brokeredTTSExchanger contexttoken.Exchanger if contextTokenTTSConfig.Enabled() { sharedTTSClient, clientErr := contexttoken.NewTTSClient(contextTokenTTSConfig) if clientErr != nil { setupLog.Error(clientErr, "unable to create brokered transaction-token exchanger") os.Exit(1) } + brokeredTTSExchanger = sharedTTSClient brokeredTransactionExchange = &worker.TransactionExchangeConfig{ TTS: contextTokenTTSConfig, Exchanger: sharedTTSClient, @@ -832,6 +1082,28 @@ func main() { OutboundScope: contextTokenOutboundScope, } } + var acpMCPRegistry *tools.Registry + if acpRuntimeEnabled { + acpMCPRegistry = tools.NewRegistry() + if err := tools.RegisterBrokeredCoordinationTools(acpMCPRegistry, mgr.GetClient()); err != nil { + setupLog.Error(err, "unable to register ACP MCP broker coordination tools") + os.Exit(1) + } + if err := tools.RegisterBrokeredDelegateTaskTool( + acpMCPRegistry, + mgr.GetClient(), + tools.BrokeredDelegateTaskTransactionExchangeConfig{ + TTS: contextTokenTTSConfig, + Exchanger: brokeredTTSExchanger, + SubjectTokenType: contextTokenSubjectTokenType, + ChildScope: contextTokenChildScope, + ResolveSubjectToken: newBrokeredDelegateTaskSubjectTokenResolver(mgr.GetAPIReader(), workerenv.ServiceAccountTokenFile), + }, + ); err != nil { + setupLog.Error(err, "unable to register configured ACP delegate_task broker") + os.Exit(1) + } + } // Create SQLite store if storeBackend != "sqlite" { @@ -839,20 +1111,137 @@ func main() { os.Exit(1) } - db, err := sqlite.NewDB(storePath) + sqliteStore, err := sqlite.OpenLockedStore(storePath) if err != nil { - setupLog.Error(err, "unable to create SQLite database", "path", storePath) + setupLog.Error(err, "unable to acquire the exclusive SQLite store and run migrations", "path", storePath) os.Exit(1) } - - sqliteStore := sqlite.NewStore(db, storePath) if err := mgr.Add(sqliteStore); err != nil { setupLog.Error(err, "unable to add SQLite store as runnable") os.Exit(1) } + snapshotCipher, cipherErr := loadAgentExecutionSnapshotCipher(agentExecutionSnapshotKeyFile) + if cipherErr != nil { + setupLog.Error(cipherErr, "unable to load agent execution snapshot key; snapshot encryption fails closed", + "path", agentExecutionSnapshotKeyFile) + os.Exit(1) + } + if cipherErr := sqliteStore.SetAgentExecutionSnapshotCipher(snapshotCipher); cipherErr != nil { + setupLog.Error(cipherErr, "unable to activate agent execution snapshot key; snapshot encryption fails closed", + "path", agentExecutionSnapshotKeyFile) + os.Exit(1) + } + agentExecutionSnapshotStore := sqliteStore + setupLog.Info("agent execution binding stage enabled: executable agent Tasks freeze an immutable encrypted snapshot and write-once binding before dispatch") + snapshotRetentionManager := &controller.AgentExecutionSnapshotRetentionManager{ + APIReader: mgr.GetAPIReader(), + Store: sqliteStore, + Namespace: watchNamespace, + Retention: agentExecutionSnapshotRetention, + Interval: agentExecutionSnapshotRetentionInterval, + } + if err := mgr.Add(snapshotRetentionManager); err != nil { + setupLog.Error(err, "unable to add agent execution snapshot retention manager") + os.Exit(1) + } + controlNamespace, err := acpControlNamespace(acpRuntimeEnabled || harnessV1Enabled, currentPodNamespace()) + if err != nil { + setupLog.Error(err, "unable to configure Kubernetes ACP control store") + os.Exit(1) + } - // Create helper components + // Create helper components. Kubernetes ACP admission is feature-gated, but + // the control store, epoch manager, and cleanup recovery remain available in + // a controller Pod after admission is disabled so pre-existing durable ACP + // Sessions can still be reclaimed safely. + acpAdmissionGate := controller.NewACPAdmissionGate() sessionManager := controller.NewSessionManager(sqliteStore) + var taskCleanupControlStore store.DurableControlStore + var durableControlStore store.DurableControlStore + var controllerEpochManager *controller.ControllerEpochManager + var acpSessionContinuity *controller.ACPSessionContinuity + var kubeControlStore *storekube.Store + if controlNamespace != "" { + controlStoreOptions := []storekube.Option{ + storekube.WithAPIReader(mgr.GetAPIReader()), + storekube.WithWatchNamespace(watchNamespace), + } + if harnessV1Enabled { + controlStoreOptions = append(controlStoreOptions, storekube.WithoutClusterScopedBranchClaims()) + } + kubeControlStore, err = storekube.NewComposite( + mgr.GetClient(), controlNamespace, sqliteStore, controlStoreOptions..., + ) + if err != nil { + setupLog.Error(err, "unable to configure Kubernetes ACP control store") + os.Exit(1) + } + controllerEpochManager = controller.NewControllerEpochManager(kubeControlStore, controllerHolderID). + WithMirror(sqliteStore) + sessionManager.SetACPSessionCleanup(kubeControlStore, controllerEpochManager) + if err := mgr.Add(controllerEpochManager); err != nil { + setupLog.Error(err, "unable to add controller epoch manager") + os.Exit(1) + } + sessionCleanupRecovery := controller.NewSessionCleanupRecoveryManager(kubeControlStore, controllerEpochManager) + if err := mgr.Add(sessionCleanupRecovery); err != nil { + setupLog.Error(err, "unable to add Session cleanup recovery manager") + os.Exit(1) + } + } + controlStoreWiring, err := newACPControlStoreWiring(acpRuntimeEnabled, kubeControlStore) + if err != nil { + setupLog.Error(err, "unable to configure ACP control-store wiring") + os.Exit(1) + } + taskCleanupControlStore = controlStoreWiring.taskCleanup + durableControlStore = controlStoreWiring.runtime + if acpRuntimeEnabled || harnessV1Enabled { + if kubeControlStore == nil { + setupLog.Error(errors.New("kubernetes session control store is unavailable"), + "unable to create shared agent Session continuity manager") + os.Exit(1) + } + if harnessV1Enabled { + acpSessionContinuity, err = controller.NewHarnessV1SessionContinuity(controller.HarnessV1SessionContinuityConfig{ + SessionControls: kubeControlStore, Transcripts: sqliteStore, Lineages: sqliteStore, + }) + } else { + acpSessionContinuity, err = controller.NewACPSessionContinuity(controller.ACPSessionContinuityConfig{ + SessionControls: kubeControlStore, Transcripts: sqliteStore, Publications: kubeControlStore, BranchClaims: kubeControlStore, + Lineages: sqliteStore, + }) + } + if err != nil { + setupLog.Error(err, "unable to create shared agent Session continuity manager") + os.Exit(1) + } + } + + var artifactRetentionWiring acpArtifactRetentionWiring + var publisherClient *publisherservice.Client + var artifactCapabilitySecret []byte + publisherWorkspaceArtifactMaxBytes := artifactcap.DefaultWorkspaceArtifactMaxBytes + if acpRuntimeEnabled { + artifactRoot := strings.TrimSpace(os.Getenv("ORKA_ACP_ARTIFACT_ROOT")) + if artifactRoot == "" { + artifactRoot = artifactcap.DefaultRoot + } + artifactRetentionWiring, err = newACPArtifactRetentionWiring(true, artifactRoot) + if err != nil { + setupLog.Error(err, "unable to configure ACP artifact retention") + os.Exit(1) + } + if err := mgr.Add(artifactRetentionWiring.collector); err != nil { + setupLog.Error(err, "unable to add ACP artifact retention") + os.Exit(1) + } + publisherClient, artifactCapabilitySecret, publisherWorkspaceArtifactMaxBytes, err = workspacePublisherClientFromEnv() + if err != nil { + setupLog.Error(err, "unable to configure Workspace/Publisher client") + os.Exit(1) + } + } sessionManager.SetGatewayEventStore(sqliteStore) maxTasksPerNamespaceValue := int32(maxTasksPerNamespace) //nolint:gosec // flag default is non-negative gatewayConfig := gatewayruntime.Config{ @@ -872,10 +1261,12 @@ func main() { setupLog.Error(err, "unable to add gateway service") os.Exit(1) } + } webhookNotifier := controller.NewWebhookNotifier() webhookNotifier.SetKubeClient(mgr.GetClient()) jobBuilder := controller.NewJobBuilder(mgr.GetClient()) + jobBuilder.ControllerMode = mode jobBuilder.AIWorkerImage = aiWorkerImage jobBuilder.GeneralWorkerImage = generalWorkerImage jobBuilder.AIWorkerServiceAccountName = aiWorkerServiceAccountName @@ -930,46 +1321,191 @@ func main() { agentSandboxConfig.ControllerNamespace = currentPodNamespace() } - // Setup Task controller with helper components - if err := (&controller.TaskReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - JobBuilder: jobBuilder, - SessionManager: sessionManager, - WebhookNotifier: webhookNotifier, - KubeClient: kubeClient, - OutboundAccessResolver: outboundAccessResolver, - BrokeredTransactionExchange: brokeredTransactionExchange, - ResultStore: sqliteStore, - PlanStore: sqliteStore, - MessageStore: sqliteStore, - ArtifactStore: sqliteStore, - ExecutionEventStore: sqliteStore, - EnforceNamespaceIsolation: enforceNamespaceIsolation, - MaxTasksPerNamespace: maxTasksPerNamespaceValue, - ExecutionWorkspaceDefaultProvider: executionWorkspaceDefaultProvider, - WorkspaceProviderAPIEnabled: workspaceProviderAPIEnabled, - AgentSandboxEnabled: agentSandboxEnabled, - AgentSandboxConfig: agentSandboxConfig, - SubstrateEnabled: substrateEnabled, - SubstrateConfig: substrateConfig, - AIWorkerServiceAccountName: aiWorkerServiceAccountName, - VendorWorkerServiceAccountName: vendorWorkerServiceAccountName, - ContainerWorkerServiceAccountName: containerWorkerServiceAccountName, - AIWorkerClusterRoleName: aiWorkerClusterRoleName, - VendorWorkerClusterRoleName: vendorWorkerClusterRoleName, - ContainerWorkerClusterRoleName: containerWorkerClusterRoleName, - WorkerClusterRoleBindingNamePrefix: workerClusterRoleBindingNamePrefix, - EnforceTransactionCredentialAuth: contextTokenAuthzConfig.Mode == api.ContextTokenAuthorizationModeEnforce, + if acpRuntimeEnabled { + runtimePoolReconciler := &controller.RuntimePoolReconciler{ + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + RuntimeNamespace: acpRuntimeNamespace, + } + providerProxyLabels, err := parseExactLabels(acpProviderProxyPodLabels) + if err != nil { + setupLog.Error(err, "unable to configure authenticated ACP provider proxy labels") + os.Exit(1) + } + runtimePoolReconciler.ControllerNamespace = controlNamespace + runtimePoolReconciler.ControllerAPIURL = jobBuilder.ControllerURL + runtimePoolReconciler.ControllerAPIPort = int32(apiPort) + runtimePoolReconciler.WorkspaceArtifactMaxBytes = publisherWorkspaceArtifactMaxBytes + runtimePoolReconciler.ProviderProxy = controller.RuntimePoolProviderProxyConfig{ + BaseURL: acpProviderProxyBaseURL, + Namespace: acpProviderProxyNamespace, + PodLabels: providerProxyLabels, + BearerTokenFile: acpProviderProxyTokenFile, + } + runtimePoolReconciler.Epochs = controllerEpochManager + runtimePoolReconciler.EnablePDB = true + runtimePoolReconciler.AllowedImages = controller.ACPRuntimeImages{ + Codex: acpCodexRuntimeImage, Claude: acpClaudeRuntimeImage, Copilot: acpCopilotRuntimeImage, + Opencode: acpOpencodeRuntimeImage, + } + if err := runtimePoolReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "RuntimePool") + os.Exit(1) + } + } + + // Setup Task controller with helper components. + taskReconciler := &controller.TaskReconciler{ + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + JobBuilder: jobBuilder, + SessionManager: sessionManager, + WebhookNotifier: webhookNotifier, + KubeClient: kubeClient, + ResultStore: sqliteStore, + PlanStore: sqliteStore, + MessageStore: sqliteStore, + ArtifactStore: sqliteStore, + ExecutionEventStore: sqliteStore, + DurableControlStore: taskCleanupControlStore, + AgentExecutionSnapshots: agentExecutionSnapshotStore, + MCPRegistry: acpMCPRegistry, + HarnessV1Enabled: harnessV1Enabled, + HarnessV1Endpoint: harnessV1Endpoint, + HarnessV1AuthSecretNamespace: harnessV1AuthSecretNamespace, + HarnessV1AuthSecretName: harnessV1AuthSecretName, + HarnessV1AuthSecretKey: harnessV1AuthSecretKey, + HarnessV1Attempts: sqliteStore, + ACPArtifactRetirer: artifactRetentionWiring.taskCleanup, + ACPPublicationReclaimer: publisherClient, + ControllerEpochManager: controllerEpochManager, + ACPAdmissionGate: acpAdmissionGate, + ACPRuntimeEnabled: acpRuntimeEnabled, + ACPRuntimeImages: controller.ACPRuntimeImages{ + Codex: acpCodexRuntimeImage, Claude: acpClaudeRuntimeImage, Copilot: acpCopilotRuntimeImage, + Opencode: acpOpencodeRuntimeImage, + }, + ACPRuntimeNamespace: acpRuntimeNamespace, + OutboundAccessResolver: outboundAccessResolver, + BrokeredTransactionExchange: brokeredTransactionExchange, + + EnforceNamespaceIsolation: enforceNamespaceIsolation, + MaxTasksPerNamespace: maxTasksPerNamespaceValue, + ExecutionWorkspaceDefaultProvider: executionWorkspaceDefaultProvider, + WorkspaceProviderAPIEnabled: workspaceProviderAPIEnabled, + AgentSandboxEnabled: agentSandboxEnabled, + AgentSandboxConfig: agentSandboxConfig, + SubstrateEnabled: substrateEnabled, + SubstrateConfig: substrateConfig, + AIWorkerServiceAccountName: aiWorkerServiceAccountName, + VendorWorkerServiceAccountName: vendorWorkerServiceAccountName, + ContainerWorkerServiceAccountName: containerWorkerServiceAccountName, + AIWorkerClusterRoleName: aiWorkerClusterRoleName, + VendorWorkerClusterRoleName: vendorWorkerClusterRoleName, + ContainerWorkerClusterRoleName: containerWorkerClusterRoleName, + WorkerRoleBindingNamePrefix: workerRoleBindingNamePrefix, + EnforceTransactionCredentialAuth: contextTokenAuthzConfig.Mode == api.ContextTokenAuthorizationModeEnforce, TransactionCredentialReadScopes: append( []string(nil), contextTokenAuthzConfig.SecretCredentialReadScopes()..., ), OutboundAccessTrust: outboundAccessTrust, - }).SetupWithManager(mgr); err != nil { + } + if err := taskReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Task") os.Exit(1) } + var harnessV1HTTPClient *http.Client + if harnessV1Enabled { + if controllerEpochManager == nil || agentExecutionSnapshotStore == nil { + setupLog.Error(errors.New("harness v1 requires controller epoch and encrypted snapshot stores"), + "unable to add harness v1 dispatcher") + os.Exit(1) + } + var clientErr error + harnessV1HTTPClient, clientErr = newHarnessV1TLSHTTPClient(harnessV1CAFile) + if clientErr != nil { + setupLog.Error(clientErr, "unable to configure harness v1 TLS client") + os.Exit(1) + } + harnessV1Dispatcher := &controller.HarnessV1Dispatcher{ + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Attempts: sqliteStore, + Snapshots: agentExecutionSnapshotStore, + ResultStore: sqliteStore, + EventStore: sqliteStore, + ExternalEffects: kubeControlStore, + BrokeredToolExecutor: controller.HarnessV1BrokeredToolExecutorFunc(func( + ctx context.Context, + namespace string, + tool *corev1alpha1.Tool, + request harness.ToolCallRequest, + ) (json.RawMessage, error) { + executor := worker.NewToolExecutorForNamespace(namespace, kubeClient, nil, outboundAccessResolver) + executor.SetTransactionExchangeConfig(brokeredTransactionExchange) + execCtx := worker.WithToolCallID(ctx, request.ToolCallID) + execCtx = worker.WithToolIdempotencyKey(execCtx, request.IdempotencyKey) + result, executeErr := executor.Execute(execCtx, tool, request.Input) + return json.RawMessage(result), executeErr + }), + Sessions: acpSessionContinuity, + Epochs: controllerEpochManager, + Interval: harnessV1DispatchInterval, + MaxConcurrent: harnessV1DispatchWorkers, + HTTPClient: harnessV1HTTPClient, + } + taskReconciler.HarnessV1SettlementAcknowledger = harnessV1Dispatcher + if err := mgr.Add(harnessV1Dispatcher); err != nil { + setupLog.Error(err, "unable to add harness v1 dispatcher") + os.Exit(1) + } + } + if harnessV1Enabled || acpRuntimeEnabled { + // Session settlement in both harness planes commits terminal Task status + // through the shared Kubernetes outbox. + agentOutboxProjector := &controller.ACPOutboxProjector{ + Client: mgr.GetClient(), Store: kubeControlStore, Epochs: controllerEpochManager, WorkerID: controllerHolderID + "-outbox", + } + if err := mgr.Add(agentOutboxProjector); err != nil { + setupLog.Error(err, "unable to add agent outbox projector") + os.Exit(1) + } + } + if acpRuntimeEnabled { + acpDispatcher := &controller.ACPDispatcher{ + Client: mgr.GetClient(), APIReader: mgr.GetAPIReader(), Store: durableControlStore, ResultStore: sqliteStore, + Snapshots: agentExecutionSnapshotStore, + Epochs: controllerEpochManager, Sessions: acpSessionContinuity, + Publisher: publisherClient, ArtifactCapabilitySecret: artifactCapabilitySecret, + ArtifactReservations: artifactRetentionWiring.collector, + AdmissionGate: acpAdmissionGate, + IdlePoolTTL: acpIdlePoolTTL, + MCPRegistry: acpMCPRegistry, + } + if err := mgr.Add(acpDispatcher); err != nil { + setupLog.Error(err, "unable to add ACP dispatcher") + os.Exit(1) + } + if strings.TrimSpace(acpUpgradeDrainOptions.MarkerNamespace) == "" { + acpUpgradeDrainOptions.MarkerNamespace = controlNamespace + } + upgradeDrain := controller.NewACPUpgradeDrainCoordinator( + mgr.GetClient(), mgr.GetAPIReader(), controllerEpochManager, durableControlStore, + &controller.KubernetesACPUpgradeDrainBarrierObserver{Reader: mgr.GetAPIReader(), Outbox: sqliteStore}, + acpAdmissionGate, acpUpgradeDrainOptions, + ) + if err := mgr.Add(upgradeDrain); err != nil { + setupLog.Error(err, "unable to add ACP planned-upgrade drain coordinator") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("acp-upgrade-drain", upgradeDrain.ReadyzChecker()); err != nil { + setupLog.Error(err, "unable to add ACP planned-upgrade readiness check") + os.Exit(1) + } + } if err := (&controller.OutboundAccessPolicyReconciler{ Client: mgr.GetClient(), @@ -1012,8 +1548,8 @@ func main() { ) os.Exit(1) } - registerWorkspaceCoreControllers := workspaceProviderAPIEnabled - if !workspaceProviderAPIEnabled { + registerWorkspaceCoreControllers := acpRuntimeEnabled && workspaceProviderAPIEnabled + if acpRuntimeEnabled && !workspaceProviderAPIEnabled { workspaceAPIsInstalled, err := workspaceCleanupAPIsInstalled(mgr.GetRESTMapper()) if err != nil { setupLog.Error(err, "unable to discover workspace cleanup APIs") @@ -1088,8 +1624,10 @@ func main() { } if err := (&controller.AgentRuntimeReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + Scheme: mgr.GetScheme(), + HarnessV1HTTPClient: harnessV1HTTPClient, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "AgentRuntime") os.Exit(1) @@ -1133,11 +1671,12 @@ func main() { } if err := (&controller.RepositoryScanReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - SecurityStore: sqliteStore, - ArtifactStore: sqliteStore, - ResultStore: sqliteStore, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + SecurityStore: sqliteStore, + ArtifactStore: sqliteStore, + ResultStore: sqliteStore, + PublicationStore: sqliteStore, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "RepositoryScan") os.Exit(1) @@ -1164,7 +1703,12 @@ func main() { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } - + if managerAdmissionEnabled { + if err := mgr.AddReadyzCheck("webhook", mgr.GetWebhookServer().StartedChecker()); err != nil { + setupLog.Error(err, "unable to set up webhook ready check") + os.Exit(1) + } + } // Register coordination tools the Anthropic/OpenAI proxy advertises but that // RegisterChatToolsDefault does not provide. Without these the proxy lists the // tool in coordinatorProxyTools but ToLLMTools silently drops it, leaving the @@ -1176,6 +1720,7 @@ func main() { apiServer := api.NewServer(mgr.GetClient(), sessionManager, api.ServerConfig{ Port: apiPort, WatchNamespace: watchNamespace, + ExecutionMode: mode, EnforceNamespaceIsolation: enforceNamespaceIsolation, OIDC: api.OIDCConfig{ Issuer: oidcIssuer, @@ -1191,6 +1736,7 @@ func main() { PlanStore: sqliteStore, MessageStore: sqliteStore, ArtifactStore: sqliteStore, + ArtifactReservations: artifactRetentionWiring.runtimeReservations, MemoryStore: sqliteStore, MemoryProposalStore: sqliteStore, SecurityStore: sqliteStore, @@ -1213,8 +1759,46 @@ func main() { MaxTasksPerTurn: chatMaxTasksPerTurn, MaxSessionSize: chatMaxSessionSize, MaxPrematureEndRetries: chatMaxPrematureEndRetries, + RuntimeAvailability: api.ACPRuntimeAvailability{ + Codex: acpRuntimeEnabled && controller.ACPRuntimeImageAvailable(acpCodexRuntimeImage), + Claude: acpRuntimeEnabled && controller.ACPRuntimeImageAvailable(acpClaudeRuntimeImage), + Copilot: acpRuntimeEnabled && controller.ACPRuntimeImageAvailable(acpCopilotRuntimeImage), + OpenCode: acpRuntimeEnabled && controller.ACPRuntimeImageAvailable(acpOpencodeRuntimeImage), + }, }, }) + if acpRuntimeEnabled { + mcpBroker, err := controller.NewProductionACPMCPBroker(controller.ACPMCPBrokerDependencies{ + Reader: mgr.GetAPIReader(), Epochs: controllerEpochManager, ControlStore: durableControlStore, + KubeClient: kubeClient, Registry: acpMCPRegistry, + OutboundAccess: outboundAccessResolver, TransactionExchange: brokeredTransactionExchange, + EnforceTransactionCredentialAuth: contextTokenAuthzConfig.Mode == api.ContextTokenAuthorizationModeEnforce, + TransactionCredentialReadScopes: contextTokenAuthzConfig.SecretCredentialReadScopes(), + ContextFactory: func(ctx context.Context, request harnessv2.MCPBrokerCallRequest) (*tools.ToolContext, error) { + task, ok := controller.ACPMCPAuthenticatedTaskFromContext(ctx) + if !ok || task.Namespace != request.Namespace || task.UID != string(request.Metadata.TaskUID) { + return nil, fmt.Errorf("authenticated ACP MCP task context is unavailable") + } + return &tools.ToolContext{ + Client: mgr.GetClient(), KubeClient: kubeClient, Namespace: request.Namespace, + SessionID: string(request.Authorization.RuntimeSessionUID), TaskID: task.Name, + TaskUID: task.UID, ParentTaskID: task.ParentTaskID, AgentName: task.AgentName, + Tenant: request.Namespace, WatchNamespace: watchNamespace, + EnforceNamespaceIsolation: enforceNamespaceIsolation, Brokered: true, + ResultStore: sqliteStore, MessageStore: sqliteStore, SessionDeleter: sessionManager, + MemoryReader: sqliteStore, MemoryProposalWriter: sqliteStore, TranscriptSearcher: sqliteStore, + }, nil + }, + }) + if err != nil { + setupLog.Error(err, "unable to construct ACP MCP broker") + os.Exit(1) + } + if err := apiServer.RegisterACPMCPBroker(mcpBroker); err != nil { + setupLog.Error(err, "unable to register ACP MCP broker") + os.Exit(1) + } + } // Add API server as a runnable if err := mgr.Add(apiServer); err != nil { @@ -1223,12 +1807,186 @@ func main() { } setupLog.Info("starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(processCtx); err != nil { + stopProcess() setupLog.Error(err, "problem running manager") os.Exit(1) } } +func newBrokeredDelegateTaskSubjectTokenResolver( + reader crclient.Reader, + serviceAccountTokenFile string, +) tools.DelegateTaskSubjectTokenResolver { + return func(ctx context.Context, parentTask *corev1alpha1.Task, tokenSource string) (string, error) { + switch tokenSource { + case contexttoken.TTSTokenSourceServiceAccount: + return workerenv.ReadTokenFile(serviceAccountTokenFile, "controller service account token") + case contexttoken.TTSTokenSourceIncoming: + if reader == nil { + return "", fmt.Errorf("kubernetes reader is required for incoming brokered transaction tokens") + } + if parentTask == nil || parentTask.UID == "" { + return "", fmt.Errorf("authenticated parent Task identity is required for incoming brokered transaction tokens") + } + secretName := strings.TrimSpace(parentTask.Annotations[labels.AnnotationTransactionTokenSecret]) + if secretName == "" { + return "", fmt.Errorf("authenticated parent Task does not reference an incoming transaction-token Secret") + } + secret := &corev1.Secret{} + if err := reader.Get(ctx, crclient.ObjectKey{Name: secretName, Namespace: parentTask.Namespace}, secret); err != nil { + return "", fmt.Errorf("read authenticated parent transaction-token Secret: %w", err) + } + if !secretOwnedByTask(secret, parentTask) { + return "", fmt.Errorf("authenticated parent transaction-token Secret is not owned by the parent Task") + } + token := strings.TrimSpace(string(secret.Data["token"])) + if token == "" { + return "", fmt.Errorf("authenticated parent transaction-token Secret token is missing or empty") + } + return token, nil + case contexttoken.TTSTokenSourceNone: + return "", fmt.Errorf("context token TTS token source %q does not provide a subject token", tokenSource) + default: + return "", fmt.Errorf("unsupported context token TTS token source %q", tokenSource) + } + } +} + +func secretOwnedByTask(secret *corev1.Secret, task *corev1alpha1.Task) bool { + if secret == nil || task == nil || task.UID == "" || secret.Namespace != task.Namespace { + return false + } + for _, owner := range secret.OwnerReferences { + if owner.APIVersion == corev1alpha1.GroupVersion.String() && owner.Kind == taskResourceKind && + owner.Name == task.Name && owner.UID == task.UID { + return true + } + } + return false +} + +func workspacePublisherClientFromEnv() (*publisherservice.Client, []byte, int64, error) { + artifactSecretPath := strings.TrimSpace(os.Getenv("ORKA_ACP_ARTIFACT_CAPABILITY_SECRET_FILE")) + var artifactSecret []byte + if artifactSecretPath != "" { + value, err := os.ReadFile(artifactSecretPath) + if err != nil { + return nil, nil, 0, fmt.Errorf("read ACP artifact capability secret: %w", err) + } + artifactSecret = []byte(strings.TrimSpace(string(value))) + } + baseURL := strings.TrimSpace(os.Getenv("ORKA_WORKSPACE_PUBLISHER_URL")) + if baseURL == "" { + return nil, artifactSecret, artifactcap.DefaultWorkspaceArtifactMaxBytes, nil + } + bearerPath := strings.TrimSpace(os.Getenv("ORKA_WORKSPACE_PUBLISHER_CONTROLLER_TOKEN_FILE")) + capabilityPath := strings.TrimSpace(os.Getenv("ORKA_WORKSPACE_PUBLISHER_CAPABILITY_SECRET_FILE")) + if bearerPath == "" || capabilityPath == "" { + return nil, nil, 0, fmt.Errorf("Workspace/Publisher auth file paths are required") + } + bearer, err := os.ReadFile(bearerPath) + if err != nil { + return nil, nil, 0, fmt.Errorf("read Workspace/Publisher controller token: %w", err) + } + capability, err := os.ReadFile(capabilityPath) + if err != nil { + return nil, nil, 0, fmt.Errorf("read Workspace/Publisher capability secret: %w", err) + } + client, err := publisherservice.NewClient(publisherservice.ClientConfig{ + BaseURL: baseURL, BearerToken: []byte(strings.TrimSpace(string(bearer))), + CapabilitySecret: []byte(strings.TrimSpace(string(capability))), + }) + if err != nil { + return nil, nil, 0, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + capabilities, err := client.Capabilities(ctx) + if err != nil { + return nil, nil, 0, fmt.Errorf("read Workspace/Publisher capabilities: %w", err) + } + if capabilities.Protocol != publisherservice.ProtocolVersion { + return nil, nil, 0, fmt.Errorf("Workspace/Publisher protocol %q is incompatible", capabilities.Protocol) + } + maxArtifactBytes := capabilities.Limits.MaxWorkspaceArtifactBytes + if maxArtifactBytes <= 0 || maxArtifactBytes == math.MaxInt64 { + return nil, nil, 0, fmt.Errorf("Workspace/Publisher max workspace artifact bytes must be positive and less than %d", int64(math.MaxInt64)) + } + return client, artifactSecret, maxArtifactBytes, nil +} + +func envStringDefault(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envDurationDefault(name string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func parseExactLabels(raw string) (map[string]string, error) { + result := map[string]string{} + for entry := range strings.SplitSeq(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + key, value, ok := strings.Cut(entry, "=") + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if !ok || key == "" || value == "" { + return nil, fmt.Errorf("provider proxy Pod label %q must be key=value", entry) + } + if _, exists := result[key]; exists { + return nil, fmt.Errorf("provider proxy Pod label %q is duplicated", key) + } + result[key] = value + } + if len(result) == 0 { + return nil, fmt.Errorf("at least one provider proxy Pod label is required") + } + return result, nil +} + +func managerCacheOptions(watchNamespace, acpRuntimeNamespace string) cache.Options { + watchNamespace = strings.TrimSpace(watchNamespace) + if watchNamespace == "" { + return cache.Options{} + } + + options := cache.Options{ + DefaultNamespaces: map[string]cache.Config{watchNamespace: {}}, + } + runtimeNamespace := strings.TrimSpace(acpRuntimeNamespace) + if runtimeNamespace == "" { + return options + } + + runtimeChildNamespaces := map[string]cache.Config{ + watchNamespace: {}, + runtimeNamespace: {}, + } + options.ByObject = make(map[crclient.Object]cache.ByObject) + options.ByObject[&appsv1.Deployment{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + options.ByObject[&appsv1.ReplicaSet{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + options.ByObject[&corev1.Pod{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + options.ByObject[&corev1.Service{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + options.ByObject[&corev1.Secret{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + options.ByObject[&networkingv1.NetworkPolicy{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + options.ByObject[&policyv1.PodDisruptionBudget{}] = cache.ByObject{Namespaces: runtimeChildNamespaces} + return options +} + func envBool(name string) bool { value := strings.TrimSpace(os.Getenv(name)) if value == "" { @@ -1242,13 +2000,123 @@ func envBool(name string) bool { return parsed } +func acpControlNamespace(runtimeEnabled bool, controllerNamespace string) (string, error) { + controllerNamespace = strings.TrimSpace(controllerNamespace) + if controllerNamespace == "" { + if !runtimeEnabled { + return "", nil + } + return "", fmt.Errorf("controller namespace is unavailable") + } + return controllerNamespace, nil +} + +type acpArtifactRetentionWiring struct { + collector *artifactcap.Collector + taskCleanup artifactcap.IdentityRetirer + runtimeReservations artifactcap.CapabilityReservationRecorder +} + +func newACPArtifactRetentionWiring(runtimeEnabled bool, root string) (acpArtifactRetentionWiring, error) { + if !runtimeEnabled { + return acpArtifactRetentionWiring{}, nil + } + collector, err := artifactcap.NewCollector(artifactcap.CollectorConfig{Root: root}) + if err != nil { + return acpArtifactRetentionWiring{}, err + } + wiring := acpArtifactRetentionWiring{ + collector: collector, + taskCleanup: collector, + runtimeReservations: collector, + } + return wiring, nil +} + +type acpControlStoreWiring struct { + taskCleanup store.DurableControlStore + runtime store.DurableControlStore +} + +func newACPControlStoreWiring(runtimeEnabled bool, kubeControlStore *storekube.Store) (acpControlStoreWiring, error) { + var wiring acpControlStoreWiring + if !runtimeEnabled { + return wiring, nil + } + if kubeControlStore == nil { + return acpControlStoreWiring{}, fmt.Errorf("kubernetes ACP control store is unavailable") + } + wiring.taskCleanup = kubeControlStore + wiring.runtime = kubeControlStore + return wiring, nil +} + +func currentControllerHolderID() string { + if holder := strings.TrimSpace(os.Getenv("ORKA_CONTROLLER_HOLDER_ID")); holder != "" { + return holder + } + hostname, err := os.Hostname() + if err != nil { + hostname = "" + } + return controllerHolderIDForIncarnation(hostname, controllerProcessIncarnation) +} + +func controllerHolderIDForIncarnation(hostname, incarnation string) string { + hostname = strings.TrimSpace(hostname) + if hostname == "" { + hostname = "controller" + } + return hostname + "-" + strings.TrimSpace(incarnation) +} + func currentPodNamespace() string { if namespace := strings.TrimSpace(os.Getenv(workerenv.PodNamespace)); namespace != "" { return namespace } - data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + data, err := os.ReadFile(serviceAccountNamespaceFile) if err != nil { return "" } return strings.TrimSpace(string(data)) } + +// loadAgentExecutionSnapshotCipher reads the AES-256 snapshot key from a file +// holding either exactly 32 raw bytes or whitespace-padded base64 text. +func loadAgentExecutionSnapshotCipher(path string) (*sqlite.AgentExecutionSnapshotCipher, error) { + raw, err := os.ReadFile(path) // #nosec G304 -- operator-supplied key path. + if err != nil { + return nil, err + } + key := raw + if len(key) != sqlite.AgentExecutionSnapshotKeyBytes { + decoded, decodeErr := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(raw))) + if decodeErr != nil || len(decoded) != sqlite.AgentExecutionSnapshotKeyBytes { + return nil, fmt.Errorf("snapshot key must be %d raw bytes or their base64 encoding", sqlite.AgentExecutionSnapshotKeyBytes) + } + key = decoded + } + return sqlite.NewAgentExecutionSnapshotCipher(key) +} + +func validateAgentExecutionSnapshotOptions( + mode executionmode.Mode, + keyFile string, + retention time.Duration, + interval time.Duration, +) error { + if strings.TrimSpace(keyFile) == "" { + return fmt.Errorf("%s requires --agent-execution-snapshot-key-file", mode) + } + if retention <= 0 || interval <= 0 { + return errors.New("agent execution snapshot retention and retention interval must be positive") + } + return nil +} + +func validateHarnessV1DispatchOptions(interval time.Duration, workers int) error { + if interval <= 0 { + return errors.New("harness v1 dispatch interval must be positive") + } + return controller.ValidateHarnessV1DispatchWorkers(workers) +} diff --git a/cmd/main_test.go b/cmd/main_test.go index b0e4b8e03..d8b70fd61 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -1,14 +1,523 @@ package main import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "slices" + "strings" "testing" + "time" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + policyv1 "k8s.io/api/policy/v1" "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + corev1alpha1 "github.com/orka-agents/orka/api/v1alpha1" workspacev1alpha1 "github.com/orka-agents/orka/api/workspace/v1alpha1" + "github.com/orka-agents/orka/internal/artifactcap" + "github.com/orka-agents/orka/internal/contexttoken" + "github.com/orka-agents/orka/internal/executionmode" + "github.com/orka-agents/orka/internal/labels" + "github.com/orka-agents/orka/internal/outboundaccess" + publisherservice "github.com/orka-agents/orka/internal/publisher/service" + storekube "github.com/orka-agents/orka/internal/store/kube" ) +func TestBrokeredDelegateTaskSubjectTokenResolverUsesOwnedIncomingSecret(t *testing.T) { + parent := &corev1alpha1.Task{ObjectMeta: metav1.ObjectMeta{ + Name: "parent", Namespace: "team-a", UID: types.UID("parent-uid"), + Annotations: map[string]string{labels.AnnotationTransactionTokenSecret: "parent-token"}, + }} + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "parent-token", Namespace: parent.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: corev1alpha1.GroupVersion.String(), Kind: taskResourceKind, Name: parent.Name, UID: parent.UID, + }}, + }, + Data: map[string][]byte{"token": []byte(" request-scoped-token ")}, + } + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + resolver := newBrokeredDelegateTaskSubjectTokenResolver(reader, "") + + token, err := resolver(context.Background(), parent, contexttoken.TTSTokenSourceIncoming) + if err != nil { + t.Fatalf("resolve incoming subject token: %v", err) + } + if token != "request-scoped-token" { + t.Fatalf("resolved token = %q, want request-scoped token", token) + } +} + +func TestControllerHolderIDIsProcessIncarnationUnique(t *testing.T) { + first := controllerHolderIDForIncarnation("workstation", "first-process") + second := controllerHolderIDForIncarnation("workstation", "second-process") + if first == second { + t.Fatalf("holder IDs for distinct process incarnations match: %q", first) + } + if first == "workstation" || second == "workstation" { + t.Fatal("holder ID omitted its process incarnation") + } +} + +func TestValidateStaticTrustedServiceReferences(t *testing.T) { + sameNamespace, err := outboundaccess.ParseTrustedServiceReferences("team-a/gateway:8443") + if err != nil { + t.Fatal(err) + } + crossNamespace, err := outboundaccess.ParseTrustedServiceReferences("shared/gateway:8443") + if err != nil { + t.Fatal(err) + } + if err := validateStaticTrustedServiceReferences("team-a", outboundaccess.TrustConfig{ + Gateways: sameNamespace, + }); err != nil { + t.Fatalf("same-namespace trust rejected: %v", err) + } + if err := validateStaticTrustedServiceReferences("team-a", outboundaccess.TrustConfig{ + TokenEndpoints: crossNamespace, + }); err == nil || !strings.Contains(err.Error(), `must be in controller watch namespace "team-a"`) { + t.Fatalf("cross-namespace trust error = %v", err) + } +} + +func TestCurrentControllerHolderIDPreservesExplicitOverride(t *testing.T) { + t.Setenv("ORKA_CONTROLLER_HOLDER_ID", " explicit-controller ") + if got := currentControllerHolderID(); got != "explicit-controller" { + t.Fatalf("currentControllerHolderID() = %q, want explicit-controller", got) + } +} + +func TestBrokeredDelegateTaskSubjectTokenResolverRejectsUnownedIncomingSecret(t *testing.T) { + parent := &corev1alpha1.Task{ObjectMeta: metav1.ObjectMeta{ + Name: "parent", Namespace: "team-a", UID: types.UID("parent-uid"), + Annotations: map[string]string{labels.AnnotationTransactionTokenSecret: "other-token"}, + }} + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-token", Namespace: parent.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: corev1alpha1.GroupVersion.String(), Kind: taskResourceKind, Name: "other", UID: types.UID("other-uid"), + }}, + }, + Data: map[string][]byte{"token": []byte("must-not-be-used")}, + } + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build() + resolver := newBrokeredDelegateTaskSubjectTokenResolver(reader, "") + + _, err := resolver(context.Background(), parent, contexttoken.TTSTokenSourceIncoming) + if err == nil || !strings.Contains(err.Error(), "not owned by the parent Task") { + t.Fatalf("resolve incoming subject token error = %v, want owner rejection", err) + } +} + +func TestBrokeredDelegateTaskSubjectTokenResolverReadsControllerServiceAccountPerRequest(t *testing.T) { + path := filepath.Join(t.TempDir(), "service-account-token") + if err := os.WriteFile(path, []byte(" controller-service-account-token "), 0o600); err != nil { + t.Fatal(err) + } + resolver := newBrokeredDelegateTaskSubjectTokenResolver(nil, path) + + token, err := resolver(context.Background(), &corev1alpha1.Task{}, contexttoken.TTSTokenSourceServiceAccount) + if err != nil { + t.Fatalf("resolve service account subject token: %v", err) + } + if token != "controller-service-account-token" { + t.Fatalf("resolved service account token = %q", token) + } +} + +func TestWorkspacePublisherClientFromEnvUsesBoundedFallbackWithoutPublisher(t *testing.T) { + t.Setenv("ORKA_WORKSPACE_PUBLISHER_URL", "") + t.Setenv("ORKA_ACP_ARTIFACT_CAPABILITY_SECRET_FILE", "") + + publisherClient, artifactSecret, gotLimit, err := workspacePublisherClientFromEnv() + if err != nil { + t.Fatal(err) + } + if publisherClient != nil { + t.Fatal("Workspace/Publisher client is non-nil") + } + if len(artifactSecret) != 0 { + t.Fatalf("artifact capability secret length = %d, want 0", len(artifactSecret)) + } + if gotLimit != artifactcap.DefaultWorkspaceArtifactMaxBytes { + t.Fatalf("workspace artifact fallback = %d, want %d", gotLimit, artifactcap.DefaultWorkspaceArtifactMaxBytes) + } +} + +func TestWorkspacePublisherClientFromEnvNegotiatesWorkspaceArtifactLimit(t *testing.T) { + const wantLimit = int64(192 << 20) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != publisherservice.CapabilitiesPath { + http.NotFound(writer, request) + return + } + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(publisherservice.CapabilitiesResponse{ + Protocol: publisherservice.ProtocolVersion, + Limits: publisherservice.CapabilityLimits{ + MaxWorkspaceArtifactBytes: wantLimit, + }, + }); err != nil { + t.Errorf("encode capabilities: %v", err) + } + })) + defer server.Close() + + dir := t.TempDir() + bearerPath := filepath.Join(dir, "controller-token") + capabilityPath := filepath.Join(dir, "capability-secret") + if err := os.WriteFile(bearerPath, []byte("controller-token-0123456789abcdef"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(capabilityPath, []byte("capability-secret-0123456789abcdef"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ORKA_WORKSPACE_PUBLISHER_URL", server.URL) + t.Setenv("ORKA_WORKSPACE_PUBLISHER_CONTROLLER_TOKEN_FILE", bearerPath) + t.Setenv("ORKA_WORKSPACE_PUBLISHER_CAPABILITY_SECRET_FILE", capabilityPath) + t.Setenv("ORKA_ACP_ARTIFACT_CAPABILITY_SECRET_FILE", "") + + publisherClient, artifactSecret, gotLimit, err := workspacePublisherClientFromEnv() + if err != nil { + t.Fatal(err) + } + if publisherClient == nil { + t.Fatal("Workspace/Publisher client is nil") + } + if len(artifactSecret) != 0 { + t.Fatalf("artifact capability secret length = %d, want 0", len(artifactSecret)) + } + if gotLimit != wantLimit { + t.Fatalf("workspace artifact limit = %d, want %d", gotLimit, wantLimit) + } +} + +func TestWorkspacePublisherClientFromEnvRejectsInvalidArtifactCapability(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(publisherservice.CapabilitiesResponse{ + Protocol: publisherservice.ProtocolVersion, + }) + })) + defer server.Close() + + dir := t.TempDir() + bearerPath := filepath.Join(dir, "controller-token") + capabilityPath := filepath.Join(dir, "capability-secret") + if err := os.WriteFile(bearerPath, []byte("controller-token-0123456789abcdef"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(capabilityPath, []byte("capability-secret-0123456789abcdef"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ORKA_WORKSPACE_PUBLISHER_URL", server.URL) + t.Setenv("ORKA_WORKSPACE_PUBLISHER_CONTROLLER_TOKEN_FILE", bearerPath) + t.Setenv("ORKA_WORKSPACE_PUBLISHER_CAPABILITY_SECRET_FILE", capabilityPath) + t.Setenv("ORKA_ACP_ARTIFACT_CAPABILITY_SECRET_FILE", "") + + if _, _, _, err := workspacePublisherClientFromEnv(); err == nil { + t.Fatal("workspacePublisherClientFromEnv() error = nil, want invalid Publisher limit") + } +} + +func TestACPControlNamespace(t *testing.T) { + tests := []struct { + name string + runtimeEnabled bool + controllerNamespace string + want string + wantErr bool + }{ + { + name: "disabled runtime does not require controller namespace", + }, + { + name: "disabled runtime keeps discovered controller namespace for cleanup", + controllerNamespace: "orka-system", + want: "orka-system", + }, + { + name: "enabled runtime fails closed without controller namespace", + runtimeEnabled: true, + wantErr: true, + }, + { + name: "enabled runtime rejects blank controller namespace", + runtimeEnabled: true, + controllerNamespace: " ", + wantErr: true, + }, + { + name: "enabled runtime uses controller namespace", + runtimeEnabled: true, + controllerNamespace: " orka-system ", + want: "orka-system", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := acpControlNamespace(tt.runtimeEnabled, tt.controllerNamespace) + if (err != nil) != tt.wantErr { + t.Fatalf("acpControlNamespace() error = %v, wantErr %t", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("acpControlNamespace() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestACPArtifactRetentionWiring(t *testing.T) { + tests := []struct { + name string + runtimeEnabled bool + wantCollector bool + wantRuntimeReservations bool + }{ + { + name: "harness v1 has no ACP artifact wiring", + }, + { + name: "enabled runtime exposes reservation recorder", + runtimeEnabled: true, + wantCollector: true, + wantRuntimeReservations: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wiring, err := newACPArtifactRetentionWiring( + tt.runtimeEnabled, + filepath.Join(t.TempDir(), "artifacts"), + ) + if err != nil { + t.Fatalf("newACPArtifactRetentionWiring() error = %v", err) + } + if got := wiring.collector != nil; got != tt.wantCollector { + t.Fatalf("collector present = %t, want %t", got, tt.wantCollector) + } + if wiring.collector == nil { + if wiring.taskCleanup != nil || wiring.runtimeReservations != nil { + t.Fatal("disabled ACP artifact wiring retained active components") + } + return + } + if wiring.taskCleanup != wiring.collector { + t.Fatal("Task cleanup retirer does not preserve the collector") + } + if got := wiring.runtimeReservations != nil; got != tt.wantRuntimeReservations { + t.Fatalf("runtime reservation recorder present = %t, want %t", got, tt.wantRuntimeReservations) + } + if wiring.runtimeReservations != nil && wiring.runtimeReservations != wiring.collector { + t.Fatal("runtime reservation recorder does not preserve the collector") + } + if !wiring.collector.NeedLeaderElection() { + t.Fatal("collector must remain a leader-elected cleanup runnable") + } + }) + } +} + +func TestACPArtifactRetentionWiringFailsClosedForUnsafeV2Root(t *testing.T) { + if _, err := newACPArtifactRetentionWiring(true, "relative/artifacts"); err == nil { + t.Fatal("newACPArtifactRetentionWiring() error = nil, want unsafe-root error") + } +} + +func TestACPControlStoreWiring(t *testing.T) { + tests := []struct { + name string + runtimeEnabled bool + withStore bool + wantTaskCleanup bool + wantRuntime bool + wantErr bool + }{ + { + name: "disabled runtime without controller namespace has no control store", + }, + { + name: "harness v1 does not receive ACP cleanup wiring", + withStore: true, + }, + { + name: "enabled runtime shares store with Task cleanup", + runtimeEnabled: true, + withStore: true, + wantTaskCleanup: true, + wantRuntime: true, + }, + { + name: "enabled runtime fails closed without control store", + runtimeEnabled: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var kubeControlStore *storekube.Store + if tt.withStore { + kubeControlStore = &storekube.Store{} + } + + wiring, err := newACPControlStoreWiring(tt.runtimeEnabled, kubeControlStore) + if (err != nil) != tt.wantErr { + t.Fatalf("newACPControlStoreWiring() error = %v, wantErr %t", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got := wiring.taskCleanup; (got != nil) != tt.wantTaskCleanup { + t.Fatalf("task cleanup store present = %t, want %t", got != nil, tt.wantTaskCleanup) + } else if got != nil && got != kubeControlStore { + t.Fatal("task cleanup store does not preserve the Kubernetes store") + } + if got := wiring.runtime; (got != nil) != tt.wantRuntime { + t.Fatalf("runtime store present = %t, want %t", got != nil, tt.wantRuntime) + } else if got != nil && got != kubeControlStore { + t.Fatal("runtime store does not preserve the Kubernetes store") + } + }) + } +} + +func TestManagerCacheOptions(t *testing.T) { + childTypes := []client.Object{ + &appsv1.Deployment{}, + &appsv1.ReplicaSet{}, + &corev1.Pod{}, + &corev1.Service{}, + &corev1.Secret{}, + &networkingv1.NetworkPolicy{}, + &policyv1.PodDisruptionBudget{}, + } + tests := []struct { + name string + watchNamespace string + runtimeNamespace string + wantDefault []string + wantRuntimeChild []string + wantChildOverrides bool + }{ + { + name: "cluster-wide watch is unrestricted", + runtimeNamespace: "orka-runtimes", + }, + { + name: "tenant defaults and distinct runtime child namespace", + watchNamespace: "tenant-a", + runtimeNamespace: "orka-runtimes", + wantDefault: []string{"tenant-a"}, + wantRuntimeChild: []string{"orka-runtimes", "tenant-a"}, + wantChildOverrides: true, + }, + { + name: "identical tenant and runtime namespaces are deduplicated", + watchNamespace: "tenant-a", + runtimeNamespace: "tenant-a", + wantDefault: []string{"tenant-a"}, + wantRuntimeChild: []string{"tenant-a"}, + wantChildOverrides: true, + }, + { + name: "v2 runtime children use the isolated runtime namespace", + watchNamespace: "tenant-a", + runtimeNamespace: "orka-runtimes", + wantDefault: []string{"tenant-a"}, + wantRuntimeChild: []string{"orka-runtimes", "tenant-a"}, + wantChildOverrides: true, + }, + { + name: "blank runtime namespace keeps tenant defaults", + watchNamespace: "tenant-a", + runtimeNamespace: " ", + wantDefault: []string{"tenant-a"}, + wantRuntimeChild: []string{"tenant-a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + options := managerCacheOptions(tt.watchNamespace, tt.runtimeNamespace) + assertCacheNamespaces(t, options.DefaultNamespaces, tt.wantDefault) + + for _, object := range []client.Object{ + &corev1alpha1.Task{}, + &corev1alpha1.Agent{}, + &corev1.ConfigMap{}, + } { + if _, ok := cacheByObjectForType(options, object); ok { + t.Fatalf("default-cached object %T unexpectedly has a ByObject override", object) + } + assertCacheNamespaces(t, effectiveCacheNamespaces(options, object), tt.wantDefault) + } + + wantOverrides := 0 + if tt.wantChildOverrides { + wantOverrides += len(childTypes) + } + if got := len(options.ByObject); got != wantOverrides { + t.Fatalf("ByObject override count = %d, want %d", got, wantOverrides) + } + for _, object := range childTypes { + _, overridden := cacheByObjectForType(options, object) + if overridden != tt.wantChildOverrides { + t.Fatalf("ByObject override for %T = %t, want %t", object, overridden, tt.wantChildOverrides) + } + assertCacheNamespaces(t, effectiveCacheNamespaces(options, object), tt.wantRuntimeChild) + } + }) + } +} + +func effectiveCacheNamespaces(options cache.Options, object client.Object) map[string]cache.Config { + if byObject, ok := cacheByObjectForType(options, object); ok { + return byObject.Namespaces + } + return options.DefaultNamespaces +} + +func cacheByObjectForType(options cache.Options, object client.Object) (cache.ByObject, bool) { + objectType := reflect.TypeOf(object) + for candidate, byObject := range options.ByObject { + if reflect.TypeOf(candidate) == objectType { + return byObject, true + } + } + return cache.ByObject{}, false +} + +func assertCacheNamespaces(t *testing.T, namespaces map[string]cache.Config, want []string) { + t.Helper() + got := make([]string, 0, len(namespaces)) + for namespace := range namespaces { + got = append(got, namespace) + } + slices.Sort(got) + if !slices.Equal(got, want) { + t.Fatalf("cache namespaces = %v, want %v", got, want) + } +} + func TestWorkspaceCleanupAPIsInstalled(t *testing.T) { mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{workspacev1alpha1.GroupVersion}) mapper.Add( @@ -53,6 +562,29 @@ func TestWorkspaceCleanupAPIsInstalled(t *testing.T) { } } +func TestManagerWebhookAdmissionEnabled(t *testing.T) { + tests := []struct { + name string + taskProvenance bool + workspaceClassUse bool + want bool + }{ + {name: "separate admission runtime", want: false}, + {name: "task provenance", taskProvenance: true, want: true}, + {name: "workspace class use", workspaceClassUse: true, want: true}, + {name: "all manager admission", taskProvenance: true, workspaceClassUse: true, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := managerWebhookAdmissionEnabled(tt.taskProvenance, tt.workspaceClassUse); got != tt.want { + t.Fatalf("managerWebhookAdmissionEnabled(%t, %t) = %t, want %t", + tt.taskProvenance, tt.workspaceClassUse, got, tt.want) + } + }) + } +} + func TestValidateWorkspaceProviderSecurityConfig(t *testing.T) { if err := validateWorkspaceProviderSecurityConfig(false, false); err != nil { t.Fatalf("disabled API validation: %v", err) @@ -64,3 +596,92 @@ func TestValidateWorkspaceProviderSecurityConfig(t *testing.T) { t.Fatal("workspace API enabled without class-use admission") } } + +func TestValidateAgentExecutionSnapshotOptions(t *testing.T) { + tests := []struct { + name string + mode executionmode.Mode + keyFile string + retention time.Duration + interval time.Duration + wantError bool + }{ + { + name: "harness v1 requires key", mode: executionmode.HarnessV1, + retention: time.Hour, interval: time.Minute, wantError: true, + }, + { + name: "harness v2 requires key", mode: executionmode.HarnessV2, + retention: time.Hour, interval: time.Minute, wantError: true, + }, + { + name: "harness v1 enabled", mode: executionmode.HarnessV1, + keyFile: "/var/run/orka/snapshot/key", retention: 30 * 24 * time.Hour, interval: time.Hour, + }, + { + name: "harness v2 enabled", mode: executionmode.HarnessV2, + keyFile: "/var/run/orka/snapshot/key", retention: 30 * 24 * time.Hour, interval: time.Hour, + }, + { + name: "zero retention", mode: executionmode.HarnessV2, + keyFile: "/var/run/orka/snapshot/key", retention: 0, interval: time.Hour, wantError: true, + }, + { + name: "negative retention", mode: executionmode.HarnessV2, keyFile: "/var/run/orka/snapshot/key", + retention: -time.Hour, interval: time.Hour, wantError: true, + }, + { + name: "zero interval", mode: executionmode.HarnessV2, + keyFile: "/var/run/orka/snapshot/key", retention: time.Hour, interval: 0, wantError: true, + }, + { + name: "negative interval", mode: executionmode.HarnessV2, keyFile: "/var/run/orka/snapshot/key", + retention: time.Hour, interval: -time.Minute, wantError: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAgentExecutionSnapshotOptions(tt.mode, tt.keyFile, tt.retention, tt.interval) + if (err != nil) != tt.wantError { + t.Fatalf("validation error = %v, wantError = %t", err, tt.wantError) + } + }) + } +} + +func TestLoadAgentExecutionSnapshotCipherAcceptsDeploymentKeyFormats(t *testing.T) { + raw := []byte(strings.Repeat("k", 32)) + rawWithWhitespaceEdges := append([]byte{' '}, []byte(strings.Repeat("r", 30))...) + rawWithWhitespaceEdges = append(rawWithWhitespaceEdges, '\n') + encoded := base64.StdEncoding.EncodeToString(raw) + + tests := []struct { + name string + contents []byte + wantError bool + }{ + {name: "exact raw bytes", contents: raw}, + {name: "exact raw bytes with whitespace edges", contents: rawWithWhitespaceEdges}, + {name: "base64", contents: []byte(encoded)}, + {name: "base64 with normal trailing newline", contents: []byte(encoded + "\n")}, + {name: "base64 with surrounding whitespace", contents: []byte(" \t" + encoded + "\r\n")}, + { + name: "trimmed raw bytes are not silently accepted", + contents: []byte(" " + strings.Repeat("x", 31) + " "), wantError: true, + }, + {name: "malformed", contents: []byte("not-a-snapshot-key"), wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "snapshot-key") + if err := os.WriteFile(path, tt.contents, 0o600); err != nil { + t.Fatal(err) + } + _, err := loadAgentExecutionSnapshotCipher(path) + if (err != nil) != tt.wantError { + t.Fatalf("loadAgentExecutionSnapshotCipher() error = %v, wantError = %t", err, tt.wantError) + } + }) + } +} diff --git a/cmd/orka-acp-exec-helper/main.go b/cmd/orka-acp-exec-helper/main.go new file mode 100644 index 000000000..2a2044a4b --- /dev/null +++ b/cmd/orka-acp-exec-helper/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/orka-agents/orka/internal/acp" +) + +func main() { + if err := acp.RunExecHelper(os.Args[1:], os.Environ()); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "ACP exec helper failed: %v\n", err) + os.Exit(126) + } +} diff --git a/cmd/orka-acp-runtime/main.go b/cmd/orka-acp-runtime/main.go new file mode 100644 index 000000000..f756189bb --- /dev/null +++ b/cmd/orka-acp-runtime/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/orka-agents/orka/internal/acp" + "github.com/orka-agents/orka/workers/acp/supervisor" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + slog.SetDefault(logger) + if _, err := acp.HardenSupervisorProcess(); err != nil { + logger.Error("failed to harden ACP supervisor", "error", err) + os.Exit(1) + } + cfg, err := supervisor.LoadConfigFromEnv() + if err != nil { + logger.Error("invalid ACP supervisor configuration", "error", err) + os.Exit(1) + } + runtimeServer, err := supervisor.New(cfg) + cfg.ProviderProxy.UpstreamBearerToken = "" + if err != nil { + logger.Error("create ACP supervisor", "error", err) + os.Exit(1) + } + httpServer := &http.Server{ + Addr: cfg.ListenAddress, + Handler: runtimeServer.Handler(), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 32 << 10, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + shutdownResult := make(chan error, 1) + go func() { + <-ctx.Done() + runtimeServer.BeginDrain("process_shutdown") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + shutdownErr := httpServer.Shutdown(shutdownCtx) + if shutdownErr != nil { + shutdownErr = errors.Join(shutdownErr, httpServer.Close()) + } + shutdownResult <- shutdownErr + }() + + logger.Info( + "ACP supervisor listening", "address", cfg.ListenAddress, "provider", cfg.Provider.Kind, + "runtimeInstanceID", cfg.Fence.RuntimeInstanceID, + ) + serveErr := httpServer.ListenAndServe() + if errors.Is(serveErr, http.ErrServerClosed) { + serveErr = nil + } + var shutdownErr error + if ctx.Err() != nil { + shutdownErr = <-shutdownResult + } else if serveErr != nil { + runtimeServer.BeginDrain("http_serve_failed") + shutdownErr = httpServer.Close() + } + cleanupCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + cleanupErr := runtimeServer.Close(cleanupCtx) + if err := errors.Join(serveErr, shutdownErr, cleanupErr); err != nil { + logger.Error("ACP supervisor stopped with incomplete cleanup", "error", err) + os.Exit(1) + } +} diff --git a/cmd/orka-admission/main.go b/cmd/orka-admission/main.go new file mode 100644 index 000000000..0eed986c6 --- /dev/null +++ b/cmd/orka-admission/main.go @@ -0,0 +1,284 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +// orka-admission serves the stateless, fail-closed admission boundary shared +// by isolated harness-v1 and harness-v2 installations. It owns no controllers, +// dispatch state, runtime credentials, SQLite database, or leader-election +// lease. +package main + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "flag" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + corev1alpha1 "github.com/orka-agents/orka/api/v1alpha1" + orkaadmission "github.com/orka-agents/orka/internal/admission" + "github.com/orka-agents/orka/internal/controller" +) + +var admissionScheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(admissionScheme)) + utilruntime.Must(corev1alpha1.AddToScheme(admissionScheme)) +} + +type options struct { + healthProbeBindAddress string + preStopDelay time.Duration + webhookCertPath string + webhookCertName string + webhookCertKey string + webhookServiceDNSName string + webhookPort int + enableHTTP2 bool + controllerUsernames string + taskProvenanceTrustedUsers string + taskProvenanceTrustedSAs string +} + +func (o *options) bind(fs *flag.FlagSet) { + fs.StringVar(&o.healthProbeBindAddress, "health-probe-bind-address", ":8081", + "The address on which to serve liveness and readiness probes.") + fs.DurationVar(&o.preStopDelay, "pre-stop-delay", 0, + "If nonzero, wait for endpoint removal and exit without starting the admission service.") + fs.StringVar(&o.webhookCertPath, "webhook-cert-path", "", + "The directory containing the webhook serving certificate and private key.") + fs.StringVar(&o.webhookCertName, "webhook-cert-name", "tls.crt", + "The webhook serving certificate filename.") + fs.StringVar(&o.webhookCertKey, "webhook-cert-key", "tls.key", + "The webhook serving private-key filename.") + fs.StringVar(&o.webhookServiceDNSName, "webhook-service-dns-name", "", + "The Kubernetes Service DNS name that the webhook serving certificate must cover.") + fs.IntVar(&o.webhookPort, "webhook-port", 9443, "The HTTPS webhook listener port.") + fs.BoolVar(&o.enableHTTP2, "enable-http2", false, + "Enable HTTP/2 on the webhook listener. HTTP/2 is disabled by default.") + fs.StringVar(&o.controllerUsernames, "controller-usernames", os.Getenv("ORKA_ADMISSION_CONTROLLER_USERNAMES"), + "Comma-separated exact Kubernetes usernames authorized for controller-owned Task execution writes.") + fs.StringVar(&o.taskProvenanceTrustedUsers, "task-provenance-trusted-users", + os.Getenv("ORKA_ADMISSION_TASK_PROVENANCE_TRUSTED_USERS"), + "Comma-separated exact Kubernetes usernames authorized to write controller-managed Task provenance; "+ + "defaults to controller-usernames.") + fs.StringVar(&o.taskProvenanceTrustedSAs, "task-provenance-trusted-service-accounts", + os.Getenv("ORKA_ADMISSION_TASK_PROVENANCE_TRUSTED_SERVICE_ACCOUNTS"), + "Comma-separated ServiceAccount names trusted in each Task namespace to write managed provenance.") +} + +func (o options) validate() error { + if strings.TrimSpace(o.webhookCertPath) == "" { + return errors.New("--webhook-cert-path is required") + } + if err := validateFilename("--webhook-cert-name", o.webhookCertName); err != nil { + return err + } + if err := validateFilename("--webhook-cert-key", o.webhookCertKey); err != nil { + return err + } + if strings.TrimSpace(o.webhookServiceDNSName) == "" { + return errors.New("--webhook-service-dns-name is required") + } + if o.webhookPort < 1 || o.webhookPort > 65535 { + return errors.New("--webhook-port must be between 1 and 65535") + } + if len(splitCommaList(o.controllerUsernames)) == 0 { + return errors.New("--controller-usernames must contain at least one exact username") + } + return nil +} + +func validateFilename(flagName, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" || trimmed == "." || trimmed != filepath.Base(trimmed) { + return fmt.Errorf("%s must be a nonempty filename without path components", flagName) + } + return nil +} + +func splitCommaList(raw string) []string { + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + values = append(values, value) + } + } + return values +} + +func main() { + var opts options + opts.bind(flag.CommandLine) + zapOptions := zap.Options{Development: false} + zapOptions.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOptions))) + setupLog := ctrl.Log.WithName("setup") + if opts.preStopDelay != 0 { + if err := runPreStopDelay(opts.preStopDelay, time.Sleep); err != nil { + setupLog.Error(err, "invalid admission pre-stop delay") + os.Exit(2) + } + return + } + if err := opts.validate(); err != nil { + setupLog.Error(err, "invalid admission configuration") + os.Exit(2) + } + + tlsOptions := make([]func(*tls.Config), 0, 1) + if !opts.enableHTTP2 { + tlsOptions = append(tlsOptions, func(config *tls.Config) { + config.NextProtos = []string{"http/1.1"} + }) + } + webhookServer := webhook.NewServer(webhook.Options{ + Port: opts.webhookPort, + CertDir: opts.webhookCertPath, + CertName: opts.webhookCertName, + KeyName: opts.webhookCertKey, + TLSOpts: tlsOptions, + }) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: admissionScheme, + Metrics: metricsserver.Options{BindAddress: "0"}, + HealthProbeBindAddress: opts.healthProbeBindAddress, + LeaderElection: false, + WebhookServer: webhookServer, + }) + if err != nil { + setupLog.Error(err, "unable to create admission manager") + os.Exit(1) + } + // GetWebhookServer lazily adds the configured server to the manager's + // runnable set. Registering only on the pre-manager value would not start it. + webhookServer = mgr.GetWebhookServer() + + controllerUsernames := splitCommaList(opts.controllerUsernames) + provenanceUsers := strings.TrimSpace(opts.taskProvenanceTrustedUsers) + if provenanceUsers == "" { + provenanceUsers = strings.Join(controllerUsernames, ",") + } + orkaadmission.RegisterTaskProvenanceWebhook( + webhookServer, + admissionScheme, + orkaadmission.NewTaskProvenanceConfig(true, provenanceUsers, opts.taskProvenanceTrustedSAs, ""), + ) + orkaadmission.RegisterWorkspaceClassUseWebhooks( + webhookServer, + admissionScheme, + controller.WorkspaceClassAuthorizer{Client: mgr.GetClient()}, + ) + orkaadmission.RegisterExecutionModeWebhooks( + webhookServer, + admissionScheme, + mgr.GetAPIReader(), + orkaadmission.ExecutionModeConfig{ControllerUsernames: controllerUsernames}, + ) + + if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil { + setupLog.Error(err, "unable to register liveness check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("webhook-certificates", servingCertificateFilesChecker( + opts.webhookCertPath, opts.webhookCertName, opts.webhookCertKey, opts.webhookServiceDNSName, + )); err != nil { + setupLog.Error(err, "unable to register webhook certificate readiness check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("webhook", webhookServer.StartedChecker()); err != nil { + setupLog.Error(err, "unable to register readiness check") + os.Exit(1) + } + + setupLog.Info("starting stateless execution-mode admission service", + "webhookPort", opts.webhookPort, + "controllerUsernames", strings.Join(controllerUsernames, ","), + ) + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "admission service stopped with an error") + os.Exit(1) + } +} + +func runPreStopDelay(delay time.Duration, sleep func(time.Duration)) error { + const maxDelay = 20 * time.Second + if delay <= 0 || delay > maxDelay { + return fmt.Errorf("--pre-stop-delay must be greater than zero and no more than %s", maxDelay) + } + if sleep == nil { + return errors.New("pre-stop delay requires a sleep function") + } + sleep(delay) + return nil +} + +func servingCertificateFilesChecker(directory, certificateName, keyName, serviceDNSName string) healthz.Checker { + return func(_ *http.Request) error { + for _, name := range []string{certificateName, keyName} { + path := filepath.Join(directory, name) + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("webhook serving certificate file %s is unavailable: %w", name, err) + } + if !info.Mode().IsRegular() || info.Size() == 0 { + return fmt.Errorf("webhook serving certificate file %s is not a nonempty regular file", name) + } + } + + pair, err := tls.LoadX509KeyPair( + filepath.Join(directory, certificateName), + filepath.Join(directory, keyName), + ) + if err != nil { + return fmt.Errorf("load webhook serving certificate and private key: %w", err) + } + if len(pair.Certificate) == 0 { + return errors.New("webhook serving certificate chain is empty") + } + + now := time.Now() + for index, rawCertificate := range pair.Certificate { + certificate, err := x509.ParseCertificate(rawCertificate) + if err != nil { + return fmt.Errorf("parse webhook serving certificate chain entry %d: %w", index, err) + } + if now.Before(certificate.NotBefore) { + return fmt.Errorf("webhook serving certificate chain entry %d is not valid before %s", + index, certificate.NotBefore.UTC().Format(time.RFC3339)) + } + if !now.Before(certificate.NotAfter) { + return fmt.Errorf("webhook serving certificate chain entry %d expired at %s", + index, certificate.NotAfter.UTC().Format(time.RFC3339)) + } + if index == 0 { + if err := certificate.VerifyHostname(strings.TrimSpace(serviceDNSName)); err != nil { + return fmt.Errorf("webhook serving certificate is not valid for %s: %w", serviceDNSName, err) + } + } + } + return nil + } +} diff --git a/cmd/orka-admission/main_test.go b/cmd/orka-admission/main_test.go new file mode 100644 index 000000000..d1595f389 --- /dev/null +++ b/cmd/orka-admission/main_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestOptionsValidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(*options) + wantErr bool + }{ + {name: "valid"}, + {name: "certificate directory required", mutate: func(o *options) { o.webhookCertPath = "" }, wantErr: true}, + {name: "service DNS name required", mutate: func(o *options) { o.webhookServiceDNSName = "" }, wantErr: true}, + {name: "controller identity required", mutate: func(o *options) { o.controllerUsernames = " , " }, wantErr: true}, + { + name: "multiple controller identities", + mutate: func(o *options) { o.controllerUsernames = "controller-v1,controller-v2" }, + }, + {name: "certificate filename only", mutate: func(o *options) { o.webhookCertName = "../tls.crt" }, wantErr: true}, + {name: "key filename only", mutate: func(o *options) { o.webhookCertKey = "/tls.key" }, wantErr: true}, + {name: "port lower bound", mutate: func(o *options) { o.webhookPort = 0 }, wantErr: true}, + {name: "port upper bound", mutate: func(o *options) { o.webhookPort = 65536 }, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + opts := options{ + webhookCertPath: "/certs", + webhookCertName: "tls.crt", + webhookCertKey: "tls.key", + webhookServiceDNSName: "orka-admission.orka-system.svc", + webhookPort: 9443, + controllerUsernames: "system:serviceaccount:orka-system:orka-controller-manager", + } + if tt.mutate != nil { + tt.mutate(&opts) + } + if err := opts.validate(); (err != nil) != tt.wantErr { + t.Fatalf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRunPreStopDelay(t *testing.T) { + t.Parallel() + + var slept time.Duration + if err := runPreStopDelay(5*time.Second, func(delay time.Duration) { slept = delay }); err != nil { + t.Fatalf("runPreStopDelay() error = %v", err) + } + if slept != 5*time.Second { + t.Fatalf("sleep delay = %s, want 5s", slept) + } + for _, delay := range []time.Duration{-time.Second, 21 * time.Second} { + if err := runPreStopDelay(delay, func(time.Duration) {}); err == nil { + t.Errorf("runPreStopDelay(%s) error = nil, want validation failure", delay) + } + } + if err := runPreStopDelay(time.Second, nil); err == nil { + t.Fatal("runPreStopDelay() with nil sleep error = nil") + } +} + +func TestServingCertificateFilesChecker(t *testing.T) { + t.Parallel() + now := time.Now() + valid := newServingCertificateFixture(t, now.Add(-time.Hour), now.Add(time.Hour)) + other := newServingCertificateFixture(t, now.Add(-time.Hour), now.Add(time.Hour)) + future := newServingCertificateFixture(t, now.Add(time.Hour), now.Add(2*time.Hour)) + expired := newServingCertificateFixture(t, now.Add(-2*time.Hour), now.Add(-time.Hour)) + wrongDNS := newServingCertificateFixture(t, now.Add(-time.Hour), now.Add(time.Hour), "other.orka-system.svc") + + tests := []struct { + name string + certificatePEM []byte + keyPEM []byte + wantErrContains string + }{ + {name: "missing files", wantErrContains: "unavailable"}, + {name: "certificate only", certificatePEM: valid.certificatePEM, wantErrContains: "tls.key is unavailable"}, + { + name: "empty certificate", + certificatePEM: []byte{}, + keyPEM: valid.keyPEM, + wantErrContains: "not a nonempty regular file", + }, + { + name: "malformed certificate", + certificatePEM: []byte("certificate"), + keyPEM: valid.keyPEM, + wantErrContains: "load webhook serving certificate", + }, + { + name: "mismatched private key", + certificatePEM: valid.certificatePEM, + keyPEM: other.keyPEM, + wantErrContains: "private key does not match", + }, + { + name: "not yet valid", + certificatePEM: future.certificatePEM, + keyPEM: future.keyPEM, + wantErrContains: "is not valid before", + }, + {name: "expired", certificatePEM: expired.certificatePEM, keyPEM: expired.keyPEM, wantErrContains: "expired at"}, + { + name: "wrong service DNS name", + certificatePEM: wrongDNS.certificatePEM, + keyPEM: wrongDNS.keyPEM, + wantErrContains: "is not valid for orka-admission.orka-system.svc", + }, + { + name: "expired chain certificate", + certificatePEM: append(append([]byte{}, valid.certificatePEM...), expired.certificatePEM...), + keyPEM: valid.keyPEM, + wantErrContains: "chain entry 1 expired at", + }, + {name: "valid", certificatePEM: valid.certificatePEM, keyPEM: valid.keyPEM}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + directory := t.TempDir() + if tt.certificatePEM != nil { + if err := os.WriteFile(filepath.Join(directory, "tls.crt"), tt.certificatePEM, 0o600); err != nil { + t.Fatal(err) + } + } + if tt.keyPEM != nil { + if err := os.WriteFile(filepath.Join(directory, "tls.key"), tt.keyPEM, 0o600); err != nil { + t.Fatal(err) + } + } + + checker := servingCertificateFilesChecker( + directory, "tls.crt", "tls.key", "orka-admission.orka-system.svc", + ) + err := checker(httptest.NewRequest("GET", "/readyz", nil)) + if tt.wantErrContains == "" { + if err != nil { + t.Fatalf("certificate readiness failed: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErrContains) { + t.Fatalf("certificate readiness error = %v, want error containing %q", err, tt.wantErrContains) + } + }) + } +} + +type servingCertificateFixture struct { + certificatePEM []byte + keyPEM []byte +} + +func newServingCertificateFixture( + t *testing.T, + notBefore, notAfter time.Time, + dnsNames ...string, +) servingCertificateFixture { + t.Helper() + dnsName := "orka-admission.orka-system.svc" + if len(dnsNames) > 0 { + dnsName = dnsNames[0] + } + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: dnsName}, + DNSNames: []string{dnsName}, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + return servingCertificateFixture{ + certificatePEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER}), + keyPEM: pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }), + } +} + +func TestSplitCommaList(t *testing.T) { + t.Parallel() + got := splitCommaList(" one, ,two , three") + want := []string{"one", "two", "three"} + if len(got) != len(want) { + t.Fatalf("splitCommaList() = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("splitCommaList() = %#v, want %#v", got, want) + } + } +} diff --git a/cmd/orka-agent-harness-wrapper/.gitignore b/cmd/orka-agent-harness-wrapper/.gitignore deleted file mode 100644 index 5ec983e30..000000000 --- a/cmd/orka-agent-harness-wrapper/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Bundled Copilot CLI artifacts generated by github.com/github/copilot-sdk/go/cmd/bundler. -*.zst -*.license -zcopilot_*.go diff --git a/cmd/orka-agent-harness-wrapper/abort_rollover.go b/cmd/orka-agent-harness-wrapper/abort_rollover.go new file mode 100644 index 000000000..cd735a2e5 --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/abort_rollover.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/orka-agents/orka/internal/harness" +) + +const defaultAbortRolloverTimeout = time.Minute + +func runAbortRollover(args []string) error { + fs := flag.NewFlagSet("abort-rollover", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var ( + endpoint string + bearerTokenFile string + caFile string + expectedGeneration string + timeout time.Duration + ) + fs.StringVar(&endpoint, "endpoint", "", "wrapper HTTPS endpoint") + fs.StringVar(&bearerTokenFile, "bearer-token-file", "", "file containing the wrapper control bearer") + fs.StringVar(&caFile, "ca-file", "", "CA bundle used to authenticate the wrapper") + fs.StringVar(&expectedGeneration, "expected-generation", "", "exact live ledger generation to reopen") + fs.DurationVar(&timeout, "timeout", defaultAbortRolloverTimeout, "maximum rollover abort duration") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parse abort-rollover command: %w", err) + } + endpoint = strings.TrimSpace(endpoint) + bearerTokenFile = strings.TrimSpace(bearerTokenFile) + caFile = strings.TrimSpace(caFile) + expectedGeneration = strings.TrimSpace(expectedGeneration) + if endpoint == "" || bearerTokenFile == "" || caFile == "" || expectedGeneration == "" { + return fmt.Errorf("abort-rollover endpoint, bearer token file, CA file, and expected generation are required") + } + if timeout <= 0 { + return fmt.Errorf("abort-rollover timeout must be positive") + } + tokenBytes, err := os.ReadFile(bearerTokenFile) + if err != nil { + return fmt.Errorf("read wrapper abort-rollover bearer token file: %w", err) + } + bearer := strings.TrimSpace(string(tokenBytes)) + if bearer == "" { + return fmt.Errorf("wrapper abort-rollover bearer token file is empty") + } + httpClient, err := newWrapperTLSHTTPClient(endpoint, caFile) + if err != nil { + return fmt.Errorf("configure wrapper abort-rollover TLS: %w", err) + } + client, err := harness.NewClient( + endpoint, + harness.WithBearerToken(bearer), + harness.WithControlTimeout(min(10*time.Second, timeout)), + harness.WithHTTPClient(httpClient), + ) + if err != nil { + return fmt.Errorf("configure wrapper abort-rollover client: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if _, err := client.AbortDurableRollover(ctx, expectedGeneration); err != nil { + return fmt.Errorf("abort wrapper ledger rollover: %w", err) + } + return nil +} diff --git a/cmd/orka-agent-harness-wrapper/abort_rollover_test.go b/cmd/orka-agent-harness-wrapper/abort_rollover_test.go new file mode 100644 index 000000000..e84dca260 --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/abort_rollover_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/orka-agents/orka/internal/harness" +) + +func TestRunAbortRolloverAuthenticatesAndRequiresExactGeneration(t *testing.T) { + const token = "abort-rollover-controller-token-value" + var calls atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != harness.AdminAbortRolloverPath || r.Method != http.MethodPost { + harness.WriteError(w, http.StatusNotFound, "not found") + return + } + if r.Header.Get("Authorization") != "Bearer "+token { + harness.WriteError(w, http.StatusUnauthorized, "unauthorized") + return + } + var request harness.DurableRolloverAbortRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.ExpectedGeneration != "generation-41" { + harness.WriteError(w, http.StatusBadRequest, "invalid abort") + return + } + harness.WriteJSON(w, http.StatusOK, harness.DurableRolloverAbortResponse{ + CurrentGeneration: request.ExpectedGeneration, + AdmissionReopened: true, + }) + })) + defer server.Close() + caFile := writeTLSServerCA(t, server) + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(token), 0o600); err != nil { + t.Fatal(err) + } + + if err := runAbortRollover([]string{ + "--endpoint=" + server.URL, + "--bearer-token-file=" + tokenFile, + "--ca-file=" + caFile, + "--expected-generation=generation-41", + "--timeout=2s", + }); err != nil { + t.Fatalf("runAbortRollover: %v", err) + } + if calls.Load() != 1 { + t.Fatalf("abort calls = %d, want 1", calls.Load()) + } +} + +func TestRunAbortRolloverRejectsInvalidResponseWithoutLeakingBearer(t *testing.T) { + const token = "abort-rollover-secret-token-value" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + harness.WriteJSON(w, http.StatusOK, harness.DurableRolloverAbortResponse{ + CurrentGeneration: "different-generation", + AdmissionReopened: true, + }) + })) + defer server.Close() + caFile := writeTLSServerCA(t, server) + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(token), 0o600); err != nil { + t.Fatal(err) + } + + err := runAbortRollover([]string{ + "--endpoint=" + server.URL, + "--bearer-token-file=" + tokenFile, + "--ca-file=" + caFile, + "--expected-generation=generation-41", + }) + if err == nil || !strings.Contains(err.Error(), "invalid rollover abort") { + t.Fatalf("runAbortRollover error = %v, want invalid-response rejection", err) + } + if strings.Contains(err.Error(), token) { + t.Fatalf("runAbortRollover error leaked bearer: %v", err) + } +} diff --git a/cmd/orka-agent-harness-wrapper/drain.go b/cmd/orka-agent-harness-wrapper/drain.go new file mode 100644 index 000000000..b0939d2ec --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/drain.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/orka-agents/orka/internal/harness" +) + +const ( + defaultDrainTimeout = 30 * time.Minute + defaultDrainPollInterval = time.Second +) + +func runDrain(args []string) error { + fs := flag.NewFlagSet("drain", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var ( + endpoint string + bearerTokenFile string + caFile string + nextGeneration string + timeout time.Duration + pollInterval time.Duration + ) + fs.StringVar(&endpoint, "endpoint", "", "wrapper HTTPS endpoint") + fs.StringVar(&bearerTokenFile, "bearer-token-file", "", "file containing the wrapper control bearer") + fs.StringVar(&caFile, "ca-file", "", "CA bundle used to authenticate the wrapper") + fs.StringVar(&nextGeneration, "next-generation", "", "optional exact replacement ledger generation") + fs.DurationVar(&timeout, "timeout", defaultDrainTimeout, "maximum close-and-drain duration") + fs.DurationVar(&pollInterval, "poll-interval", defaultDrainPollInterval, "drain status poll interval") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parse drain command: %w", err) + } + endpoint = strings.TrimSpace(endpoint) + bearerTokenFile = strings.TrimSpace(bearerTokenFile) + caFile = strings.TrimSpace(caFile) + nextGeneration = strings.TrimSpace(nextGeneration) + if endpoint == "" || bearerTokenFile == "" || caFile == "" { + return fmt.Errorf("drain endpoint, bearer token file, and CA file are required") + } + if timeout <= 0 || pollInterval <= 0 || pollInterval > timeout { + return fmt.Errorf("drain timeout and poll interval must be positive, with poll interval no greater than timeout") + } + tokenBytes, err := os.ReadFile(bearerTokenFile) + if err != nil { + return fmt.Errorf("read wrapper drain bearer token file: %w", err) + } + bearer := strings.TrimSpace(string(tokenBytes)) + if bearer == "" { + return fmt.Errorf("wrapper drain bearer token file is empty") + } + controlTimeout := min(10*time.Second, timeout) + httpClient, err := newWrapperTLSHTTPClient(endpoint, caFile) + if err != nil { + return fmt.Errorf("configure wrapper drain TLS: %w", err) + } + client, err := harness.NewClient( + endpoint, + harness.WithBearerToken(bearer), + harness.WithControlTimeout(controlTimeout), + harness.WithHTTPClient(httpClient), + ) + if err != nil { + return fmt.Errorf("configure wrapper drain client: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if _, err := client.CloseDurableAdmission(ctx); err != nil { + return fmt.Errorf("close wrapper admission: %w", err) + } + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + for { + status, err := client.DurableDrainStatus(ctx) + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("wrapper drain timed out: %w", ctx.Err()) + } + return fmt.Errorf("read wrapper drain status: %w", err) + } + if !status.AdmissionClosed { + return fmt.Errorf("wrapper did not retain its durable admission close") + } + if status.Completed { + if nextGeneration != "" { + if _, err := client.PrepareDurableRollover(ctx, nextGeneration); err != nil { + return fmt.Errorf("prepare wrapper ledger rollover: %w", err) + } + } + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("wrapper drain timed out: %w", ctx.Err()) + case <-ticker.C: + } + } +} diff --git a/cmd/orka-agent-harness-wrapper/drain_test.go b/cmd/orka-agent-harness-wrapper/drain_test.go new file mode 100644 index 000000000..3e23d0e0d --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/drain_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/orka-agents/orka/internal/harness" +) + +func TestRunDrainClosesWaitsAndPreparesExactGeneration(t *testing.T) { + const token = "drain-controller-token-value" + var ( + drainCalls atomic.Int32 + rolloverCalls atomic.Int32 + ) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+token { + harness.WriteError(w, http.StatusUnauthorized, "unauthorized") + return + } + switch r.URL.Path { + case harness.AdminClosePath: + harness.WriteJSON(w, http.StatusOK, harness.DurableAdmissionCloseResponse{AdmissionClosed: true}) + case harness.AdminDrainPath: + completed := drainCalls.Add(1) >= 2 + harness.WriteJSON(w, http.StatusOK, harness.DurableDrainStatus{ + AdmissionClosed: true, + Completed: completed, + }) + case harness.AdminRolloverPath: + rolloverCalls.Add(1) + var request harness.DurableRolloverPrepareRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.NextGeneration != "42" { + harness.WriteError(w, http.StatusBadRequest, "invalid rollover") + return + } + harness.WriteJSON(w, http.StatusOK, harness.DurableRolloverPrepareResponse{ + CurrentGeneration: "41", + NextGeneration: request.NextGeneration, + Prepared: true, + }) + default: + harness.WriteError(w, http.StatusNotFound, "not found") + } + })) + defer server.Close() + caFile := writeTLSServerCA(t, server) + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(token), 0o600); err != nil { + t.Fatal(err) + } + + if err := runDrain([]string{ + "--endpoint=" + server.URL, + "--bearer-token-file=" + tokenFile, + "--ca-file=" + caFile, + "--next-generation=42", + "--timeout=2s", + "--poll-interval=1ms", + }); err != nil { + t.Fatalf("runDrain: %v", err) + } + if drainCalls.Load() < 2 || rolloverCalls.Load() != 1 { + t.Fatalf("calls: drain=%d rollover=%d, want >=2/1", drainCalls.Load(), rolloverCalls.Load()) + } +} + +func TestRunDrainTimesOutWithoutPreparingRolloverOrLeakingBearer(t *testing.T) { + const token = "timeout-drain-token-value" + var rolloverCalls atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case harness.AdminClosePath: + harness.WriteJSON(w, http.StatusOK, harness.DurableAdmissionCloseResponse{AdmissionClosed: true}) + case harness.AdminDrainPath: + harness.WriteJSON(w, http.StatusOK, harness.DurableDrainStatus{AdmissionClosed: true}) + case harness.AdminRolloverPath: + rolloverCalls.Add(1) + harness.WriteError(w, http.StatusInternalServerError, "unexpected") + } + })) + defer server.Close() + caFile := writeTLSServerCA(t, server) + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(token), 0o600); err != nil { + t.Fatal(err) + } + + err := runDrain([]string{ + "--endpoint=" + server.URL, + "--bearer-token-file=" + tokenFile, + "--ca-file=" + caFile, + "--next-generation=2", + "--timeout=30ms", + "--poll-interval=5ms", + }) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("runDrain timeout error = %v", err) + } + if strings.Contains(err.Error(), token) { + t.Fatalf("runDrain error leaked bearer: %v", err) + } + if rolloverCalls.Load() != 0 { + t.Fatalf("rollover calls = %d, want 0", rolloverCalls.Load()) + } +} + +func TestRunDrainWithoutReplacementGenerationClosesAndDrainsOnly(t *testing.T) { + const token = "shutdown-drain-token-value" + var rolloverCalls atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+token { + harness.WriteError(w, http.StatusUnauthorized, "unauthorized") + return + } + switch r.URL.Path { + case harness.AdminClosePath: + harness.WriteJSON(w, http.StatusOK, harness.DurableAdmissionCloseResponse{AdmissionClosed: true}) + case harness.AdminDrainPath: + harness.WriteJSON(w, http.StatusOK, harness.DurableDrainStatus{AdmissionClosed: true, Completed: true}) + case harness.AdminRolloverPath: + rolloverCalls.Add(1) + harness.WriteError(w, http.StatusInternalServerError, "unexpected") + default: + harness.WriteError(w, http.StatusNotFound, "not found") + } + })) + defer server.Close() + caFile := writeTLSServerCA(t, server) + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(token), 0o600); err != nil { + t.Fatal(err) + } + + if err := runDrain([]string{ + "--endpoint=" + server.URL, + "--bearer-token-file=" + tokenFile, + "--ca-file=" + caFile, + "--timeout=2s", + "--poll-interval=1ms", + }); err != nil { + t.Fatalf("runDrain: %v", err) + } + if rolloverCalls.Load() != 0 { + t.Fatalf("rollover calls = %d, want 0", rolloverCalls.Load()) + } +} diff --git a/cmd/orka-agent-harness-wrapper/main.go b/cmd/orka-agent-harness-wrapper/main.go index b676d775c..9d9ca290a 100644 --- a/cmd/orka-agent-harness-wrapper/main.go +++ b/cmd/orka-agent-harness-wrapper/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/tls" "flag" "fmt" "net/http" @@ -16,6 +17,11 @@ import ( "github.com/orka-agents/orka/workers/harness/cliwrapper" ) +const ( + envHarnessWrapperTLSCertFile = "ORKA_HARNESS_WRAPPER_TLS_CERT_FILE" + envHarnessWrapperTLSKeyFile = "ORKA_HARNESS_WRAPPER_TLS_KEY_FILE" +) + type repeatedString []string func (r *repeatedString) String() string { return strings.Join(*r, ",") } @@ -32,21 +38,30 @@ func main() { } func run(args []string) error { - if len(args) > 0 && args[0] == "copilot-turn" { - return cliwrapper.RunCopilotTurnCLI(context.Background(), os.Stdin, os.Stdout) + if len(args) > 0 { + switch args[0] { + case "copilot-turn": + return cliwrapper.RunCopilotTurnCLI(context.Background(), os.Stdin, os.Stdout) + case "drain": + return runDrain(args[1:]) + case "abort-rollover": + return runAbortRollover(args[1:]) + } } cfg, err := cliwrapper.LoadConfigFromEnvUnvalidated() if err != nil { return err } _ = os.Unsetenv(cliwrapper.EnvAuthValue) - authValueFromEnv := cfg.AuthValue - cfg.AuthValue = "" var extraArgs repeatedString var extraEnv repeatedString + tlsCertFile := strings.TrimSpace(os.Getenv(envHarnessWrapperTLSCertFile)) + tlsKeyFile := strings.TrimSpace(os.Getenv(envHarnessWrapperTLSKeyFile)) fs := flag.NewFlagSet("orka-agent-harness-wrapper", flag.ContinueOnError) fs.SetOutput(os.Stderr) - fs.StringVar(&cfg.ListenAddr, "listen-addr", cfg.ListenAddr, "HTTP listen address") + fs.StringVar(&cfg.ListenAddr, "listen-addr", cfg.ListenAddr, "HTTPS listen address") + fs.StringVar(&tlsCertFile, "tls-cert-file", tlsCertFile, "server TLS certificate file") + fs.StringVar(&tlsKeyFile, "tls-key-file", tlsKeyFile, "server TLS private key file") fs.StringVar(&cfg.Runtime, "runtime", cfg.Runtime, "runtime adapter: generic, codex, claude, copilot, opencode, multi") fs.StringVar(&cfg.WorkDir, "workdir", cfg.WorkDir, "default command working directory") fs.StringVar(&cfg.Generic.Command, "command", cfg.Generic.Command, "generic adapter command path") @@ -61,6 +76,24 @@ func run(args []string) error { fs.Int64Var(&cfg.StderrLimitBytes, "stderr-limit-bytes", cfg.StderrLimitBytes, "stderr capture limit") fs.DurationVar(&cfg.CancelGrace, "cancel-grace-period", cfg.CancelGrace, "SIGTERM to SIGKILL grace period") fs.DurationVar(&cfg.TurnRetention, "turn-retention", cfg.TurnRetention, "completed turn in-memory retention TTL") + fs.StringVar( + &cfg.AdmissionLedgerPath, + "admission-ledger-path", + cfg.AdmissionLedgerPath, + "durable wrapper admission ledger path", + ) + fs.StringVar( + &cfg.LedgerGeneration, + "ledger-generation", + cfg.LedgerGeneration, + "durable wrapper admission ledger generation", + ) + fs.DurationVar( + &cfg.LedgerRetention, + "ledger-retention", + cfg.LedgerRetention, + "minimum retention for controller-settled wrapper ledger records", + ) fs.StringVar(&cfg.Copilot.Path, "copilot-cli-path", cfg.Copilot.Path, "Copilot CLI path for the copilot adapter") fs.StringVar( &cfg.Copilot.HelperPath, @@ -68,7 +101,6 @@ func run(args []string) error { cfg.Copilot.HelperPath, "helper executable path for the copilot adapter", ) - fs.StringVar(&cfg.AuthValue, "bearer-token", cfg.AuthValue, "required bearer token for turn/event/cancel endpoints") fs.BoolVar( &cfg.AllowUnauthenticated, "allow-unauthenticated", @@ -78,12 +110,14 @@ func run(args []string) error { if err := fs.Parse(args); err != nil { return err } + tlsCertFile = strings.TrimSpace(tlsCertFile) + tlsKeyFile = strings.TrimSpace(tlsKeyFile) + if tlsCertFile == "" || tlsKeyFile == "" { + return fmt.Errorf("TLS certificate and private key files are required") + } if len(extraArgs) > 0 { cfg.Generic.Args = append(cfg.Generic.Args, extraArgs...) } - if cfg.AuthValue == "" { - cfg.AuthValue = authValueFromEnv - } if len(extraEnv) > 0 { cfg.Generic.Env = append(cfg.Generic.Env, extraEnv...) cfg.CommandEnv = append(cfg.CommandEnv, extraEnv...) @@ -116,11 +150,24 @@ func run(args []string) error { if err != nil { return err } - httpServer := &http.Server{Addr: cfg.ListenAddr, Handler: server.Handler()} + defer func() { + if closeErr := server.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close wrapper admission ledger: %v\n", closeErr) + } + }() + httpServer := &http.Server{ + Addr: cfg.ListenAddr, Handler: server.Handler(), + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + } errCh := make(chan error, 1) go func() { - fmt.Fprintf(os.Stderr, "orka agent harness wrapper listening on %s (runtime=%s)\n", cfg.ListenAddr, adapter.Name()) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Fprintf( + os.Stderr, + "orka agent harness wrapper listening with TLS on %s (runtime=%s)\n", + cfg.ListenAddr, + adapter.Name(), + ) + if err := httpServer.ListenAndServeTLS(tlsCertFile, tlsKeyFile); err != nil && err != http.ErrServerClosed { errCh <- err return } diff --git a/cmd/orka-agent-harness-wrapper/main_test.go b/cmd/orka-agent-harness-wrapper/main_test.go new file mode 100644 index 000000000..57713a41d --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/main_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "strings" + "testing" +) + +func TestRunRejectsRawBearerTokenFlagWithoutEchoingValue(t *testing.T) { + const token = "raw-wrapper-bearer-must-not-reach-argv" + err := run([]string{"--bearer-token=" + token}) + if err == nil || !strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("run() error = %v, want unknown bearer-token flag", err) + } + if strings.Contains(err.Error(), token) { + t.Fatal("run() error exposed rejected bearer token") + } +} diff --git a/cmd/orka-agent-harness-wrapper/tls.go b/cmd/orka-agent-harness-wrapper/tls.go new file mode 100644 index 000000000..fdf1557ae --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/tls.go @@ -0,0 +1,34 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/url" + "os" + "strings" +) + +func newWrapperTLSHTTPClient(endpoint, caFile string) (*http.Client, error) { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return nil, fmt.Errorf("wrapper endpoint must be an HTTPS URL without user info") + } + caFile = strings.TrimSpace(caFile) + if caFile == "" { + return nil, fmt.Errorf("wrapper CA file is required") + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read wrapper CA file: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("wrapper CA file contains no valid certificates") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots} + return &http.Client{Transport: transport}, nil +} diff --git a/cmd/orka-agent-harness-wrapper/tls_test.go b/cmd/orka-agent-harness-wrapper/tls_test.go new file mode 100644 index 000000000..10ca30223 --- /dev/null +++ b/cmd/orka-agent-harness-wrapper/tls_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "encoding/pem" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func writeTLSServerCA(t *testing.T, server *httptest.Server) string { + t.Helper() + caFile := filepath.Join(t.TempDir(), "ca.crt") + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + if err := os.WriteFile(caFile, caPEM, 0o600); err != nil { + t.Fatal(err) + } + return caFile +} + +func TestNewWrapperTLSHTTPClientRejectsPlaintextEndpoint(t *testing.T) { + if _, err := newWrapperTLSHTTPClient("http://wrapper.default.svc:8080", "unused"); err == nil { + t.Fatal("plaintext wrapper endpoint was accepted") + } +} diff --git a/cmd/orka-image-ref-validator/main.go b/cmd/orka-image-ref-validator/main.go new file mode 100644 index 000000000..5de986c4f --- /dev/null +++ b/cmd/orka-image-ref-validator/main.go @@ -0,0 +1,83 @@ +package main + +import ( + _ "crypto/sha256" + "fmt" + "net" + "os" + "strconv" + "strings" + + distributionref "github.com/distribution/reference" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "at least one container image reference is required") + os.Exit(2) + } + for _, value := range os.Args[1:] { + if err := validateImageReference(value); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } +} + +func validateImageReference(value string) error { + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("container image reference contains a newline") + } + named, err := distributionref.ParseNormalizedNamed(value) + if err != nil { + return fmt.Errorf("invalid container image reference %q: %w", value, err) + } + if err := validateRegistryTransport(distributionref.Domain(named)); err != nil { + return fmt.Errorf("invalid container image reference %q: %w", value, err) + } + digested, ok := named.(distributionref.Digested) + if !ok { + return fmt.Errorf("container image reference %q is not digest pinned", value) + } + digest := digested.Digest().String() + if len(digest) != len("sha256:")+64 || !strings.HasPrefix(digest, "sha256:") { + return fmt.Errorf("container image reference %q must use a sha256 digest", value) + } + return nil +} + +func validateRegistryTransport(domain string) error { + port := "" + if after, ok := strings.CutPrefix(domain, "["); ok { + var host string + if strings.Contains(domain, "]:") { + var err error + host, port, err = net.SplitHostPort(domain) + if err != nil { + return fmt.Errorf("invalid bracketed registry host: %w", err) + } + } else { + if !strings.HasSuffix(domain, "]") { + return fmt.Errorf("invalid bracketed registry host") + } + host = strings.TrimSuffix(after, "]") + } + ip := net.ParseIP(host) + if ip == nil || !strings.Contains(host, ":") { + return fmt.Errorf("bracketed registry host is not a valid IPv6 address") + } + } else if strings.Contains(domain, ":") { + var err error + _, port, err = net.SplitHostPort(domain) + if err != nil { + return fmt.Errorf("invalid registry host or port: %w", err) + } + } + if port != "" { + value, err := strconv.ParseUint(port, 10, 16) + if err != nil || value == 0 { + return fmt.Errorf("registry port must be between 1 and 65535") + } + } + return nil +} diff --git a/cmd/orka-image-ref-validator/main_test.go b/cmd/orka-image-ref-validator/main_test.go new file mode 100644 index 000000000..cc5257755 --- /dev/null +++ b/cmd/orka-image-ref-validator/main_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "strings" + "testing" +) + +func TestValidateImageReference(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + for _, value := range []string{ + "docker.io/example/acp@" + digest, + "registry--prod.example.com:5000/team/acp:release@" + digest, + "[2001:db8::1]:5000/team/acp@" + digest, + "acp@" + digest, + } { + if err := validateImageReference(value); err != nil { + t.Errorf("validateImageReference(%q) error = %v", value, err) + } + } + + longPath := "docker.io/" + strings.Repeat("a", 256) + "@" + digest + for _, value := range []string{ + "not-digest-pinned", + "https://registry.example.com/team/acp@" + digest, + "registry.example.com:notaport/team/acp@" + digest, + "registry.example.com:70000/team/acp@" + digest, + "[127.0.0.1]/team/acp@" + digest, + "[:::]/team/acp@" + digest, + "docker.io/team/@" + digest, + "docker.io/example/acp\n#@" + digest, + longPath, + } { + if err := validateImageReference(value); err == nil { + t.Errorf("validateImageReference(%q) error = nil, want rejection", value) + } + } +} diff --git a/cmd/orka-provider-auth-proxy/credentials.go b/cmd/orka-provider-auth-proxy/credentials.go new file mode 100644 index 000000000..2e9b81e55 --- /dev/null +++ b/cmd/orka-provider-auth-proxy/credentials.go @@ -0,0 +1,436 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + defaultTokenReloadInterval = 5 * time.Second + defaultPreviousTokenOverlap = 10 * time.Minute + maxPreviousTokenOverlap = 24 * time.Hour + tokenFileReadAttempts = 3 + tokenFileReadRetryDelay = 2 * time.Millisecond + maxBearerTokenBytes = 4096 + maxTokenDeadlineBytes = 128 +) + +var errTokenReload = errors.New("provider auth token reload failed") + +type bearerTokenSnapshot struct { + currentDigest [sha256.Size]byte + previousDigest [sha256.Size]byte + previousValidUntil time.Time + hasPrevious bool + ready bool +} + +type bearerTokenStore struct { + now func() time.Time + snapshot atomic.Pointer[bearerTokenSnapshot] +} + +func newBearerTokenStore(now func() time.Time) *bearerTokenStore { + if now == nil { + now = time.Now + } + store := &bearerTokenStore{now: now} + store.disable() + return store +} + +func newStaticBearerTokenStore(token []byte) (*bearerTokenStore, error) { + if err := validateBearerToken(token); err != nil { + return nil, err + } + store := newBearerTokenStore(time.Now) + store.activate(token, nil, time.Time{}) + return store, nil +} + +func (s *bearerTokenStore) activate(current, previous []byte, previousValidUntil time.Time) { + next := &bearerTokenSnapshot{ + currentDigest: sha256.Sum256(current), + ready: true, + } + if len(previous) != 0 { + next.previousDigest = sha256.Sum256(previous) + next.previousValidUntil = previousValidUntil + next.hasPrevious = true + } + s.snapshot.Store(next) +} + +func (s *bearerTokenStore) disable() { + s.snapshot.Store(&bearerTokenSnapshot{}) +} + +func (s *bearerTokenStore) isReady() bool { + return s.snapshot.Load().ready +} + +func (s *bearerTokenStore) authorized(values []string) bool { + if len(values) != 1 { + return false + } + scheme, credential, ok := strings.Cut(values[0], " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || credential == "" || strings.ContainsAny(credential, " \t\r\n") { + return false + } + active := s.snapshot.Load() + if !active.ready { + return false + } + provided := sha256.Sum256([]byte(credential)) + currentMatch := subtle.ConstantTimeCompare(provided[:], active.currentDigest[:]) + previousMatch := 0 + if active.hasPrevious && s.now().Before(active.previousValidUntil) { + previousMatch = subtle.ConstantTimeCompare(provided[:], active.previousDigest[:]) + } + return currentMatch|previousMatch == 1 +} + +type tokenFileReloaderConfig struct { + CurrentTokenFile string + PreviousTokenFile string + PreviousTokenValidUntilFile string + ReloadInterval time.Duration + PreviousTokenOverlap time.Duration +} + +type tokenFileReloader struct { + config tokenFileReloaderConfig + store *bearerTokenStore + now func() time.Time + mu sync.Mutex +} + +func newTokenFileReloader(config tokenFileReloaderConfig, store *bearerTokenStore) (*tokenFileReloader, error) { + config.CurrentTokenFile = strings.TrimSpace(config.CurrentTokenFile) + config.PreviousTokenFile = strings.TrimSpace(config.PreviousTokenFile) + config.PreviousTokenValidUntilFile = strings.TrimSpace(config.PreviousTokenValidUntilFile) + if config.CurrentTokenFile == "" { + return nil, fmt.Errorf("current provider auth token file is required") + } + if (config.PreviousTokenFile == "") != (config.PreviousTokenValidUntilFile == "") { + return nil, fmt.Errorf("previous provider auth token and validity files must be configured together") + } + if pathsOverlap(config.CurrentTokenFile, config.PreviousTokenFile, config.PreviousTokenValidUntilFile) { + return nil, fmt.Errorf("provider auth token file paths must differ") + } + if config.ReloadInterval <= 0 { + return nil, fmt.Errorf("provider auth token reload interval must be positive") + } + if config.PreviousTokenOverlap <= 0 || config.PreviousTokenOverlap > maxPreviousTokenOverlap { + return nil, fmt.Errorf("provider auth previous token overlap must be positive and at most %s", maxPreviousTokenOverlap) + } + if store == nil { + return nil, fmt.Errorf("provider auth token store is required") + } + return &tokenFileReloader{ + config: config, + store: store, + now: store.now, + }, nil +} + +func pathsOverlap(paths ...string) bool { + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + if _, ok := seen[path]; ok { + return true + } + seen[path] = struct{}{} + } + return false +} + +func (r *tokenFileReloader) reload() error { + r.mu.Lock() + defer r.mu.Unlock() + + current, previous, validUntilFile, err := readStableTokenFiles( + r.config.CurrentTokenFile, + r.config.PreviousTokenFile, + r.config.PreviousTokenValidUntilFile, + ) + if err != nil { + r.store.disable() + return errTokenReload + } + defer clear(current.contents) + defer clear(previous.contents) + defer clear(validUntilFile.contents) + normalizeMountedToken(¤t) + normalizeMountedToken(&previous) + + previousValidUntil, err := r.validateTokenFiles(current, previous, validUntilFile) + if err != nil { + r.store.disable() + return errTokenReload + } + r.store.activate(current.contents, previous.contents, previousValidUntil) + return nil +} + +func normalizeMountedToken(snapshot *tokenFileSnapshot) { + if snapshot == nil || len(snapshot.contents) == 0 { + return + } + trimmed := bytes.TrimSpace(snapshot.contents) + copy(snapshot.contents, trimmed) + clear(snapshot.contents[len(trimmed):]) + snapshot.contents = snapshot.contents[:len(trimmed)] +} + +func (r *tokenFileReloader) validateTokenFiles( + current tokenFileSnapshot, + previous tokenFileSnapshot, + validUntilFile tokenFileSnapshot, +) (time.Time, error) { + if err := validateBearerToken(current.contents); err != nil { + return time.Time{}, errTokenReload + } + if previous.present != validUntilFile.present { + return time.Time{}, errTokenReload + } + if !previous.present { + return time.Time{}, nil + } + if err := validateBearerToken(previous.contents); err != nil { + return time.Time{}, errTokenReload + } + currentDigest := sha256.Sum256(current.contents) + previousDigest := sha256.Sum256(previous.contents) + if subtle.ConstantTimeCompare(currentDigest[:], previousDigest[:]) == 1 { + return time.Time{}, errTokenReload + } + validUntil, err := parseTokenDeadline(validUntilFile.contents) + if err != nil || validUntil.After(r.now().Add(r.config.PreviousTokenOverlap)) { + return time.Time{}, errTokenReload + } + return validUntil, nil +} + +func (r *tokenFileReloader) run(ctx context.Context, logMessage func(string)) { + ticker := time.NewTicker(r.config.ReloadInterval) + defer ticker.Stop() + failed := false + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := r.reload(); err != nil { + if !failed && logMessage != nil { + logMessage("provider auth proxy token reload failed; authentication is disabled until a valid reload") + } + failed = true + continue + } + if failed && logMessage != nil { + logMessage("provider auth proxy token reload recovered") + } + failed = false + } + } +} + +func parseTokenDeadline(contents []byte) (time.Time, error) { + if len(contents) == 0 || len(contents) > maxTokenDeadlineBytes { + return time.Time{}, errTokenReload + } + value := strings.TrimSpace(string(contents)) + if value == "" || strings.ContainsAny(value, "\r\n\t ") { + return time.Time{}, errTokenReload + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, errTokenReload + } + return parsed, nil +} + +type tokenFileSnapshot struct { + contents []byte + info os.FileInfo + present bool +} + +func readStableTokenFiles( + currentPath string, + previousPath string, + validUntilPath string, +) (tokenFileSnapshot, tokenFileSnapshot, tokenFileSnapshot, error) { + return readStableTokenFilesWithWait(currentPath, previousPath, validUntilPath, waitForNextTokenFileRead) +} + +func readStableTokenFilesWithWait( + currentPath string, + previousPath string, + validUntilPath string, + wait func(int), +) (tokenFileSnapshot, tokenFileSnapshot, tokenFileSnapshot, error) { + configuredPaths := [3]string{currentPath, previousPath, validUntilPath} + for attempt := range tokenFileReadAttempts { + readPaths, generation, projected, err := credentialReadPaths(configuredPaths) + if err != nil { + wait(attempt) + continue + } + current, err := readTokenFile(readPaths[0], false, maxBearerTokenBytes) + if err != nil { + if projected { + wait(attempt) + continue + } + return tokenFileSnapshot{}, tokenFileSnapshot{}, tokenFileSnapshot{}, err + } + previous, err := readTokenFile(readPaths[1], true, maxBearerTokenBytes) + if err != nil { + clear(current.contents) + if projected { + wait(attempt) + continue + } + return tokenFileSnapshot{}, tokenFileSnapshot{}, tokenFileSnapshot{}, err + } + validUntil, err := readTokenFile(readPaths[2], true, maxTokenDeadlineBytes) + if err != nil { + clear(current.contents) + clear(previous.contents) + if projected { + wait(attempt) + continue + } + return tokenFileSnapshot{}, tokenFileSnapshot{}, tokenFileSnapshot{}, err + } + if credentialReadUnchanged(configuredPaths, readPaths, generation, projected, current, previous, validUntil) { + return current, previous, validUntil, nil + } + clear(current.contents) + clear(previous.contents) + clear(validUntil.contents) + wait(attempt) + } + return tokenFileSnapshot{}, tokenFileSnapshot{}, tokenFileSnapshot{}, errTokenReload +} + +func waitForNextTokenFileRead(attempt int) { + if attempt+1 < tokenFileReadAttempts { + time.Sleep(tokenFileReadRetryDelay) + } +} + +func credentialReadPaths(configured [3]string) ([3]string, string, bool, error) { + if configured[1] == "" || configured[2] == "" { + return configured, "", false, nil + } + directory := filepath.Dir(configured[0]) + if filepath.Dir(configured[1]) != directory || filepath.Dir(configured[2]) != directory { + return configured, "", false, nil + } + generationLink := filepath.Join(directory, "..data") + generation, err := os.Readlink(generationLink) + if errors.Is(err, os.ErrNotExist) { + return configured, "", false, nil + } + if err != nil { + return [3]string{}, "", false, err + } + generationDirectory := generation + if !filepath.IsAbs(generationDirectory) { + generationDirectory = filepath.Join(directory, generationDirectory) + } + generationDirectory = filepath.Clean(generationDirectory) + relative, err := filepath.Rel(directory, generationDirectory) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return [3]string{}, "", false, errTokenReload + } + info, err := os.Stat(generationDirectory) + if err != nil || !info.IsDir() { + return [3]string{}, "", false, errTokenReload + } + return [3]string{ + filepath.Join(generationDirectory, filepath.Base(configured[0])), + filepath.Join(generationDirectory, filepath.Base(configured[1])), + filepath.Join(generationDirectory, filepath.Base(configured[2])), + }, generationDirectory, true, nil +} + +func credentialReadUnchanged( + configured [3]string, + readPaths [3]string, + generation string, + projected bool, + current tokenFileSnapshot, + previous tokenFileSnapshot, + validUntil tokenFileSnapshot, +) bool { + if projected { + nextPaths, nextGeneration, nextProjected, err := credentialReadPaths(configured) + if err != nil || !nextProjected || nextGeneration != generation || nextPaths != readPaths { + return false + } + } + return tokenFileUnchanged(readPaths[0], current) && tokenFileUnchanged(readPaths[1], previous) && + tokenFileUnchanged(readPaths[2], validUntil) +} + +func readTokenFile(path string, optional bool, maxBytes int64) (tokenFileSnapshot, error) { + if path == "" { + return tokenFileSnapshot{}, nil + } + file, err := os.Open(path) + if err != nil { + if optional && errors.Is(err, os.ErrNotExist) { + return tokenFileSnapshot{}, nil + } + return tokenFileSnapshot{}, err + } + defer file.Close() //nolint:errcheck + + contents, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) + if err != nil { + clear(contents) + return tokenFileSnapshot{}, err + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + clear(contents) + return tokenFileSnapshot{}, errTokenReload + } + return tokenFileSnapshot{contents: contents, info: info, present: true}, nil +} + +func tokenFileUnchanged(path string, snapshot tokenFileSnapshot) bool { + if path == "" { + return !snapshot.present + } + info, err := os.Stat(path) + if !snapshot.present { + return errors.Is(err, os.ErrNotExist) + } + return err == nil && os.SameFile(snapshot.info, info) && snapshot.info.Size() == info.Size() && + snapshot.info.ModTime().Equal(info.ModTime()) +} diff --git a/cmd/orka-provider-auth-proxy/credentials_test.go b/cmd/orka-provider-auth-proxy/credentials_test.go new file mode 100644 index 000000000..a7d16fb65 --- /dev/null +++ b/cmd/orka-provider-auth-proxy/credentials_test.go @@ -0,0 +1,552 @@ +package main + +import ( + "context" + "crypto/sha256" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const ( + testProviderTokenOld = "0123456789abcdef0123456789abcdef" + testProviderTokenNext = "11111111111111111111111111111111" + testProviderTokenNew = "22222222222222222222222222222222" +) + +func TestTokenFileReloaderSupportsLegacySingleTokenFile(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenOld) + + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, time.Minute) + if err := reloader.reload(); err != nil { + t.Fatalf("reload legacy token file: %v", err) + } + if !store.isReady() || !tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("legacy single-token file was not accepted") + } + if tokenAuthorized(store, testProviderTokenNext) { + t.Fatal("unconfigured token was accepted") + } +} + +func TestTokenFileReloaderTrimsMountedCurrentAndPreviousTokens(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, "\n"+testProviderTokenNew+"\r\n") + writeTokenFile(t, previousPath, "\t"+testProviderTokenOld+"\n") + writeTokenDeadline(t, validUntilPath, now.Add(time.Minute)) + + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, time.Minute) + if err := reloader.reload(); err != nil { + t.Fatalf("reload newline-terminated mounted tokens: %v", err) + } + if !tokenAuthorized(store, testProviderTokenNew) || !tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("trimmed current and previous mounted tokens were not accepted") + } + if tokenAuthorized(store, "\n"+testProviderTokenNew) || tokenAuthorized(store, testProviderTokenOld+"\n") { + t.Fatal("authorization accepted whitespace as part of a bearer token") + } +} + +func TestTokenFileReloaderFailsClosedWhenCurrentFileDisappears(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenOld) + + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, time.Minute) + if err := reloader.reload(); err != nil { + t.Fatalf("initial reload: %v", err) + } + if err := os.Remove(currentPath); err != nil { + t.Fatalf("remove current token file: %v", err) + } + if err := reloader.reload(); err == nil { + t.Fatal("reload succeeded after current token file disappeared") + } + if store.isReady() || tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("missing current token file did not disable authentication") + } +} + +func TestTokenFileReloaderFailsClosedAndRecovers(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenOld) + + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, time.Minute) + if err := reloader.reload(); err != nil { + t.Fatalf("initial reload: %v", err) + } + writeTokenFile(t, previousPath, "malformed token material that must never be logged") + writeTokenDeadline(t, validUntilPath, now.Add(time.Minute)) + if err := reloader.reload(); err == nil { + t.Fatal("malformed reload succeeded") + } + if store.isReady() || tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("last-known token remained active after malformed reload") + } + + proxy, err := newProviderAuthProxyWithTokenStore(proxyConfig{UpstreamBaseURL: "http://upstream.example"}, store) + if err != nil { + t.Fatalf("new proxy: %v", err) + } + assertProxyStatus(t, proxy, readinessPath, "", http.StatusServiceUnavailable) + assertProxyStatus(t, proxy, healthPath, "", http.StatusOK) + assertProxyStatus(t, proxy, "/v1/models", testProviderTokenOld, http.StatusUnauthorized) + + now = now.Add(time.Second) + writeTokenFile(t, previousPath, testProviderTokenNext) + writeTokenDeadline(t, validUntilPath, now.Add(time.Minute)) + if err := reloader.reload(); err != nil { + t.Fatalf("recovery reload: %v", err) + } + if !store.isReady() || !tokenAuthorized(store, testProviderTokenOld) || !tokenAuthorized(store, testProviderTokenNext) { + t.Fatal("valid credentials did not recover authentication") + } + + if err := os.Remove(validUntilPath); err != nil { + t.Fatalf("remove validity file: %v", err) + } + if err := reloader.reload(); err == nil { + t.Fatal("previous token without an absolute validity file succeeded") + } + if store.isReady() { + t.Fatal("incomplete previous-token pair did not fail closed") + } +} + +func TestPreviousTokenOverlapExpiresWithoutExtensionAcrossRestart(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenNew) + writeTokenFile(t, previousPath, testProviderTokenOld) + + overlap := 2 * time.Minute + validUntil := now.Add(overlap) + writeTokenDeadline(t, validUntilPath, validUntil) + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, overlap) + if err := reloader.reload(); err != nil { + t.Fatalf("initial reload: %v", err) + } + if !tokenAuthorized(store, testProviderTokenOld) || !tokenAuthorized(store, testProviderTokenNew) { + t.Fatal("current and previous tokens were not both accepted during overlap") + } + + now = validUntil.Add(time.Nanosecond) + if tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("previous token remained accepted after absolute deadline") + } + if err := reloader.reload(); err != nil { + t.Fatalf("unchanged reload: %v", err) + } + if tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("periodic reload extended an unchanged previous token") + } + if !tokenAuthorized(store, testProviderTokenNew) { + t.Fatal("current token expired with previous token") + } + + // Recreate every mounted file to model a fresh Pod/projected-volume + // materialization while preserving the same absolute expiry metadata. + writeTokenFile(t, currentPath, testProviderTokenNew) + writeTokenFile(t, previousPath, testProviderTokenOld) + writeTokenDeadline(t, validUntilPath, validUntil) + restartedStore, restartedReloader := newTestTokenFileReloader( + t, + &now, + currentPath, + previousPath, + validUntilPath, + overlap, + ) + if err := restartedReloader.reload(); err != nil { + t.Fatalf("restart reload: %v", err) + } + if tokenAuthorized(restartedStore, testProviderTokenOld) { + t.Fatal("process restart extended an expired previous token") + } +} + +func TestPreviousTokenDeadlineCannotExceedConfiguredOverlap(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenNew) + writeTokenFile(t, previousPath, testProviderTokenOld) + writeTokenDeadline(t, validUntilPath, now.Add(time.Minute+time.Second)) + + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, time.Minute) + if err := reloader.reload(); err == nil { + t.Fatal("overlong previous-token deadline succeeded") + } + if store.isReady() { + t.Fatal("overlong previous-token deadline did not fail closed") + } +} + +func TestTokenFileReloaderReadsOneProjectedSecretGeneration(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + overlap := 5 * time.Minute + + firstGeneration := filepath.Join(directory, "..2026_07_25_01") + if err := os.Mkdir(firstGeneration, 0o700); err != nil { + t.Fatalf("create first projected generation: %v", err) + } + writeTokenFile(t, filepath.Join(firstGeneration, "token"), testProviderTokenOld) + switchProjectedGeneration(t, directory, filepath.Base(firstGeneration)) + if err := os.Symlink(filepath.Join("..data", "token"), currentPath); err != nil { + t.Fatalf("create visible current-token symlink: %v", err) + } + + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, overlap) + if err := reloader.reload(); err != nil { + t.Fatalf("initial projected reload: %v", err) + } + + secondGeneration := filepath.Join(directory, "..2026_07_25_02") + if err := os.Mkdir(secondGeneration, 0o700); err != nil { + t.Fatalf("create second projected generation: %v", err) + } + writeTokenFile(t, filepath.Join(secondGeneration, "token"), testProviderTokenNew) + writeTokenFile(t, filepath.Join(secondGeneration, "previous-token"), testProviderTokenOld) + writeTokenDeadline(t, filepath.Join(secondGeneration, "previous-token-valid-until"), now.Add(overlap)) + switchProjectedGeneration(t, directory, filepath.Base(secondGeneration)) + + // Kubernetes publishes ..data before creating visible symlinks for newly + // added keys. Reload must still read the complete selected generation. + if _, err := os.Lstat(previousPath); !os.IsNotExist(err) { + t.Fatalf("previous-token visible path unexpectedly exists: %v", err) + } + if err := reloader.reload(); err != nil { + t.Fatalf("rotated projected reload: %v", err) + } + if !tokenAuthorized(store, testProviderTokenNew) || !tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("projected generation was not published as one credential pair") + } +} + +func TestReadStableTokenFilesRetriesProjectedGenerationResolution(t *testing.T) { + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + if err := os.Symlink("..missing-generation", filepath.Join(directory, "..data")); err != nil { + t.Fatalf("create dangling projected generation: %v", err) + } + generation := filepath.Join(directory, "..2026_07_25_retry") + if err := os.Mkdir(generation, 0o700); err != nil { + t.Fatalf("create replacement projected generation: %v", err) + } + writeTokenFile(t, filepath.Join(generation, "token"), testProviderTokenNew) + + waits := 0 + current, previous, validUntil, err := readStableTokenFilesWithWait( + currentPath, + previousPath, + validUntilPath, + func(int) { + waits++ + if waits == 1 { + switchProjectedGeneration(t, directory, filepath.Base(generation)) + } + }, + ) + if err != nil { + t.Fatalf("read replacement projected generation: %v", err) + } + defer clear(current.contents) + defer clear(previous.contents) + defer clear(validUntil.contents) + if waits != 1 { + t.Fatalf("retry waits = %d, want 1", waits) + } + if string(current.contents) != testProviderTokenNew || previous.present || validUntil.present { + t.Fatal("retry did not return the complete replacement generation") + } +} + +func TestTokenRotationProxyFirst(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenOld) + + overlap := 5 * time.Minute + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, overlap) + if err := reloader.reload(); err != nil { + t.Fatalf("initial reload: %v", err) + } + + now = now.Add(time.Minute) + validUntil := now.Add(overlap) + writeTokenFile(t, currentPath, testProviderTokenNew) + writeTokenFile(t, previousPath, testProviderTokenOld) + writeTokenDeadline(t, validUntilPath, validUntil) + if err := reloader.reload(); err != nil { + t.Fatalf("proxy-first reload: %v", err) + } + if !tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("old controller token was rejected after proxy-first rotation") + } + if !tokenAuthorized(store, testProviderTokenNew) { + t.Fatal("new controller token was rejected after proxy-first rotation") + } + + now = validUntil.Add(time.Nanosecond) + if tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("old token remained accepted after proxy-first overlap") + } +} + +func TestTokenRotationControllerFirstWithPreloadedOverlapToken(t *testing.T) { + now := time.Now().UTC() + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenOld) + + overlap := 5 * time.Minute + store, reloader := newTestTokenFileReloader(t, &now, currentPath, previousPath, validUntilPath, overlap) + if err := reloader.reload(); err != nil { + t.Fatalf("initial reload: %v", err) + } + if tokenAuthorized(store, testProviderTokenNext) { + t.Fatal("unstaged next token was accepted") + } + + // Pre-stage the next controller token in the overlap slot, then switch the + // controller before changing which token is designated current by the proxy. + now = now.Add(time.Minute) + writeTokenFile(t, previousPath, testProviderTokenNext) + writeTokenDeadline(t, validUntilPath, now.Add(overlap)) + if err := reloader.reload(); err != nil { + t.Fatalf("pre-stage next token: %v", err) + } + if !tokenAuthorized(store, testProviderTokenOld) || !tokenAuthorized(store, testProviderTokenNext) { + t.Fatal("pre-staged controller-first tokens were not both accepted") + } + + now = now.Add(time.Minute) + validUntil := now.Add(overlap) + writeTokenFile(t, currentPath, testProviderTokenNext) + writeTokenFile(t, previousPath, testProviderTokenOld) + writeTokenDeadline(t, validUntilPath, validUntil) + if !tokenAuthorized(store, testProviderTokenNext) { + t.Fatal("controller-first request failed before the proxy observed the role swap") + } + if err := reloader.reload(); err != nil { + t.Fatalf("normalize controller-first token roles: %v", err) + } + if !tokenAuthorized(store, testProviderTokenNext) || !tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("normalized controller-first tokens were not both accepted") + } + + now = validUntil.Add(time.Nanosecond) + if tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("old token remained accepted after controller-first overlap") + } +} + +func TestBearerTokenStorePublishesCredentialPairsAtomically(t *testing.T) { + now := time.Now().UTC() + store := newBearerTokenStore(func() time.Time { return now }) + validUntil := now.Add(time.Hour) + store.activate([]byte(testProviderTokenOld), []byte(testProviderTokenNext), validUntil) + + oldDigest := sha256.Sum256([]byte(testProviderTokenOld)) + nextDigest := sha256.Sum256([]byte(testProviderTokenNext)) + newDigest := sha256.Sum256([]byte(testProviderTokenNew)) + var invalid atomic.Bool + var readers sync.WaitGroup + for range 8 { + readers.Go(func() { + for range 10_000 { + active := store.snapshot.Load() + oldPair := active.currentDigest == oldDigest && active.previousDigest == nextDigest + newPair := active.currentDigest == newDigest && active.previousDigest == oldDigest + if !active.ready || !active.hasPrevious || (!oldPair && !newPair) { + invalid.Store(true) + return + } + } + }) + } + for range 10_000 { + store.activate([]byte(testProviderTokenNew), []byte(testProviderTokenOld), validUntil) + store.activate([]byte(testProviderTokenOld), []byte(testProviderTokenNext), validUntil) + } + readers.Wait() + if invalid.Load() { + t.Fatal("reader observed a partially published credential pair") + } +} + +func TestPeriodicTokenReloadDoesNotLogTokenMaterial(t *testing.T) { + directory := t.TempDir() + currentPath := filepath.Join(directory, "token") + previousPath := filepath.Join(directory, "previous-token") + validUntilPath := filepath.Join(directory, "previous-token-valid-until") + writeTokenFile(t, currentPath, testProviderTokenOld) + + store := newBearerTokenStore(time.Now) + reloader, err := newTokenFileReloader(tokenFileReloaderConfig{ + CurrentTokenFile: currentPath, + PreviousTokenFile: previousPath, + PreviousTokenValidUntilFile: validUntilPath, + ReloadInterval: 5 * time.Millisecond, + PreviousTokenOverlap: time.Minute, + }, store) + if err != nil { + t.Fatalf("new reloader: %v", err) + } + if err := reloader.reload(); err != nil { + t.Fatalf("initial reload: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var messagesMu sync.Mutex + var messages []string + go reloader.run(ctx, func(message string) { + messagesMu.Lock() + defer messagesMu.Unlock() + messages = append(messages, message) + }) + + malformed := "malformed token material that must never be logged" + writeTokenFile(t, currentPath, malformed) + eventually(t, time.Second, func() bool { return !store.isReady() }) + if tokenAuthorized(store, testProviderTokenOld) { + t.Fatal("old token remained accepted after periodic malformed reload") + } + + writeTokenFile(t, currentPath, testProviderTokenNew) + eventually(t, time.Second, func() bool { + return store.isReady() && tokenAuthorized(store, testProviderTokenNew) + }) + + eventually(t, time.Second, func() bool { + messagesMu.Lock() + defer messagesMu.Unlock() + joined := strings.Join(messages, "\n") + return strings.Contains(joined, "reload failed") && strings.Contains(joined, "reload recovered") + }) + cancel() + messagesMu.Lock() + joined := strings.Join(messages, "\n") + messagesMu.Unlock() + for _, secret := range []string{malformed, testProviderTokenOld, testProviderTokenNew} { + if strings.Contains(joined, secret) { + t.Fatalf("token material appeared in reload logs: %q", joined) + } + } +} + +func newTestTokenFileReloader( + t *testing.T, + now *time.Time, + currentPath string, + previousPath string, + validUntilPath string, + overlap time.Duration, +) (*bearerTokenStore, *tokenFileReloader) { + t.Helper() + store := newBearerTokenStore(func() time.Time { return *now }) + reloader, err := newTokenFileReloader(tokenFileReloaderConfig{ + CurrentTokenFile: currentPath, + PreviousTokenFile: previousPath, + PreviousTokenValidUntilFile: validUntilPath, + ReloadInterval: time.Second, + PreviousTokenOverlap: overlap, + }, store) + if err != nil { + t.Fatalf("new token file reloader: %v", err) + } + return store, reloader +} + +func writeTokenFile(t *testing.T, path, token string) { + t.Helper() + if err := os.WriteFile(path, []byte(token), 0o600); err != nil { + t.Fatalf("write token file: %v", err) + } +} + +func writeTokenDeadline(t *testing.T, path string, deadline time.Time) { + t.Helper() + if err := os.WriteFile(path, []byte(deadline.UTC().Format(time.RFC3339Nano)), 0o600); err != nil { + t.Fatalf("write token deadline: %v", err) + } +} + +func switchProjectedGeneration(t *testing.T, directory, generation string) { + t.Helper() + temporaryLink := filepath.Join(directory, "..data_tmp") + if err := os.Symlink(generation, temporaryLink); err != nil { + t.Fatalf("create projected generation link: %v", err) + } + if err := os.Rename(temporaryLink, filepath.Join(directory, "..data")); err != nil { + t.Fatalf("publish projected generation: %v", err) + } +} + +func tokenAuthorized(store *bearerTokenStore, token string) bool { + return store.authorized([]string{"Bearer " + token}) +} + +func assertProxyStatus(t *testing.T, proxy *providerAuthProxy, path, token string, expected int) { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "http://proxy"+path, nil) + if token != "" { + request.Header.Set(authorizationHeader, "Bearer "+token) + } + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != expected { + t.Fatalf("%s status = %d, want %d", path, response.Code, expected) + } +} + +func eventually(t *testing.T, timeout time.Duration, condition func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("condition was not satisfied before timeout") +} diff --git a/cmd/orka-provider-auth-proxy/main.go b/cmd/orka-provider-auth-proxy/main.go new file mode 100644 index 000000000..633d63f87 --- /dev/null +++ b/cmd/orka-provider-auth-proxy/main.go @@ -0,0 +1,136 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "context" + "flag" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +func main() { + listenAddress := flag.String("listen-address", envDefault("ORKA_PROVIDER_AUTH_PROXY_LISTEN_ADDRESS", ":8080"), "HTTP listen address") + upstreamBaseURL := flag.String("upstream-base-url", os.Getenv("ORKA_PROVIDER_AUTH_PROXY_UPSTREAM_BASE_URL"), "Unauthenticated Vekil upstream base URL") + tokenFile := flag.String("token-file", envDefault("ORKA_PROVIDER_AUTH_PROXY_TOKEN_FILE", "/var/run/secrets/orka/provider-auth/token"), "Mounted current bearer token file") + previousTokenFile := flag.String("previous-token-file", os.Getenv("ORKA_PROVIDER_AUTH_PROXY_PREVIOUS_TOKEN_FILE"), "Optional mounted previous/overlap bearer token file") + previousTokenValidUntilFile := flag.String("previous-token-valid-until-file", os.Getenv("ORKA_PROVIDER_AUTH_PROXY_PREVIOUS_TOKEN_VALID_UNTIL_FILE"), "Optional mounted RFC3339 expiry file for the previous/overlap token") + tokenReloadInterval := flag.Duration("token-reload-interval", envDurationDefault("ORKA_PROVIDER_AUTH_PROXY_TOKEN_RELOAD_INTERVAL", defaultTokenReloadInterval), "Bearer token file reload interval") + previousTokenOverlap := flag.Duration("previous-token-overlap", envDurationDefault("ORKA_PROVIDER_AUTH_PROXY_PREVIOUS_TOKEN_OVERLAP", defaultPreviousTokenOverlap), "Maximum previous/overlap token acceptance window") + maxRequestBytes := flag.Int64("max-request-bytes", envInt64Default("ORKA_PROVIDER_AUTH_PROXY_MAX_REQUEST_BYTES", defaultMaxRequestBytes), "Maximum streamed request body size") + maxResponseBytes := flag.Int64("max-response-bytes", envInt64Default("ORKA_PROVIDER_AUTH_PROXY_MAX_RESPONSE_BYTES", defaultMaxResponseBytes), "Maximum streamed response body size") + responseHeaderTimeout := flag.Duration("response-header-timeout", envDurationDefault("ORKA_PROVIDER_AUTH_PROXY_RESPONSE_HEADER_TIMEOUT", defaultResponseHeaderTimeout), "Upstream response header timeout") + maxConcurrentRequests := flag.Int("max-concurrent-requests", envIntDefault("ORKA_PROVIDER_AUTH_PROXY_MAX_CONCURRENT_REQUESTS", defaultMaxConcurrentRequests), "Maximum concurrent upstream requests") + flag.Parse() + + tokens := newBearerTokenStore(time.Now) + reloader, err := newTokenFileReloader(tokenFileReloaderConfig{ + CurrentTokenFile: *tokenFile, + PreviousTokenFile: *previousTokenFile, + PreviousTokenValidUntilFile: *previousTokenValidUntilFile, + ReloadInterval: *tokenReloadInterval, + PreviousTokenOverlap: *previousTokenOverlap, + }, tokens) + if err != nil { + log.Fatalf("invalid provider auth proxy token reload configuration: %v", err) + } + if err := reloader.reload(); err != nil { + log.Fatal("provider auth proxy token files are unavailable or invalid") + } + proxy, err := newProviderAuthProxyWithTokenStore(proxyConfig{ + UpstreamBaseURL: *upstreamBaseURL, + MaxRequestBytes: *maxRequestBytes, + MaxResponseBytes: *maxResponseBytes, + ResponseHeaderTimeout: *responseHeaderTimeout, + MaxConcurrentRequests: *maxConcurrentRequests, + }, tokens) + if err != nil { + log.Fatalf("invalid provider auth proxy configuration: %v", err) + } + listener, err := net.Listen("tcp", strings.TrimSpace(*listenAddress)) + if err != nil { + log.Fatalf("listen: %v", err) + } + server := newProxyHTTPServer(strings.TrimSpace(*listenAddress), proxy) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go reloader.run(ctx, func(message string) { log.Print(message) }) + serveErr := make(chan error, 1) + go func() { + serveErr <- server.Serve(listener) + }() + log.Printf("provider auth proxy listening on %s", listener.Addr()) + select { + case err := <-serveErr: + if err != nil && !errorsIsServerClosed(err) { + log.Fatalf("serve provider auth proxy: %v", err) + } + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("provider auth proxy shutdown failed") + } + if err := <-serveErr; err != nil && !errorsIsServerClosed(err) { + log.Printf("provider auth proxy stopped unexpectedly") + } + } +} + +func errorsIsServerClosed(err error) bool { + return err == http.ErrServerClosed +} + +func envDefault(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envInt64Default(name string, fallback int64) int64 { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + log.Fatalf("invalid %s", name) + } + return parsed +} + +func envIntDefault(name string, fallback int) int { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + log.Fatalf("invalid %s", name) + } + return parsed +} + +func envDurationDefault(name string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil { + log.Fatalf("invalid %s", name) + } + return parsed +} diff --git a/cmd/orka-provider-auth-proxy/proxy.go b/cmd/orka-provider-auth-proxy/proxy.go new file mode 100644 index 000000000..7c9e95782 --- /dev/null +++ b/cmd/orka-provider-auth-proxy/proxy.go @@ -0,0 +1,269 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/orka-agents/orka/internal/providerproxy" +) + +const ( + healthPath = "/healthz" + readinessPath = "/readyz" + defaultMaxRequestBytes = 32 << 20 + defaultMaxResponseBytes = 64 << 20 + defaultResponseHeaderTimeout = 30 * time.Second + defaultReadHeaderTimeout = 5 * time.Second + defaultIdleTimeout = 30 * time.Second + defaultMaxConcurrentRequests = 32 + authorizationHeader = "Authorization" +) + +var errRequestBodyTooLarge = errors.New("provider request body exceeds limit") + +type proxyConfig struct { + UpstreamBaseURL string + MaxRequestBytes int64 + MaxResponseBytes int64 + ResponseHeaderTimeout time.Duration + MaxConcurrentRequests int +} + +type providerAuthProxy struct { + upstreamBase *url.URL + tokens *bearerTokenStore + maxRequestBytes int64 + maxResponseBytes int64 + client *http.Client + requestSlots chan struct{} +} + +func newProviderAuthProxy(cfg proxyConfig, bearerToken []byte) (*providerAuthProxy, error) { + tokens, err := newStaticBearerTokenStore(bearerToken) + if err != nil { + return nil, err + } + return newProviderAuthProxyWithTokenStore(cfg, tokens) +} + +func newProviderAuthProxyWithTokenStore(cfg proxyConfig, tokens *bearerTokenStore) (*providerAuthProxy, error) { + normalized, upstream, err := normalizeProxyConfig(cfg) + if err != nil { + return nil, err + } + if tokens == nil { + return nil, fmt.Errorf("provider auth token store is required") + } + transport := &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: false, + DisableKeepAlives: false, + MaxIdleConns: normalized.MaxConcurrentRequests, + MaxIdleConnsPerHost: normalized.MaxConcurrentRequests, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: normalized.ResponseHeaderTimeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: 64 << 10, + DisableCompression: true, + } + return &providerAuthProxy{ + upstreamBase: upstream, + tokens: tokens, + maxRequestBytes: normalized.MaxRequestBytes, + maxResponseBytes: normalized.MaxResponseBytes, + client: &http.Client{ + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + requestSlots: make(chan struct{}, normalized.MaxConcurrentRequests), + }, nil +} + +func normalizeProxyConfig(cfg proxyConfig) (proxyConfig, *url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(cfg.UpstreamBaseURL)) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + return proxyConfig{}, nil, fmt.Errorf("provider upstream base URL is invalid") + } + if parsed.Path == "" { + parsed.Path = "/" + } + if providerproxy.HasUnsafePathSegment(parsed.Path) { + return proxyConfig{}, nil, fmt.Errorf("provider upstream base URL is invalid") + } + if cfg.MaxRequestBytes <= 0 { + cfg.MaxRequestBytes = defaultMaxRequestBytes + } + if cfg.MaxResponseBytes <= 0 { + cfg.MaxResponseBytes = defaultMaxResponseBytes + } + if cfg.ResponseHeaderTimeout <= 0 { + cfg.ResponseHeaderTimeout = defaultResponseHeaderTimeout + } + if cfg.MaxConcurrentRequests <= 0 { + cfg.MaxConcurrentRequests = defaultMaxConcurrentRequests + } + cfg.UpstreamBaseURL = parsed.String() + return cfg, parsed, nil +} + +func validateBearerToken(token []byte) error { + if len(token) < 32 || len(token) > maxBearerTokenBytes { + return fmt.Errorf("provider auth bearer token is invalid") + } + for _, value := range token { + if value <= ' ' || value == 0x7f { + return fmt.Errorf("provider auth bearer token is invalid") + } + } + return nil +} + +func (p *providerAuthProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == healthPath { + serveHealth(w, r) + return + } + if r.URL.Path == readinessPath { + if !p.tokens.isReady() { + providerproxy.WriteError(w, http.StatusServiceUnavailable, "provider proxy authentication is unavailable") + return + } + serveHealth(w, r) + return + } + if !p.authorized(r.Header.Values(authorizationHeader)) { + w.Header().Set("WWW-Authenticate", `Bearer realm="orka-provider-auth-proxy"`) + providerproxy.WriteError(w, http.StatusUnauthorized, "provider proxy authentication required") + return + } + if !providerproxy.TryAcquireSlot(p.requestSlots) { + providerproxy.WriteError(w, http.StatusTooManyRequests, "provider proxy request capacity is exhausted") + return + } + defer providerproxy.ReleaseSlot(p.requestSlots) + if r.Method == http.MethodConnect || r.Method == http.MethodTrace { + providerproxy.WriteError(w, http.StatusMethodNotAllowed, "provider request method is not allowed") + return + } + if providerproxy.HasUnsafePathSegment(r.URL.Path) { + providerproxy.WriteError(w, http.StatusBadRequest, "provider request path is invalid") + return + } + if providerproxy.HasDisallowedContentEncoding(r.Header) { + providerproxy.WriteError(w, http.StatusUnsupportedMediaType, "compressed provider requests are forbidden") + return + } + if r.ContentLength > p.maxRequestBytes { + providerproxy.WriteError(w, http.StatusRequestEntityTooLarge, "provider request body exceeds limit") + return + } + + target := providerproxy.Target(p.upstreamBase, r.URL.Path, r.URL.RawQuery) + body := &boundedReadCloser{ReadCloser: r.Body, remaining: p.maxRequestBytes} + upstreamRequest, err := http.NewRequestWithContext(r.Context(), r.Method, target.String(), body) + if err != nil { + providerproxy.WriteError(w, http.StatusBadGateway, "provider request could not be prepared") + return + } + upstreamRequest.ContentLength = r.ContentLength + providerproxy.CopyRequestHeaders(upstreamRequest.Header, r.Header) + upstreamRequest.Header.Set("Accept-Encoding", "identity") + + response, err := p.client.Do(upstreamRequest) + if err != nil { + if errors.Is(err, errRequestBodyTooLarge) { + providerproxy.WriteError(w, http.StatusRequestEntityTooLarge, "provider request body exceeds limit") + return + } + providerproxy.WriteError(w, http.StatusBadGateway, "provider upstream request failed") + return + } + defer response.Body.Close() //nolint:errcheck + if response.StatusCode >= http.StatusMultipleChoices && response.StatusCode < http.StatusBadRequest { + providerproxy.WriteError(w, http.StatusBadGateway, "provider upstream redirects are forbidden") + return + } + if providerproxy.HasDisallowedContentEncoding(response.Header) { + providerproxy.WriteError(w, http.StatusBadGateway, "compressed provider responses are forbidden") + return + } + if response.ContentLength > p.maxResponseBytes { + providerproxy.WriteError(w, http.StatusBadGateway, "provider upstream response exceeds limit") + return + } + providerproxy.CopyResponseHeaders(w.Header(), response.Header) + w.WriteHeader(response.StatusCode) + // The nil flusher keeps this proxy's original buffered write behavior. + if err := providerproxy.StreamBoundedResponse(w, response.Body, p.maxResponseBytes, nil); err != nil { + panic(http.ErrAbortHandler) + } +} + +func (p *providerAuthProxy) authorized(values []string) bool { + return p.tokens.authorized(values) +} + +func serveHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + providerproxy.WriteError(w, http.StatusMethodNotAllowed, "health probe method is not allowed") + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = io.WriteString(w, "ok\n") + } +} + +type boundedReadCloser struct { + io.ReadCloser + remaining int64 +} + +func (r *boundedReadCloser) Read(buffer []byte) (int, error) { + if r.remaining < 0 { + return 0, errRequestBodyTooLarge + } + maxRead := min(int64(len(buffer)), r.remaining+1) + n, err := r.ReadCloser.Read(buffer[:maxRead]) + if int64(n) > r.remaining { + allowed := int(r.remaining) + r.remaining = -1 + return allowed, errRequestBodyTooLarge + } + r.remaining -= int64(n) + return n, err +} + +func newProxyHTTPServer(address string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: address, + Handler: handler, + ReadHeaderTimeout: defaultReadHeaderTimeout, + IdleTimeout: defaultIdleTimeout, + MaxHeaderBytes: 32 << 10, + BaseContext: func(net.Listener) context.Context { + return context.Background() + }, + } +} diff --git a/cmd/orka-provider-auth-proxy/proxy_test.go b/cmd/orka-provider-auth-proxy/proxy_test.go new file mode 100644 index 000000000..5d127b431 --- /dev/null +++ b/cmd/orka-provider-auth-proxy/proxy_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testSharedProviderToken = "0123456789abcdef0123456789abcdef" + +func TestProviderAuthProxyRejectsMissingAndWrongBearerTokens(t *testing.T) { + upstreamCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamCalls++ + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(upstream.Close) + proxy := newTestProxy(t, upstream.URL, testSharedProviderToken) + + for name, authorization := range map[string]string{ + "missing": "", + "wrong": "Bearer 0123456789abcdef0123456789abcdeg", + "basic": "Basic " + testSharedProviderToken, + } { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "http://proxy/v1/responses", strings.NewReader(`{"model":"test"}`)) + if authorization != "" { + request.Header.Set(authorizationHeader, authorization) + } + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) + } + }) + } + if upstreamCalls != 0 { + t.Fatalf("upstream calls = %d, want 0", upstreamCalls) + } +} + +func TestProviderAuthProxyForwardsAuthorizedRequestWithoutSensitiveHeaders(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/base/v1/responses" || r.URL.RawQuery != "stream=true" { + t.Fatalf("upstream URL = %s, want /base/v1/responses?stream=true", r.URL.String()) + } + for _, name := range []string{authorizationHeader, "X-Api-Key", "Cookie", "Txn-Token", "X-Orka-Internal"} { + if value := r.Header.Get(name); value != "" { + t.Fatalf("upstream received sensitive header %s", name) + } + } + if got := r.Header.Get("Accept-Encoding"); got != "identity" { + t.Fatalf("Accept-Encoding = %q, want identity", got) + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("Content-Type = %q", got) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read upstream request: %v", err) + } + if string(body) != `{"model":"test"}` { + t.Fatalf("upstream body = %q", body) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "secret=value") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + t.Cleanup(upstream.Close) + proxy := newTestProxy(t, upstream.URL+"/base", testSharedProviderToken) + request := httptest.NewRequest(http.MethodPost, "http://proxy/v1/responses?stream=true", strings.NewReader(`{"model":"test"}`)) + request.Header.Set(authorizationHeader, "Bearer "+testSharedProviderToken) + request.Header.Set("X-Api-Key", "child-key") + request.Header.Set("Cookie", "child=cookie") + request.Header.Set("Txn-Token", "transaction") + request.Header.Set("X-Orka-Internal", "internal") + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != `{"ok":true}` { + t.Fatalf("response = %d %q", response.Code, response.Body.String()) + } + if value := response.Header().Get("Set-Cookie"); value != "" { + t.Fatalf("sensitive response header leaked: %q", value) + } +} + +func TestProviderAuthProxyHealthDoesNotRequireAuthentication(t *testing.T) { + proxy := newTestProxy(t, "http://upstream.example", testSharedProviderToken) + for _, path := range []string{healthPath, readinessPath} { + request := httptest.NewRequest(http.MethodGet, "http://proxy"+path, nil) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != "ok\n" { + t.Fatalf("%s response = %d %q", path, response.Code, response.Body.String()) + } + } +} + +func TestProviderAuthProxyRejectsRedirectsAndCompressedResponses(t *testing.T) { + for name, handler := range map[string]http.HandlerFunc{ + "redirect": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "http://elsewhere.example") + w.WriteHeader(http.StatusTemporaryRedirect) + }, + "compressed": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + _, _ = io.WriteString(w, "compressed") + }, + } { + t.Run(name, func(t *testing.T) { + upstream := httptest.NewServer(handler) + t.Cleanup(upstream.Close) + proxy := newTestProxy(t, upstream.URL, testSharedProviderToken) + request := httptest.NewRequest(http.MethodGet, "http://proxy/v1/models", nil) + request.Header.Set(authorizationHeader, "Bearer "+testSharedProviderToken) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway) + } + }) + } +} + +func TestProviderAuthProxyRejectsOversizeKnownRequest(t *testing.T) { + upstreamCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamCalls++ + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(upstream.Close) + proxy, err := newProviderAuthProxy(proxyConfig{UpstreamBaseURL: upstream.URL, MaxRequestBytes: 4}, []byte(testSharedProviderToken)) + if err != nil { + t.Fatalf("new proxy: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "http://proxy/v1/responses", strings.NewReader("12345")) + request.Header.Set(authorizationHeader, "Bearer "+testSharedProviderToken) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge) + } + if upstreamCalls != 0 { + t.Fatalf("upstream calls = %d, want 0", upstreamCalls) + } +} + +//nolint:unparam // The stable parameter keeps call sites explicit across related test cases. +func newTestProxy(t *testing.T, upstreamURL, token string) *providerAuthProxy { + t.Helper() + proxy, err := newProviderAuthProxy(proxyConfig{UpstreamBaseURL: upstreamURL}, []byte(token)) + if err != nil { + t.Fatalf("new provider auth proxy: %v", err) + } + return proxy +} diff --git a/cmd/orka-scm-egress-proxy/auth.go b/cmd/orka-scm-egress-proxy/auth.go new file mode 100644 index 000000000..7a5aca3d6 --- /dev/null +++ b/cmd/orka-scm-egress-proxy/auth.go @@ -0,0 +1,101 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "bytes" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +type proxyAuthenticator struct { + tokenDigest [sha256.Size]byte +} + +func loadProxyAuthenticator(path string) (*proxyAuthenticator, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, fmt.Errorf("proxy token path must be absolute and clean") + } + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm()&0o027 != 0 || + info.Size() < minProxyTokenBytes || info.Size() > maxProxyTokenBytes+2 { + return nil, fmt.Errorf("proxy token file is missing or unsafe") + } + value, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read proxy token file: %w", err) + } + value = bytes.TrimSuffix(value, []byte("\r\n")) + value = bytes.TrimSuffix(value, []byte("\n")) + return newProxyAuthenticator(value) +} + +func ensureKubernetesServiceAccountTokenAbsent(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("kubernetes service-account token path must be absolute and clean") + } + if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("kubernetes service-account token is mounted") + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect Kubernetes service-account token path: %w", err) + } + return nil +} + +func newProxyAuthenticator(token []byte) (*proxyAuthenticator, error) { + if err := validateProxyToken(token); err != nil { + return nil, err + } + return &proxyAuthenticator{tokenDigest: sha256.Sum256(token)}, nil +} + +func validateProxyToken(token []byte) error { + if len(token) < minProxyTokenBytes || len(token) > maxProxyTokenBytes { + return fmt.Errorf("proxy token length is invalid") + } + for _, current := range token { + if !isProxyTokenCharacter(current) { + return fmt.Errorf("proxy token contains an unsupported character") + } + } + return nil +} + +func isProxyTokenCharacter(value byte) bool { + return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || + value >= '0' && value <= '9' || strings.ContainsRune("-._~", rune(value)) +} + +func (a *proxyAuthenticator) authorized(request *http.Request) bool { + if a == nil { + return false + } + values := request.Header.Values("Proxy-Authorization") + if len(values) != 1 { + return false + } + const prefix = "Basic " + if !strings.HasPrefix(values[0], prefix) || len(values[0]) > 1024 { + return false + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(values[0], prefix)) + if err != nil || bytes.ContainsAny(decoded, "\r\n\x00") { + return false + } + username, token, found := bytes.Cut(decoded, []byte{':'}) + if !found || string(username) != proxyUsername { + return false + } + digest := sha256.Sum256(token) + return subtle.ConstantTimeCompare(digest[:], a.tokenDigest[:]) == 1 +} diff --git a/cmd/orka-scm-egress-proxy/config.go b/cmd/orka-scm-egress-proxy/config.go new file mode 100644 index 000000000..f923ef505 --- /dev/null +++ b/cmd/orka-scm-egress-proxy/config.go @@ -0,0 +1,236 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "net/url" + "sort" + "strings" + "time" +) + +const ( + defaultListenAddress = ":8080" + defaultAllowedHosts = "github.com" + defaultForgeAPIBaseURL = "https://api.github.com" + defaultTokenFile = "/var/run/secrets/orka/scm-egress/token" + defaultKubernetesTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + defaultMaxRequestHeaderBytes = int64(32 << 10) + defaultMaxResponseHeader = int64(64 << 10) + defaultMaxRequestBytes = int64(4 << 20) + defaultMaxResponseBytes = int64(8 << 20) + defaultMaxTunnelBytes = int64(1 << 30) + defaultMaxConcurrent = 8 + defaultResolutionTimeout = 5 * time.Second + defaultConnectTimeout = 10 * time.Second + defaultResponseHeaderTimeout = 30 * time.Second + defaultForwardTimeout = 2 * time.Minute + defaultIdleTimeout = 30 * time.Second + defaultTunnelTimeout = 10 * time.Minute + defaultShutdownTimeout = 15 * time.Second + proxyUsername = "orka-publisher" + maxProxyTokenBytes = 256 + minProxyTokenBytes = 32 + maxResolvedAddresses = 32 +) + +var ( + errHostDenied = errors.New("target host is not allowed") + errAddressDenied = errors.New("target address is not public") + errResolutionFailed = errors.New("target resolution failed") +) + +type resolver interface { + LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) +} + +type contextDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +type proxyConfig struct { + AllowedHosts map[string]struct{} + MaxRequestHeaderBytes int64 + MaxResponseHeader int64 + MaxRequestBytes int64 + MaxResponseBytes int64 + MaxTunnelBytes int64 + MaxConcurrent int + ResolutionTimeout time.Duration + ConnectTimeout time.Duration + ResponseHeaderTimeout time.Duration + ForwardTimeout time.Duration + IdleTimeout time.Duration + TunnelTimeout time.Duration + Resolver resolver + Dialer contextDialer +} + +func normalizeProxyConfig(config proxyConfig) (proxyConfig, error) { + if len(config.AllowedHosts) == 0 { + return proxyConfig{}, fmt.Errorf("at least one allowed host is required") + } + for host := range config.AllowedHosts { + if err := validateHostname(host); err != nil { + return proxyConfig{}, fmt.Errorf("allowed host is invalid: %w", err) + } + } + applyProxyConfigDefaults(&config) + if err := validateProxyConfigBounds(config); err != nil { + return proxyConfig{}, err + } + if config.Resolver == nil { + config.Resolver = net.DefaultResolver + } + if config.Dialer == nil { + config.Dialer = &net.Dialer{Timeout: config.ConnectTimeout, KeepAlive: -1} + } + return config, nil +} + +func applyProxyConfigDefaults(config *proxyConfig) { + if config.MaxRequestHeaderBytes == 0 { + config.MaxRequestHeaderBytes = defaultMaxRequestHeaderBytes + } + if config.MaxResponseHeader == 0 { + config.MaxResponseHeader = defaultMaxResponseHeader + } + if config.MaxRequestBytes == 0 { + config.MaxRequestBytes = defaultMaxRequestBytes + } + if config.MaxResponseBytes == 0 { + config.MaxResponseBytes = defaultMaxResponseBytes + } + if config.MaxTunnelBytes == 0 { + config.MaxTunnelBytes = defaultMaxTunnelBytes + } + if config.MaxConcurrent == 0 { + config.MaxConcurrent = defaultMaxConcurrent + } + if config.ResolutionTimeout == 0 { + config.ResolutionTimeout = defaultResolutionTimeout + } + if config.ConnectTimeout == 0 { + config.ConnectTimeout = defaultConnectTimeout + } + if config.ResponseHeaderTimeout == 0 { + config.ResponseHeaderTimeout = defaultResponseHeaderTimeout + } + if config.ForwardTimeout == 0 { + config.ForwardTimeout = defaultForwardTimeout + } + if config.IdleTimeout == 0 { + config.IdleTimeout = defaultIdleTimeout + } + if config.TunnelTimeout == 0 { + config.TunnelTimeout = defaultTunnelTimeout + } +} + +func validateProxyConfigBounds(config proxyConfig) error { + if config.MaxRequestHeaderBytes < 1024 || config.MaxRequestHeaderBytes > 1<<20 || + config.MaxResponseHeader < 1024 || config.MaxResponseHeader > 1<<20 || + config.MaxRequestBytes < 1 || config.MaxRequestBytes > 1<<30 || + config.MaxResponseBytes < 1 || config.MaxResponseBytes > 1<<30 || + config.MaxTunnelBytes < 1 || config.MaxTunnelBytes > 16<<30 || + config.MaxConcurrent < 1 || config.MaxConcurrent > 1024 || + config.ResolutionTimeout <= 0 || config.ResolutionTimeout > time.Minute || + config.ConnectTimeout <= 0 || config.ConnectTimeout > time.Minute || + config.ResponseHeaderTimeout <= 0 || config.ResponseHeaderTimeout > 5*time.Minute || + config.ForwardTimeout <= 0 || config.ForwardTimeout > 30*time.Minute || + config.IdleTimeout <= 0 || config.IdleTimeout > 5*time.Minute || + config.TunnelTimeout <= 0 || config.TunnelTimeout > time.Hour { + return fmt.Errorf("proxy limits are invalid") + } + return nil +} + +func allowedHosts(rawHosts, rawForgeAPI string) (map[string]struct{}, error) { + hosts := make(map[string]struct{}) + for raw := range strings.SplitSeq(rawHosts, ",") { + host := strings.TrimSpace(raw) + if host == "" { + continue + } + if err := validateHostname(host); err != nil { + return nil, fmt.Errorf("allowed host is invalid: %w", err) + } + hosts[host] = struct{}{} + } + if strings.TrimSpace(rawForgeAPI) != "" { + forgeHost, err := forgeAPIHostname(rawForgeAPI) + if err != nil { + return nil, err + } + hosts[forgeHost] = struct{}{} + } + if len(hosts) == 0 { + return nil, fmt.Errorf("at least one allowed host is required") + } + return hosts, nil +} + +func forgeAPIHostname(raw string) (string, error) { + if strings.TrimSpace(raw) != raw || len(raw) > 2048 { + return "", fmt.Errorf("forge API base URL is invalid") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || + parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Opaque != "" { + return "", fmt.Errorf("forge API base URL is invalid") + } + if parsed.Port() != "" && parsed.Port() != "443" { + return "", fmt.Errorf("forge API base URL must use port 443") + } + host := parsed.Hostname() + if err := validateHostname(host); err != nil { + return "", fmt.Errorf("forge API base URL host is invalid: %w", err) + } + return host, nil +} + +func validateHostname(host string) error { + if host == "" || len(host) > 253 || host != strings.ToLower(host) || strings.HasSuffix(host, ".") || + net.ParseIP(host) != nil { + return fmt.Errorf("hostname must be an exact lower-case DNS name") + } + labels := strings.Split(host, ".") + if len(labels) < 2 { + return fmt.Errorf("hostname must contain at least two labels") + } + for _, label := range labels { + if err := validateHostnameLabel(label); err != nil { + return err + } + } + return nil +} + +func validateHostnameLabel(label string) error { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("hostname label is invalid") + } + for _, current := range label { + if (current < 'a' || current > 'z') && (current < '0' || current > '9') && current != '-' { + return fmt.Errorf("hostname label contains an unsupported character") + } + } + return nil +} + +func sortedAddresses(addresses []netip.Addr) []netip.Addr { + result := append([]netip.Addr(nil), addresses...) + sort.Slice(result, func(left, right int) bool { + return result[left].Compare(result[right]) < 0 + }) + return result +} diff --git a/cmd/orka-scm-egress-proxy/main.go b/cmd/orka-scm-egress-proxy/main.go new file mode 100644 index 000000000..7158255a3 --- /dev/null +++ b/cmd/orka-scm-egress-proxy/main.go @@ -0,0 +1,204 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "context" + "errors" + "flag" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +func main() { + listenAddress := flag.String( + "listen-address", + envDefault("ORKA_SCM_EGRESS_PROXY_LISTEN_ADDRESS", defaultListenAddress), + "HTTP proxy listen address", + ) + allowedHostsValue := flag.String( + "allowed-hosts", + envDefault("ORKA_SCM_EGRESS_PROXY_ALLOWED_HOSTS", defaultAllowedHosts), + "Comma-separated exact lower-case SCM hostnames", + ) + forgeAPIBaseURL := flag.String( + "forge-api-base-url", + envDefault("ORKA_SCM_EGRESS_PROXY_FORGE_API_BASE_URL", defaultForgeAPIBaseURL), + "Optional HTTPS forge API base URL whose exact hostname is allowed", + ) + tokenFile := flag.String( + "token-file", + envDefault("ORKA_SCM_EGRESS_PROXY_TOKEN_FILE", defaultTokenFile), + "Publisher proxy-auth token file", + ) + maxRequestHeaderBytes := flag.Int64( + "max-request-header-bytes", + envInt64Default("ORKA_SCM_EGRESS_PROXY_MAX_REQUEST_HEADER_BYTES", defaultMaxRequestHeaderBytes), + "Maximum request header bytes", + ) + maxResponseHeaderBytes := flag.Int64( + "max-response-header-bytes", + envInt64Default("ORKA_SCM_EGRESS_PROXY_MAX_RESPONSE_HEADER_BYTES", defaultMaxResponseHeader), + "Maximum forward response header bytes", + ) + maxRequestBytes := flag.Int64( + "max-request-bytes", + envInt64Default("ORKA_SCM_EGRESS_PROXY_MAX_REQUEST_BYTES", defaultMaxRequestBytes), + "Maximum forward request body bytes", + ) + maxResponseBytes := flag.Int64( + "max-response-bytes", + envInt64Default("ORKA_SCM_EGRESS_PROXY_MAX_RESPONSE_BYTES", defaultMaxResponseBytes), + "Maximum forward response body bytes", + ) + maxTunnelBytes := flag.Int64( + "max-tunnel-bytes", + envInt64Default("ORKA_SCM_EGRESS_PROXY_MAX_TUNNEL_BYTES", defaultMaxTunnelBytes), + "Maximum bytes in each CONNECT tunnel direction", + ) + maxConcurrent := flag.Int( + "max-concurrent", + envIntDefault("ORKA_SCM_EGRESS_PROXY_MAX_CONCURRENT", defaultMaxConcurrent), + "Maximum concurrent requests and tunnels", + ) + resolutionTimeout := flag.Duration( + "resolution-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_RESOLUTION_TIMEOUT", defaultResolutionTimeout), + "Per-request DNS resolution timeout", + ) + connectTimeout := flag.Duration( + "connect-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_CONNECT_TIMEOUT", defaultConnectTimeout), + "Per-address TCP connection timeout", + ) + responseHeaderTimeout := flag.Duration( + "response-header-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_RESPONSE_HEADER_TIMEOUT", defaultResponseHeaderTimeout), + "Forward response header timeout", + ) + forwardTimeout := flag.Duration( + "forward-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_FORWARD_TIMEOUT", defaultForwardTimeout), + "Maximum complete forward request lifetime", + ) + idleTimeout := flag.Duration( + "idle-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_IDLE_TIMEOUT", defaultIdleTimeout), + "Connection and tunnel idle timeout", + ) + tunnelTimeout := flag.Duration( + "tunnel-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_TUNNEL_TIMEOUT", defaultTunnelTimeout), + "Maximum CONNECT tunnel lifetime", + ) + shutdownTimeout := flag.Duration( + "shutdown-timeout", + envDurationDefault("ORKA_SCM_EGRESS_PROXY_SHUTDOWN_TIMEOUT", defaultShutdownTimeout), + "Graceful shutdown timeout", + ) + flag.Parse() + + if err := ensureKubernetesServiceAccountTokenAbsent(defaultKubernetesTokenFile); err != nil { + log.Fatal("SCM egress proxy must not have Kubernetes API credentials") + } + hosts, err := allowedHosts(*allowedHostsValue, *forgeAPIBaseURL) + if err != nil { + log.Fatal("invalid SCM egress host policy") + } + authenticator, err := loadProxyAuthenticator(strings.TrimSpace(*tokenFile)) + if err != nil { + log.Fatal("SCM egress proxy authentication is unavailable") + } + proxy, err := newSCMEgressProxy(proxyConfig{ + AllowedHosts: hosts, MaxRequestHeaderBytes: *maxRequestHeaderBytes, + MaxResponseHeader: *maxResponseHeaderBytes, MaxRequestBytes: *maxRequestBytes, + MaxResponseBytes: *maxResponseBytes, MaxTunnelBytes: *maxTunnelBytes, + MaxConcurrent: *maxConcurrent, ResolutionTimeout: *resolutionTimeout, + ConnectTimeout: *connectTimeout, ResponseHeaderTimeout: *responseHeaderTimeout, + ForwardTimeout: *forwardTimeout, IdleTimeout: *idleTimeout, TunnelTimeout: *tunnelTimeout, + }, authenticator) + if err != nil { + log.Fatal("invalid SCM egress proxy configuration") + } + listener, err := net.Listen("tcp", strings.TrimSpace(*listenAddress)) + if err != nil { + log.Fatal("SCM egress proxy listener is unavailable") + } + server := &http.Server{ + Addr: strings.TrimSpace(*listenAddress), Handler: proxy, + ReadHeaderTimeout: min(*idleTimeout, 10*time.Second), ReadTimeout: *idleTimeout, + WriteTimeout: *responseHeaderTimeout, IdleTimeout: *idleTimeout, + MaxHeaderBytes: int(*maxRequestHeaderBytes), + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + serveResult := make(chan error, 1) + go func() { serveResult <- server.Serve(listener) }() + log.Printf("SCM egress proxy listening on %s", listener.Addr()) + select { + case err := <-serveResult: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal("SCM egress proxy stopped unexpectedly") + } + case <-ctx.Done(): + shutdownContext, cancel := context.WithTimeout(context.Background(), *shutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownContext); err != nil { + log.Print("SCM egress proxy shutdown timed out") + } + } +} + +func envDefault(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envInt64Default(name string, fallback int64) int64 { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || strconv.FormatInt(parsed, 10) != value { + log.Fatalf("invalid %s", name) + } + return parsed +} + +func envIntDefault(name string, fallback int) int { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 || strconv.Itoa(value) != raw { + log.Fatalf("invalid %s", name) + } + return value +} + +func envDurationDefault(name string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil { + log.Fatalf("invalid %s", name) + } + return parsed +} diff --git a/cmd/orka-scm-egress-proxy/network.go b/cmd/orka-scm-egress-proxy/network.go new file mode 100644 index 000000000..72ed0e4ec --- /dev/null +++ b/cmd/orka-scm-egress-proxy/network.go @@ -0,0 +1,162 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "strconv" +) + +var deniedAddressPrefixes = mustPrefixes( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "2001::/32", + "2001:db8::/32", + "2002::/16", + "fc00::/7", + "fe80::/10", + "ff00::/8", +) + +func mustPrefixes(values ...string) []netip.Prefix { + result := make([]netip.Prefix, 0, len(values)) + for _, value := range values { + result = append(result, netip.MustParsePrefix(value)) + } + return result +} + +func (p *scmEgressProxy) dialAllowedHost(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := splitTarget(address) + if err != nil { + return nil, err + } + if !p.hostAllowed(host) { + return nil, errHostDenied + } + resolutionContext, cancel := context.WithTimeout(ctx, p.config.ResolutionTimeout) + addresses, lookupErr := p.config.Resolver.LookupNetIP(resolutionContext, "ip", host) + cancel() + if lookupErr != nil || len(addresses) == 0 { + return nil, errResolutionFailed + } + addresses = sortedAddresses(addresses) + if len(addresses) > maxResolvedAddresses { + return nil, errResolutionFailed + } + if err := validateResolvedAddresses(addresses); err != nil { + return nil, err + } + connectionContext, cancel := context.WithTimeout(ctx, p.config.ConnectTimeout) + defer cancel() + return p.connectResolved(connectionContext, network, port, addresses) +} + +func splitTarget(address string) (string, string, error) { + host, port, err := net.SplitHostPort(address) + if err != nil || port != "443" { + return "", "", errHostDenied + } + if err := validateHostname(host); err != nil { + return "", "", errHostDenied + } + return host, port, nil +} + +func validateResolvedAddresses(addresses []netip.Addr) error { + for _, address := range addresses { + if !publicAddress(address) { + return errAddressDenied + } + } + return nil +} + +func publicAddress(address netip.Addr) bool { + if !address.IsValid() { + return false + } + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || + address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() || address.IsMulticast() || + address.IsUnspecified() { + return false + } + for _, prefix := range deniedAddressPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} + +func (p *scmEgressProxy) connectResolved( + ctx context.Context, + network string, + port string, + addresses []netip.Addr, +) (net.Conn, error) { + var failures []error + for _, address := range addresses { + connection, err := p.config.Dialer.DialContext( + ctx, + network, + net.JoinHostPort(address.String(), port), + ) + if err != nil { + failures = append(failures, err) + continue + } + if err := validateConnectedPeer(connection, address); err != nil { + _ = connection.Close() + return nil, err + } + return connection, nil + } + return nil, fmt.Errorf("connect to allowed target: %w", errors.Join(failures...)) +} + +func validateConnectedPeer(connection net.Conn, expected netip.Addr) error { + host, _, err := net.SplitHostPort(connection.RemoteAddr().String()) + if err != nil { + return errAddressDenied + } + actual, err := netip.ParseAddr(host) + if err != nil { + return errAddressDenied + } + actual = actual.Unmap() + if !publicAddress(actual) || actual != expected.Unmap() { + return errAddressDenied + } + return nil +} + +func targetAddress(host string) string { + return net.JoinHostPort(host, strconv.Itoa(443)) +} diff --git a/cmd/orka-scm-egress-proxy/proxy.go b/cmd/orka-scm-egress-proxy/proxy.go new file mode 100644 index 000000000..0b913963a --- /dev/null +++ b/cmd/orka-scm-egress-proxy/proxy.go @@ -0,0 +1,505 @@ +/* +Copyright (c) 2026. + +MIT License - see LICENSE file for details. +*/ + +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/textproto" + "net/url" + "strings" + "sync/atomic" + "time" +) + +const ( + healthPath = "/healthz" + readinessPath = "/readyz" +) + +var ( + errRedirectDenied = errors.New("upstream redirects are denied") + errTunnelLimit = errors.New("tunnel byte limit exceeded") + errRequestTooLarge = errors.New("request body limit exceeded") +) + +type scmEgressProxy struct { + config proxyConfig + authenticator *proxyAuthenticator + client *http.Client + requestSlots chan struct{} +} + +func newSCMEgressProxy(config proxyConfig, authenticator *proxyAuthenticator) (*scmEgressProxy, error) { + normalized, err := normalizeProxyConfig(config) + if err != nil { + return nil, err + } + if authenticator == nil { + return nil, fmt.Errorf("proxy authenticator is required") + } + proxy := &scmEgressProxy{ + config: normalized, authenticator: authenticator, + requestSlots: make(chan struct{}, normalized.MaxConcurrent), + } + proxy.client = proxy.newForwardClient(nil) + return proxy, nil +} + +func (p *scmEgressProxy) newForwardClient(tlsConfig *tls.Config) *http.Client { + transport := &http.Transport{ + Proxy: nil, + DialContext: p.dialAllowedHost, + ForceAttemptHTTP2: false, + DisableKeepAlives: true, + DisableCompression: true, + TLSClientConfig: tlsConfig, + TLSHandshakeTimeout: p.config.ConnectTimeout, + ResponseHeaderTimeout: p.config.ResponseHeaderTimeout, + ExpectContinueTimeout: time.Second, + MaxResponseHeaderBytes: p.config.MaxResponseHeader, + } + return &http.Client{ + Transport: transport, + Timeout: p.config.ForwardTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return errRedirectDenied + }, + } +} + +func (p *scmEgressProxy) hostAllowed(host string) bool { + _, allowed := p.config.AllowedHosts[host] + return allowed +} + +func (p *scmEgressProxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if p.serveProbe(writer, request) { + return + } + if !p.authenticator.authorized(request) { + writer.Header().Set("Proxy-Authenticate", `Basic realm="orka-scm-egress"`) + writeProxyError(writer, http.StatusProxyAuthRequired, "proxy authentication required") + return + } + if requestHeaderBytes(request) > p.config.MaxRequestHeaderBytes { + writeProxyError(writer, http.StatusRequestHeaderFieldsTooLarge, "request headers exceed proxy limit") + return + } + if !tryAcquire(p.requestSlots) { + writeProxyError(writer, http.StatusTooManyRequests, "proxy capacity is exhausted") + return + } + defer releaseSlot(p.requestSlots) + if request.Method == http.MethodConnect { + p.handleConnect(writer, request) + return + } + p.handleForward(writer, request) +} + +func (p *scmEgressProxy) serveProbe(writer http.ResponseWriter, request *http.Request) bool { + if request.Method != http.MethodGet || request.URL.IsAbs() { + return false + } + if request.URL.Path != healthPath && request.URL.Path != readinessPath { + return false + } + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.WriteHeader(http.StatusOK) + _, _ = io.WriteString(writer, "ok\n") + return true +} + +func (p *scmEgressProxy) handleConnect(writer http.ResponseWriter, request *http.Request) { + if request.ContentLength > 0 || request.TransferEncoding != nil { + writeProxyError(writer, http.StatusBadRequest, "CONNECT request body is forbidden") + return + } + host, port, err := splitTarget(request.Host) + if err != nil || !p.hostAllowed(host) { + writeProxyError(writer, http.StatusForbidden, "target is not allowed") + return + } + upstream, err := p.dialAllowedHost(request.Context(), "tcp", net.JoinHostPort(host, port)) + if err != nil { + writeDialError(writer, err) + return + } + hijacker, ok := writer.(http.Hijacker) + if !ok { + _ = upstream.Close() + writeProxyError(writer, http.StatusInternalServerError, "CONNECT is unavailable") + return + } + client, buffered, err := hijacker.Hijack() + if err != nil { + _ = upstream.Close() + return + } + if err := client.SetDeadline(time.Time{}); err != nil { + _ = client.Close() + _ = upstream.Close() + return + } + if err := upstream.SetDeadline(time.Time{}); err != nil { + _ = client.Close() + _ = upstream.Close() + return + } + p.runTunnel(client, upstream, buffered) +} + +func (p *scmEgressProxy) runTunnel(client, upstream net.Conn, buffered *bufio.ReadWriter) { + defer func() { _ = client.Close() }() + defer func() { _ = upstream.Close() }() + if _, err := buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + if err := buffered.Flush(); err != nil { + return + } + activity := newTunnelActivity() + clientBudget := p.config.MaxTunnelBytes + if buffered.Reader.Buffered() > 0 { + count, err := copyBuffered(upstream, buffered.Reader, clientBudget, p.config.IdleTimeout, activity) + if err != nil { + return + } + clientBudget -= count + } + p.copyTunnel(client, upstream, clientBudget, activity) +} + +func copyBuffered( + destination net.Conn, + reader *bufio.Reader, + limit int64, + idleTimeout time.Duration, + activity *tunnelActivity, +) (int64, error) { + buffered := reader.Buffered() + if int64(buffered) > limit { + return 0, errTunnelLimit + } + data := make([]byte, buffered) + if _, err := io.ReadFull(reader, data); err != nil { + return 0, err + } + deadline := &deadlineConn{Conn: destination, idleTimeout: idleTimeout, activity: activity} + written, err := io.CopyBuffer(deadline, bytes.NewReader(data), make([]byte, 32<<10)) + return written, err +} + +func (p *scmEgressProxy) copyTunnel(client, upstream net.Conn, clientBudget int64, activity *tunnelActivity) { + results := make(chan error, 2) + go func() { + err := boundedTunnelCopy( + &deadlineConn{Conn: upstream, idleTimeout: p.config.IdleTimeout, activity: activity}, + &deadlineConn{Conn: client, idleTimeout: p.config.IdleTimeout, activity: activity}, + clientBudget, + ) + if err == nil { + // The client finished sending cleanly; propagate the half-close so + // the upstream response direction can keep streaming. + closeWriteSide(upstream) + } + results <- err + }() + go func() { + err := boundedTunnelCopy( + &deadlineConn{Conn: client, idleTimeout: p.config.IdleTimeout, activity: activity}, + &deadlineConn{Conn: upstream, idleTimeout: p.config.IdleTimeout, activity: activity}, + p.config.MaxTunnelBytes, + ) + if err == nil { + closeWriteSide(client) + } + results <- err + }() + timer := time.NewTimer(p.config.TunnelTimeout) + defer timer.Stop() + // A clean EOF in one direction must not tear down an active transfer in + // the other, so wait for both directions unless one fails or the overall + // tunnel budget expires. + for range 2 { + select { + case err := <-results: + if err != nil { + return + } + case <-timer.C: + return + } + } +} + +func boundedTunnelCopy(destination io.Writer, source io.Reader, limit int64) error { + written, err := io.CopyBuffer(destination, io.LimitReader(source, limit+1), make([]byte, 32<<10)) + if written > limit { + return errTunnelLimit + } + return err +} + +// tunnelActivity tracks the last byte movement across both tunnel directions +// so idle enforcement applies to the tunnel as a whole: a quiet request +// direction must not time out an active response direction. +type tunnelActivity struct { + lastNanos atomic.Int64 +} + +func newTunnelActivity() *tunnelActivity { + activity := &tunnelActivity{} + activity.touch() + return activity +} + +func (a *tunnelActivity) touch() { + a.lastNanos.Store(time.Now().UnixNano()) +} + +func (a *tunnelActivity) idleFor(idle time.Duration) bool { + return time.Since(time.Unix(0, a.lastNanos.Load())) >= idle +} + +type writeCloser interface { + CloseWrite() error +} + +func closeWriteSide(conn net.Conn) { + if half, ok := conn.(writeCloser); ok { + _ = half.CloseWrite() + } +} + +type deadlineConn struct { + net.Conn + idleTimeout time.Duration + activity *tunnelActivity +} + +func (c *deadlineConn) Read(value []byte) (int, error) { + for { + if err := c.SetReadDeadline(time.Now().Add(c.idleTimeout)); err != nil { + return 0, err + } + read, err := c.Conn.Read(value) + if read > 0 { + c.activity.touch() + } + if read == 0 && isTimeoutError(err) && !c.activity.idleFor(c.idleTimeout) { + continue + } + return read, err + } +} + +func (c *deadlineConn) Write(value []byte) (int, error) { + written := 0 + for { + if err := c.SetWriteDeadline(time.Now().Add(c.idleTimeout)); err != nil { + return written, err + } + count, err := c.Conn.Write(value[written:]) + written += count + if count > 0 { + c.activity.touch() + } + if written < len(value) && isTimeoutError(err) && !c.activity.idleFor(c.idleTimeout) { + continue + } + return written, err + } +} + +func isTimeoutError(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +func (p *scmEgressProxy) handleForward(writer http.ResponseWriter, request *http.Request) { + target, err := forwardTarget(request) + if err != nil || !p.hostAllowed(target.Hostname()) { + writeProxyError(writer, http.StatusForbidden, "target is not allowed") + return + } + body, err := readRequestBody(request, p.config.MaxRequestBytes) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, errRequestTooLarge) { + status = http.StatusRequestEntityTooLarge + } + writeProxyError(writer, status, "request body is invalid or exceeds proxy limit") + return + } + outbound := outboundRequest(request, target, body) + response, err := p.client.Do(outbound) + if err != nil { + writeForwardError(writer, err) + return + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode >= http.StatusMultipleChoices && response.StatusCode < http.StatusBadRequest { + writeProxyError(writer, http.StatusBadGateway, "upstream redirect is forbidden") + return + } + if responseHeaderBytes(response.Header) > p.config.MaxResponseHeader { + writeProxyError(writer, http.StatusBadGateway, "upstream response headers exceed proxy limit") + return + } + responseBody, err := readBounded(response.Body, p.config.MaxResponseBytes) + if err != nil { + writeProxyError(writer, http.StatusBadGateway, "upstream response exceeds proxy limit") + return + } + copyResponseHeaders(writer.Header(), response.Header) + writer.WriteHeader(response.StatusCode) + _, _ = writer.Write(responseBody) +} + +func forwardTarget(request *http.Request) (*url.URL, error) { + if request.Method == http.MethodTrace || !request.URL.IsAbs() || request.URL.Scheme != "https" || + request.URL.User != nil || request.URL.Fragment != "" || request.URL.Opaque != "" { + return nil, errHostDenied + } + if request.URL.Port() != "" && request.URL.Port() != "443" { + return nil, errHostDenied + } + if err := validateHostname(request.URL.Hostname()); err != nil { + return nil, errHostDenied + } + target := *request.URL + target.Host = targetAddress(target.Hostname()) + return &target, nil +} + +func readRequestBody(request *http.Request, limit int64) ([]byte, error) { + if request.ContentLength > limit { + return nil, errRequestTooLarge + } + if request.Body == nil { + return nil, nil + } + defer func() { _ = request.Body.Close() }() + return readBounded(request.Body, limit) +} + +func readBounded(reader io.Reader, limit int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errRequestTooLarge + } + return data, nil +} + +func outboundRequest(request *http.Request, target *url.URL, body []byte) *http.Request { + outbound := request.Clone(request.Context()) + outbound.URL = target + outbound.RequestURI = "" + outbound.Host = target.Hostname() + outbound.Header = request.Header.Clone() + stripHopByHopHeaders(outbound.Header) + outbound.Header.Del("Proxy-Authorization") + outbound.Header.Del("Content-Length") + outbound.Body = io.NopCloser(bytes.NewReader(body)) + outbound.ContentLength = int64(len(body)) + outbound.TransferEncoding = nil + outbound.Trailer = nil + return outbound +} + +func stripHopByHopHeaders(header http.Header) { + for _, value := range header.Values("Connection") { + for token := range strings.SplitSeq(value, ",") { + header.Del(textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(token))) + } + } + for _, name := range []string{ + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Proxy-Connection", "Te", "Trailer", + "Transfer-Encoding", "Upgrade", + } { + header.Del(name) + } +} + +func copyResponseHeaders(destination, source http.Header) { + cloned := source.Clone() + stripHopByHopHeaders(cloned) + for name, values := range cloned { + for _, value := range values { + destination.Add(name, value) + } + } +} + +func requestHeaderBytes(request *http.Request) int64 { + return int64(len(request.Method) + len(request.Host) + len(request.URL.String()) + 4 + headerBytes(request.Header)) +} + +func responseHeaderBytes(header http.Header) int64 { return int64(headerBytes(header)) } + +func headerBytes(header http.Header) int { + total := 0 + for name, values := range header { + for _, value := range values { + total += len(name) + len(value) + 4 + } + } + return total +} + +func writeDialError(writer http.ResponseWriter, err error) { + if errors.Is(err, errHostDenied) || errors.Is(err, errAddressDenied) { + writeProxyError(writer, http.StatusForbidden, "target is not allowed") + return + } + if errors.Is(err, context.DeadlineExceeded) { + writeProxyError(writer, http.StatusGatewayTimeout, "target connection timed out") + return + } + writeProxyError(writer, http.StatusBadGateway, "target connection failed") +} + +func writeForwardError(writer http.ResponseWriter, err error) { + if errors.Is(err, errRedirectDenied) { + writeProxyError(writer, http.StatusBadGateway, "upstream redirect is forbidden") + return + } + writeDialError(writer, err) +} + +func writeProxyError(writer http.ResponseWriter, status int, message string) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.WriteHeader(status) + _, _ = io.WriteString(writer, message+"\n") +} + +func tryAcquire(slots chan struct{}) bool { + select { + case slots <- struct{}{}: + return true + default: + return false + } +} + +func releaseSlot(slots chan struct{}) { <-slots } diff --git a/cmd/orka-scm-egress-proxy/proxy_test.go b/cmd/orka-scm-egress-proxy/proxy_test.go new file mode 100644 index 000000000..29aa6af1a --- /dev/null +++ b/cmd/orka-scm-egress-proxy/proxy_test.go @@ -0,0 +1,585 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +const testProxyToken = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_" + +func TestSCMEgressProxyRejectsUnlistedProviderHost(t *testing.T) { + var lookups atomic.Int32 + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + lookups.Add(1) + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil + }), + }) + request := connectRequest("api.openai.com:443") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } + if lookups.Load() != 0 { + t.Fatalf("DNS lookups = %d, want 0", lookups.Load()) + } +} + +func TestSCMEgressProxyRejectsPrivateAndRebindingAnswers(t *testing.T) { + for name, addresses := range map[string][]netip.Addr{ + "kubernetes-service": { + netip.MustParseAddr("10.0.0.1"), + }, + "private": { + netip.MustParseAddr("10.0.0.8"), + }, + "loopback": { + netip.MustParseAddr("127.0.0.1"), + }, + "metadata": { + netip.MustParseAddr("169.254.169.254"), + }, + "mixed-public-private": { + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("192.168.1.7"), + }, + } { + t.Run(name, func(t *testing.T) { + var dials atomic.Int32 + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return addresses, nil + }), + Dialer: dialerFunc(func(context.Context, string, string) (net.Conn, error) { + dials.Add(1) + return nil, fmt.Errorf("unexpected dial") + }), + }) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, connectRequest("github.com:443")) + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } + if dials.Load() != 0 { + t.Fatalf("dials = %d, want 0", dials.Load()) + } + }) + } +} + +func TestSCMEgressProxyAllowsAuthenticatedGitHubCONNECT(t *testing.T) { + upstream, upstreamAddress := startEchoServer(t) + defer func() { _ = upstream.Close() }() + publicAddress := netip.MustParseAddr("1.1.1.1") + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + Resolver: resolverFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) { + if network != "ip" || host != "github.com" { + t.Fatalf("lookup = %s %s", network, host) + } + return []netip.Addr{publicAddress}, nil + }), + Dialer: localTestDialer(t, upstreamAddress, publicAddress), + }) + server := httptest.NewServer(proxy) + defer server.Close() + + connection, err := net.DialTimeout("tcp", strings.TrimPrefix(server.URL, "http://"), time.Second) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer func() { _ = connection.Close() }() + if _, err := fmt.Fprintf( + connection, + "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Authorization: %s\r\n\r\n", + proxyAuthorization(), + ); err != nil { + t.Fatalf("write CONNECT: %v", err) + } + reader := bufio.NewReader(connection) + status, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read CONNECT status: %v", err) + } + if status != "HTTP/1.1 200 Connection Established\r\n" { + t.Fatalf("CONNECT status = %q", status) + } + for { + line, readErr := reader.ReadString('\n') + if readErr != nil { + t.Fatalf("read CONNECT header: %v", readErr) + } + if line == "\r\n" { + break + } + } + if _, err := connection.Write([]byte("github-connect-ok")); err != nil { + t.Fatalf("write tunnel payload: %v", err) + } + payload := make([]byte, len("github-connect-ok")) + if _, err := io.ReadFull(reader, payload); err != nil { + t.Fatalf("read tunnel payload: %v", err) + } + if string(payload) != "github-connect-ok" { + t.Fatalf("tunnel payload = %q", payload) + } +} + +func TestSCMEgressProxyCONNECTSurvivesQuietRequestDirection(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen upstream: %v", err) + } + defer func() { _ = listener.Close() }() + const ( + chunkCount = 12 + chunkGap = 100 * time.Millisecond + ) + chunk := bytes.Repeat([]byte("x"), 1024) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer func() { _ = connection.Close() }() + request := make([]byte, 4) + if _, readErr := io.ReadFull(connection, request); readErr != nil { + return + } + for range chunkCount { + if _, writeErr := connection.Write(chunk); writeErr != nil { + return + } + time.Sleep(chunkGap) + } + }() + publicAddress := netip.MustParseAddr("1.1.1.1") + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + // The response stream outlives the idle timeout, so the quiet request + // direction hits its read deadline repeatedly while the response + // direction is still moving bytes. + IdleTimeout: 500 * time.Millisecond, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{publicAddress}, nil + }), + Dialer: localTestDialer(t, listener.Addr().String(), publicAddress), + }) + server := httptest.NewServer(proxy) + defer server.Close() + + connection, reader := openTestCONNECTTunnel(t, server.URL) + if err := connection.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, err := connection.Write([]byte("ping")); err != nil { + t.Fatalf("write tunnel payload: %v", err) + } + payload := make([]byte, chunkCount*len(chunk)) + if _, err := io.ReadFull(reader, payload); err != nil { + t.Fatalf("read streamed payload: %v", err) + } +} + +func TestSCMEgressProxyCONNECTHalfCloseKeepsResponseStreaming(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen upstream: %v", err) + } + defer func() { _ = listener.Close() }() + payload := bytes.Repeat([]byte("y"), 64<<10) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer func() { _ = connection.Close() }() + // The upstream only responds after the request direction is fully + // drained to EOF, which requires the proxy to propagate the client + // half-close instead of tearing the tunnel down. + if _, copyErr := io.Copy(io.Discard, connection); copyErr != nil { + return + } + _, _ = connection.Write(payload) + }() + publicAddress := netip.MustParseAddr("1.1.1.1") + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + IdleTimeout: 2 * time.Second, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{publicAddress}, nil + }), + Dialer: localTestDialer(t, listener.Addr().String(), publicAddress), + }) + server := httptest.NewServer(proxy) + defer server.Close() + + connection, reader := openTestCONNECTTunnel(t, server.URL) + if err := connection.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, err := connection.Write([]byte("ping")); err != nil { + t.Fatalf("write tunnel payload: %v", err) + } + tcpConnection, ok := connection.(*net.TCPConn) + if !ok { + t.Fatalf("connection type = %T, want *net.TCPConn", connection) + } + if err := tcpConnection.CloseWrite(); err != nil { + t.Fatalf("half-close tunnel: %v", err) + } + received := make([]byte, len(payload)) + if _, err := io.ReadFull(reader, received); err != nil { + t.Fatalf("read payload after half-close: %v", err) + } + if !bytes.Equal(received, payload) { + t.Fatal("payload mismatch after half-close") + } +} + +func openTestCONNECTTunnel(t *testing.T, proxyURL string) (net.Conn, *bufio.Reader) { + t.Helper() + connection, err := net.DialTimeout("tcp", strings.TrimPrefix(proxyURL, "http://"), time.Second) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + t.Cleanup(func() { _ = connection.Close() }) + if _, err := fmt.Fprintf( + connection, + "CONNECT github.com:443 HTTP/1.1\r\nHost: github.com:443\r\nProxy-Authorization: %s\r\n\r\n", + proxyAuthorization(), + ); err != nil { + t.Fatalf("write CONNECT: %v", err) + } + reader := bufio.NewReader(connection) + status, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read CONNECT status: %v", err) + } + if status != "HTTP/1.1 200 Connection Established\r\n" { + t.Fatalf("CONNECT status = %q", status) + } + for { + line, readErr := reader.ReadString('\n') + if readErr != nil { + t.Fatalf("read CONNECT header: %v", readErr) + } + if line == "\r\n" { + break + } + } + return connection, reader +} + +func TestSCMEgressProxyRejectsRedirect(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Location", "https://github.com/redirected") + writer.WriteHeader(http.StatusFound) + })) + defer upstream.Close() + proxy := testForwardProxy(t, upstream, 1024, 1024) + request := httptest.NewRequest(http.MethodGet, "https://github.com/start", nil) + request.Header.Set("Proxy-Authorization", proxyAuthorization()) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway) + } +} + +func TestSCMEgressProxyRejectsOversizedHeaders(t *testing.T) { + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + MaxRequestHeaderBytes: 1024, + }) + request := connectRequest("github.com:443") + request.Header.Set("X-Oversized", strings.Repeat("x", 2048)) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusRequestHeaderFieldsTooLarge { + t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestHeaderFieldsTooLarge) + } +} + +func TestSCMEgressProxyRejectsOversizedRequestsAndResponses(t *testing.T) { + t.Run("known request", func(t *testing.T) { + var lookups atomic.Int32 + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + MaxRequestBytes: 4, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + lookups.Add(1) + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil + }), + }) + request := httptest.NewRequest(http.MethodPost, "https://github.com/upload", strings.NewReader("12345")) + request.Header.Set("Proxy-Authorization", proxyAuthorization()) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge) + } + if lookups.Load() != 0 { + t.Fatalf("DNS lookups = %d, want 0", lookups.Load()) + } + }) + + t.Run("streamed request", func(t *testing.T) { + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + MaxRequestBytes: 4, + }) + request := httptest.NewRequest(http.MethodPost, "https://github.com/upload", strings.NewReader("12345")) + request.ContentLength = -1 + request.Header.Set("Proxy-Authorization", proxyAuthorization()) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", response.Code, http.StatusRequestEntityTooLarge) + } + }) + + t.Run("response", func(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(writer, "12345") + })) + defer upstream.Close() + proxy := testForwardProxy(t, upstream, 1024, 4) + request := httptest.NewRequest(http.MethodGet, "https://github.com/download", nil) + request.Header.Set("Proxy-Authorization", proxyAuthorization()) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway) + } + }) +} + +func TestSCMEgressProxyRejectsPlainHTTPAndNon443Targets(t *testing.T) { + proxy := newTestSCMProxy(t, proxyConfig{AllowedHosts: map[string]struct{}{"github.com": {}}}) + + plainRequest := httptest.NewRequest(http.MethodGet, "http://github.com/repository", nil) + plainRequest.Header.Set("Proxy-Authorization", proxyAuthorization()) + plainResponse := httptest.NewRecorder() + proxy.ServeHTTP(plainResponse, plainRequest) + if plainResponse.Code != http.StatusForbidden { + t.Fatalf("plain HTTP status = %d, want %d", plainResponse.Code, http.StatusForbidden) + } + + connectResponse := httptest.NewRecorder() + proxy.ServeHTTP(connectResponse, connectRequest("github.com:8443")) + if connectResponse.Code != http.StatusForbidden { + t.Fatalf("non-443 CONNECT status = %d, want %d", connectResponse.Code, http.StatusForbidden) + } +} + +func TestSCMEgressProxyRejectsConnectedPeerMismatch(t *testing.T) { + listener, localAddress := startEchoServer(t) + defer func() { _ = listener.Close() }() + resolved := netip.MustParseAddr("1.1.1.1") + mismatched := netip.MustParseAddr("8.8.8.8") + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{resolved}, nil + }), + Dialer: dialerFunc(func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != net.JoinHostPort(resolved.String(), "443") { + t.Fatalf("dial = %s %s", network, address) + } + dialer := net.Dialer{} + connection, err := dialer.DialContext(ctx, "tcp", localAddress) + if err != nil { + return nil, err + } + return &remoteAddressConn{ + Conn: connection, + remote: &net.TCPAddr{ + IP: net.ParseIP(mismatched.String()), Port: 443, + }, + }, nil + }), + }) + response := httptest.NewRecorder() + proxy.ServeHTTP(response, connectRequest("github.com:443")) + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } +} + +func TestSCMEgressProxyRequiresAuthentication(t *testing.T) { + proxy := newTestSCMProxy(t, proxyConfig{AllowedHosts: map[string]struct{}{"github.com": {}}}) + request := connectRequest("github.com:443") + request.Header.Del("Proxy-Authorization") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusProxyAuthRequired { + t.Fatalf("status = %d, want %d", response.Code, http.StatusProxyAuthRequired) + } +} + +func TestSCMEgressProxyRejectsMountedKubernetesServiceAccountToken(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing-token") + if err := ensureKubernetesServiceAccountTokenAbsent(missing); err != nil { + t.Fatalf("missing Kubernetes token rejected: %v", err) + } + + mounted := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(mounted, []byte("not-a-real-token"), 0o600); err != nil { + t.Fatalf("write mounted Kubernetes token fixture: %v", err) + } + if err := ensureKubernetesServiceAccountTokenAbsent(mounted); err == nil { + t.Fatal("mounted Kubernetes service-account token was accepted") + } +} + +func TestAllowedHostsRequireExactLowercaseDNSNames(t *testing.T) { + for _, value := range []string{"GitHub.com", "github.com.", "127.0.0.1", "*.github.com", "github"} { + t.Run(value, func(t *testing.T) { + if _, err := allowedHosts(value, ""); err == nil { + t.Fatal("allowedHosts accepted unsafe host") + } + }) + } + hosts, err := allowedHosts("github.com", "https://api.github.com") + if err != nil { + t.Fatalf("allowedHosts: %v", err) + } + for _, host := range []string{"github.com", "api.github.com"} { + if _, ok := hosts[host]; !ok { + t.Fatalf("host %q was not allowed", host) + } + } +} + +func newTestSCMProxy(t *testing.T, config proxyConfig) *scmEgressProxy { + t.Helper() + authenticator, err := newProxyAuthenticator([]byte(testProxyToken)) + if err != nil { + t.Fatalf("new authenticator: %v", err) + } + proxy, err := newSCMEgressProxy(config, authenticator) + if err != nil { + t.Fatalf("new proxy: %v", err) + } + return proxy +} + +func connectRequest(target string) *http.Request { + request := httptest.NewRequest(http.MethodConnect, "http://proxy.invalid", nil) + request.Host = target + request.Header.Set("Proxy-Authorization", proxyAuthorization()) + return request +} + +func proxyAuthorization() string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(proxyUsername+":"+testProxyToken)) +} + +type resolverFunc func(context.Context, string, string) ([]netip.Addr, error) + +func (function resolverFunc) LookupNetIP( + ctx context.Context, + network string, + host string, +) ([]netip.Addr, error) { + return function(ctx, network, host) +} + +type dialerFunc func(context.Context, string, string) (net.Conn, error) + +func (function dialerFunc) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return function(ctx, network, address) +} + +type remoteAddressConn struct { + net.Conn + remote net.Addr +} + +func (connection *remoteAddressConn) RemoteAddr() net.Addr { return connection.remote } + +func (connection *remoteAddressConn) CloseWrite() error { + if half, ok := connection.Conn.(writeCloser); ok { + return half.CloseWrite() + } + return nil +} + +func localTestDialer(t *testing.T, localAddress string, publicAddress netip.Addr) dialerFunc { + t.Helper() + return func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != net.JoinHostPort(publicAddress.String(), "443") { + t.Fatalf("dial = %s %s", network, address) + } + dialer := net.Dialer{} + connection, err := dialer.DialContext(ctx, "tcp", localAddress) + if err != nil { + return nil, err + } + return &remoteAddressConn{ + Conn: connection, + remote: &net.TCPAddr{ + IP: net.ParseIP(publicAddress.String()), + Port: 443, + }, + }, nil + } +} + +func testForwardProxy( + t *testing.T, + upstream *httptest.Server, + maxRequestBytes int64, + maxResponseBytes int64, +) *scmEgressProxy { + t.Helper() + upstreamAddress := strings.TrimPrefix(upstream.URL, "https://") + publicAddress := netip.MustParseAddr("1.1.1.1") + proxy := newTestSCMProxy(t, proxyConfig{ + AllowedHosts: map[string]struct{}{"github.com": {}}, + MaxRequestBytes: maxRequestBytes, + MaxResponseBytes: maxResponseBytes, + Resolver: resolverFunc(func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{publicAddress}, nil + }), + Dialer: localTestDialer(t, upstreamAddress, publicAddress), + }) + proxy.client = proxy.newForwardClient(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec // test-only TLS server + return proxy +} + +func startEchoServer(t *testing.T) (net.Listener, string) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen upstream: %v", err) + } + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer func() { _ = connection.Close() }() + _, _ = io.Copy(connection, connection) + }() + return listener, listener.Addr().String() +} diff --git a/cmd/orka-workspace-agent/main_test.go b/cmd/orka-workspace-agent/main_test.go index fccee07e0..0cd00b9f4 100644 --- a/cmd/orka-workspace-agent/main_test.go +++ b/cmd/orka-workspace-agent/main_test.go @@ -1880,7 +1880,22 @@ func TestWorkspaceAgentBoundsRetainedOperationResults(t *testing.T) { ); !errors.Is(err, errOperationResultExpired) { t.Fatalf("expired operation retry error = %v, want %v", err, errOperationResultExpired) } - newRequest := execRequest{OperationID: "after-result-expiry", Command: []string{"true"}} + releasePath := filepath.Join(t.TempDir(), "release-after-tombstone-eviction") + t.Cleanup(func() { + _ = os.WriteFile(releasePath, nil, 0o600) + server.mu.Lock() + cancel := server.executionCancels["after-result-expiry"] + server.mu.Unlock() + if cancel != nil { + cancel() + } + }) + newRequest := execRequest{ + OperationID: "after-result-expiry", + Command: []string{ + "sh", "-c", `while [ ! -e "$1" ]; do sleep 0.01; done`, "sh", releasePath, + }, + } if _, err := server.startExecution(newRequest, normalized, 1); err != nil { t.Fatalf("new operation rejected by tombstones: %v", err) } @@ -1893,6 +1908,29 @@ func TestWorkspaceAgentBoundsRetainedOperationResults(t *testing.T) { if remainingTombstones != 0 { t.Fatalf("expired tombstones retained = %d", remainingTombstones) } + if err := os.WriteFile(releasePath, nil, 0o600); err != nil { + t.Fatalf("release operation after tombstone eviction: %v", err) + } + deadline := time.Now().Add(3 * time.Second) + for { + result, found, conflict, expired := server.loadExecution(newRequest.OperationID, 1) + if conflict || expired || !found { + t.Fatalf( + "released operation unavailable: found=%t conflict=%t expired=%t", + found, conflict, expired, + ) + } + if !result.Running { + if result.State != workspaceagent.OperationStateSucceeded { + t.Fatalf("released operation state = %q, want %q", result.State, workspaceagent.OperationStateSucceeded) + } + break + } + if time.Now().After(deadline) { + t.Fatal("released operation did not complete") + } + time.Sleep(10 * time.Millisecond) + } if _, err := server.startExecution(request, normalized, 1); !errors.Is(err, errOperationResultExpired) { t.Fatalf("operation ownership expired within active epoch: %v", err) } diff --git a/cmd/orka-workspace-publisher/main.go b/cmd/orka-workspace-publisher/main.go new file mode 100644 index 000000000..7eb8bb94c --- /dev/null +++ b/cmd/orka-workspace-publisher/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + publisherservice "github.com/orka-agents/orka/internal/publisher/service" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + setPrivateUmask() + config, err := publisherservice.LoadConfigFromEnv() + if err != nil { + logger.Error("invalid workspace publisher configuration", "error", err) + os.Exit(1) + } + server, err := publisherservice.New(config) + if err != nil { + logger.Error("create workspace publisher", "error", err) + os.Exit(1) + } + httpServer := &http.Server{ + Addr: config.ListenAddress, Handler: server.Handler(), + ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 3 * time.Minute, + WriteTimeout: 3 * time.Minute, IdleTimeout: 2 * time.Minute, MaxHeaderBytes: 32 << 10, + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + logger.Error("workspace publisher shutdown failed", "error", err) + } + }() + server.LogStartup(logger) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("workspace publisher failed", "error", err) + os.Exit(1) + } +} diff --git a/cmd/orka-workspace-publisher/umask_other.go b/cmd/orka-workspace-publisher/umask_other.go new file mode 100644 index 000000000..af1ae8d66 --- /dev/null +++ b/cmd/orka-workspace-publisher/umask_other.go @@ -0,0 +1,5 @@ +//go:build !unix + +package main + +func setPrivateUmask() {} diff --git a/cmd/orka-workspace-publisher/umask_unix.go b/cmd/orka-workspace-publisher/umask_unix.go new file mode 100644 index 000000000..51259812c --- /dev/null +++ b/cmd/orka-workspace-publisher/umask_unix.go @@ -0,0 +1,9 @@ +//go:build unix + +package main + +import "syscall" + +func setPrivateUmask() { + _ = syscall.Umask(0o077) +} diff --git a/config/acp-production/README.md b/config/acp-production/README.md new file mode 100644 index 000000000..3ee3330d2 --- /dev/null +++ b/config/acp-production/README.md @@ -0,0 +1,46 @@ +# ACP harness-v2 production overlay + +This is the canonical direct-Kustomize deployment surface for one static +`harness-v2` Orka installation. It includes the cross-namespace Vekil ingress +policy and renders controller, provider proxy, SCM proxy, and +Workspace/Publisher images by immutable digest. It never deploys the harness +v1 wrapper and cannot adopt or continue work from a v1 installation. + +The checked-in all-zero digests are intentional fail-closed placeholders. Use +`make deploy` with digest-pinned `IMG`, `WORKSPACE_PUBLISHER_IMG`, +`ACP_CODEX_RUNTIME_IMG`, `ACP_CLAUDE_RUNTIME_IMG`, +`ACP_COPILOT_RUNTIME_IMG`, and `ACP_OPENCODE_RUNTIME_IMG`, or replace all four +runtime entries in `runtime-images.env` before applying. Never deploy a rendered +all-zero placeholder. + +The production overlay intentionally excludes CRDs. Apply the reviewed shared +v1/v2-compatible CRD bundle through one designated cluster-level owner before +the workload wave; fresh clusters may use `make install`. Every other Orka +release on the cluster must leave CRD ownership with that owner. `make deploy` +verifies the required live schema before applying only workload resources. + +The controller requires a non-empty watched namespace labeled +`orka.ai/controller-mode: harness-v2`. Its leader-election Lease, SQLite store, +ServiceAccount, API Service, Secrets, and runtime namespace belong only to this +installation. Do not point it at a namespace watched by a `harness-v1` +controller, reuse a v1 PVC, or change the namespace label in place. + +This overlay is also not an adoption path for a pre-static controller that +implicitly enabled ACP. `scripts/apply-acp-production.sh` inspects the live +namespace and any existing controller before its first write. An existing +namespace must already claim static `harness-v2`; any live controller must also +declare that mode and the `orka-system` watch namespace. A missing controller +is recoverable only under that retained namespace claim. Settle or retire older +installations and deploy this overlay as a fresh installation and namespace. + +This overlay deploys the replicated `orka-admission` runtime but not the +cluster-scoped `ValidatingWebhookConfiguration`. Provision +`orka-system/orka-admission-tls` first, wait for both admission endpoints, and +smoke-test every retained handler before the designated cluster admission +owner applies `config/orka-admission-webhooks`. A shared admission owner must +configure every isolated controller ServiceAccount as an exact trusted +username; individual releases must not race to own the webhook configuration. + +For same-cluster v1/v2 operation, deploy v1 as a separate release with a +different release namespace, watched namespace, endpoint, RBAC, storage, and +data plane. See `docs/harness-v1-v2-coexistence-plan.md`. diff --git a/config/acp-production/kustomization.yaml b/config/acp-production/kustomization.yaml new file mode 100644 index 000000000..3c845c98b --- /dev/null +++ b/config/acp-production/kustomization.yaml @@ -0,0 +1,31 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# Canonical production ACP overlay. The nested ACP workload base applies the +# orka-system namespace, while the Vekil ingress policy deliberately retains +# its explicit vekil-system namespace. +resources: + - ../acp-workload + # Runtime resources only. The fail-closed webhook base is applied after TLS, + # endpoint, and handler-smoke checks. + - ../orka-admission + - ../vekil-ingress +configMapGenerator: + - name: acp-runtime-images + namespace: orka-system + envs: + - runtime-images.env +generatorOptions: + labels: + orka.ai/acp-runtime-images: "true" +# Fail-closed placeholders: direct rendering is immutable but not runnable +# until an operator replaces all four runtime digests (make deploy does this atomically). +images: + - name: controller + newName: docker.io/sozercan/orka + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + - name: ghcr.io/orka-agents/orka + newName: docker.io/sozercan/orka + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + - name: docker.io/sozercan/orka-workspace-publisher + newName: docker.io/sozercan/orka-workspace-publisher + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 diff --git a/config/acp-production/runtime-images.env b/config/acp-production/runtime-images.env new file mode 100644 index 000000000..873bacd63 --- /dev/null +++ b/config/acp-production/runtime-images.env @@ -0,0 +1,7 @@ +# Fail-closed placeholders. `make deploy` renders a temporary overlay with +# immutable runtime references; Kustomize hashes this ConfigMap generation and +# rewrites the controller's configMapKeyRef so every change rolls the Pod. +ORKA_ACP_CODEX_RUNTIME_IMAGE=docker.io/sozercan/orka-acp-codex@sha256:0000000000000000000000000000000000000000000000000000000000000000 +ORKA_ACP_CLAUDE_RUNTIME_IMAGE=docker.io/sozercan/orka-acp-claude@sha256:0000000000000000000000000000000000000000000000000000000000000000 +ORKA_ACP_COPILOT_RUNTIME_IMAGE=docker.io/sozercan/orka-acp-copilot@sha256:0000000000000000000000000000000000000000000000000000000000000000 +ORKA_ACP_OPENCODE_RUNTIME_IMAGE=docker.io/sozercan/orka-acp-opencode@sha256:0000000000000000000000000000000000000000000000000000000000000000 diff --git a/config/acp-workload/README.md b/config/acp-workload/README.md new file mode 100644 index 000000000..266b1e73e --- /dev/null +++ b/config/acp-workload/README.md @@ -0,0 +1,33 @@ +# ACP harness-v2 workload base + +This Kustomize base deploys one static `harness-v2` Orka controller, worker +RBAC, ACP runtime broker proxies, and the clean-room workspace publisher +without installing CRDs. It does not contain the harness v1 wrapper or a +cross-protocol fallback. + +Before applying it directly, create the required Secrets in `orka-system`: + +- `acp-artifact-capability` with `capability-secret` +- `agent-execution-snapshot-key` with `snapshot-key` containing exactly 32 raw + bytes or their base64 encoding +- `workspace-publisher-auth` with `controller-token` and `operation-capability-secret` +- `provider-auth-proxy` with `token` +- `scm-egress-proxy-auth` with `token` + +The base claims `orka-system` with `orka.ai/controller-mode: harness-v2` and +starts the controller with `--controller-mode=harness-v2` and +`--watch-namespace=orka-system`. An overlay that chooses a different namespace +must patch the namespace claim and watch argument together before the first +installation. Never retarget an existing installation or change its mode +claim in place. Use a dedicated controller namespace, ServiceAccount, Lease, +store, and runtime namespace for this installation. + +Use `config/acp-production` or `make deploy` for the supported digest-pinned +production flow. That path creates missing Secrets without printing their values, +renders immutable controller, runtime, and publisher image references, and +applies the namespace and generated runtime-image ConfigMap before dependent +workloads. + +CRDs are shared across every Orka release on a cluster. Apply the compatible +CRD bundle through one designated owner; this workload base must not compete +with another release for CRD or cluster-scoped admission ownership. diff --git a/config/acp-workload/api_service.yaml b/config/acp-workload/api_service.yaml new file mode 100644 index 000000000..e6cb85bd9 --- /dev/null +++ b/config/acp-workload/api_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: api + namespace: system +spec: + ports: + - name: http + port: 8080 + protocol: TCP + targetPort: 8080 + selector: + control-plane: controller-manager + app.kubernetes.io/name: orka diff --git a/config/acp-workload/kustomization.yaml b/config/acp-workload/kustomization.yaml new file mode 100644 index 000000000..56a44d490 --- /dev/null +++ b/config/acp-workload/kustomization.yaml @@ -0,0 +1,174 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namePrefix: orka- +configurations: + - kustomizeconfig.yaml +resources: + - ../rbac + - ../manager + - ../publisher + - ../provider-proxy + - ../scm-egress-proxy + - ../admission + - ../policy + - runtime_namespace.yaml + - runtime_rbac.yaml + - v2_controller_cluster_role.yaml + - metrics_service.yaml + - api_service.yaml +patches: + - path: manager_metrics_patch.yaml + target: + kind: Deployment + name: controller-manager + - path: namespace_name_patch.yaml + target: + kind: Namespace + name: system +replacements: +- source: + group: "" + version: v1 + kind: Namespace + name: system + fieldPath: metadata.name + targets: + - select: + group: "" + version: v1 + kind: ServiceAccount + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: rbac.authorization.k8s.io + version: v1 + kind: Role + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: rbac.authorization.k8s.io + version: v1 + kind: RoleBinding + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: apps + version: v1 + kind: Deployment + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: networking.k8s.io + version: v1 + kind: NetworkPolicy + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: "" + version: v1 + kind: PersistentVolumeClaim + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: "" + version: v1 + kind: Service + namespace: system + fieldPaths: + - metadata.namespace + - select: + group: rbac.authorization.k8s.io + version: v1 + kind: RoleBinding + fieldPaths: + - subjects.0.namespace + - select: + group: rbac.authorization.k8s.io + version: v1 + kind: ClusterRoleBinding + fieldPaths: + - subjects.0.namespace + - select: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicy + name: gateway-task-protection + fieldPaths: + - spec.variables.[name=controllerNamespace].expression + options: + delimiter: "'" + index: 1 +- source: + group: "" + version: v1 + kind: ServiceAccount + name: controller-manager + fieldPath: metadata.name + targets: + - select: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicy + name: gateway-task-protection + fieldPaths: + - spec.variables.[name=controllerServiceAccount].expression + options: + delimiter: "'" + index: 1 +- source: + group: "" + version: v1 + kind: ServiceAccount + name: ai-worker + fieldPath: metadata.name + targets: + - select: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicy + name: gateway-task-protection + fieldPaths: + - spec.variables.[name=aiWorkerServiceAccount].expression + options: + delimiter: "'" + index: 1 +- source: + group: "" + version: v1 + kind: ServiceAccount + name: vendor-worker + fieldPath: metadata.name + targets: + - select: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicy + name: gateway-task-protection + fieldPaths: + - spec.variables.[name=vendorWorkerServiceAccount].expression + options: + delimiter: "'" + index: 1 +- source: + group: "" + version: v1 + kind: ServiceAccount + name: container-worker + fieldPath: metadata.name + targets: + - select: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicy + name: gateway-task-protection + fieldPaths: + - spec.variables.[name=containerWorkerServiceAccount].expression + options: + delimiter: "'" + index: 1 diff --git a/config/acp-workload/kustomizeconfig.yaml b/config/acp-workload/kustomizeconfig.yaml new file mode 100644 index 000000000..a4cf625bf --- /dev/null +++ b/config/acp-workload/kustomizeconfig.yaml @@ -0,0 +1,27 @@ +nameReference: +- kind: ServiceAccount + version: v1 + fieldSpecs: + - kind: RoleBinding + group: rbac.authorization.k8s.io + version: v1 + path: subjects/name + - kind: ClusterRoleBinding + group: rbac.authorization.k8s.io + version: v1 + path: subjects/name +- kind: ClusterRole + group: rbac.authorization.k8s.io + version: v1 + fieldSpecs: + - kind: ClusterRole + group: rbac.authorization.k8s.io + version: v1 + path: rules/resourceNames +- kind: Namespace + version: v1 + fieldSpecs: + - kind: ClusterRole + group: rbac.authorization.k8s.io + version: v1 + path: rules/resourceNames diff --git a/config/acp-workload/manager_metrics_patch.yaml b/config/acp-workload/manager_metrics_patch.yaml new file mode 100644 index 000000000..2aaef6536 --- /dev/null +++ b/config/acp-workload/manager_metrics_patch.yaml @@ -0,0 +1,4 @@ +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/config/acp-workload/metrics_service.yaml b/config/acp-workload/metrics_service.yaml new file mode 100644 index 000000000..dfb4408f6 --- /dev/null +++ b/config/acp-workload/metrics_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: controller-manager + app.kubernetes.io/name: orka diff --git a/config/acp-workload/namespace_name_patch.yaml b/config/acp-workload/namespace_name_patch.yaml new file mode 100644 index 000000000..be6d5ca30 --- /dev/null +++ b/config/acp-workload/namespace_name_patch.yaml @@ -0,0 +1,3 @@ +- op: replace + path: /metadata/name + value: orka-system diff --git a/config/acp-workload/remove_admission_policy_binding_namespace.yaml b/config/acp-workload/remove_admission_policy_binding_namespace.yaml new file mode 100644 index 000000000..c5c2ab765 --- /dev/null +++ b/config/acp-workload/remove_admission_policy_binding_namespace.yaml @@ -0,0 +1,12 @@ +apiVersion: builtin +kind: PatchTransformer +metadata: + name: remove-admission-policy-binding-namespace +patch: |- + - op: remove + path: /metadata/namespace +target: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicyBinding + name: gateway-task-protection diff --git a/config/acp-workload/remove_admission_policy_namespace.yaml b/config/acp-workload/remove_admission_policy_namespace.yaml new file mode 100644 index 000000000..a510dab5a --- /dev/null +++ b/config/acp-workload/remove_admission_policy_namespace.yaml @@ -0,0 +1,12 @@ +apiVersion: builtin +kind: PatchTransformer +metadata: + name: remove-admission-policy-namespace +patch: |- + - op: remove + path: /metadata/namespace +target: + group: admissionregistration.k8s.io + version: v1 + kind: ValidatingAdmissionPolicy + name: gateway-task-protection diff --git a/config/acp-workload/runtime_namespace.yaml b/config/acp-workload/runtime_namespace.yaml new file mode 100644 index 000000000..286ad378c --- /dev/null +++ b/config/acp-workload/runtime_namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: orka-runtimes + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: acp-runtime + app.kubernetes.io/managed-by: kustomize + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/audit: restricted diff --git a/config/acp-workload/runtime_rbac.yaml b/config/acp-workload/runtime_rbac.yaml new file mode 100644 index 000000000..f6886770d --- /dev/null +++ b/config/acp-workload/runtime_rbac.yaml @@ -0,0 +1,77 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: runtime-controller-role + namespace: orka-runtimes +rules: +- apiGroups: + - apps + resources: + - deployments + - replicasets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - pods + - secrets + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - networkpolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - policy + resources: + - poddisruptionbudgets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: runtime-controller-rolebinding + namespace: orka-runtimes +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: runtime-controller-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/acp-workload/v2_controller_cluster_role.yaml b/config/acp-workload/v2_controller_cluster_role.yaml new file mode 100644 index 000000000..edb1668e0 --- /dev/null +++ b/config/acp-workload/v2_controller_cluster_role.yaml @@ -0,0 +1,119 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: v2-controller-cluster-role +rules: +- apiGroups: + - "" + resourceNames: + - orka-runtimes + resources: + - namespaces + verbs: + - get +- apiGroups: + - apiextensions.k8s.io + resourceNames: + - tasks.core.orka.ai + - gatewayclasses.gateway.orka.ai + - gateways.gateway.orka.ai + - gatewaybindings.gateway.orka.ai + resources: + - customresourcedefinitions + verbs: + - get +- apiGroups: + - core.orka.ai + resources: + - branchclaims + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - core.orka.ai + resources: + - branchclaims/status + verbs: + - get + - patch + - update +- apiGroups: + - core.orka.ai + resources: + - branchclaims/finalizers + verbs: + - update +- apiGroups: + - gateway.orka.ai + resources: + - gatewayclasses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - gateway.orka.ai + resources: + - gatewayclasses/status + verbs: + - get + - patch + - update +- apiGroups: + - gateway.orka.ai + resources: + - gatewayclasses/finalizers + verbs: + - update +- apiGroups: + - workspace.orka.ai + resources: + - executionworkspaceproviders + verbs: + - get + - list + - patch + - update + - watch +- apiGroups: + - workspace.orka.ai + resources: + - executionworkspaceproviders/status + verbs: + - get + - patch + - update +- apiGroups: + - workspace.orka.ai + resources: + - executionworkspaceproviders/finalizers + verbs: + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: v2-controller-cluster-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: v2-controller-cluster-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/crd/bases/core.orka.ai_agentruntimes.yaml b/config/crd/bases/core.orka.ai_agentruntimes.yaml index e9623b7ae..707f8640a 100644 --- a/config/crd/bases/core.orka.ai_agentruntimes.yaml +++ b/config/crd/bases/core.orka.ai_agentruntimes.yaml @@ -24,8 +24,8 @@ spec: - jsonPath: .spec.deployment.mode name: Mode type: string - - jsonPath: .status.observedCapabilities.runtimeName - name: Runtime + - jsonPath: .status.observedCapabilities.runtimeInstanceID + name: Instance type: string - jsonPath: .metadata.creationTimestamp name: Age @@ -33,7 +33,8 @@ spec: name: v1alpha1 schema: openAPIV3Schema: - description: AgentRuntime is the Schema for registered Orka harness runtimes. + description: AgentRuntime is the Schema for registered external Orka harness + runtimes. properties: apiVersion: description: |- @@ -53,19 +54,27 @@ spec: metadata: type: object spec: - description: AgentRuntimeRegistrySpec defines the desired state of a registered - Orka harness runtime. + description: |- + AgentRuntimeRegistrySpec defines the desired state of a registered Orka harness runtime. + The dual schema has no contractVersion default: omission is tolerated only for + stored objects awaiting the one-time bridge classification and is never + interpreted as either protocol. Fail-closed admission requires an explicit + value for new registrations. properties: capabilities: - description: Capabilities declares readiness requirements Orka checks - against the runtime. + description: |- + Capabilities pins the runtime capability claims. Required with the exact + instance/profile/limits/governance shape for orka.harness.v2; historically + optional for orka.harness.v1. properties: brokeredToolClasses: - description: BrokeredToolClasses lists brokered tool classes the - runtime must advertise when brokered mode is required. + description: |- + BrokeredToolClasses lists the harness v1 brokered tool classes supported + by the runtime. v1 only; historically optional. items: - description: AgentRuntimeBrokeredToolClass declares which classes - of Orka-brokered tools a runtime can request. + description: |- + AgentRuntimeBrokeredToolClass classifies Tool CRDs. It is shared by the Tool + API and by harness v1 AgentRuntime capability declarations. enum: - read - write @@ -73,44 +82,293 @@ spec: type: string type: array x-kubernetes-list-type: set + limits: + description: Limits must exactly match /v2/capabilities. v2 only. + properties: + maxBufferedEvents: + format: int32 + minimum: 1 + type: integer + maxConcurrentPrompts: + format: int32 + minimum: 1 + type: integer + maxEventLineBytes: + format: int32 + minimum: 1 + type: integer + maxPendingPermissions: + format: int32 + minimum: 1 + type: integer + maxPromptLeaseMillis: + format: int64 + minimum: 1 + type: integer + maxRequestBytes: + format: int32 + minimum: 1 + type: integer + maxResidentSessions: + format: int32 + minimum: 1 + type: integer + maxTerminalResultBytes: + format: int32 + minimum: 1 + type: integer + maxUpdateEventsPerSecond: + format: int32 + minimum: 1 + type: integer + maxWorkspaceDeltaBytes: + format: int64 + minimum: 1 + type: integer + minPromptLeaseMillis: + format: int64 + minimum: 1 + type: integer + required: + - maxBufferedEvents + - maxConcurrentPrompts + - maxEventLineBytes + - maxPendingPermissions + - maxPromptLeaseMillis + - maxRequestBytes + - maxResidentSessions + - maxTerminalResultBytes + - maxUpdateEventsPerSecond + - maxWorkspaceDeltaBytes + - minPromptLeaseMillis + type: object + profile: + description: Profile is the exact immutable profile accepted by + v2 session creation. + properties: + acpProfile: + description: ACPProfile is the reviewed ACP profile. + enum: + - acp.v1 + type: string + adapterDigest: + description: AdapterDigest pins the adapter/CLI artifact set. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + adapterName: + description: AdapterName identifies the sole adapter contained + by this external profile. + maxLength: 128 + minLength: 1 + type: string + agentConfigurationDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + approvalPolicyDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + digest: + description: Digest is the canonical orka.harness.v2 runtime-profile + digest. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + digestSchemaVersion: + description: DigestSchemaVersion identifies the canonical + profile digest schema. + enum: + - 1 + format: int32 + type: integer + mcpConfigurationDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + model: + maxLength: 256 + minLength: 1 + type: string + modelLimits: + description: ModelLimits pins optional reviewed model token + capacities. + properties: + context: + description: Context is the maximum model context capacity + in tokens. + format: int64 + minimum: 1 + type: integer + output: + description: Output is the maximum generated output in + tokens. + format: int64 + minimum: 1 + type: integer + required: + - context + - output + type: object + x-kubernetes-validations: + - message: model context limit must exceed output limit + rule: self.context > self.output + providerKind: + maxLength: 128 + minLength: 1 + type: string + proxyCredentialRole: + maxLength: 256 + minLength: 1 + type: string + proxyCredentialScope: + maxLength: 1024 + minLength: 1 + type: string + resourceClass: + maxLength: 128 + minLength: 1 + type: string + toolPolicyDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + workspaceIntent: + description: WorkspaceIntent is the one immutable strict intent + represented by this profile. + enum: + - read + - write + type: string + required: + - acpProfile + - adapterDigest + - adapterName + - agentConfigurationDigest + - approvalPolicyDigest + - digest + - digestSchemaVersion + - mcpConfigurationDigest + - model + - providerKind + - proxyCredentialRole + - proxyCredentialScope + - resourceClass + - toolPolicyDigest + - workspaceIntent + type: object + runtimeInstanceID: + description: |- + RuntimeInstanceID is the immutable external supervisor instance expected from + authenticated /v2/status and every conformance response. v2 only. + maxLength: 253 + minLength: 1 + type: string supportsArtifacts: - description: SupportsArtifacts requires the runtime to advertise - artifact/result reference support when true. + description: SupportsArtifacts declares harness v1 artifact support. + v1 only. type: boolean supportsCancel: - description: SupportsCancel requires the runtime to advertise - cancellation support when true. + description: SupportsCancel declares harness v1 turn cancellation + support. v1 only. type: boolean supportsContinuation: - description: SupportsContinuation requires the runtime to advertise - continuation after Orka-brokered tool results when true. + description: SupportsContinuation declares harness v1 brokered + continuation support. v1 only. + type: boolean + supportsDrain: + description: SupportsDrain must exactly match the static v2 capability + claim. + type: boolean + supportsPublicationFinalization: + description: SupportsPublicationFinalization must exactly match + the static v2 capability claim. type: boolean supportsRuntimeSessions: - description: SupportsRuntimeSessions requires the runtime to advertise - stable runtime sessions when true. + description: SupportsRuntimeSessions declares harness v1 runtime + session support. v1 only. type: boolean toolExecutionModes: - description: ToolExecutionModes lists tool execution modes the - runtime must advertise. + description: |- + ToolExecutionModes lists the harness v1 tool execution modes supported by + the runtime. v1 only; historically optional. items: - description: AgentRuntimeToolExecutionMode declares how custom - runtimes interact with tools. + description: AgentRuntimeToolExecutionMode describes how a harness + v1 runtime executes tools. enum: - observed - brokered type: string type: array x-kubernetes-list-type: set + workspaceGovernance: + description: WorkspaceGovernance must exactly match the static + v2 capability claim. + properties: + cancellationSettlement: + type: boolean + duplicateSafeMutations: + type: boolean + exactInstanceFencing: + type: boolean + mode: + description: Mode selects strict Orka governance or an explicit + trusted escape hatch. + enum: + - strict-governed + - trusted-non-governed + type: string + noDirectSCMPublication: + type: boolean + orkaOwnedCleanRoomPublication: + type: boolean + orkaOwnedWorkspaceDeltas: + type: boolean + promptScopedBrokerAuthorization: + type: boolean + trusted: + description: |- + Trusted must be true only for trusted-non-governed runtimes. Such runtimes + are ineligible for Tasks requesting strict read or write guarantees. + type: boolean + required: + - cancellationSettlement + - duplicateSafeMutations + - exactInstanceFencing + - mode + - noDirectSCMPublication + - orkaOwnedCleanRoomPublication + - orkaOwnedWorkspaceDeltas + - promptScopedBrokerAuthorization + - trusted + type: object + x-kubernetes-validations: + - message: trusted-non-governed runtimes must be explicitly marked + trusted + rule: self.mode != 'trusted-non-governed' || self.trusted + - message: strict-governed runtimes must not use the trusted non-governed + escape hatch + rule: self.mode != 'strict-governed' || !self.trusted + - message: strict-governed runtimes must claim every strict workspace + governance guarantee + rule: self.mode != 'strict-governed' || (self.orkaOwnedWorkspaceDeltas + && self.promptScopedBrokerAuthorization && self.noDirectSCMPublication + && self.orkaOwnedCleanRoomPublication && self.exactInstanceFencing + && self.duplicateSafeMutations && self.cancellationSettlement) + - message: trusted-non-governed runtimes must not claim strict + workspace guarantees + rule: self.mode != 'trusted-non-governed' || (!self.orkaOwnedWorkspaceDeltas + && !self.promptScopedBrokerAuthorization && !self.noDirectSCMPublication + && !self.orkaOwnedCleanRoomPublication && !self.exactInstanceFencing + && !self.duplicateSafeMutations && !self.cancellationSettlement) type: object clientAuth: - description: ClientAuth configures controller-to-runtime authentication. + description: ClientAuth configures controller authentication and mutation + authorization. properties: bearerTokenSecretRef: description: |- - BearerAuthRef points to the bearer token Secret used for mutating harness endpoints. - The referenced Secret must opt in with label orka.ai/agent-runtime-auth=true, - may set orka.ai/agent-runtime-name= to restrict use to one AgentRuntime, - and must set annotation orka.ai/agent-runtime-endpoint= to bind the token to one endpoint. + BearerAuthRef points to the harness v1 bearer token Secret used for + mutating v1 harness endpoints. The referenced Secret must opt in with + label orka.ai/agent-runtime-auth=true, may set + orka.ai/agent-runtime-name= to restrict use to one AgentRuntime, + and must set annotation orka.ai/agent-runtime-endpoint= + to bind the token to one endpoint. properties: key: description: Key is the Secret data key containing the bearer @@ -125,28 +383,80 @@ spec: - key - name type: object - required: - - bearerTokenSecretRef + controllerBearerTokenSecretRef: + description: |- + ControllerBearerTokenSecretRef supplies the controller bearer token used by + authenticated v2 status and mutation endpoints. + properties: + key: + description: Key is the Secret data key. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the Secret name in the AgentRuntime namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + operationCapabilitySecretRef: + description: |- + OperationCapabilitySecretRef supplies the HMAC secret used to bind every + v2 mutation to its exact fence, operation identity, request digest, and expiry. + properties: + key: + description: Key is the Secret data key. + maxLength: 253 + minLength: 1 + type: string + name: + description: Name is the Secret name in the AgentRuntime namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object type: object + x-kubernetes-validations: + - message: legacy v1 and v2 client auth shapes are mutually exclusive + rule: '!(has(self.bearerTokenSecretRef) && (has(self.controllerBearerTokenSecretRef) + || has(self.operationCapabilitySecretRef)))' + - message: v2 client auth requires both controllerBearerTokenSecretRef + and operationCapabilitySecretRef + rule: has(self.controllerBearerTokenSecretRef) == has(self.operationCapabilitySecretRef) + - message: client auth requires either the v1 or the v2 credential + shape + rule: has(self.bearerTokenSecretRef) || has(self.controllerBearerTokenSecretRef) contractVersion: - default: orka.harness.v1 - description: ContractVersion is the Orka harness contract this runtime - must implement. + description: |- + ContractVersion is the Orka harness contract this runtime must implement. + It is immutable once set. Required for new registrations through + fail-closed admission; the bridge schema tolerates omission only on + unchanged stored objects while execution admission is closed. enum: - orka.harness.v1 + - orka.harness.v2 type: string deployment: description: Deployment identifies the runtime endpoint provider. properties: endpoint: description: |- - Endpoint is the base URL for a pre-deployed or external orka.harness.v1 service. - It must not contain credentials; bearer auth is configured via clientAuth. + Endpoint is the base URL for an external harness service. It must not + contain credentials, query parameters, or fragments. pattern: ^https?://[^\s@?#]+$ type: string mode: - description: Mode is the deployment mode. The first milestone - supports external endpoints only. + description: |- + Mode is the deployment mode. External AgentRuntime registrations are not + scaled or recycled by Orka. enum: - external-endpoint type: string @@ -156,9 +466,40 @@ spec: type: object required: - clientAuth - - contractVersion - deployment type: object + x-kubernetes-validations: + - message: contractVersion is immutable once set + rule: '!has(oldSelf.contractVersion) || (has(self.contractVersion) && + self.contractVersion == oldSelf.contractVersion)' + - message: orka.harness.v1 requires the legacy bearerTokenSecretRef client + auth shape + rule: '!has(self.contractVersion) || self.contractVersion != ''orka.harness.v1'' + || (has(self.clientAuth.bearerTokenSecretRef) && !has(self.clientAuth.controllerBearerTokenSecretRef) + && !has(self.clientAuth.operationCapabilitySecretRef))' + - message: orka.harness.v1 capabilities must not carry v2 capability fields + rule: '!has(self.contractVersion) || self.contractVersion != ''orka.harness.v1'' + || !has(self.capabilities) || (!has(self.capabilities.runtimeInstanceID) + && !has(self.capabilities.profile) && !has(self.capabilities.limits) + && !has(self.capabilities.workspaceGovernance) && !has(self.capabilities.supportsDrain) + && !has(self.capabilities.supportsPublicationFinalization))' + - message: orka.harness.v2 requires the v2 controller bearer and operation + capability client auth shape + rule: '!has(self.contractVersion) || self.contractVersion != ''orka.harness.v2'' + || (has(self.clientAuth.controllerBearerTokenSecretRef) && has(self.clientAuth.operationCapabilitySecretRef) + && !has(self.clientAuth.bearerTokenSecretRef))' + - message: orka.harness.v2 requires pinned instance, profile, limits, + and workspace governance capabilities + rule: '!has(self.contractVersion) || self.contractVersion != ''orka.harness.v2'' + || (has(self.capabilities) && has(self.capabilities.runtimeInstanceID) + && has(self.capabilities.profile) && has(self.capabilities.limits) + && has(self.capabilities.workspaceGovernance))' + - message: orka.harness.v2 capabilities must not carry v1 capability fields + rule: '!has(self.contractVersion) || self.contractVersion != ''orka.harness.v2'' + || !has(self.capabilities) || (!has(self.capabilities.toolExecutionModes) + && !has(self.capabilities.brokeredToolClasses) && !has(self.capabilities.supportsCancel) + && !has(self.capabilities.supportsRuntimeSessions) && !has(self.capabilities.supportsContinuation) + && !has(self.capabilities.supportsArtifacts))' status: description: AgentRuntimeStatus defines the observed state of an AgentRuntime. properties: @@ -232,20 +573,26 @@ spec: type: string observedAuthRefResourceVersion: description: |- - ObservedAuthRefResourceVersion is the resourceVersion of the bearer auth Secret - used for the last readiness probe. It is non-secret metadata used to decide - when token rotation requires a fresh authenticated conformance turn. + ObservedAuthRefResourceVersion is the resourceVersion of the harness v1 + bearer auth Secret used for the last v1 readiness probe. It is non-secret + metadata used to decide when token rotation requires a fresh authenticated + conformance turn. v2 probes use the two v2 auth resource-version fields. type: string observedCapabilities: description: ObservedCapabilities contains sanitized capabilities from the last probe. properties: + acpVersion: + type: string + adapterDigest: + type: string + adapterName: + type: string brokeredToolClasses: - description: BrokeredToolClasses are the brokered tool classes - advertised by /v1/capabilities. items: - description: AgentRuntimeBrokeredToolClass declares which classes - of Orka-brokered tools a runtime can request. + description: |- + AgentRuntimeBrokeredToolClass classifies Tool CRDs. It is shared by the Tool + API and by harness v1 AgentRuntime capability declarations. enum: - read - write @@ -253,63 +600,123 @@ spec: type: string type: array x-kubernetes-list-type: set + controllerEpoch: + format: int64 + type: integer + lifecycle: + type: string + limits: + description: Limits records the v2 protocol bounds. It is absent + for harness v1. + properties: + maxBufferedEvents: + format: int32 + minimum: 1 + type: integer + maxConcurrentPrompts: + format: int32 + minimum: 1 + type: integer + maxEventLineBytes: + format: int32 + minimum: 1 + type: integer + maxPendingPermissions: + format: int32 + minimum: 1 + type: integer + maxPromptLeaseMillis: + format: int64 + minimum: 1 + type: integer + maxRequestBytes: + format: int32 + minimum: 1 + type: integer + maxResidentSessions: + format: int32 + minimum: 1 + type: integer + maxTerminalResultBytes: + format: int32 + minimum: 1 + type: integer + maxUpdateEventsPerSecond: + format: int32 + minimum: 1 + type: integer + maxWorkspaceDeltaBytes: + format: int64 + minimum: 1 + type: integer + minPromptLeaseMillis: + format: int64 + minimum: 1 + type: integer + required: + - maxBufferedEvents + - maxConcurrentPrompts + - maxEventLineBytes + - maxPendingPermissions + - maxPromptLeaseMillis + - maxRequestBytes + - maxResidentSessions + - maxTerminalResultBytes + - maxUpdateEventsPerSecond + - maxWorkspaceDeltaBytes + - minPromptLeaseMillis + type: object maxConcurrentTurns: - description: MaxConcurrentTurns is the advertised concurrency - ceiling. type: integer maxOutputBytes: - description: MaxOutputBytes is the advertised maximum output payload - size. format: int64 type: integer maxTurnSeconds: - description: MaxTurnSeconds is the advertised per-turn duration - ceiling. + type: integer + model: + type: string + profileDigestSchemaVersion: + format: int32 type: integer protocolVersion: - description: ProtocolVersion is the runtime's advertised Orka - protocol version. type: string providerKind: - description: ProviderKind is the provider kind advertised by /v1/capabilities. + type: string + runtimeInstanceID: type: string runtimeName: - description: RuntimeName is the runtime name advertised by /v1/capabilities. + type: string + runtimePoolGeneration: + format: int64 + type: integer + runtimePoolUID: + type: string + runtimeProfileDigest: type: string runtimeVersion: - description: RuntimeVersion is the runtime version advertised - by /v1/capabilities. + type: string + supervisorBootID: type: string supportsArtifacts: - description: SupportsArtifacts reports whether the runtime advertises - artifact/result reference support. type: boolean supportsCancel: - description: SupportsCancel reports whether the runtime advertises - cancellation support. type: boolean supportsContinuation: - description: SupportsContinuation reports whether the runtime - advertises continuation support. + type: boolean + supportsDrain: + type: boolean + supportsPublicationFinalization: type: boolean supportsRuntimeSessions: - description: SupportsRuntimeSessions reports whether the runtime - advertises runtime-session support. type: boolean supportsSuspend: - description: SupportsSuspend reports whether the runtime advertises - suspend support. type: boolean supportsWorkspaceSnapshot: - description: SupportsWorkspaceSnapshot reports whether the runtime - advertises workspace snapshots. type: boolean toolExecutionModes: - description: ToolExecutionModes are the tool modes advertised - by /v1/capabilities. items: - description: AgentRuntimeToolExecutionMode declares how custom - runtimes interact with tools. + description: AgentRuntimeToolExecutionMode describes how a harness + v1 runtime executes tools. enum: - observed - brokered @@ -317,14 +724,83 @@ spec: type: array x-kubernetes-list-type: set transport: - description: Transport is the runtime transport, normally http+sse. type: string + workspaceGovernance: + description: WorkspaceGovernance records the v2 workspace guarantees. + It is absent for harness v1. + properties: + cancellationSettlement: + type: boolean + duplicateSafeMutations: + type: boolean + exactInstanceFencing: + type: boolean + mode: + description: Mode selects strict Orka governance or an explicit + trusted escape hatch. + enum: + - strict-governed + - trusted-non-governed + type: string + noDirectSCMPublication: + type: boolean + orkaOwnedCleanRoomPublication: + type: boolean + orkaOwnedWorkspaceDeltas: + type: boolean + promptScopedBrokerAuthorization: + type: boolean + trusted: + description: |- + Trusted must be true only for trusted-non-governed runtimes. Such runtimes + are ineligible for Tasks requesting strict read or write guarantees. + type: boolean + required: + - cancellationSettlement + - duplicateSafeMutations + - exactInstanceFencing + - mode + - noDirectSCMPublication + - orkaOwnedCleanRoomPublication + - orkaOwnedWorkspaceDeltas + - promptScopedBrokerAuthorization + - trusted + type: object + x-kubernetes-validations: + - message: trusted-non-governed runtimes must be explicitly marked + trusted + rule: self.mode != 'trusted-non-governed' || self.trusted + - message: strict-governed runtimes must not use the trusted non-governed + escape hatch + rule: self.mode != 'strict-governed' || !self.trusted + - message: strict-governed runtimes must claim every strict workspace + governance guarantee + rule: self.mode != 'strict-governed' || (self.orkaOwnedWorkspaceDeltas + && self.promptScopedBrokerAuthorization && self.noDirectSCMPublication + && self.orkaOwnedCleanRoomPublication && self.exactInstanceFencing + && self.duplicateSafeMutations && self.cancellationSettlement) + - message: trusted-non-governed runtimes must not claim strict + workspace guarantees + rule: self.mode != 'trusted-non-governed' || (!self.orkaOwnedWorkspaceDeltas + && !self.promptScopedBrokerAuthorization && !self.noDirectSCMPublication + && !self.orkaOwnedCleanRoomPublication && !self.exactInstanceFencing + && !self.duplicateSafeMutations && !self.cancellationSettlement) type: object + observedControllerAuthRefResourceVersion: + description: |- + ObservedControllerAuthRefResourceVersion is the bearer Secret version used + by the last successful or failed authenticated conformance probe. + type: string observedGeneration: description: ObservedGeneration is the latest generation reconciled into this status. format: int64 type: integer + observedOperationCapabilityRefResourceVersion: + description: |- + ObservedOperationCapabilityRefResourceVersion is the HMAC Secret version used + by the last mutation conformance probe. + type: string ready: description: Ready indicates the runtime passed the configured Orka readiness checks. diff --git a/config/crd/bases/core.orka.ai_agents.yaml b/config/crd/bases/core.orka.ai_agents.yaml index 6bcb07722..76f7d04c9 100644 --- a/config/crd/bases/core.orka.ai_agents.yaml +++ b/config/crd/bases/core.orka.ai_agents.yaml @@ -1094,10 +1094,10 @@ spec: type: array workspace: description: |- - Workspace requests an upstream agent-sandbox execution workspace for agent Tasks. - When enabled, the Task controller validates the request and propagates the - resolved sandbox settings to the agent worker Job. The worker wrapper then - claims the sandbox workspace and runs the configured agent runtime inside it. + Workspace requests an execution workspace for worker-backed Task types. + ACP core agent Tasks reject this field because their ephemeral workspace is + owned by RuntimeSession lifecycle and clean-room publication. Actor-backed + RuntimeSession support is a future integration behind the v2 lifecycle seam. properties: boot: description: |- @@ -1247,6 +1247,13 @@ spec: Model defines the LLM model configuration Provider field is optional if providerRef is set properties: + contextWindow: + description: |- + ContextWindow is the reviewed model context capacity in tokens. Built-in + runtimes that manage their own compaction require this value explicitly. + format: int32 + minimum: 1 + type: integer fallbacks: description: |- Fallbacks defines alternative providers to try when the primary fails. @@ -1267,7 +1274,10 @@ spec: type: object type: array maxTokens: - description: MaxTokens limits the response length + description: |- + MaxTokens limits the response length. OpenCode validates positive reviewed + limits at its runtime-specific admission boundary; existing Agent objects + may retain the legacy zero value. format: int32 type: integer name: @@ -1284,7 +1294,6 @@ spec: - openai type: string temperature: - default: 0.7 description: Temperature controls randomness in generation maximum: 2 minimum: 0 @@ -1382,6 +1391,17 @@ spec: Runtime configures this Agent for external CLI runtimes (type: agent tasks). When set, this Agent is for type: agent tasks only (mutually exclusive with providerRef). properties: + contractVersion: + description: |- + ContractVersion is the immutable harness protocol selector for built-in + runtime types. There is no default: a missing selector is never + interpreted as either protocol, and fail-closed admission requires an + explicit value on new built-in Agents. runtime.type alone (including + opencode, which exists in both protocols) is never protocol evidence. + enum: + - orka.harness.v1 + - orka.harness.v2 + type: string defaultAllowBash: description: |- DefaultAllowBash controls whether bash is allowed by default for tasks using this Agent. @@ -1428,15 +1448,21 @@ spec: description: Type specifies which built-in CLI runtime to use. Use runtimeRef for admin-registered custom runtimes. enum: - - copilot - claude - codex + - copilot - opencode type: string type: object x-kubernetes-validations: - message: exactly one of type or runtimeRef is required rule: has(self.type) != has(self.runtimeRef) + - message: runtime.contractVersion is immutable once set + rule: '!has(oldSelf.contractVersion) || (has(self.contractVersion) + && self.contractVersion == oldSelf.contractVersion)' + - message: runtime.contractVersion applies only to built-in runtime + types; runtimeRef derives the protocol from the referenced AgentRuntime + rule: '!has(self.contractVersion) || has(self.type)' secretRef: description: SecretRef references a Secret containing LLM API keys properties: @@ -1520,6 +1546,9 @@ spec: description: Inline is the inline prompt text type: string type: object + x-kubernetes-validations: + - message: system prompt must use only one of inline or configMapRef + rule: '!(has(self.inline) && self.inline.size() > 0 && has(self.configMapRef))' tools: description: Tools lists the default tools available to this agent items: @@ -1547,6 +1576,11 @@ spec: x-kubernetes-validations: - message: execution.workspace.classRef is only supported on Task specs rule: '!has(self.execution) || !has(self.execution.workspace) || !has(self.execution.workspace.classRef)' + - message: opencode orka.harness.v2 runtime does not support spec.systemPrompt + rule: '!(has(self.runtime) && has(self.runtime.type) && self.runtime.type + == ''opencode'' && has(self.runtime.contractVersion) && self.runtime.contractVersion + == ''orka.harness.v2'' && has(self.systemPrompt) && ((has(self.systemPrompt.inline) + && self.systemPrompt.inline.size() > 0) || has(self.systemPrompt.configMapRef)))' status: description: AgentStatus defines the observed state of Agent properties: diff --git a/config/crd/bases/core.orka.ai_branchclaims.yaml b/config/crd/bases/core.orka.ai_branchclaims.yaml new file mode 100644 index 000000000..1328a8fe0 --- /dev/null +++ b/config/crd/bases/core.orka.ai_branchclaims.yaml @@ -0,0 +1,197 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: branchclaims.core.orka.ai +spec: + group: core.orka.ai + names: + kind: BranchClaim + listKind: BranchClaimList + plural: branchclaims + shortNames: + - bclaim + singular: branchclaim + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.repositoryId + name: Repository + type: string + - jsonPath: .spec.ref + name: Ref + type: string + - jsonPath: .spec.ownerKind + name: Owner + type: string + - jsonPath: .status.generation + name: Generation + type: integer + - jsonPath: .status.availability + name: Availability + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + BranchClaim is the cluster-wide Kubernetes-authoritative ownership and exact + baseline record for one canonical repository branch. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: BranchClaimSpec is the immutable repository/ref ownership + identity. + properties: + id: + maxLength: 1024 + minLength: 1 + type: string + ownerKind: + description: BranchClaimOwnerKind identifies the durable owner of + an Orka-managed branch. + enum: + - Task + - Session + type: string + ownerUid: + maxLength: 1024 + minLength: 1 + type: string + ref: + maxLength: 1024 + pattern: ^refs/heads/.+$ + type: string + repositoryId: + maxLength: 1024 + minLength: 1 + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + required: + - id + - ownerKind + - ownerUid + - ref + - repositoryId + - requestDigest + type: object + x-kubernetes-validations: + - message: branch claim spec is immutable + rule: self == oldSelf + status: + description: BranchClaimStatus is the exact generation, baseline, and + availability CAS. + properties: + availability: + description: BranchClaimAvailability gates further branch mutation. + enum: + - Available + - ReconciliationBlocked + type: string + blockedReason: + maxLength: 16384 + type: string + controllerEpoch: + description: ControllerEpoch is the exact epoch that performed the + last mutation. + format: int64 + minimum: 1 + type: integer + controllerEpochLeaseResourceVersion: + description: |- + ControllerEpochLeaseResourceVersion is the resourceVersion of the + authoritative controller-epoch Lease observed by the mutation. + maxLength: 64 + type: string + controllerEpochName: + description: |- + ControllerEpochName identifies the controller epoch domain checked before + the mutation. + maxLength: 253 + type: string + createdAt: + description: CreatedAt is the normalized logical creation time. + format: date-time + type: string + generation: + format: int64 + minimum: 1 + type: integer + lastOperationDigest: + description: LastOperationDigest binds LastOperationID to exact canonical + input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + lastOperationId: + description: LastOperationID is the last idempotent mutation identity + applied. + maxLength: 1024 + type: string + lastVerified: + description: LastVerified is the independently observed exact target + ref. + properties: + absent: + type: boolean + sha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + required: + - absent + type: object + x-kubernetes-validations: + - message: absent and sha are mutually exclusive + rule: '!(self.absent && has(self.sha) && size(self.sha) > 0)' + relatedPublicationId: + maxLength: 1024 + type: string + updatedAt: + description: UpdatedAt is the normalized logical mutation time. + format: date-time + type: string + version: + description: |- + Version is the monotonic domain CAS version. It advances once for each + successfully persisted logical mutation. + format: int64 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: available branch claims must clear block metadata + rule: '!has(self.availability) || self.availability != ''Available'' + || ((!has(self.blockedReason) || size(self.blockedReason) == 0) && + (!has(self.relatedPublicationId) || size(self.relatedPublicationId) + == 0))' + - message: reconciliation-blocked branch claims require a reason + rule: '!has(self.availability) || self.availability != ''ReconciliationBlocked'' + || (has(self.blockedReason) && size(self.blockedReason) > 0)' + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_controllerepochs.yaml b/config/crd/bases/core.orka.ai_controllerepochs.yaml new file mode 100644 index 000000000..9d1c486f2 --- /dev/null +++ b/config/crd/bases/core.orka.ai_controllerepochs.yaml @@ -0,0 +1,109 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: controllerepochs.core.orka.ai +spec: + group: core.orka.ai + names: + kind: ControllerEpoch + listKind: ControllerEpochList + plural: controllerepochs + shortNames: + - cepoch + singular: controllerepoch + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.epoch + name: Epoch + type: integer + - jsonPath: .status.holderId + name: Holder + type: string + - jsonPath: .status.version + name: Version + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ControllerEpoch is the human-visible Kubernetes control record paired with + an authoritative namespaced Lease. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + ControllerEpochSpec is the immutable epoch-domain identity. The associated + coordination.k8s.io Lease is the CAS authority for holder and epoch changes. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: controller epoch spec is immutable + rule: self == oldSelf + status: + description: |- + ControllerEpochStatus mirrors the authoritative Lease state for inspection + and recovery. LeaseResourceVersion identifies the exact Lease revision. + properties: + acquiredAt: + format: date-time + type: string + epoch: + format: int64 + minimum: 1 + type: integer + holderId: + maxLength: 1024 + type: string + leaseName: + maxLength: 253 + type: string + leaseResourceVersion: + maxLength: 64 + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + updatedAt: + format: date-time + type: string + version: + format: int64 + minimum: 1 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_externaleffects.yaml b/config/crd/bases/core.orka.ai_externaleffects.yaml new file mode 100644 index 000000000..e17b0a44f --- /dev/null +++ b/config/crd/bases/core.orka.ai_externaleffects.yaml @@ -0,0 +1,191 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: externaleffects.core.orka.ai +spec: + group: core.orka.ai + names: + kind: ExternalEffect + listKind: ExternalEffectList + plural: externaleffects + shortNames: + - eeffect + singular: externaleffect + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.state + name: State + type: string + - jsonPath: .spec.kind + name: Kind + type: string + - jsonPath: .status.attempts + name: Attempts + type: integer + - jsonPath: .status.version + name: Version + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ExternalEffect is the Kubernetes-authoritative canonical idempotency record + for an operation outside SQLite. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + ExternalEffectSpec is the immutable canonical identity and request binding. + The identity namespace intentionally duplicates metadata.namespace so a + serialized record remains self-describing and can be checked fail-closed. + properties: + aggregateId: + maxLength: 1024 + minLength: 1 + type: string + id: + maxLength: 1024 + minLength: 1 + type: string + identityNamespace: + maxLength: 1024 + minLength: 1 + type: string + kind: + maxLength: 1024 + minLength: 1 + type: string + operationId: + maxLength: 1024 + minLength: 1 + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + required: + - aggregateId + - id + - identityNamespace + - kind + - operationId + - requestDigest + type: object + x-kubernetes-validations: + - message: external effect spec is immutable + rule: self == oldSelf + status: + description: |- + ExternalEffectStatus contains the mutable state, response, lease, and epoch + fence for one canonical external effect. + properties: + attempts: + format: int64 + minimum: 0 + type: integer + controllerEpoch: + description: ControllerEpoch is the exact epoch that performed the + last mutation. + format: int64 + minimum: 1 + type: integer + controllerEpochLeaseResourceVersion: + description: |- + ControllerEpochLeaseResourceVersion is the resourceVersion of the + authoritative controller-epoch Lease observed by the mutation. + maxLength: 64 + type: string + controllerEpochName: + description: |- + ControllerEpochName identifies the controller epoch domain checked before + the mutation. + maxLength: 253 + type: string + createdAt: + description: CreatedAt is the normalized logical creation time. + format: date-time + type: string + lastOperationDigest: + description: LastOperationDigest binds LastOperationID to exact canonical + input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + lastOperationId: + description: LastOperationID is the last idempotent mutation identity + applied. + maxLength: 1024 + type: string + leaseExpiresAt: + format: date-time + type: string + leaseOwner: + maxLength: 1024 + type: string + response: + description: |- + Response stores a bounded JSON response for idempotent replay. Large + response bodies should remain in the artifact store and be referenced by + a compact receipt instead. + x-kubernetes-preserve-unknown-fields: true + responseDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + state: + description: |- + ExternalEffectControlState is the durable state of one idempotent operation + performed outside the controller's SQLite transaction boundary. + enum: + - Pending + - InFlight + - Succeeded + - Failed + - OutcomeUnknown + type: string + updatedAt: + description: UpdatedAt is the normalized logical mutation time. + format: date-time + type: string + version: + description: |- + Version is the monotonic domain CAS version. It advances once for each + successfully persisted logical mutation. + format: int64 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: in-flight external effects require a lease owner and expiry + rule: '!has(self.state) || self.state != ''InFlight'' || (has(self.leaseOwner) + && size(self.leaseOwner) > 0 && has(self.leaseExpiresAt))' + - message: non-in-flight external effects must clear lease fields + rule: '!has(self.state) || self.state == ''InFlight'' || ((!has(self.leaseOwner) + || size(self.leaseOwner) == 0) && !has(self.leaseExpiresAt))' + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_promptattempts.yaml b/config/crd/bases/core.orka.ai_promptattempts.yaml new file mode 100644 index 000000000..e2781f0cc --- /dev/null +++ b/config/crd/bases/core.orka.ai_promptattempts.yaml @@ -0,0 +1,280 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: promptattempts.core.orka.ai +spec: + group: core.orka.ai + names: + kind: PromptAttempt + listKind: PromptAttemptList + plural: promptattempts + shortNames: + - pattempt + singular: promptattempt + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.executionState + name: Execution + type: string + - jsonPath: .status.deliveryState + name: Delivery + type: string + - jsonPath: .spec.attempt + name: Attempt + type: integer + - jsonPath: .status.version + name: Version + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PromptAttempt is the Kubernetes-authoritative prompt execution and delivery + control record. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + PromptAttemptSpec is the immutable identity and request binding for one + Task prompt attempt. + properties: + attempt: + description: Attempt is the one-based Task attempt number. + format: int64 + minimum: 1 + type: integer + bindingDigest: + description: |- + BindingDigest identifies the immutable Task-lifetime v2 execution + binding. It is optional only so pre-coexistence records remain readable; + all PromptAttempts newly created through DurableControlStore require it. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + credentialBindings: + description: CredentialBindings is the immutable, role-separated Secret + identity set. + items: + description: |- + PromptCredentialBinding freezes one role-specific Secret identity without + storing credential material. + properties: + namespace: + maxLength: 253 + minLength: 1 + type: string + resourceVersion: + maxLength: 253 + minLength: 1 + type: string + role: + enum: + - SourceRead + - TargetRead + - TargetWrite + - Forge + type: string + secretKey: + maxLength: 253 + minLength: 1 + type: string + secretName: + maxLength: 253 + minLength: 1 + type: string + secretUid: + maxLength: 253 + minLength: 1 + type: string + required: + - namespace + - resourceVersion + - role + - secretKey + - secretName + - secretUid + type: object + maxItems: 4 + type: array + x-kubernetes-list-map-keys: + - role + x-kubernetes-list-type: map + id: + description: ID is the canonical DurableControlStore prompt-attempt + ID. + maxLength: 1024 + minLength: 1 + type: string + promptId: + description: PromptID is the immutable prompt identity within the + attempt. + maxLength: 1024 + minLength: 1 + type: string + requestDigest: + description: RequestDigest binds the prompt identity to exact canonical + input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + snapshotDigest: + description: |- + SnapshotDigest identifies the immutable encrypted execution snapshot. + It is optional only so pre-coexistence records remain readable; all + PromptAttempts newly created through DurableControlStore require it. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + taskUid: + description: TaskUID is the immutable Kubernetes UID of the Task. + maxLength: 1024 + minLength: 1 + type: string + required: + - attempt + - id + - promptId + - requestDigest + - taskUid + type: object + x-kubernetes-validations: + - message: prompt attempt spec is immutable + rule: self == oldSelf + - message: bindingDigest and snapshotDigest must be recorded together + rule: has(self.bindingDigest) == has(self.snapshotDigest) + status: + description: PromptAttemptStatus holds the exact execution and delivery + state machines. + properties: + controllerEpoch: + description: ControllerEpoch is the exact epoch that performed the + last mutation. + format: int64 + minimum: 1 + type: integer + controllerEpochLeaseResourceVersion: + description: |- + ControllerEpochLeaseResourceVersion is the resourceVersion of the + authoritative controller-epoch Lease observed by the mutation. + maxLength: 64 + type: string + controllerEpochName: + description: |- + ControllerEpochName identifies the controller epoch domain checked before + the mutation. + maxLength: 253 + type: string + createdAt: + description: CreatedAt is the normalized logical creation time. + format: date-time + type: string + deliveryState: + description: PromptAttemptDeliveryState is the durable delivery state + for one prompt. + enum: + - NotRequested + - Validating + - Preparing + - Prepared + - Publishing + - Verifying + - VerifiedExact + - DeliveredSuperseded + - ReadValidated + - NoChange + - CancelledBeforePublish + - ReadOnlyWorkspaceModified + - DeliveryConflict + - CredentialBlocked + - PublicationOutcomeUnknown + type: string + executionState: + description: PromptAttemptExecutionState is the durable prompt execution + state. + enum: + - Queued + - Reserved + - SessionStarting + - Planned + - Submitting + - SubmittedUnknown + - Accepted + - Running + - Settling + - Succeeded + - Failed + - Cancelled + - OutcomeUnknown + type: string + lastOperationDigest: + description: LastOperationDigest binds LastOperationID to exact canonical + input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + lastOperationId: + description: LastOperationID is the last idempotent mutation identity + applied. + maxLength: 1024 + type: string + outcomeMarker: + maxLength: 16384 + type: string + runtimeInstanceId: + description: RuntimeInstanceID is immutable after first binding. + maxLength: 1024 + type: string + sessionLeaseGeneration: + description: SessionLeaseGeneration is immutable after first binding. + format: int64 + minimum: 1 + type: integer + sessionUid: + description: SessionUID is immutable after first binding. + maxLength: 1024 + type: string + terminalReason: + maxLength: 16384 + type: string + updatedAt: + description: UpdatedAt is the normalized logical mutation time. + format: date-time + type: string + version: + description: |- + Version is the monotonic domain CAS version. It advances once for each + successfully persisted logical mutation. + format: int64 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: OutcomeUnknown requires an explicit outcome marker + rule: '!has(self.executionState) || self.executionState != ''OutcomeUnknown'' + || (has(self.outcomeMarker) && size(self.outcomeMarker) > 0)' + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_publications.yaml b/config/crd/bases/core.orka.ai_publications.yaml new file mode 100644 index 000000000..0b921bca9 --- /dev/null +++ b/config/crd/bases/core.orka.ai_publications.yaml @@ -0,0 +1,529 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: publications.core.orka.ai +spec: + group: core.orka.ai + names: + kind: Publication + listKind: PublicationList + plural: publications + shortNames: + - pubctl + singular: publication + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.state + name: State + type: string + - jsonPath: .spec.generation + name: Generation + type: integer + - jsonPath: .spec.targetRef + name: Target + type: string + - jsonPath: .status.version + name: Version + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Publication is the Kubernetes-authoritative clean-room publication + record. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + PublicationSpec is the immutable clean-room publication identity and input. + Mutable receipts and forge intent live only in status. + properties: + artifactDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + artifactId: + description: ArtifactID identifies the durable content-addressed change + artifact. + maxLength: 1024 + minLength: 1 + type: string + artifactMediaType: + maxLength: 255 + minLength: 1 + type: string + artifactSizeBytes: + format: int64 + minimum: 1 + type: integer + attempt: + format: int64 + minimum: 1 + type: integer + baseline: + description: |- + ControlRemoteRefState is an exact remote-ref observation. Absent and SHA are + mutually exclusive. The all-zero value is reserved for an explicitly unknown + observation in PublicationOutcomeUnknown receipts. + properties: + absent: + type: boolean + sha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + required: + - absent + type: object + x-kubernetes-validations: + - message: absent and sha are mutually exclusive + rule: '!(self.absent && has(self.sha) && size(self.sha) > 0)' + branchClaimGeneration: + format: int64 + minimum: 1 + type: integer + branchClaimId: + maxLength: 1024 + minLength: 1 + type: string + commitIdentity: + maxLength: 1024 + minLength: 1 + type: string + commitMessage: + maxLength: 16384 + minLength: 1 + type: string + commitTimestamp: + format: date-time + type: string + generation: + format: int64 + minimum: 1 + type: integer + id: + maxLength: 1024 + minLength: 1 + type: string + promptId: + maxLength: 1024 + minLength: 1 + type: string + publicationCredentialRef: + description: |- + PublicationCredentialRef identifies an operation-scoped Secret reference; + it never contains credential material. + maxLength: 1024 + minLength: 1 + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + sessionUid: + maxLength: 1024 + type: string + sourceBaselineSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + sourceRef: + description: SourceRef is the exact immutable source ref or revision + selector. + maxLength: 1024 + minLength: 1 + type: string + sourceRepositoryId: + maxLength: 1024 + minLength: 1 + type: string + targetRef: + maxLength: 1024 + pattern: ^refs/heads/.+$ + type: string + targetRepositoryId: + maxLength: 1024 + minLength: 1 + type: string + taskUid: + maxLength: 1024 + minLength: 1 + type: string + required: + - artifactDigest + - artifactId + - artifactMediaType + - artifactSizeBytes + - attempt + - baseline + - branchClaimGeneration + - branchClaimId + - commitIdentity + - commitMessage + - commitTimestamp + - generation + - id + - promptId + - publicationCredentialRef + - requestDigest + - sourceBaselineSha + - sourceRef + - sourceRepositoryId + - targetRef + - targetRepositoryId + - taskUid + type: object + x-kubernetes-validations: + - message: publication spec is immutable + rule: self == oldSelf + status: + description: PublicationStatus contains mutable state, exact receipts, + and epoch fencing. + properties: + controllerEpoch: + description: ControllerEpoch is the exact epoch that performed the + last mutation. + format: int64 + minimum: 1 + type: integer + controllerEpochLeaseResourceVersion: + description: |- + ControllerEpochLeaseResourceVersion is the resourceVersion of the + authoritative controller-epoch Lease observed by the mutation. + maxLength: 64 + type: string + controllerEpochName: + description: |- + ControllerEpochName identifies the controller epoch domain checked before + the mutation. + maxLength: 253 + type: string + createdAt: + description: CreatedAt is the normalized logical creation time. + format: date-time + type: string + lastOperationDigest: + description: LastOperationDigest binds LastOperationID to exact canonical + input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + lastOperationId: + description: LastOperationID is the last idempotent mutation identity + applied. + maxLength: 1024 + type: string + prIntent: + description: |- + PublicationPullRequestIntent is the exact forge tuple persisted before the + first forge API call. + properties: + baseRef: + maxLength: 1024 + pattern: ^refs/heads/.+$ + type: string + baseRepositoryId: + maxLength: 1024 + minLength: 1 + type: string + expectedHeadSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + headRef: + maxLength: 1024 + pattern: ^refs/heads/.+$ + type: string + headRepositoryId: + maxLength: 1024 + minLength: 1 + type: string + publicationGeneration: + format: int64 + minimum: 1 + type: integer + required: + - baseRef + - baseRepositoryId + - expectedHeadSha + - headRef + - headRepositoryId + - publicationGeneration + type: object + preparedReceipt: + description: PreparedPublicationControlReceipt records deterministic + commit preparation. + properties: + bundleArtifactId: + maxLength: 1024 + minLength: 1 + type: string + bundleDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + bundleMediaType: + maxLength: 255 + minLength: 1 + type: string + bundleRef: + pattern: ^refs/orka/publications/[a-f0-9]{64}$ + type: string + bundleSizeBytes: + format: int64 + minimum: 1 + type: integer + commitSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + manifestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + operationId: + maxLength: 1024 + minLength: 1 + type: string + preparedAt: + format: date-time + type: string + relativeRoot: + description: |- + RelativeRoot is the canonical repository-relative workspace root applied + to every path in the immutable delta artifact. + maxLength: 1024 + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + treeSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + required: + - bundleArtifactId + - bundleDigest + - bundleMediaType + - bundleRef + - bundleSizeBytes + - commitSha + - manifestDigest + - operationId + - preparedAt + - requestDigest + - treeSha + type: object + publishReceipt: + description: PublishOperationControlReceipt records the exact server-enforced + ref CAS. + properties: + acknowledgementUnknown: + type: boolean + expectedCommitSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + operationId: + maxLength: 1024 + minLength: 1 + type: string + publishedAt: + format: date-time + type: string + remoteBefore: + description: |- + ControlRemoteRefState is an exact remote-ref observation. Absent and SHA are + mutually exclusive. The all-zero value is reserved for an explicitly unknown + observation in PublicationOutcomeUnknown receipts. + properties: + absent: + type: boolean + sha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + required: + - absent + type: object + x-kubernetes-validations: + - message: absent and sha are mutually exclusive + rule: '!(self.absent && has(self.sha) && size(self.sha) > 0)' + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + targetRef: + maxLength: 1024 + pattern: ^refs/heads/.+$ + type: string + targetRepositoryId: + maxLength: 1024 + minLength: 1 + type: string + required: + - acknowledgementUnknown + - expectedCommitSha + - operationId + - publishedAt + - remoteBefore + - requestDigest + - targetRef + - targetRepositoryId + type: object + pullRequestReceipt: + description: PullRequestOperationControlReceipt snapshots exact forge + reconciliation. + properties: + forgeId: + maxLength: 1024 + minLength: 1 + type: string + headSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + intentKey: + maxLength: 1024 + minLength: 1 + type: string + operationId: + maxLength: 1024 + minLength: 1 + type: string + reconciledAt: + format: date-time + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + state: + maxLength: 128 + minLength: 1 + type: string + url: + maxLength: 2048 + minLength: 1 + type: string + required: + - forgeId + - headSha + - intentKey + - operationId + - reconciledAt + - requestDigest + - state + - url + type: object + state: + description: PublicationControlState is the clean-room publication + state machine. + enum: + - Preparing + - Prepared + - Publishing + - Verifying + - VerifiedExact + - DeliveredSuperseded + - CancelledBeforePublish + - DeliveryConflict + - CredentialBlocked + - PreparationFailed + - PublicationOutcomeUnknown + type: string + terminalReason: + maxLength: 16384 + type: string + updatedAt: + description: UpdatedAt is the normalized logical mutation time. + format: date-time + type: string + verificationReceipt: + description: PublicationVerificationControlReceipt is an independent + remote observation. + properties: + descendantProofDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + expectedCommitSha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + observedRemote: + description: |- + ControlRemoteRefState is an exact remote-ref observation. Absent and SHA are + mutually exclusive. The all-zero value is reserved for an explicitly unknown + observation in PublicationOutcomeUnknown receipts. + properties: + absent: + type: boolean + sha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + required: + - absent + type: object + x-kubernetes-validations: + - message: absent and sha are mutually exclusive + rule: '!(self.absent && has(self.sha) && size(self.sha) > 0)' + operationId: + maxLength: 1024 + minLength: 1 + type: string + outcome: + description: PublicationControlState is the clean-room publication + state machine. + enum: + - Preparing + - Prepared + - Publishing + - Verifying + - VerifiedExact + - DeliveredSuperseded + - CancelledBeforePublish + - DeliveryConflict + - CredentialBlocked + - PreparationFailed + - PublicationOutcomeUnknown + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + verifiedAt: + format: date-time + type: string + required: + - expectedCommitSha + - observedRemote + - operationId + - outcome + - requestDigest + - verifiedAt + type: object + version: + description: |- + Version is the monotonic domain CAS version. It advances once for each + successfully persisted logical mutation. + format: int64 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: failure and unknown publication states require a terminal reason + rule: '!has(self.state) || !(self.state in [''DeliveryConflict'', ''CredentialBlocked'', + ''PreparationFailed'', ''PublicationOutcomeUnknown'']) || (has(self.terminalReason) + && size(self.terminalReason) > 0)' + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_repositorymonitors.yaml b/config/crd/bases/core.orka.ai_repositorymonitors.yaml index 9e2d52ca6..8dabeb7e0 100644 --- a/config/crd/bases/core.orka.ai_repositorymonitors.yaml +++ b/config/crd/bases/core.orka.ai_repositorymonitors.yaml @@ -176,9 +176,28 @@ spec: description: Branch is the default base branch for repository-wide monitoring decisions. type: string + forgeCredentialRef: + description: |- + ForgeCredentialRef references the GitHub API credential used only for + controller-owned forge reads and mutations. Write workflows and GitHub label + triggers require this explicit reference. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic gitSecretRef: - description: GitSecretRef references GitHub credentials for repository - monitor operations. + description: |- + GitSecretRef is the backward-compatible source-read credential reference. + ReadCredentialRef takes precedence when both are set. GitSecretRef is never + used for publication writes or forge mutations. properties: name: default: "" @@ -329,6 +348,57 @@ spec: enum: - github type: string + publicationCredentialRef: + description: |- + PublicationCredentialRef references the target-repository write credential + used only for exact compare-and-swap publication. Write workflows require + this explicit reference. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + publicationReadCredentialRef: + description: |- + PublicationReadCredentialRef references the target-repository read + credential used only for publication preflight and independent verification. + Write workflows require this explicit reference. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + readCredentialRef: + description: |- + ReadCredentialRef references the source-repository clone/read credential. + It is resolved only by the clean-room workspace boundary. When omitted, + GitSecretRef remains the backward-compatible read-only fallback. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic repair: description: Repair controls bounded repair behavior. properties: diff --git a/config/crd/bases/core.orka.ai_repositoryscans.yaml b/config/crd/bases/core.orka.ai_repositoryscans.yaml index 776b58b50..d66b61335 100644 --- a/config/crd/bases/core.orka.ai_repositoryscans.yaml +++ b/config/crd/bases/core.orka.ai_repositoryscans.yaml @@ -103,12 +103,32 @@ spec: required: - name type: object + forgeCredentialRef: + description: |- + ForgeCredentialRef references the GitHub API credential used only for + controller-owned pull request reconciliation. Patch workflows require this + explicit reference. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic forkRepo: description: ForkRepo is the writable fork repository URL used for patch proposals. type: string gitSecretRef: - description: GitSecretRef references git credentials for private repositories. + description: |- + GitSecretRef is the backward-compatible source-read credential reference. + ReadCredentialRef takes precedence when both are set. GitSecretRef is never + used for publication writes or forge mutations. properties: name: default: "" @@ -156,6 +176,57 @@ spec: enum: - github type: string + publicationCredentialRef: + description: |- + PublicationCredentialRef references the target-repository write credential + used only for exact compare-and-swap publication. Patch workflows require + this explicit reference. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + publicationReadCredentialRef: + description: |- + PublicationReadCredentialRef references the target-repository read + credential used only for publication preflight and independent verification. + Patch workflows require this explicit reference. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + readCredentialRef: + description: |- + ReadCredentialRef references the source-repository clone/read credential. + It is resolved only by the clean-room workspace boundary. When omitted, + GitSecretRef remains the backward-compatible read-only fallback. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic ref: description: Ref is a specific git ref, tag, or commit SHA to checkout for scan tasks. diff --git a/config/crd/bases/core.orka.ai_runtimepools.yaml b/config/crd/bases/core.orka.ai_runtimepools.yaml new file mode 100644 index 000000000..60b385f92 --- /dev/null +++ b/config/crd/bases/core.orka.ai_runtimepools.yaml @@ -0,0 +1,666 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: runtimepools.core.orka.ai +spec: + group: core.orka.ai + names: + kind: RuntimePool + listKind: RuntimePoolList + plural: runtimepools + shortNames: + - rtpool + singular: runtimepool + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.lifecycle + name: Lifecycle + type: string + - jsonPath: .status.admissionState + name: Admission + type: string + - jsonPath: .status.desiredReplicas + name: Desired + type: integer + - jsonPath: .status.currentReplicas + name: Current + type: integer + - jsonPath: .status.capacity.residentSessions + name: Sessions + type: integer + - jsonPath: .status.capacity.runningPrompts + name: Prompts + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: RuntimePool is the Schema for controller-owned ACP runtime pools. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + RuntimePoolSpec defines the desired state of a controller-owned logical pool. + Trust-domain placement and the runtime image/profile are immutable; rollout + uses drain-and-replace rather than changing an in-memory instance in place. + properties: + capacity: + default: + maxResidentSessions: 10 + maxRunningPrompts: 4 + description: Capacity sets resident-session and running-prompt limits. + properties: + maxResidentSessions: + default: 10 + description: MaxResidentSessions is the maximum number of resident + RuntimeSessions. + format: int32 + maximum: 1000 + minimum: 1 + type: integer + maxRunningPrompts: + default: 4 + description: MaxRunningPrompts is the maximum number of concurrently + running prompts. + format: int32 + maximum: 1000 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: maxRunningPrompts cannot exceed maxResidentSessions + rule: self.maxRunningPrompts <= self.maxResidentSessions + coldStartTimeoutSeconds: + default: 120 + description: ColdStartTimeoutSeconds bounds a 0 -> 1 startup before + the pool is marked degraded. + format: int32 + maximum: 3600 + minimum: 1 + type: integer + desiredReplicas: + default: 0 + description: |- + DesiredReplicas is zero or one. More than one runtime Pod would make + stateful exact-instance routing ambiguous. + format: int32 + maximum: 1 + minimum: 0 + type: integer + runtime: + description: Runtime pins the immutable supervisor image and behavior + profile. + properties: + image: + description: Image is a digest-pinned OCI image. Mutable tags + are intentionally rejected. + maxLength: 2048 + pattern: ^[^\s@]+@sha256:[a-f0-9]{64}$ + type: string + profile: + description: Profile is the immutable runtime profile enforced + for every active instance. + properties: + acpProfile: + description: ACPProfile is the reviewed ACP wire/profile identifier. + enum: + - acp.v1 + type: string + adapterDigests: + additionalProperties: + type: string + description: AdapterDigests pins every adapter and provider + CLI artifact used by the pool. + maxProperties: 32 + minProperties: 1 + type: object + agentConfigurationDigest: + description: AgentConfigurationDigest freezes non-secret Agent/runtime + configuration. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + approvalPolicyDigest: + description: ApprovalPolicyDigest freezes the effective approval + policy. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + digest: + description: Digest is the canonical immutable runtime-profile + digest. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + digestSchemaVersion: + description: DigestSchemaVersion identifies the canonicalization + schema used to compute Digest. + maxLength: 64 + minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ + type: string + mcpConfigurationDigest: + description: MCPConfigurationDigest freezes prompt-scoped + broker/MCP configuration. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + model: + description: Model is the exact reviewed model identifier. + maxLength: 256 + minLength: 1 + type: string + modelLimits: + description: |- + ModelLimits pins the reviewed context and output capacities used by the + runtime's local compaction policy. + properties: + context: + description: Context is the maximum model context capacity + in tokens. + format: int64 + minimum: 1 + type: integer + output: + description: Output is the maximum generated output in + tokens. + format: int64 + minimum: 1 + type: integer + required: + - context + - output + type: object + x-kubernetes-validations: + - message: model context limit must exceed output limit + rule: self.context > self.output + protocolVersion: + default: orka.harness.v2 + description: ProtocolVersion is the controller-to-supervisor + protocol profile. + enum: + - orka.harness.v2 + type: string + providerKind: + description: ProviderKind selects the one provider adapter + present in the immutable image. + enum: + - codex + - claude + - copilot + - opencode + type: string + proxyCredentialRole: + description: ProxyCredentialRole identifies the provider-proxy + client role, never a secret value. + maxLength: 256 + minLength: 1 + type: string + proxyCredentialScope: + description: ProxyCredentialScope is the bounded model/session + capability scope. + maxLength: 1024 + minLength: 1 + type: string + resourceClass: + description: ResourceClass is the controller-supported pool + resource class included in Digest. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$ + type: string + toolPolicyDigest: + description: ToolPolicyDigest freezes the effective tool allow/deny + policy. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + workspaceIntent: + description: WorkspaceIntent is part of the immutable runtime + profile. + enum: + - read + - write + type: string + required: + - acpProfile + - adapterDigests + - agentConfigurationDigest + - approvalPolicyDigest + - digest + - digestSchemaVersion + - mcpConfigurationDigest + - model + - providerKind + - proxyCredentialRole + - proxyCredentialScope + - resourceClass + - toolPolicyDigest + - workspaceIntent + type: object + x-kubernetes-validations: + - message: OpenCode runtime profiles require modelLimits + rule: self.providerKind != 'opencode' || has(self.modelLimits) + required: + - image + - profile + type: object + runtimeNamespace: + description: |- + RuntimeNamespace is the physical namespace for controller-owned runtime + resources. When omitted, the controller selects its configured runtime namespace. + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + trustDomain: + description: TrustDomain is the logical namespace/identity boundary + served by this pool. + properties: + identity: + description: |- + Identity is the controller-defined, canonical trust-domain identity. It + must remain stable across physical runtime namespace or Pod replacement. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace is the Task namespace represented by this + trust domain. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + required: + - identity + - namespace + type: object + required: + - runtime + - trustDomain + type: object + x-kubernetes-validations: + - message: trustDomain is immutable + rule: self.trustDomain == oldSelf.trustDomain + - message: runtimeNamespace is immutable + rule: has(self.runtimeNamespace) == has(oldSelf.runtimeNamespace) && + (!has(self.runtimeNamespace) || self.runtimeNamespace == oldSelf.runtimeNamespace) + - message: runtime image and profile are immutable + rule: self.runtime == oldSelf.runtime + status: + description: RuntimePoolStatus defines the observed state of a controller-owned + pool. + properties: + activeInstance: + description: |- + ActiveInstance is the exact selected Pod and supervisor boot. It is empty + unless one instance has been authoritatively selected. + properties: + bootID: + description: BootID is the immutable supervisor boot identifier + inside the selected Pod. + maxLength: 128 + minLength: 1 + type: string + controllerEpoch: + description: ControllerEpoch is the durable controller epoch to + which this instance is bound. + format: int64 + minimum: 1 + type: integer + lastObservedTime: + description: LastObservedTime is the last authenticated status + observation for this instance. + format: date-time + type: string + podAddress: + description: |- + PodAddress is the exact Pod address used for stateful routing, not a + load-balanced Service endpoint. + maxLength: 253 + minLength: 1 + type: string + podName: + description: PodName is the exact selected runtime Pod name. + maxLength: 253 + minLength: 1 + type: string + podNamespace: + description: PodNamespace is the namespace containing the selected + runtime Pod. + maxLength: 63 + minLength: 1 + type: string + podUID: + description: PodUID is the Kubernetes UID of the selected Pod. + maxLength: 128 + minLength: 1 + type: string + profileDigest: + description: ProfileDigest is the immutable runtime-profile digest + advertised by this instance. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + profileDigestSchemaVersion: + description: ProfileDigestSchemaVersion is the digest schema advertised + by this instance. + maxLength: 64 + minLength: 1 + type: string + protocolVersion: + description: ProtocolVersion is the supervisor protocol actually + advertised by this instance. + enum: + - orka.harness.v2 + type: string + providerTokenGeneration: + description: |- + ProviderTokenGeneration is a non-secret digest generation for the exact + provider capability mounted into this runtime Pod. It lets the controller + prove that a selected instance converged on the intended proxy credential + without exposing the bearer token. + pattern: ^[a-f0-9]{16}$ + type: string + runtimeInstanceID: + description: RuntimeInstanceID is the portable v2 instance fence + derived from PodUID and BootID. + maxLength: 253 + minLength: 1 + type: string + required: + - bootID + - controllerEpoch + - podAddress + - podName + - podNamespace + - podUID + - profileDigest + - profileDigestSchemaVersion + - protocolVersion + - providerTokenGeneration + - runtimeInstanceID + type: object + admissionState: + description: AdmissionState is the authoritative admission gate for + new RuntimeSessions. + enum: + - Closed + - Accepting + - Draining + - Ambiguous + type: string + capacity: + description: Capacity reports effective limits, use, and queued demand. + properties: + finalizingSessions: + description: FinalizingSessions is the count reserved for validation, + publication, or finalization. + format: int32 + minimum: 0 + type: integer + liveDescendants: + description: LiveDescendants is the authenticated count of tracked + runtime descendants. + format: int32 + minimum: 0 + type: integer + maxResidentSessions: + description: MaxResidentSessions is the effective configured resident-session + limit. + format: int32 + minimum: 0 + type: integer + maxRunningPrompts: + description: MaxRunningPrompts is the effective configured running-prompt + limit. + format: int32 + minimum: 0 + type: integer + pendingPermissions: + description: PendingPermissions is the authenticated count of + unresolved prompt permissions. + format: int32 + minimum: 0 + type: integer + queuedTasks: + description: QueuedTasks is durable unsatisfied demand assigned + to this pool. + format: int32 + minimum: 0 + type: integer + reservations: + description: |- + Reservations is the bounded authoritative set of coordinator-owned + pre-admission capacity claims. + items: + description: |- + RuntimePoolCapacityReservationStatus is one durable, exact-instance capacity + claim. The composite key is the pool UID, Task UID, attempt, and controller + epoch. A reservation claims resident-session and prompt admission slots until + the supervisor accepts the corresponding work or the reservation expires. + properties: + attempt: + description: Attempt is the Task attempt that owns the claim. + format: int32 + minimum: 1 + type: integer + controllerEpoch: + description: ControllerEpoch fences the claim to one controller + leadership epoch. + format: int64 + minimum: 1 + type: integer + expiresAt: + description: |- + ExpiresAt is renewed while pre-admission work is active. A later + dispatcher may reclaim the claim after this time. + format: date-time + type: string + poolUID: + description: PoolUID fences the claim to the exact RuntimePool + object. + maxLength: 128 + minLength: 1 + type: string + promptSlots: + description: PromptSlots is one until the prompt is accepted + by the supervisor. + format: int32 + maximum: 1 + minimum: 0 + type: integer + reservedAt: + description: ReservedAt is the first successful resource-version + CAS for this claim. + format: date-time + type: string + residentSlots: + description: |- + ResidentSlots is zero after a RuntimeSession is admitted and one while a + new resident-session slot is still reserved. + format: int32 + maximum: 1 + minimum: 0 + type: integer + runtimeInstanceID: + description: RuntimeInstanceID binds admission to the exact + selected Pod/boot pair. + maxLength: 253 + minLength: 1 + type: string + taskUID: + description: TaskUID is the immutable Task identity that + owns the claim. + maxLength: 128 + minLength: 1 + type: string + required: + - attempt + - controllerEpoch + - expiresAt + - poolUID + - promptSlots + - reservedAt + - residentSlots + - runtimeInstanceID + - taskUID + type: object + x-kubernetes-validations: + - message: a capacity reservation must claim at least one slot + rule: self.residentSlots + self.promptSlots > 0 + maxItems: 1000 + type: array + x-kubernetes-list-map-keys: + - poolUID + - taskUID + - attempt + - controllerEpoch + x-kubernetes-list-type: map + reservedPrompts: + description: ReservedPrompts is the sum of prompt slots in Reservations. + format: int32 + minimum: 0 + type: integer + reservedSessions: + description: ReservedSessions is the sum of resident slots in + Reservations. + format: int32 + minimum: 0 + type: integer + residentSessions: + description: ResidentSessions is the authenticated supervisor + count of resident sessions. + format: int32 + minimum: 0 + type: integer + runningPrompts: + description: RunningPrompts is the authenticated supervisor count + of active prompts. + format: int32 + minimum: 0 + type: integer + type: object + conditions: + description: |- + Conditions report admission, Pod Security, quota, scheduling, rollout, and + other controller-observed failures. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + controllerEpoch: + description: ControllerEpoch is the durable epoch required for authoritative + pool writes. + format: int64 + minimum: 0 + type: integer + currentReplicas: + description: CurrentReplicas is the number of non-terminated runtime + Pods owned by the pool. + format: int32 + minimum: 0 + type: integer + desiredReplicas: + description: DesiredReplicas is the desired replica count observed + by the controller. + format: int32 + maximum: 1 + minimum: 0 + type: integer + lifecycle: + description: Lifecycle is the explicit pool lifecycle. + enum: + - Stopped + - Starting + - Serving + - Draining + - Quiescent + - Stopping + - Degraded + - Ambiguous + type: string + message: + description: Message contains bounded, sanitized reconciliation context. + maxLength: 1024 + type: string + observedGeneration: + description: ObservedGeneration is the latest RuntimePool generation + reconciled by the controller. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_runtimesessioncontrols.yaml b/config/crd/bases/core.orka.ai_runtimesessioncontrols.yaml new file mode 100644 index 000000000..eeac33fe3 --- /dev/null +++ b/config/crd/bases/core.orka.ai_runtimesessioncontrols.yaml @@ -0,0 +1,371 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: runtimesessioncontrols.core.orka.ai +spec: + group: core.orka.ai + names: + kind: RuntimeSessionControl + listKind: RuntimeSessionControlList + plural: runtimesessioncontrols + shortNames: + - rsctrl + singular: runtimesessioncontrol + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.lifecycle + name: Lifecycle + type: string + - jsonPath: .status.availability + name: Availability + type: string + - jsonPath: .status.generation + name: Generation + type: integer + - jsonPath: .status.mutationLeaseGeneration + name: Lease + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + RuntimeSessionControl is the Kubernetes-authoritative RuntimeSession control + record. SessionTurn/transcript/deferred-outbox data remains in one durable + SQLite transaction; the Kubernetes store completes the authoritative + SessionControl/BranchClaim CAS before activating the terminal projection. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + RuntimeSessionControlSpec contains immutable session identity, ownership, and + profile bindings. Profile changes create a new session generation in status; + they do not mutate this immutable record identity. + properties: + owner: + description: Owner identifies the immutable Task or durable Session + owner. + properties: + kind: + enum: + - Task + - Session + - RuntimePool + - PromptAttempt + type: string + uid: + maxLength: 1024 + minLength: 1 + type: string + required: + - kind + - uid + type: object + profileDigestSchemaVersion: + description: ProfileDigestSchemaVersion identifies how RuntimeProfileDigest + was built. + maxLength: 64 + type: string + requestDigest: + description: RequestDigest binds creation to exact canonical input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + runtimePoolRef: + description: RuntimePoolRef is the controller-owned logical pool name + when known. + maxLength: 253 + type: string + runtimePoolUid: + description: RuntimePoolUID fences the pool object across delete/recreate. + maxLength: 1024 + type: string + runtimeProfileDigest: + description: RuntimeProfileDigest binds the session to immutable runtime + behavior. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + sessionName: + description: |- + SessionName is the immutable user-visible Session key within the object + namespace. The Kubernetes object name is a digest-derived storage key and + must not be treated as the Session name. + maxLength: 1024 + minLength: 1 + type: string + sessionUid: + description: SessionUID is the immutable Orka Session identity. + maxLength: 1024 + minLength: 1 + type: string + required: + - owner + - requestDigest + - sessionName + - sessionUid + type: object + x-kubernetes-validations: + - message: runtime session control spec is immutable + rule: self == oldSelf + status: + description: |- + RuntimeSessionControlStatus contains the lifecycle, generation, mutation + Lease, and independently verified recovery baseline. + properties: + availability: + description: RuntimeSessionControlAvailability gates the Session mutation + lease. + enum: + - Available + - ReconciliationBlocked + type: string + blockedReason: + maxLength: 16384 + type: string + controllerEpoch: + description: ControllerEpoch is the exact epoch that performed the + last mutation. + format: int64 + minimum: 1 + type: integer + controllerEpochLeaseResourceVersion: + description: |- + ControllerEpochLeaseResourceVersion is the resourceVersion of the + authoritative controller-epoch Lease observed by the mutation. + maxLength: 64 + type: string + controllerEpochName: + description: |- + ControllerEpochName identifies the controller epoch domain checked before + the mutation. + maxLength: 253 + type: string + createdAt: + description: CreatedAt is the normalized logical creation time. + format: date-time + type: string + generation: + description: Generation is the monotonic ACP RuntimeSession generation. + format: int64 + minimum: 1 + type: integer + lastOperationDigest: + description: LastOperationDigest binds LastOperationID to exact canonical + input. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + lastOperationId: + description: LastOperationID is the last idempotent mutation identity + applied. + maxLength: 1024 + type: string + lifecycle: + description: RuntimeSessionControlLifecycle is the durable RuntimeSession + lifecycle. + enum: + - Creating + - Idle + - PromptRunning + - Validating + - PreparingPublication + - PublicationPrepared + - Publishing + - Verifying + - Finalizing + - Cancelling + - Poisoned + - Deleting + - Deleted + type: string + lineage: + description: |- + Lineage is established or verified in the same RuntimeSessionControl + status CAS that mirrors the authoritative mutation Lease. + properties: + configDigest: + description: |- + ConfigDigest freezes the configuration/execution-snapshot identity used + when the lineage was established. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + contractVersion: + description: |- + AgentRuntimeContractVersion identifies the Orka-facing runtime contract. + During harness coexistence both protocol values are schema-valid; omission is + never protocol evidence and is tolerated only for stored objects awaiting the + one-time bridge classification. + enum: + - orka.harness.v1 + - orka.harness.v2 + type: string + establishedAt: + format: date-time + type: string + generation: + format: int64 + minimum: 1 + type: integer + namespaceUID: + description: |- + NamespaceUID prevents a same-name recreated namespace from attaching to + durable state owned by the previous namespace identity. + type: string + runtimeIdentity: + description: RuntimeIdentity is the built-in runtime type or AgentRuntime + UID. + maxLength: 1024 + minLength: 1 + type: string + sessionUid: + description: SessionUID repeats the immutable control identity + at the lineage fence. + maxLength: 1024 + minLength: 1 + type: string + required: + - configDigest + - contractVersion + - establishedAt + - generation + - namespaceUID + - runtimeIdentity + - sessionUid + type: object + mutationLease: + description: |- + RuntimeSessionMutationLeaseStatus mirrors the namespaced Kubernetes Lease + that serializes mutation for one immutable SessionUID. + properties: + acquiredAt: + format: date-time + type: string + attempt: + format: int64 + minimum: 1 + type: integer + expiresAt: + format: date-time + type: string + generation: + format: int64 + minimum: 1 + type: integer + leaseName: + maxLength: 253 + minLength: 1 + type: string + leaseResourceVersion: + maxLength: 64 + minLength: 1 + type: string + promptId: + maxLength: 1024 + minLength: 1 + type: string + requestDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + taskUid: + maxLength: 1024 + minLength: 1 + type: string + required: + - acquiredAt + - attempt + - generation + - leaseName + - leaseResourceVersion + - promptId + - requestDigest + - taskUid + type: object + mutationLeaseGeneration: + description: MutationLeaseGeneration is monotonic and never reused + for SessionUID. + format: int64 + minimum: 0 + type: integer + relatedPromptAttemptId: + maxLength: 1024 + type: string + relatedPublicationId: + maxLength: 1024 + type: string + updatedAt: + description: UpdatedAt is the normalized logical mutation time. + format: date-time + type: string + verifiedBaseline: + description: ControlVerifiedBranchBaseline is an independently verified + branch baseline. + properties: + ref: + maxLength: 1024 + pattern: ^refs/heads/.+$ + type: string + repositoryId: + maxLength: 1024 + minLength: 1 + type: string + sha: + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + required: + - ref + - repositoryId + - sha + type: object + version: + description: |- + Version is the monotonic domain CAS version. It advances once for each + successfully persisted logical mutation. + format: int64 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: available sessions must clear reconciliation block metadata + rule: '!has(self.availability) || self.availability != ''Available'' + || ((!has(self.blockedReason) || size(self.blockedReason) == 0) && + (!has(self.relatedPromptAttemptId) || size(self.relatedPromptAttemptId) + == 0) && (!has(self.relatedPublicationId) || size(self.relatedPublicationId) + == 0))' + - message: reconciliation-blocked sessions require a reason + rule: '!has(self.availability) || self.availability != ''ReconciliationBlocked'' + || (has(self.blockedReason) && size(self.blockedReason) > 0)' + - message: runtime Session lineage is append-once and immutable + rule: '!has(oldSelf.lineage) || (has(self.lineage) && self.lineage == + oldSelf.lineage)' + required: + - spec + type: object + x-kubernetes-validations: + - message: runtime Session lineage UID must match the immutable control Session + UID + rule: '!has(self.status) || !has(self.status.lineage) || self.status.lineage.sessionUid + == self.spec.sessionUid' + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/core.orka.ai_tasks.yaml b/config/crd/bases/core.orka.ai_tasks.yaml index 704b92442..ece87c000 100644 --- a/config/crd/bases/core.orka.ai_tasks.yaml +++ b/config/crd/bases/core.orka.ai_tasks.yaml @@ -93,21 +93,26 @@ spec: minimum: 1 type: integer workspace: - description: Workspace defines the working directory configuration + description: |- + Workspace is the legacy harness v1 agent workspace configuration at its + historical JSON path. It is a preserved compatibility read surface for + stored v1 Tasks only: it can never be introduced or changed, and it is + not an authority surface for new work. properties: branch: - description: Branch is the git branch to checkout + description: Branch is the git branch to checkout. type: string forkRepo: description: ForkRepo is the writable fork repository URL - for pushing changes + for pushing changes. type: string gitRepo: - description: GitRepo is the repository URL to clone + description: GitRepo is the repository URL to clone. type: string gitSecretRef: - description: GitSecretRef references a Secret containing git - credentials + description: |- + GitSecretRef references a Secret containing git credentials. Adopted + legacy bindings freeze the exact Secret identity; new bindings reject it. properties: name: default: "" @@ -122,23 +127,29 @@ spec: x-kubernetes-map-type: atomic prBaseBranch: description: PRBaseBranch is the upstream branch to target - for pull requests + for pull requests. type: string pushBranch: description: |- - PushBranch is the remote branch name to push changes to after the agent completes. - When set, FinalizeResult will commit and push changes to this branch. + PushBranch is the remote branch name to push changes to after the agent + completes. type: string ref: description: Ref is a specific git ref (commit SHA, tag) to - checkout + checkout. type: string subPath: description: SubPath is a subdirectory within the repo to - use as workspace root + use as workspace root. type: string type: object type: object + x-kubernetes-validations: + - message: legacy agentRuntime.workspace is a preserved harness v1 + compatibility surface; new Tasks must use spec.workspace + optionalOldSelf: true + rule: '!has(self.workspace) || (oldSelf.hasValue() && has(oldSelf.value().workspace) + && self.workspace == oldSelf.value().workspace)' ai: description: AI contains AI-specific configuration (when type is "ai") properties: @@ -1367,10 +1378,10 @@ spec: type: array workspace: description: |- - Workspace requests an upstream agent-sandbox execution workspace for agent Tasks. - When enabled, the Task controller validates the request and propagates the - resolved sandbox settings to the agent worker Job. The worker wrapper then - claims the sandbox workspace and runs the configured agent runtime inside it. + Workspace requests an execution workspace for worker-backed Task types. + ACP core agent Tasks reject this field because their ephemeral workspace is + owned by RuntimeSession lifecycle and clean-room publication. Actor-backed + RuntimeSession support is a future integration behind the v2 lifecycle seam. properties: boot: description: |- @@ -1803,51 +1814,258 @@ spec: type: string workspace: description: |- - Workspace defines repository checkout and push settings for tasks that need - a git workspace. Agent tasks can continue to use agentRuntime.workspace for - compatibility; this top-level field is used by container tasks as well. + Workspace defines the canonical repository workspace, intent, credentials, + and publication request. Agent Tasks that omit intent are interpreted as + read by controller logic; an omitted intent preserves existing container behavior. properties: + allowedPaths: + description: |- + AllowedPaths restricts publishable workspace changes to these path globs or + directory prefixes ending in /**. Empty allows every otherwise-safe path. + It is not supported for container Tasks. + items: + type: string + maxItems: 256 + type: array branch: - description: Branch is the git branch to checkout + description: Branch is the source branch to check out. + maxLength: 255 type: string - forkRepo: - description: ForkRepo is the writable fork repository URL for - pushing changes + createPR: + default: false + description: |- + CreatePR explicitly requests pull request reconciliation after branch publication. + Branch push remains the minimum durable delivery when false. It is supported only + for agent Tasks using the trusted ACP publisher boundary. + type: boolean + denyRepositoryControlPaths: + description: |- + DenyRepositoryControlPaths rejects workflow, RBAC, and chart-secret paths + before publication even when AllowedPaths is empty or otherwise matches. + It is not supported for container Tasks. + type: boolean + expectedRemoteSHA: + description: |- + ExpectedRemoteSHA requires the publication branch to exist at this exact + commit before publication. Empty means the branch must be absent. It is + supported only for agent Tasks using the trusted ACP publisher boundary. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ type: string + forgeCredentialRef: + description: |- + ForgeCredentialRef references the forge API credential used only for pull + request reconciliation when createPR=true. + properties: + key: + default: token + description: |- + Key is the Secret data key containing one bearer token or one complete + Authorization header. It defaults to "token" when omitted. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z0-9._-]+$ + type: string + name: + description: Name is the name of the Secret in the Task namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object gitRepo: - description: GitRepo is the repository URL to clone + description: |- + GitRepo is the source repository URL cloned by the clean-room workspace boundary. + Credentials must not be embedded in the URL. + maxLength: 2048 type: string - gitSecretRef: - description: GitSecretRef references a Secret containing git credentials + intent: + description: |- + Intent declares whether the verified workspace must remain unchanged or may + produce a publication artifact. It is immutable for the lifetime of the Task. + Agent Tasks that omit intent are interpreted as read by controller logic; + omitted intent preserves the existing behavior of container Tasks. + enum: + - read + - write + type: string + maxChangedFiles: + description: |- + MaxChangedFiles bounds the total changed, deleted, and symlink paths accepted + from the trusted supervisor before publication. Zero uses the runtime limit. + It is not supported for container Tasks. + format: int32 + minimum: 1 + type: integer + prBaseBranch: + description: PRBaseBranch is the upstream branch targeted when + CreatePR is true. + maxLength: 255 + type: string + publicationCredentialRef: + description: |- + PublicationCredentialRef references the target-repository write credential + used only for the exact CAS push. It is never used to clone the source. properties: - name: - default: "" + key: + default: token description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + Key is the Secret data key containing one bearer token or one complete + Authorization header. It defaults to "token" when omitted. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z0-9._-]+$ + type: string + name: + description: Name is the name of the Secret in the Task namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string + required: + - name type: object - x-kubernetes-map-type: atomic - prBaseBranch: - description: PRBaseBranch is the upstream branch to target for - pull requests + publicationGitRepo: + description: |- + PublicationGitRepo is the repository URL whose branch receives an exact CAS publication. + Credentials must not be embedded in the URL. + maxLength: 2048 type: string + publicationReadCredentialRef: + description: |- + PublicationReadCredentialRef references the target-repository read + credential used only for preflight and independent post-push verification. + properties: + key: + default: token + description: |- + Key is the Secret data key containing one bearer token or one complete + Authorization header. It defaults to "token" when omitted. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z0-9._-]+$ + type: string + name: + description: Name is the name of the Secret in the Task namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object + publicationRepository: + description: |- + PublicationRepository is the optional URL-derived identity for + PublicationGitRepo. When set, it must match the normalized credential-free + URL; for GitHub, use provider "github" and ID "github.com/owner/repo". + properties: + id: + description: |- + ID is the canonical credential-free URL identity and must match the + corresponding repository URL after normalization. For GitHub, use + "github.com/owner/repo"; GitHub GraphQL node IDs are not accepted. + maxLength: 512 + minLength: 1 + type: string + provider: + description: Provider identifies the source-control provider + or forge. + maxLength: 63 + minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ + type: string + required: + - id + - provider + type: object pushBranch: description: |- - PushBranch is the remote branch name to push changes to after the agent completes. - When set, FinalizeResult will commit and push changes to this branch. + PushBranch is the publication branch. For write Tasks the controller derives + a full-entropy Task- or Session-owned branch when this is omitted. + maxLength: 255 type: string + readCredentialRef: + description: |- + ReadCredentialRef references the one-operation clone/read credential Secret. + The Secret is resolved only by the clean-room workspace boundary. + properties: + key: + default: token + description: |- + Key is the Secret data key containing one bearer token or one complete + Authorization header. It defaults to "token" when omitted. + maxLength: 253 + minLength: 1 + pattern: ^[A-Za-z0-9._-]+$ + type: string + name: + description: Name is the name of the Secret in the Task namespace. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - name + type: object ref: - description: Ref is a specific git ref (commit SHA, tag) to checkout + description: Ref is a specific source git ref, commit SHA, or + tag to check out. + maxLength: 512 type: string + rejectBinaryFiles: + description: |- + RejectBinaryFiles rejects changed file content that is not valid text. It is + not supported for container Tasks. + type: boolean + rejectSecretLikeContent: + description: |- + RejectSecretLikeContent applies Orka's generic secret detector to changed + paths and file contents before publication. It is not supported for container Tasks. + type: boolean + sourceRepository: + description: |- + SourceRepository is the optional URL-derived identity for GitRepo. When set, + it must match the normalized credential-free URL; for GitHub, use provider + "github" and ID "github.com/owner/repo". + properties: + id: + description: |- + ID is the canonical credential-free URL identity and must match the + corresponding repository URL after normalization. For GitHub, use + "github.com/owner/repo"; GitHub GraphQL node IDs are not accepted. + maxLength: 512 + minLength: 1 + type: string + provider: + description: Provider identifies the source-control provider + or forge. + maxLength: 63 + minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ + type: string + required: + - id + - provider + type: object subPath: - description: SubPath is a subdirectory within the repo to use - as workspace root + description: SubPath is a subdirectory within the source repository + used as workspace root. + maxLength: 1024 type: string type: object + x-kubernetes-validations: + - message: createPR requires write workspace intent + rule: '!self.createPR || self.intent == ''write''' + - message: gitRepo must not contain embedded credentials, query parameters, + or fragments + rule: '!has(self.gitRepo) || (!self.gitRepo.matches(''(?i)^[A-Za-z][A-Za-z0-9+.-]*://[^/]*@'') + && !self.gitRepo.contains(''?'') && !self.gitRepo.contains(''#''))' + - message: publicationGitRepo must not contain embedded credentials, + query parameters, or fragments + rule: '!has(self.publicationGitRepo) || (!self.publicationGitRepo.matches(''(?i)^[A-Za-z][A-Za-z0-9+.-]*://[^/]*@'') + && !self.publicationGitRepo.contains(''?'') && !self.publicationGitRepo.contains(''#''))' required: - type type: object @@ -1858,12 +2076,219 @@ spec: - message: transaction is immutable rule: has(self.transaction) == has(oldSelf.transaction) && (!has(self.transaction) || self.transaction == oldSelf.transaction) + - message: type is immutable + rule: self.type == oldSelf.type + - message: effective workspace intent is immutable + rule: '(has(self.workspace) && has(self.workspace.intent) ? self.workspace.intent + : (self.type == ''agent'' ? ''read'' : self.type)) == (has(oldSelf.workspace) + && has(oldSelf.workspace.intent) ? oldSelf.workspace.intent : (oldSelf.type + == ''agent'' ? ''read'' : oldSelf.type))' + - message: agent prompt is immutable + rule: self.type != 'agent' || (has(self.prompt) == has(oldSelf.prompt) + && (!has(self.prompt) || self.prompt == oldSelf.prompt)) + - message: agentRef is immutable for agent Tasks + rule: self.type != 'agent' || (has(self.agentRef) == has(oldSelf.agentRef) + && (!has(self.agentRef) || self.agentRef == oldSelf.agentRef)) + - message: agentRuntime is immutable for agent Tasks + rule: self.type != 'agent' || (has(self.agentRuntime) == has(oldSelf.agentRuntime) + && (!has(self.agentRuntime) || self.agentRuntime == oldSelf.agentRuntime)) + - message: sessionRef is immutable for agent Tasks + rule: self.type != 'agent' || (has(self.sessionRef) == has(oldSelf.sessionRef) + && (!has(self.sessionRef) || self.sessionRef == oldSelf.sessionRef)) + - message: workspace is immutable for agent Tasks + rule: self.type != 'agent' || (has(self.workspace) == has(oldSelf.workspace) + && (!has(self.workspace) || self.workspace == oldSelf.workspace)) + - message: timeout is immutable for agent Tasks + rule: self.type != 'agent' || (has(self.timeout) == has(oldSelf.timeout) + && (!has(self.timeout) || self.timeout == oldSelf.timeout)) - message: session workspace reuse requires spec.sessionRef rule: '!has(self.execution) || !has(self.execution.workspace) || self.execution.workspace.reusePolicy != ''session'' || has(self.sessionRef)' + - message: container Tasks do not support workspace.expectedRemoteSHA + rule: self.type != 'container' || !has(self.workspace) || !has(self.workspace.expectedRemoteSHA) + - message: container Tasks do not support workspace.createPR + rule: self.type != 'container' || !has(self.workspace) || (!has(self.workspace.createPR) + || !self.workspace.createPR) + - message: container Tasks do not support clean-room workspace publication + policies + rule: self.type != 'container' || !has(self.workspace) || (!has(self.workspace.maxChangedFiles) + && (!has(self.workspace.allowedPaths) || self.workspace.allowedPaths.size() + == 0) && (!has(self.workspace.denyRepositoryControlPaths) || !self.workspace.denyRepositoryControlPaths) + && (!has(self.workspace.rejectBinaryFiles) || !self.workspace.rejectBinaryFiles) + && (!has(self.workspace.rejectSecretLikeContent) || !self.workspace.rejectSecretLikeContent)) + - message: custom-image container Tasks do not support workspace.pushBranch + publication + rule: self.type != 'container' || !has(self.workspace) || !has(self.workspace.pushBranch) + || self.workspace.pushBranch.size() == 0 || !has(self.image) || self.image.size() + == 0 status: description: TaskStatus defines the observed state of Task properties: + agentExecutionBinding: + description: |- + AgentExecutionBinding is the authoritative, write-once, immutable + execution route for this agent Task. + properties: + agent: + description: AgentExecutionAgentRef pins the exact Agent identity + resolved at binding. + properties: + generation: + format: int64 + minimum: 1 + type: integer + name: + minLength: 1 + type: string + namespace: + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - generation + - name + - namespace + - uid + type: object + backend: + description: AgentExecutionBackend identifies the isolated execution + dispatcher backend. + enum: + - harness-wrapper + - runtime-pool + - external-endpoint + type: string + bindingDigest: + description: |- + BindingDigest is the canonical digest of this binding; every durable + demand, attempt, turn, Session lease, publication, and cleanup record + copies it. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + boundAt: + format: date-time + type: string + contractVersion: + description: ContractVersion is the frozen execution protocol + for the Task lifetime. + enum: + - orka.harness.v1 + - orka.harness.v2 + type: string + runtimeProfileDigest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + runtimeProfileDigestSchemaVersion: + enum: + - 1 + format: int32 + type: integer + runtimeRef: + description: AgentExecutionRuntimeRef pins a referenced AgentRuntime + identity. + properties: + generation: + format: int64 + minimum: 1 + type: integer + name: + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - generation + - name + - uid + type: object + runtimeType: + description: RuntimeType is the built-in runtime type, empty for + runtimeRef bindings. + enum: + - claude + - codex + - copilot + - opencode + type: string + schemaVersion: + enum: + - 1 + format: int32 + type: integer + snapshot: + description: AgentExecutionSnapshotRef links the immutable non-secret + execution snapshot. + properties: + digest: + pattern: ^sha256:[a-f0-9]{64}$ + type: string + id: + description: ID is the snapshot identity in the form /sha256:. + maxLength: 512 + minLength: 1 + type: string + schemaVersion: + enum: + - 1 + format: int32 + type: integer + required: + - digest + - id + - schemaVersion + type: object + task: + description: AgentExecutionBindingTaskRef pins the bound Task + identity. + properties: + boundSpecGeneration: + description: BoundSpecGeneration is the Task spec generation + frozen into the snapshot. + format: int64 + minimum: 1 + type: integer + namespaceUID: + description: |- + NamespaceUID is the UID of the Task namespace, preventing same-name + namespace recreation from satisfying old identities. + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + type: string + required: + - boundSpecGeneration + - namespaceUID + - uid + type: object + required: + - backend + - bindingDigest + - boundAt + - contractVersion + - schemaVersion + - snapshot + - task + type: object + x-kubernetes-validations: + - message: the harness-wrapper backend requires an orka.harness.v1 + binding + rule: self.backend != 'harness-wrapper' || self.contractVersion + == 'orka.harness.v1' + - message: the runtime-pool backend requires an orka.harness.v2 binding + rule: self.backend != 'runtime-pool' || self.contractVersion == + 'orka.harness.v2' attempts: description: Attempts is the number of attempts made format: int32 @@ -1965,10 +2390,383 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + delivery: + description: Delivery reports trusted workspace validation and publication + reconciliation. + properties: + artifactDigest: + description: ArtifactDigest is the durable content-addressed workspace + delta digest. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + branch: + description: Branch is the publication branch without a refs/heads/ + prefix. + maxLength: 255 + type: string + expectedCommitSHA: + description: ExpectedCommitSHA is the exact Orka-owned commit + prepared for publication. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + lastTransitionTime: + description: LastTransitionTime is the last durable delivery-state + transition time. + format: date-time + type: string + message: + description: Message contains bounded, sanitized delivery context. + maxLength: 1024 + type: string + outcome: + description: Outcome is set only after delivery reaches a terminal + classification. + enum: + - NotRequested + - VerifiedExact + - DeliveredSuperseded + - ReadValidated + - NoChange + - CancelledBeforePublish + - ReadOnlyWorkspaceModified + - DeliveryConflict + - CredentialBlocked + - PublicationOutcomeUnknown + type: string + prReceipt: + description: PRReceipt is present only when createPR was explicitly + requested and reconciled. + properties: + baseBranch: + description: BaseBranch is the reconciled pull request base + branch. + maxLength: 255 + type: string + headBranch: + description: HeadBranch is the reconciled pull request head + branch. + maxLength: 255 + type: string + headSHA: + description: HeadSHA is the exact observed pull request head + commit. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + id: + description: ID is the provider's durable pull request identifier. + maxLength: 512 + minLength: 1 + type: string + number: + description: Number is the provider's numeric pull request + number when available. + format: int64 + minimum: 1 + type: integer + state: + description: State is the provider-observed pull request state. + maxLength: 64 + type: string + url: + description: URL is the canonical user-facing pull request + URL. It must not contain credentials. + maxLength: 2048 + type: string + required: + - id + type: object + publicationID: + description: PublicationID is the durable identity reused for + reconciliation of the same artifact. + maxLength: 253 + pattern: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,252}$ + type: string + publicationRepository: + description: PublicationRepository is the canonical repository + whose branch was reconciled. + properties: + id: + description: |- + ID is the canonical credential-free URL identity and must match the + corresponding repository URL after normalization. For GitHub, use + "github.com/owner/repo"; GitHub GraphQL node IDs are not accepted. + maxLength: 512 + minLength: 1 + type: string + provider: + description: Provider identifies the source-control provider + or forge. + maxLength: 63 + minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ + type: string + required: + - id + - provider + type: object + reason: + description: Reason is a stable machine-readable explanation for + State or Outcome. + maxLength: 128 + pattern: ^[A-Za-z][A-Za-z0-9._-]{0,127}$ + type: string + remoteBeforeSHA: + description: |- + RemoteBeforeSHA is the exact publication ref observed before the CAS push. + Nil means not yet observed; a pointer to the empty string records explicit + absence; a non-empty value records the observed object ID. + pattern: ^(|[a-f0-9]{40}|[a-f0-9]{64})$ + type: string + sourceRepository: + description: SourceRepository is the canonical repository from + which the workspace baseline was created. + properties: + id: + description: |- + ID is the canonical credential-free URL identity and must match the + corresponding repository URL after normalization. For GitHub, use + "github.com/owner/repo"; GitHub GraphQL node IDs are not accepted. + maxLength: 512 + minLength: 1 + type: string + provider: + description: Provider identifies the source-control provider + or forge. + maxLength: 63 + minLength: 1 + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$ + type: string + required: + - id + - provider + type: object + startingSHA: + description: StartingSHA is the verified source baseline before + prompt execution. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + state: + description: State is the current durable delivery state. + enum: + - NotRequested + - Validating + - Preparing + - Prepared + - Publishing + - Verifying + - VerifiedExact + - DeliveredSuperseded + - ReadValidated + - NoChange + - CancelledBeforePublish + - ReadOnlyWorkspaceModified + - DeliveryConflict + - CredentialBlocked + - PublicationOutcomeUnknown + type: string + supersedingRemoteSHA: + description: SupersedingRemoteSHA is the verified descendant that + superseded ExpectedCommitSHA. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + treeSHA: + description: TreeSHA is the deterministic clean-room tree written + by the publisher. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + verifiedRemoteSHA: + description: VerifiedRemoteSHA is the independently observed remote + branch head. + pattern: ^([a-f0-9]{40}|[a-f0-9]{64})$ + type: string + type: object + x-kubernetes-validations: + - message: delivery outcome requires the matching terminal state + rule: '!has(self.outcome) || (has(self.state) && self.state == self.outcome)' + - message: terminal delivery state requires an outcome + rule: '!has(self.state) || !(self.state in [''NotRequested'', ''VerifiedExact'', + ''DeliveredSuperseded'', ''ReadValidated'', ''NoChange'', ''CancelledBeforePublish'', + ''ReadOnlyWorkspaceModified'', ''DeliveryConflict'', ''CredentialBlocked'', + ''PublicationOutcomeUnknown'']) || has(self.outcome)' + execution: + description: |- + Execution reports the durable execution state and terminal outcome for the + current attempt. Phase remains the compatibility projection. + properties: + agentRuntimeName: + description: |- + AgentRuntimeName is the namespaced external orka.harness.v2 registration + selected for this attempt. It is mutually exclusive with RuntimePoolName. + maxLength: 253 + type: string + agentRuntimeUID: + description: |- + AgentRuntimeUID is the immutable external AgentRuntime UID fenced into the + attempt selection. + maxLength: 253 + type: string + attempt: + description: Attempt is the one-based Task execution attempt represented + by this status. + format: int32 + minimum: 1 + type: integer + controllerEpoch: + description: ControllerEpoch is the durable controller epoch fencing + this attempt. + format: int64 + minimum: 0 + type: integer + forgeCredentialResourceVersion: + description: |- + ForgeCredentialResourceVersion freezes the forge-only Secret version used + for pull request reconciliation. + maxLength: 253 + type: string + lastTransitionTime: + description: LastTransitionTime is the last durable execution-state + transition time. + format: date-time + type: string + message: + description: Message contains bounded, sanitized execution context. + maxLength: 1024 + type: string + outcome: + description: Outcome is set only after execution reaches a terminal + classification. + enum: + - Succeeded + - Failed + - Cancelled + - OutcomeUnknown + type: string + promptID: + description: PromptID is the durable prompt identity used for + submission and settlement. + maxLength: 253 + type: string + publicationCredentialResourceVersion: + description: |- + PublicationCredentialResourceVersion freezes the target-write Secret + version selected at reservation without exposing credential material. + maxLength: 253 + type: string + publicationReadCredentialResourceVersion: + description: |- + PublicationReadCredentialResourceVersion freezes the target-read Secret + version used for preflight and independent verification. + maxLength: 253 + type: string + readCredentialResourceVersion: + description: |- + ReadCredentialResourceVersion freezes the read credential Secret version + selected at reservation without exposing credential material. + maxLength: 253 + type: string + reason: + description: Reason is a stable machine-readable explanation for + State or Outcome. + maxLength: 128 + pattern: ^[A-Za-z][A-Za-z0-9._-]{0,127}$ + type: string + requestDigest: + description: RequestDigest is the canonical immutable prompt request + digest. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + runtimeInstanceID: + description: RuntimeInstanceID is the exact selected supervisor + Pod UID plus boot identity. + maxLength: 512 + type: string + runtimePoolName: + description: RuntimePoolName is the namespaced logical pool selected + for this attempt. + maxLength: 253 + type: string + runtimePoolUID: + description: RuntimePoolUID is the immutable pool UID fenced into + runtime requests. + maxLength: 253 + type: string + runtimeSessionCleanupDigest: + description: |- + RuntimeSessionCleanupDigest is the controller-owned proof that the exact + RuntimeSession requiring retirement was deleted or its immutable runtime + instance was replaced. Users may read but cannot mutate the Task status subresource. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + runtimeSessionGeneration: + description: RuntimeSessionGeneration is the monotonic profile/session + generation. + format: int64 + minimum: 0 + type: integer + runtimeSessionMCPDigest: + description: |- + RuntimeSessionMCPDigest binds the complete non-secret effective MCP policy + and descriptor configuration to a pending or reusable Session generation. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + runtimeSessionProfileDigest: + description: |- + RuntimeSessionProfileDigest freezes the immutable runtime behavior bound to + a pending or reusable Session generation. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + runtimeSessionRecreationPending: + description: |- + RuntimeSessionRecreationPending records that the exact generation is being + created or replaced and must be reconciled before a different request may + reuse that identity. + type: boolean + runtimeSessionSupervisorBootID: + description: |- + RuntimeSessionSupervisorBootID freezes the supervisor boot that owns a + pending or reusable Session generation. + maxLength: 512 + type: string + runtimeSessionUID: + description: RuntimeSessionUID is the stable controller-owned + Session execution identity. + maxLength: 253 + type: string + runtimeSessionWorkspaceDigest: + description: |- + RuntimeSessionWorkspaceDigest binds a reusable Session generation to the + exact repository, source ref, verified baseline, intent, and relative root. + It contains no credential material. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + state: + description: State is the current durable execution state. + enum: + - Queued + - Reserved + - SessionStarting + - Planned + - Submitting + - SubmittedUnknown + - Accepted + - Running + - Settling + - Succeeded + - Failed + - Cancelled + - OutcomeUnknown + type: string + type: object + x-kubernetes-validations: + - message: execution outcome requires the matching terminal state + rule: '!has(self.outcome) || (has(self.state) && self.state == self.outcome)' + - message: terminal execution state requires an outcome + rule: '!has(self.state) || !(self.state in [''Succeeded'', ''Failed'', + ''Cancelled'', ''OutcomeUnknown'']) || has(self.outcome)' executionOutcome: description: |- - ExecutionOutcome is the immutable outcome recorded when workload execution ends. Workspace - attachment revocation and cleanup continue independently while the Task is Finalizing. + ExecutionOutcome records the immutable outcome of a non-ACP workload before + provider-neutral execution-workspace finalization completes. properties: attempt: description: Attempt is the Task attempt that produced this outcome. @@ -2240,10 +3038,16 @@ spec: type: object harnessRuntime: description: |- - HarnessRuntime records the controller-resolved harness runtime target for an - in-flight agent turn. It intentionally stores only non-secret routing metadata - and Secret references, never bearer values. + HarnessRuntime records the controller-resolved harness v1 runtime target + for an in-flight agent turn. It intentionally stores only non-secret + routing metadata and Secret references, never bearer values. Compatibility + surface for harness v1 bindings. properties: + attempt: + description: Attempt is the durable harness v1 attempt number. + format: int32 + minimum: 1 + type: integer authRefField: description: AuthRefField is the Secret data field selected when the turn started. @@ -2253,31 +3057,122 @@ spec: turn started. type: string authRefResourceVersion: - description: AuthRefResourceVersion is the auth Secret resourceVersion - validated before starting the turn. + description: |- + AuthRefResourceVersion is the auth Secret resourceVersion validated + before starting the turn. + type: string + cancelRequestedAt: + description: |- + CancelRequestedAt records a durable cancellation request. Cancellation + remains nonterminal until a terminal frame or ledger receipt is observed. + format: date-time type: string contractVersion: description: ContractVersion is the Orka harness contract version used for the turn. type: string + controllerEpoch: + description: ControllerEpoch records the fenced controller epoch + driving the attempt. + format: int64 + minimum: 0 + type: integer endpoint: description: Endpoint is the non-secret harness base URL selected when the turn started. type: string + lastEventSeq: + description: LastEventSeq is the highest durably mapped harness + frame sequence. + format: int64 + minimum: 0 + type: integer + lastTransitionTime: + description: |- + LastTransitionTime is the last durable v1 attempt transition projected to + the Task. + format: date-time + type: string + message: + description: Message is bounded, sanitized execution context. + maxLength: 1024 + type: string + outcome: + description: Outcome is set only for a terminal harness v1 attempt. + enum: + - Succeeded + - Failed + - Cancelled + - OutcomeUnknown + type: string + reason: + description: Reason is a bounded machine-readable terminal reason + code. + maxLength: 256 + type: string + requestDigest: + description: |- + RequestDigest binds the canonical StartTurn request admitted by the + durable wrapper ledger. + pattern: ^sha256:[a-f0-9]{64}$ + type: string runtimeGeneration: description: RuntimeGeneration is the AgentRuntime generation selected when the turn started. format: int64 type: integer runtimeName: - description: RuntimeName is the runtime name advertised by the - harness capabilities and sent in turn metadata. + description: |- + RuntimeName is the runtime name advertised by the harness capabilities + and sent in turn metadata. type: string runtimeRefName: - description: RuntimeRefName is the AgentRuntime name for custom - runtimeRef turns. Empty means built-in CLI wrapper. + description: |- + RuntimeRefName is the AgentRuntime name for custom runtimeRef turns. + Empty means built-in CLI wrapper. + type: string + runtimeSessionID: + description: RuntimeSessionID is the deterministic, non-secret + v1 runtime-session identity. + type: string + state: + description: State is the durable harness v1 attempt state projected + for operators. + enum: + - Queued + - Reserved + - SessionStarting + - Planned + - Submitting + - SubmittedUnknown + - Accepted + - Running + - Settling + - Succeeded + - Failed + - Cancelled + - OutcomeUnknown + type: string + terminalReceiptDigest: + description: TerminalReceiptDigest identifies the authoritative + terminal or unknown receipt. + pattern: ^sha256:[a-f0-9]{64}$ + type: string + turnID: + description: TurnID is the deterministic, non-secret harness turn + identity. type: string type: object + x-kubernetes-validations: + - message: terminal harness state requires an outcome + rule: '!has(self.state) || !(self.state in [''Succeeded'', ''Failed'', + ''Cancelled'', ''OutcomeUnknown'']) || has(self.outcome)' + - message: harness outcome requires a terminal state + rule: '!has(self.outcome) || (has(self.state) && self.state in [''Succeeded'', + ''Failed'', ''Cancelled'', ''OutcomeUnknown''])' + - message: OutcomeUnknown harness state requires OutcomeUnknown outcome + rule: '!has(self.state) || self.state != ''OutcomeUnknown'' || (has(self.outcome) + && self.outcome == ''OutcomeUnknown'')' iteration: description: |- Iteration is the current autonomous loop iteration (0-based). @@ -2334,7 +3229,24 @@ spec: x-kubernetes-validations: - message: executionOutcome is immutable once recorded rule: '!has(oldSelf.executionOutcome) || self.executionOutcome == oldSelf.executionOutcome' + - message: agentExecutionBinding is write-once and immutable + rule: '!has(oldSelf.agentExecutionBinding) || (has(self.agentExecutionBinding) + && self.agentExecutionBinding == oldSelf.agentExecutionBinding)' + - message: a v1-bound Task cannot acquire new v2 execution or delivery + state + rule: '!has(self.agentExecutionBinding) || self.agentExecutionBinding.contractVersion + != ''orka.harness.v1'' || ((!has(self.execution) || (has(oldSelf.execution) + && self.execution == oldSelf.execution)) && (!has(self.delivery) || + (has(oldSelf.delivery) && self.delivery == oldSelf.delivery)))' + - message: a v2-bound Task cannot acquire new v1 harness state + rule: '!has(self.agentExecutionBinding) || self.agentExecutionBinding.contractVersion + != ''orka.harness.v2'' || !has(self.harnessRuntime) || (has(oldSelf.harnessRuntime) + && self.harnessRuntime == oldSelf.harnessRuntime)' type: object + x-kubernetes-validations: + - message: Task spec is immutable after execution authority is recorded + rule: '!has(oldSelf.status) || (!has(oldSelf.status.agentExecutionBinding) + || self.spec == oldSelf.spec)' served: true storage: true subresources: diff --git a/config/crd/bases/core.orka.ai_tools.yaml b/config/crd/bases/core.orka.ai_tools.yaml index 76a2338a2..32981b5cb 100644 --- a/config/crd/bases/core.orka.ai_tools.yaml +++ b/config/crd/bases/core.orka.ai_tools.yaml @@ -125,7 +125,13 @@ spec: - name type: object timeout: - description: 'Timeout is the request timeout (default: 30s)' + description: |- + Timeout is the request timeout (default: 30s) + Consequential (non-read) tools brokered to ACP runtimes execute under a + fixed external-effect ledger lease; their timeout must stay at or below + the controller's brokered call bound (currently four minutes). Longer + timeouts are rejected when the tool is exposed to an ACP prompt, and + brokered calls are always clamped to that bound at execution time. type: string url: description: |- diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 51452729c..aba9d7ef4 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -6,6 +6,13 @@ resources: - bases/core.orka.ai_tools.yaml - bases/core.orka.ai_agents.yaml - bases/core.orka.ai_agentruntimes.yaml +- bases/core.orka.ai_runtimepools.yaml +- bases/core.orka.ai_promptattempts.yaml +- bases/core.orka.ai_runtimesessioncontrols.yaml +- bases/core.orka.ai_branchclaims.yaml +- bases/core.orka.ai_publications.yaml +- bases/core.orka.ai_controllerepochs.yaml +- bases/core.orka.ai_externaleffects.yaml - bases/core.orka.ai_providers.yaml - bases/core.orka.ai_skills.yaml - bases/core.orka.ai_repositoryscans.yaml diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index a989c4553..7aab6e57f 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -1,144 +1,14 @@ -# Adds namespace to all resources. -namespace: orka-system - -# Value of this field is prepended to the -# names of all resources, e.g. a deployment named -# "wordpress" becomes "alices-wordpress". -# Note that it should also match with the prefix (text before '-') of the namespace -# field above. -namePrefix: orka- - -# Labels to add to all resources and selectors. -#labels: -#- includeSelectors: true -# pairs: -# someName: someValue - -configurations: -- kustomizeconfig.yaml - +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization resources: - ../crd -- ../rbac -- ../manager -- ../harness-wrapper -- ../admission -- ../policy -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -#- ../webhook -# [METRICS] Expose the controller manager metrics service. -- metrics_service.yaml -# [API] Expose the controller manager API service for worker result submission. -- api_service.yaml -# Uncomment the patches line if you enable Metrics -patches: -# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. -# More info: https://book.kubebuilder.io/reference/metrics -- path: manager_metrics_patch.yaml - target: - kind: Deployment - name: controller-manager +- ../acp-workload # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- path: manager_webhook_patch.yaml -# target: -# kind: Deployment -# name: controller-manager +#- ../webhook # Webhook CA injection replacements are intentionally not configured here. # Future webhook CA bundle injection should be wired into the manager runtime. # +kubebuilder:scaffold:crdkustomizecainjectionns # +kubebuilder:scaffold:crdkustomizecainjectionname - -replacements: -- source: - group: "" - version: v1 - kind: Namespace - name: system - fieldPath: metadata.name - targets: - - select: - group: admissionregistration.k8s.io - version: v1 - kind: ValidatingAdmissionPolicy - name: gateway-task-protection - fieldPaths: - - spec.variables.[name=controllerNamespace].expression - options: - delimiter: "'" - index: 1 -- source: - group: "" - version: v1 - kind: ServiceAccount - name: controller-manager - fieldPath: metadata.name - targets: - - select: - group: admissionregistration.k8s.io - version: v1 - kind: ValidatingAdmissionPolicy - name: gateway-task-protection - fieldPaths: - - spec.variables.[name=controllerServiceAccount].expression - options: - delimiter: "'" - index: 1 -- source: - group: "" - version: v1 - kind: ServiceAccount - name: ai-worker - fieldPath: metadata.name - targets: - - select: - group: admissionregistration.k8s.io - version: v1 - kind: ValidatingAdmissionPolicy - name: gateway-task-protection - fieldPaths: - - spec.variables.[name=aiWorkerServiceAccount].expression - options: - delimiter: "'" - index: 1 -- source: - group: "" - version: v1 - kind: ServiceAccount - name: vendor-worker - fieldPath: metadata.name - targets: - - select: - group: admissionregistration.k8s.io - version: v1 - kind: ValidatingAdmissionPolicy - name: gateway-task-protection - fieldPaths: - - spec.variables.[name=vendorWorkerServiceAccount].expression - options: - delimiter: "'" - index: 1 -- source: - group: "" - version: v1 - kind: ServiceAccount - name: container-worker - fieldPath: metadata.name - targets: - - select: - group: admissionregistration.k8s.io - version: v1 - kind: ValidatingAdmissionPolicy - name: gateway-task-protection - fieldPaths: - - spec.variables.[name=containerWorkerServiceAccount].expression - options: - delimiter: "'" - index: 1 - -transformers: -- remove_admission_policy_namespace.yaml -- remove_admission_policy_binding_namespace.yaml diff --git a/config/harness-wrapper/README.md b/config/harness-wrapper/README.md index 1bc05c13f..5442268f9 100644 --- a/config/harness-wrapper/README.md +++ b/config/harness-wrapper/README.md @@ -1,22 +1,90 @@ -# Harness wrapper authentication Secret +# Harness v1 wrapper -The canonical Kustomize installer intentionally does **not** commit or generate -the shared bearer token. Before applying `deploy/orka.yaml` directly, create the -required Secret in `orka-system` without printing the token: +This Kustomize base is the data plane for a static `harness-v1` installation. +It is intentionally absent from `config/default` and +`config/acp-production`. Deploy it only with a v1 controller whose non-empty +watch namespace is labeled `orka.ai/controller-mode: harness-v1` and whose +startup mode is `--controller-mode=harness-v1`. -```bash -set -euo pipefail +The v1 installation has its own controller namespace, watched namespace, +ServiceAccount, Lease, API endpoint, SQLite store, Secrets, and wrapper ledger. +It must not share those resources with a `harness-v2` installation. The wrapper +is never a fallback for failed ACP work, and its Tasks and Sessions cannot be +continued by v2. + +Before applying the base: + +1. Replace the all-zero wrapper image digest through an operator-controlled + overlay with the exact reviewed `repository@sha256:` reference. +2. Create distinct bearer and TLS Secrets in `orka-system` without printing + the bearer value. The serving certificate must authenticate + `agent-harness-wrapper.orka-system.svc`: + + ```bash + kubectl create namespace orka-system --dry-run=client -o yaml | kubectl apply -f - + if ! kubectl -n orka-system get secret harness-wrapper-auth >/dev/null 2>&1; then + openssl rand -hex 32 | \ + kubectl -n orka-system create secret generic harness-wrapper-auth \ + --from-file=token=/dev/stdin + fi + if ! kubectl -n orka-system get secret harness-wrapper-tls >/dev/null 2>&1; then + kubectl -n orka-system create secret generic harness-wrapper-tls \ + --from-file=tls.crt=/path/to/tls.crt \ + --from-file=tls.key=/path/to/tls.key \ + --from-file=ca.crt=/path/to/ca.crt + fi + ``` + +The wrapper has a dedicated ServiceAccount with broad token automount disabled, +a short-lived projected token used only to authenticate artifact uploads to its +controller, a PVC-backed admission ledger, controller-only ingress, and egress +limited to that controller, DNS, and public HTTPS provider/read-only SCM +endpoints. Do not mount Git, forge, publisher, provider-proxy, or other +publication credentials into this Pod. +Terminal and rejected ledger rows become reclaimable only after the controller +acknowledges their exact durable settlement. The shipped 720-hour retention +window preserves duplicate-suppression and audit/backup evidence before bounded +garbage collection; configure a longer window when policy requires it. -kubectl create namespace orka-system --dry-run=client -o yaml | kubectl apply -f - -if ! kubectl -n orka-system get secret harness-wrapper-auth >/dev/null 2>&1; then - openssl rand -hex 32 | \ - kubectl -n orka-system create secret generic harness-wrapper-auth \ - --from-file=token=/dev/stdin -fi +Before changing any wrapper Pod-template field, run the authenticated wrapper +drain client from the currently deployed wrapper image and wait for it to +succeed: -kubectl apply -f deploy/orka.yaml +```bash +/orka-agent-harness-wrapper drain \ + --endpoint=https://agent-harness-wrapper.orka-system.svc:8080 \ + --bearer-token-file=/var/run/orka/harness-wrapper-auth/token \ + --ca-file=/var/run/orka/harness-wrapper-tls/ca.crt \ + --timeout=15m \ + --poll-interval=2s \ + --next-generation=2 ``` -`make deploy` performs the same preflight and creates the Secret only when it is -absent. Helm installs use the chart-managed Secret or -`workers.harnessWrapper.auth.existingSecret` instead. +Run the command from a narrowly isolated Pod labeled +`app.kubernetes.io/name=orka,app.kubernetes.io/component=agent-harness-wrapper-drain` +that can read only the dedicated wrapper auth Secret and the current wrapper +TLS Secret, and reach only the wrapper Service. Never place the bearer token on +the command line or in logs. A timeout aborts the rollout: do not mutate the +Deployment. After a successful drain, update +`ORKA_HARNESS_WRAPPER_LEDGER_GENERATION` in the overlay to the same +`--next-generation` value and apply the Deployment change. Increment the value +exactly once for every later wrapper Pod-template replacement; ordinary Pod +restart with an unchanged template keeps the current generation. + +Certificate renewal changes only `harness-wrapper-tls`; never add TLS material +to or update `harness-wrapper-auth`, because accepted attempts pin that Secret's +UID and resourceVersion as execution authority. Treat renewal as a drained Pod +template replacement: preferably create a versioned TLS Secret, drain with the +old Secret's CA, then patch the Deployment and controller CA mount to the new +Secret while advancing the ledger generation. For same-name renewal, retain a +CA bundle that trusts the currently served certificate until the drained +replacement is running. + +This wrapper drain protects v1 turn state during a Pod-template replacement. It +is not a controller mode and does not transfer work to v2. + +For permanent shutdown or uninstall, first stop every v1 producer and revoke +new Task creation. Then omit `--next-generation`; this leaves wrapper admission +closed and never authorizes a replacement wrapper to reopen it. Retain the +ledger PVC until all v1 execution and cleanup work is settled and the reviewed +backup/retention procedure has completed. diff --git a/config/harness-wrapper/deployment.yaml b/config/harness-wrapper/deployment.yaml index 0e29a2250..a1a274ea3 100644 --- a/config/harness-wrapper/deployment.yaml +++ b/config/harness-wrapper/deployment.yaml @@ -19,92 +19,133 @@ spec: labels: app.kubernetes.io/name: orka app.kubernetes.io/component: agent-harness-wrapper + orka.ai/network-role: harness-v1 spec: serviceAccountName: agent-harness-wrapper automountServiceAccountToken: false securityContext: - # The wrapper process runs as root only so it can read 0400 controller - # token volumes and set child credentials. Runtime commands are launched - # through commandSysProcAttr with ORKA_HARNESS_WRAPPER_CHILD_UID/GID. + # The wrapper starts as root only to isolate each runtime subprocess + # under the dedicated unprivileged child UID/GID. runAsUser: 0 runAsGroup: 0 seccompProfile: type: RuntimeDefault containers: - - name: wrapper - image: ghcr.io/orka-agents/orka/agent-harness-wrapper:0.1.1 - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 8080 - env: - - name: ORKA_HARNESS_WRAPPER_RUNTIME - value: multi - - name: ORKA_HARNESS_WRAPPER_LISTEN_ADDR - value: :8080 - - name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE - value: /var/run/orka/harness-wrapper/token - - name: ORKA_ALLOW_BASH - value: "true" - # All turn subprocesses run under this unprivileged identity. - - name: ORKA_HARNESS_WRAPPER_CHILD_UID - value: "1000" - - name: ORKA_HARNESS_WRAPPER_CHILD_GID - value: "1000" - - name: ORKA_CODEX_SANDBOX_MODE - value: danger-full-access - - name: ORKA_SA_TOKEN_PATH - value: /var/run/orka/upload-token/token - volumeMounts: + - name: wrapper + # Fail-closed placeholder. An operator overlay must replace this with + # the reviewed wrapper image digest before enabling harness v1. + image: ghcr.io/orka-agents/orka/agent-harness-wrapper@sha256:0000000000000000000000000000000000000000000000000000000000000000 + imagePullPolicy: IfNotPresent + ports: + - name: https + containerPort: 8080 + protocol: TCP + env: + - name: ORKA_HARNESS_WRAPPER_RUNTIME + value: multi + - name: ORKA_HARNESS_WRAPPER_LISTEN_ADDR + value: :8080 + - name: ORKA_CONTROLLER_URL + value: http://orka-api.orka-system.svc:8080 + - name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE + value: /var/run/orka/harness-wrapper-auth/token + - name: ORKA_HARNESS_WRAPPER_TLS_CERT_FILE + value: /var/run/orka/harness-wrapper-tls/tls.crt + - name: ORKA_HARNESS_WRAPPER_TLS_KEY_FILE + value: /var/run/orka/harness-wrapper-tls/tls.key + - name: ORKA_HARNESS_WRAPPER_ADMISSION_LEDGER_PATH + value: /var/lib/orka/harness-v1/admission-ledger.db + - name: ORKA_HARNESS_WRAPPER_LEDGER_GENERATION + value: "1" + - name: ORKA_HARNESS_WRAPPER_LEDGER_RETENTION + value: 720h + - name: ORKA_ALLOW_BASH + value: "true" + - name: ORKA_HARNESS_WRAPPER_CHILD_UID + value: "1000" + - name: ORKA_HARNESS_WRAPPER_CHILD_GID + value: "1000" + - name: ORKA_CODEX_SANDBOX_MODE + value: workspace-write + volumeMounts: + - name: auth + mountPath: /var/run/orka/harness-wrapper-auth + readOnly: true + - name: tls + mountPath: /var/run/orka/harness-wrapper-tls + readOnly: true + - name: controller-api-token + mountPath: /var/run/secrets/kubernetes.io/serviceaccount + readOnly: true + - name: ledger + mountPath: /var/lib/orka/harness-v1 + - name: tmp + mountPath: /tmp + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsUser: 0 + runAsGroup: 0 + capabilities: + drop: + - ALL + add: + - SETUID + - SETGID + - CHOWN + - KILL + - FOWNER + livenessProbe: + httpGet: + path: /v1/health + port: https + scheme: HTTPS + initialDelaySeconds: 10 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /v1/ready + port: https + scheme: HTTPS + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 512Mi + limits: + cpu: "2" + memory: 2Gi + ephemeral-storage: 2Gi + volumes: - name: auth - mountPath: /var/run/orka/harness-wrapper - readOnly: true - - name: upload-token - mountPath: /var/run/orka/upload-token - readOnly: true + secret: + secretName: harness-wrapper-auth + defaultMode: 0400 + items: + - key: token + path: token + - name: tls + secret: + secretName: harness-wrapper-tls + defaultMode: 0400 + items: + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key + - key: ca.crt + path: ca.crt + - name: controller-api-token + projected: + defaultMode: 0400 + sources: + - serviceAccountToken: + path: token + expirationSeconds: 3600 + - name: ledger + persistentVolumeClaim: + claimName: harness-wrapper-ledger - name: tmp - mountPath: /tmp - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: - - ALL - add: - - SETUID - - SETGID - - CHOWN - - KILL - - FOWNER - livenessProbe: - httpGet: - path: /v1/health - port: http - initialDelaySeconds: 10 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /v1/health - port: http - initialDelaySeconds: 5 - periodSeconds: 10 - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: "2" - memory: 2Gi - volumes: - - name: upload-token - projected: - defaultMode: 0400 - sources: - - serviceAccountToken: - path: token - - name: auth - secret: - secretName: harness-wrapper-auth - defaultMode: 0400 - - name: tmp - emptyDir: {} + emptyDir: {} diff --git a/config/harness-wrapper/kustomization.yaml b/config/harness-wrapper/kustomization.yaml index 23ca489e6..3d59e8a6a 100644 --- a/config/harness-wrapper/kustomization.yaml +++ b/config/harness-wrapper/kustomization.yaml @@ -1,10 +1,16 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -# `harness-wrapper-auth` must be pre-created; see README.md. -resources: -- serviceaccount.yaml -- deployment.yaml -- service.yaml -patches: -- path: volume-mode-patch.yaml +# This compatibility data plane is intentionally not referenced by +# config/default or config/acp-production. Apply it only after explicitly +# selecting a dedicated harness-v1 controller installation. +namespace: orka-system + +# The distinct harness-wrapper-auth and harness-wrapper-tls Secrets must be +# pre-created; see README.md. +resources: + - serviceaccount.yaml + - deployment.yaml + - service.yaml + - pvc.yaml + - networkpolicy.yaml diff --git a/config/harness-wrapper/networkpolicy.yaml b/config/harness-wrapper/networkpolicy.yaml new file mode 100644 index 000000000..90718213f --- /dev/null +++ b/config/harness-wrapper/networkpolicy.yaml @@ -0,0 +1,90 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: agent-harness-wrapper + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: agent-harness-wrapper + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: agent-harness-wrapper + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + control-plane: controller-manager + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: agent-harness-wrapper-drain + ports: + - protocol: TCP + port: 8080 + # Permit only the dedicated controller, DNS, and public HTTPS for provider + # APIs and credential-free, read-only SCM access. Private and local + # destination ranges remain denied. + egress: + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + control-plane: controller-manager + ports: + - protocol: TCP + port: 8080 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 0.0.0.0/8 + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.0.0.0/24 + - 192.0.2.0/24 + - 192.168.0.0/16 + - 198.18.0.0/15 + - 198.51.100.0/24 + - 203.0.113.0/24 + - 224.0.0.0/4 + - 240.0.0.0/4 + ports: + - protocol: TCP + port: 443 + - to: + - ipBlock: + cidr: ::/0 + except: + - ::/128 + - ::1/128 + - 64:ff9b::/96 + - 64:ff9b:1::/48 + - 100::/64 + - 2001::/32 + - 2001:db8::/32 + - 2002::/16 + - fc00::/7 + - fe80::/10 + - ff00::/8 + ports: + - protocol: TCP + port: 443 diff --git a/config/harness-wrapper/pvc.yaml b/config/harness-wrapper/pvc.yaml new file mode 100644 index 000000000..f36d990cc --- /dev/null +++ b/config/harness-wrapper/pvc.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: harness-wrapper-ledger + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: agent-harness-wrapper + app.kubernetes.io/managed-by: kustomize +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/config/harness-wrapper/service.yaml b/config/harness-wrapper/service.yaml index d79491e77..fb2073e20 100644 --- a/config/harness-wrapper/service.yaml +++ b/config/harness-wrapper/service.yaml @@ -11,6 +11,7 @@ spec: app.kubernetes.io/name: orka app.kubernetes.io/component: agent-harness-wrapper ports: - - name: http - port: 8080 - targetPort: http + - name: https + port: 8080 + targetPort: https + protocol: TCP diff --git a/config/harness-wrapper/serviceaccount.yaml b/config/harness-wrapper/serviceaccount.yaml index 3f00a64b8..5c3ba980e 100644 --- a/config/harness-wrapper/serviceaccount.yaml +++ b/config/harness-wrapper/serviceaccount.yaml @@ -6,3 +6,4 @@ metadata: app.kubernetes.io/name: orka app.kubernetes.io/component: agent-harness-wrapper app.kubernetes.io/managed-by: kustomize +automountServiceAccountToken: false diff --git a/config/harness-wrapper/volume-mode-patch.yaml b/config/harness-wrapper/volume-mode-patch.yaml deleted file mode 100644 index bcc862efa..000000000 --- a/config/harness-wrapper/volume-mode-patch.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: agent-harness-wrapper -spec: - template: - spec: - volumes: - - name: upload-token - projected: - defaultMode: 0400 - - name: auth - secret: - defaultMode: 0400 diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 1b48346e4..c06226ce2 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -1,8 +1,10 @@ resources: - manager.yaml - store-pvc.yaml + apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization +namespace: system images: - newName: ghcr.io/orka-agents/orka newTag: 0.1.1 diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index c429eec25..ddc390a44 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -8,6 +8,7 @@ metadata: pod-security.kubernetes.io/enforce: baseline pod-security.kubernetes.io/warn: restricted pod-security.kubernetes.io/audit: restricted + orka.ai/controller-mode: harness-v2 name: system --- apiVersion: apps/v1 @@ -34,6 +35,7 @@ spec: labels: control-plane: controller-manager app.kubernetes.io/name: orka + orka.ai/network-role: controller spec: # TODO(user): Uncomment the following code to configure the nodeAffinity expression # according to the platforms which are supported by your solution. @@ -71,7 +73,23 @@ spec: - --health-probe-bind-address=:8081 - --store-backend=sqlite - --store-path=/data/orka.db + - --agent-execution-snapshot-key-file=/var/run/orka/agent-execution-snapshot/key + - --agent-execution-snapshot-retention=720h + - --agent-execution-snapshot-retention-interval=1h - --controller-url=http://orka-api.orka-system.svc:8080 + - --controller-mode=harness-v2 + - --watch-namespace=orka-system + - --enforce-namespace-isolation=true + - --execution-mode-controller-usernames=system:serviceaccount:orka-system:orka-controller-manager + - --acp-upgrade-drain-bind-address=127.0.0.1:8083 + - --acp-upgrade-drain-timeout=5m + - --acp-upgrade-drain-poll-interval=1s + - --acp-upgrade-drain-trigger-timeout=5m15s + - --acp-upgrade-drain-marker-namespace=orka-system + - --acp-provider-proxy-base-url=http://orka-provider-auth-proxy.orka-system.svc:8080 + - --acp-provider-proxy-namespace=orka-system + - --acp-provider-proxy-pod-labels=orka.ai/network-role=provider-auth-proxy + - --acp-provider-proxy-token-file=/var/run/orka/provider-auth/token - --ai-worker-image=ghcr.io/orka-agents/orka/ai-worker:0.1.1 - --general-worker-image=ghcr.io/orka-agents/orka/general-worker:0.1.1 - --gateway-enabled=true @@ -82,6 +100,7 @@ spec: - --gateway-terminal-retention=720h - --gateway-delivery-timeout=15s - --gateway-delivery-max-attempts=10 + - --execution-workspace-default-provider=agent-sandbox - --agent-sandbox-enabled=false - --agent-sandbox-warm-pool-policy=disabled @@ -100,12 +119,36 @@ spec: imagePullPolicy: IfNotPresent name: manager env: - - name: ORKA_HARNESS_WRAPPER_ENDPOINT - value: http://orka-agent-harness-wrapper:8080 - - name: ORKA_HARNESS_WRAPPER_BEARER_TOKEN_FILE - value: /var/run/orka/harness-wrapper/token - - name: ORKA_HARNESS_WRAPPER_SERVICE_ACCOUNT_NAME - value: orka-agent-harness-wrapper + - name: ORKA_ACP_ARTIFACT_CAPABILITY_SECRET_FILE + value: /var/run/orka/acp-artifacts/capability-secret + - name: ORKA_ACP_ARTIFACT_ROOT + value: /data/acp-artifacts + - name: ORKA_ACP_CODEX_RUNTIME_IMAGE + valueFrom: + configMapKeyRef: + name: acp-runtime-images + key: ORKA_ACP_CODEX_RUNTIME_IMAGE + - name: ORKA_ACP_CLAUDE_RUNTIME_IMAGE + valueFrom: + configMapKeyRef: + name: acp-runtime-images + key: ORKA_ACP_CLAUDE_RUNTIME_IMAGE + - name: ORKA_ACP_COPILOT_RUNTIME_IMAGE + valueFrom: + configMapKeyRef: + name: acp-runtime-images + key: ORKA_ACP_COPILOT_RUNTIME_IMAGE + - name: ORKA_ACP_OPENCODE_RUNTIME_IMAGE + valueFrom: + configMapKeyRef: + name: acp-runtime-images + key: ORKA_ACP_OPENCODE_RUNTIME_IMAGE + - name: ORKA_WORKSPACE_PUBLISHER_URL + value: http://orka-workspace-publisher.orka-system.svc:8080 + - name: ORKA_WORKSPACE_PUBLISHER_CONTROLLER_TOKEN_FILE + value: /var/run/orka/publisher-auth/controller-token + - name: ORKA_WORKSPACE_PUBLISHER_CAPABILITY_SECRET_FILE + value: /var/run/orka/publisher-auth/operation-capability-secret ports: [] securityContext: readOnlyRootFilesystem: true @@ -113,6 +156,13 @@ spec: capabilities: drop: - "ALL" + lifecycle: + preStop: + exec: + command: + - /manager + - --acp-upgrade-drain-trigger-url=http://127.0.0.1:8083/acp/upgrade-drain + - --acp-upgrade-drain-trigger-timeout=5m15s livenessProbe: httpGet: path: /healthz @@ -139,8 +189,17 @@ spec: mountPath: /data - name: tmp mountPath: /tmp - - name: harness-wrapper-auth - mountPath: /var/run/orka/harness-wrapper + - name: acp-artifact-capability + mountPath: /var/run/orka/acp-artifacts + readOnly: true + - name: workspace-publisher-auth + mountPath: /var/run/orka/publisher-auth + readOnly: true + - name: provider-auth-proxy + mountPath: /var/run/orka/provider-auth + readOnly: true + - name: agent-execution-snapshot-key + mountPath: /var/run/orka/agent-execution-snapshot readOnly: true volumes: - name: store @@ -148,8 +207,30 @@ spec: claimName: controller-manager-store - name: tmp emptyDir: {} - - name: harness-wrapper-auth + - name: acp-artifact-capability + secret: + secretName: acp-artifact-capability + defaultMode: 0400 + items: + - key: capability-secret + path: capability-secret + - name: workspace-publisher-auth + secret: + secretName: workspace-publisher-auth + defaultMode: 0400 + - name: provider-auth-proxy + secret: + secretName: provider-auth-proxy + defaultMode: 0400 + items: + - key: token + path: token + - name: agent-execution-snapshot-key secret: - secretName: harness-wrapper-auth + secretName: agent-execution-snapshot-key + defaultMode: 0400 + items: + - key: snapshot-key + path: key serviceAccountName: controller-manager - terminationGracePeriodSeconds: 10 + terminationGracePeriodSeconds: 360 diff --git a/config/orka-admission-webhooks/README.md b/config/orka-admission-webhooks/README.md new file mode 100644 index 000000000..806443d9c --- /dev/null +++ b/config/orka-admission-webhooks/README.md @@ -0,0 +1,34 @@ +# Orka fail-closed admission webhooks + +This is the second admission installation wave for the immutable namespace +mode, Agent and AgentRuntime contracts, Task execution authority, +Task-provenance, and workspace-class authorization boundaries. Apply it only +after: + +- `../orka-admission` has at least two ready Service endpoints; +- `orka-admission-tls` contains a valid serving certificate for + `orka-admission.orka-system.svc` and a `ca.crt` bundle; +- the Secret is annotated `cert-manager.io/allow-direct-injection: "true"`; +- an AdmissionReview smoke test has reached every enabled handler. + +Trusted identities embedded in `validating_webhook.yaml` must exactly match the +corresponding admission-runtime arguments. The checked-in example authorizes +the canonical direct-Kustomize controller in `orka-system`, plus the exact +controller identities for Helm releases `orka-v1` in `orka-v1-system` and +`orka-v2` in `orka-v2-system`. If any identity differs, patch the shared runtime +and all three `route-unless-controller-cleanup-safe` conditions as one reviewed +platform change before enabling the webhooks. + +The namespace webhook permits a Namespace to be created with one valid +`orka.ai/controller-mode` claim and then makes that claim immutable. It rejects +adding a claim to an existing unlabeled Namespace. The resource webhooks require +contracts and new Task bindings to match that claim. The static harness +architecture does not install admission policies for dynamic backend modes, +cross-protocol binding, migration classification, or adjudication. Each +controller accepts one startup mode and one labeled watch namespace; the two +releases do not share a Task population. + +All retained webhook entries use `failurePolicy: Fail`. Delete their +configurations before removing the last admission endpoint or its TLS Secret. +The platform owner installs this `ValidatingWebhookConfiguration` exactly +once; neither controller release owns a second copy. diff --git a/config/orka-admission-webhooks/kustomization.yaml b/config/orka-admission-webhooks/kustomization.yaml new file mode 100644 index 000000000..2929e57f9 --- /dev/null +++ b/config/orka-admission-webhooks/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - validating_webhook.yaml diff --git a/config/orka-admission-webhooks/validating_webhook.yaml b/config/orka-admission-webhooks/validating_webhook.yaml new file mode 100644 index 000000000..e9dab1b3d --- /dev/null +++ b/config/orka-admission-webhooks/validating_webhook.yaml @@ -0,0 +1,183 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: orka-admission + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize + annotations: + cert-manager.io/inject-ca-from-secret: orka-system/orka-admission-tls +# The controller identities in matchConditions must remain identical to the +# corresponding orka-admission command argument. Patch the platform-owned +# runtime and webhook configuration atomically when using noncanonical release +# namespaces or ServiceAccount names. +webhooks: + - name: namespaceexecutionmode.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-v1-namespace-execution-mode, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [""], apiVersions: [v1], resources: [namespaces], scope: Cluster} + matchConditions: + - name: execution-mode-claim-present + expression: >- + object.metadata.?labels.orValue({}).exists(k, k == 'orka.ai/controller-mode') || + oldObject.?metadata.?labels.orValue({}).exists(k, k == 'orka.ai/controller-mode') + - name: taskprovenance.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-core-orka-ai-v1alpha1-task-provenance, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [core.orka.ai], apiVersions: [v1alpha1], resources: [tasks], scope: Namespaced} + matchConditions: + - name: route-unless-controller-cleanup-safe + expression: >- + !((request.userInfo.username == 'system:serviceaccount:orka-system:orka-controller-manager' || + request.userInfo.username == 'system:serviceaccount:orka-v1-system:orka-v1' || + request.userInfo.username == 'system:serviceaccount:orka-v2-system:orka-v2') && + request.operation == 'UPDATE' && + has(oldObject.metadata.deletionTimestamp) && + has(object.metadata.deletionTimestamp) && + object.metadata.deletionTimestamp == oldObject.metadata.deletionTimestamp && + oldObject.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup') && + !object.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup') && + object.metadata.?finalizers.orValue([]) == + oldObject.metadata.?finalizers.orValue([]).filter(f, f != 'orka.ai/cleanup') && + object.apiVersion == oldObject.apiVersion && + object.kind == oldObject.kind && + object.spec == oldObject.spec && + object.?status.orValue({}) == oldObject.?status.orValue({}) && + object.metadata.name == oldObject.metadata.name && + object.metadata.?generateName.orValue('') == oldObject.metadata.?generateName.orValue('') && + object.metadata.namespace == oldObject.metadata.namespace && + object.metadata.uid == oldObject.metadata.uid && + object.metadata.resourceVersion == oldObject.metadata.resourceVersion && + object.metadata.generation == oldObject.metadata.generation && + object.metadata.creationTimestamp == oldObject.metadata.creationTimestamp && + object.metadata.?deletionGracePeriodSeconds.orValue(0) == + oldObject.metadata.?deletionGracePeriodSeconds.orValue(0) && + object.metadata.?labels.orValue({}) == oldObject.metadata.?labels.orValue({}) && + object.metadata.?annotations.orValue({}) == oldObject.metadata.?annotations.orValue({}) && + object.metadata.?ownerReferences.orValue([]) == oldObject.metadata.?ownerReferences.orValue([]) && + object.metadata.?managedFields.orValue([]) == oldObject.metadata.?managedFields.orValue([])) + - name: taskworkspaceclassuse.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-core-orka-ai-v1alpha1-task-workspace-class-use, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [core.orka.ai], apiVersions: [v1alpha1], resources: [tasks], scope: Namespaced} + matchConditions: + - name: route-unless-controller-cleanup-safe + expression: >- + !((request.userInfo.username == 'system:serviceaccount:orka-system:orka-controller-manager' || + request.userInfo.username == 'system:serviceaccount:orka-v1-system:orka-v1' || + request.userInfo.username == 'system:serviceaccount:orka-v2-system:orka-v2') && + request.operation == 'UPDATE' && + has(oldObject.metadata.deletionTimestamp) && + has(object.metadata.deletionTimestamp) && + object.metadata.deletionTimestamp == oldObject.metadata.deletionTimestamp && + oldObject.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup') && + !object.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup') && + object.metadata.?finalizers.orValue([]) == + oldObject.metadata.?finalizers.orValue([]).filter(f, f != 'orka.ai/cleanup') && + object.apiVersion == oldObject.apiVersion && + object.kind == oldObject.kind && + object.spec == oldObject.spec && + object.?status.orValue({}) == oldObject.?status.orValue({}) && + object.metadata.name == oldObject.metadata.name && + object.metadata.?generateName.orValue('') == oldObject.metadata.?generateName.orValue('') && + object.metadata.namespace == oldObject.metadata.namespace && + object.metadata.uid == oldObject.metadata.uid && + object.metadata.resourceVersion == oldObject.metadata.resourceVersion && + object.metadata.generation == oldObject.metadata.generation && + object.metadata.creationTimestamp == oldObject.metadata.creationTimestamp && + object.metadata.?deletionGracePeriodSeconds.orValue(0) == + oldObject.metadata.?deletionGracePeriodSeconds.orValue(0) && + object.metadata.?labels.orValue({}) == oldObject.metadata.?labels.orValue({}) && + object.metadata.?annotations.orValue({}) == oldObject.metadata.?annotations.orValue({}) && + object.metadata.?ownerReferences.orValue([]) == oldObject.metadata.?ownerReferences.orValue([]) && + object.metadata.?managedFields.orValue([]) == oldObject.metadata.?managedFields.orValue([])) + - name: toolworkspaceclassuse.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-core-orka-ai-v1alpha1-tool-workspace-class-use, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [core.orka.ai], apiVersions: [v1alpha1], resources: [tools], scope: Namespaced} + - name: agentcontract.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-core-orka-ai-v1alpha1-agent-contract, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [core.orka.ai], apiVersions: [v1alpha1], resources: [agents], scope: Namespaced} + - name: agentruntimecontract.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-core-orka-ai-v1alpha1-agentruntime-contract, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [core.orka.ai], apiVersions: [v1alpha1], resources: [agentruntimes], scope: Namespaced} + - name: taskexecutionauthority.core.orka.ai + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: 10 + clientConfig: + service: {name: orka-admission, namespace: orka-system, path: /validate-core-orka-ai-v1alpha1-task-execution-authority, port: 443} + rules: + - {operations: [CREATE, UPDATE], apiGroups: [core.orka.ai], apiVersions: [v1alpha1], resources: [tasks, tasks/status], scope: Namespaced} + matchConditions: + - name: route-unless-controller-cleanup-safe + expression: >- + !((request.userInfo.username == 'system:serviceaccount:orka-system:orka-controller-manager' || + request.userInfo.username == 'system:serviceaccount:orka-v1-system:orka-v1' || + request.userInfo.username == 'system:serviceaccount:orka-v2-system:orka-v2') && + request.operation == 'UPDATE' && + has(oldObject.metadata.deletionTimestamp) && + has(object.metadata.deletionTimestamp) && + object.metadata.deletionTimestamp == oldObject.metadata.deletionTimestamp && + oldObject.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup') && + !object.metadata.?finalizers.orValue([]).exists(f, f == 'orka.ai/cleanup') && + object.metadata.?finalizers.orValue([]) == + oldObject.metadata.?finalizers.orValue([]).filter(f, f != 'orka.ai/cleanup') && + object.apiVersion == oldObject.apiVersion && + object.kind == oldObject.kind && + object.spec == oldObject.spec && + object.?status.orValue({}) == oldObject.?status.orValue({}) && + object.metadata.name == oldObject.metadata.name && + object.metadata.?generateName.orValue('') == oldObject.metadata.?generateName.orValue('') && + object.metadata.namespace == oldObject.metadata.namespace && + object.metadata.uid == oldObject.metadata.uid && + object.metadata.resourceVersion == oldObject.metadata.resourceVersion && + object.metadata.generation == oldObject.metadata.generation && + object.metadata.creationTimestamp == oldObject.metadata.creationTimestamp && + object.metadata.?deletionGracePeriodSeconds.orValue(0) == + oldObject.metadata.?deletionGracePeriodSeconds.orValue(0) && + object.metadata.?labels.orValue({}) == oldObject.metadata.?labels.orValue({}) && + object.metadata.?annotations.orValue({}) == oldObject.metadata.?annotations.orValue({}) && + object.metadata.?ownerReferences.orValue([]) == oldObject.metadata.?ownerReferences.orValue([]) && + object.metadata.?managedFields.orValue([]) == oldObject.metadata.?managedFields.orValue([])) diff --git a/config/orka-admission/README.md b/config/orka-admission/README.md new file mode 100644 index 000000000..399292f2e --- /dev/null +++ b/config/orka-admission/README.md @@ -0,0 +1,37 @@ +# Orka admission runtime + +This opt-in production base deploys the stateless `orka-admission` process +independently from the controller. It intentionally does not install the +fail-closed webhook configuration; apply `../orka-admission-webhooks` only +after both replicas are ready and an AdmissionReview smoke test succeeds. + +Before applying this base: + +1. Replace `controller:latest` with the exact digest-pinned Orka controller + image that contains `/orka-admission`. +2. Create `orka-admission-tls` in `orka-system` with `tls.crt`, `tls.key`, and + `ca.crt`. When using cert-manager direct CA injection, annotate the Secret + with `cert-manager.io/allow-direct-injection: "true"`. +3. Patch any trusted controller or worker identities used by the execution + authority and provenance handlers. The checked-in example matches the + canonical direct-Kustomize controller in `orka-system`, plus Helm releases + `orka-v1` in `orka-v1-system` and `orka-v2` in `orka-v2-system`. If any + release namespace, release name, or ServiceAccount name differs, + patch `--controller-usernames`, `--task-provenance-trusted-users`, and + `--task-provenance-trusted-service-accounts`. Patch the exact same + controller usernames in every `route-unless-controller-cleanup-safe` + condition in `../orka-admission-webhooks/validating_webhook.yaml`. + +The dedicated ServiceAccount can read namespace mode claims and create +SubjectAccessReviews for workspace-class authorization. It has no Orka CRD, +Lease, SQLite, runtime-credential, dispatcher, or controller reconciliation +access. Static harness mode and namespace ownership are controller startup +contracts; this service does not provide dynamic backend-mode, +classification, or adjudication APIs. + +Install this base once in the platform-owned `orka-system` namespace. Neither +the v1 nor v2 release owns these cluster-scoped RBAC objects or the shared +`ValidatingWebhookConfiguration`. + +Uninstall in reverse order: delete the webhook configuration first, then the +runtime base after API server propagation is complete. diff --git a/config/orka-admission/deployment.yaml b/config/orka-admission/deployment.yaml new file mode 100644 index 000000000..841a356d4 --- /dev/null +++ b/config/orka-admission/deployment.yaml @@ -0,0 +1,116 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: orka-admission + namespace: orka-system + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + template: + metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + orka.ai/network-role: admission + spec: + serviceAccountName: orka-admission + automountServiceAccountToken: true + terminationGracePeriodSeconds: 30 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: admission + # Production overlays must replace this with the digest-pinned + # controller image containing /orka-admission. + image: controller:latest + imagePullPolicy: IfNotPresent + command: [/orka-admission] + args: + - --health-probe-bind-address=:8081 + - --webhook-cert-path=/var/run/orka/admission/tls + - --webhook-cert-name=tls.crt + - --webhook-cert-key=tls.key + - --webhook-service-dns-name=orka-admission.orka-system.svc + - --controller-usernames=system:serviceaccount:orka-system:orka-controller-manager,system:serviceaccount:orka-v1-system:orka-v1,system:serviceaccount:orka-v2-system:orka-v2 + - --task-provenance-trusted-users=system:serviceaccount:orka-system:orka-controller-manager,system:serviceaccount:orka-v1-system:orka-v1,system:serviceaccount:orka-v2-system:orka-v2 + - --task-provenance-trusted-service-accounts=orka-ai-worker,orka-vendor-worker,orka-v1-ai-worker,orka-v1-vendor-worker,orka-v2-ai-worker,orka-v2-vendor-worker + ports: + - name: webhook + containerPort: 9443 + protocol: TCP + - name: health + containerPort: 8081 + protocol: TCP + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + capabilities: + drop: [ALL] + lifecycle: + preStop: + exec: + command: [/orka-admission, --pre-stop-delay=5s] + readinessProbe: + httpGet: + path: /readyz + port: health + periodSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /healthz + port: health + periodSeconds: 10 + failureThreshold: 3 + resources: + requests: + cpu: 50m + memory: 64Mi + ephemeral-storage: 32Mi + limits: + cpu: 500m + memory: 256Mi + ephemeral-storage: 128Mi + volumeMounts: + - name: tls + mountPath: /var/run/orka/admission/tls + readOnly: true + volumes: + - name: tls + secret: + secretName: orka-admission-tls + optional: false + items: + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key diff --git a/config/orka-admission/kustomization.yaml b/config/orka-admission/kustomization.yaml new file mode 100644 index 000000000..70068d6e9 --- /dev/null +++ b/config/orka-admission/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: orka-system +resources: + - serviceaccount.yaml + - rbac.yaml + - deployment.yaml + - service.yaml + - poddisruptionbudget.yaml + - networkpolicy.yaml diff --git a/config/orka-admission/networkpolicy.yaml b/config/orka-admission/networkpolicy.yaml new file mode 100644 index 000000000..3cfea418a --- /dev/null +++ b/config/orka-admission/networkpolicy.yaml @@ -0,0 +1,27 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: orka-admission + namespace: orka-system + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + policyTypes: [Ingress, Egress] + ingress: + - ports: + - protocol: TCP + port: 9443 + - protocol: TCP + port: 8081 + egress: + - ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 6443 diff --git a/config/orka-admission/poddisruptionbudget.yaml b/config/orka-admission/poddisruptionbudget.yaml new file mode 100644 index 000000000..2db3c32cf --- /dev/null +++ b/config/orka-admission/poddisruptionbudget.yaml @@ -0,0 +1,15 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: orka-admission + namespace: orka-system + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +spec: + minAvailable: 1 + selector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission diff --git a/config/orka-admission/rbac.yaml b/config/orka-admission/rbac.yaml new file mode 100644 index 000000000..938f196be --- /dev/null +++ b/config/orka-admission/rbac.yaml @@ -0,0 +1,32 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: orka-admission + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +rules: + - apiGroups: [authorization.k8s.io] + resources: [subjectaccessreviews] + verbs: [create] + - apiGroups: [""] + resources: [namespaces] + verbs: [get] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: orka-admission + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: orka-admission +subjects: + - kind: ServiceAccount + name: orka-admission + namespace: orka-system diff --git a/config/orka-admission/service.yaml b/config/orka-admission/service.yaml new file mode 100644 index 000000000..f893b8c96 --- /dev/null +++ b/config/orka-admission/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: orka-admission + namespace: orka-system + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +spec: + selector: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + ports: + - name: https + port: 443 + targetPort: webhook + protocol: TCP diff --git a/config/orka-admission/serviceaccount.yaml b/config/orka-admission/serviceaccount.yaml new file mode 100644 index 000000000..add75a342 --- /dev/null +++ b/config/orka-admission/serviceaccount.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: orka-admission + namespace: orka-system + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: admission + app.kubernetes.io/managed-by: kustomize +automountServiceAccountToken: true diff --git a/config/provider-proxy/deployment.yaml b/config/provider-proxy/deployment.yaml new file mode 100644 index 000000000..ab14a6616 --- /dev/null +++ b/config/provider-proxy/deployment.yaml @@ -0,0 +1,99 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: provider-auth-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy + template: + metadata: + # Token Secret updates are reloaded in-process. Change this nonce only to + # force a Pod rollout for binary/flag changes or operator recovery. + annotations: + orka.ai/provider-auth-rollout-nonce: "0" + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy + orka.ai/network-role: provider-auth-proxy + spec: + serviceAccountName: provider-auth-proxy + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: proxy + image: controller:latest + command: [/provider-auth-proxy] + args: + - --listen-address=:8080 + - --upstream-base-url=http://vekil.vekil-system.svc:1337 + - --token-file=/var/run/secrets/orka/provider-auth/token + - --previous-token-file=/var/run/secrets/orka/provider-auth/previous-token + - --previous-token-valid-until-file=/var/run/secrets/orka/provider-auth/previous-token-valid-until + - --token-reload-interval=5s + - --previous-token-overlap=10m + ports: + - name: http + containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + resources: + requests: + cpu: 25m + memory: 32Mi + ephemeral-storage: 32Mi + limits: + cpu: 250m + memory: 128Mi + ephemeral-storage: 128Mi + readinessProbe: + httpGet: + path: /readyz + port: http + livenessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: provider-auth + mountPath: /var/run/secrets/orka/provider-auth + readOnly: true + volumes: + - name: provider-auth + projected: + # The source is optional so key/Secret removal becomes an empty + # projection that the process detects and rejects, rather than + # leaving a stale last-known token mounted. fsGroup + 0440 grants + # only this non-root Pod group read access. + defaultMode: 0440 + sources: + - secret: + name: provider-auth-proxy + optional: true + items: + - key: token + path: token + # Optional for backward compatibility with existing + # single-token Secrets. During rotation, add both keys. The + # absolute RFC3339 deadline prevents Pod restarts from + # extending the configured overlap window. + - key: previous-token + path: previous-token + - key: previous-token-valid-until + path: previous-token-valid-until diff --git a/config/provider-proxy/kustomization.yaml b/config/provider-proxy/kustomization.yaml new file mode 100644 index 000000000..7bc7ce24b --- /dev/null +++ b/config/provider-proxy/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: system +resources: + - serviceaccount.yaml + - service.yaml + - deployment.yaml + - networkpolicy.yaml +images: + - name: controller + newName: ghcr.io/orka-agents/orka + newTag: latest diff --git a/config/provider-proxy/networkpolicy.yaml b/config/provider-proxy/networkpolicy.yaml new file mode 100644 index 000000000..6b7f5f9d1 --- /dev/null +++ b/config/provider-proxy/networkpolicy.yaml @@ -0,0 +1,44 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: provider-auth-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy + policyTypes: [Ingress, Egress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: orka-runtimes + podSelector: + matchLabels: + orka.ai/network-role: provider-client + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vekil-system + podSelector: + matchLabels: + app.kubernetes.io/name: vekil + ports: + - {protocol: TCP, port: 1337} diff --git a/config/provider-proxy/service.yaml b/config/provider-proxy/service.yaml new file mode 100644 index 000000000..5049006df --- /dev/null +++ b/config/provider-proxy/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: provider-auth-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy +spec: + selector: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy + ports: + - name: http + port: 8080 + targetPort: http diff --git a/config/provider-proxy/serviceaccount.yaml b/config/provider-proxy/serviceaccount.yaml new file mode 100644 index 000000000..2583872e0 --- /dev/null +++ b/config/provider-proxy/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: provider-auth-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: provider-auth-proxy +automountServiceAccountToken: false diff --git a/config/publisher/deployment.yaml b/config/publisher/deployment.yaml new file mode 100644 index 000000000..3920d27c0 --- /dev/null +++ b/config/publisher/deployment.yaml @@ -0,0 +1,133 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: workspace-publisher + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher + template: + metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher + orka.ai/network-role: workspace-publisher + spec: + serviceAccountName: workspace-publisher + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: publisher + image: docker.io/sozercan/orka-workspace-publisher:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + env: + - name: ORKA_SCM_EGRESS_PROXY_TOKEN + valueFrom: + secretKeyRef: + name: scm-egress-proxy-auth + key: token + - name: HTTPS_PROXY + value: http://orka-publisher:$(ORKA_SCM_EGRESS_PROXY_TOKEN)@orka-scm-egress-proxy.orka-system.svc:8080 + - name: https_proxy + value: http://orka-publisher:$(ORKA_SCM_EGRESS_PROXY_TOKEN)@orka-scm-egress-proxy.orka-system.svc:8080 + - name: NO_PROXY + value: localhost,127.0.0.1,::1,.svc,.cluster.local + - name: no_proxy + value: localhost,127.0.0.1,::1,.svc,.cluster.local + - name: ORKA_PUBLISHER_SCM_EGRESS_PROXY_REQUIRED + value: "true" + - name: ORKA_PUBLISHER_LISTEN_ADDRESS + value: :8080 + - name: ORKA_PUBLISHER_TEMP_ROOT + value: /tmp/orka-workspace-publisher/runtime + - name: ORKA_PUBLISHER_CONTROLLER_TOKEN_FILE + value: /var/run/orka/publisher-auth/controller-token + - name: ORKA_PUBLISHER_OPERATION_CAPABILITY_SECRET_FILE + value: /var/run/orka/publisher-auth/operation-capability-secret + - name: ORKA_PUBLISHER_ARTIFACT_AUTHORIZATION_BROKER_URL + value: http://orka-api.orka-system.svc:8080 + - name: ORKA_PUBLISHER_ARTIFACT_API_URL + value: http://orka-api.orka-system.svc:8080 + - name: ORKA_PUBLISHER_CREDENTIAL_BROKER_URL + value: http://orka-api.orka-system.svc:8080 + - name: ORKA_PUBLISHER_ALLOWED_SCM_HOSTS + value: github.com + - name: ORKA_PUBLISHER_GITHUB_PR_ENABLED + value: "true" + - name: ORKA_PUBLISHER_GITHUB_API_BASE_URL + value: https://api.github.com + - name: ORKA_PUBLISHER_GITHUB_REQUEST_TIMEOUT + value: 15s + - name: ORKA_PUBLISHER_GITHUB_MAX_RESPONSE_BYTES + value: "4194304" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 100m + memory: 256Mi + ephemeral-storage: 512Mi + limits: + cpu: "2" + memory: 2Gi + ephemeral-storage: 2Gi + volumeMounts: + - name: data + mountPath: /data + - name: tmp + mountPath: /tmp/orka-workspace-publisher + - name: publisher-auth + mountPath: /var/run/orka/publisher-auth/controller-token + subPath: controller-token + readOnly: true + - name: publisher-auth + mountPath: /var/run/orka/publisher-auth/operation-capability-secret + subPath: operation-capability-secret + readOnly: true + readinessProbe: + httpGet: + path: /v1/health + port: http + periodSeconds: 5 + livenessProbe: + httpGet: + path: /v1/health + port: http + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: workspace-publisher + - name: tmp + emptyDir: + sizeLimit: 1Gi + - name: publisher-auth + secret: + secretName: workspace-publisher-auth + # subPath bind mounts expose regular files to the fail-closed + # publisher loader; fsGroup grants only the Pod group read access. + defaultMode: 0440 + items: + - key: controller-token + path: controller-token + - key: operation-capability-secret + path: operation-capability-secret diff --git a/config/publisher/kustomization.yaml b/config/publisher/kustomization.yaml new file mode 100644 index 000000000..b00ad5bc9 --- /dev/null +++ b/config/publisher/kustomization.yaml @@ -0,0 +1,11 @@ +namespace: system +resources: + - serviceaccount.yaml + - service.yaml + - pvc.yaml + - deployment.yaml + - networkpolicy.yaml +images: + - name: docker.io/sozercan/orka-workspace-publisher + newName: docker.io/sozercan/orka-workspace-publisher + newTag: latest diff --git a/config/publisher/networkpolicy.yaml b/config/publisher/networkpolicy.yaml new file mode 100644 index 000000000..bf246b9b8 --- /dev/null +++ b/config/publisher/networkpolicy.yaml @@ -0,0 +1,46 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: workspace-publisher +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + control-plane: controller-manager + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + control-plane: controller-manager + ports: + - protocol: TCP + port: 8080 + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy + ports: + - protocol: TCP + port: 8080 diff --git a/config/publisher/pvc.yaml b/config/publisher/pvc.yaml new file mode 100644 index 000000000..b5f2b7c6f --- /dev/null +++ b/config/publisher/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: workspace-publisher + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/config/publisher/service.yaml b/config/publisher/service.yaml new file mode 100644 index 000000000..b1ad8e204 --- /dev/null +++ b/config/publisher/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: workspace-publisher + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher +spec: + selector: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher + ports: + - name: http + port: 8080 + targetPort: http diff --git a/config/publisher/serviceaccount.yaml b/config/publisher/serviceaccount.yaml new file mode 100644 index 000000000..d8edf9db8 --- /dev/null +++ b/config/publisher/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: workspace-publisher + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher +automountServiceAccountToken: false diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 9b9a72cd7..55f8f364c 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -7,6 +7,7 @@ resources: - service_account.yaml - role.yaml - role_binding.yaml +- static_controller_cluster_role.yaml - workspace_parameter_reader_role.yaml - workspace_parameter_reader_role_binding.yaml - leader_election_role.yaml diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml index 00054d230..f69212ce7 100644 --- a/config/rbac/leader_election_role.yaml +++ b/config/rbac/leader_election_role.yaml @@ -6,6 +6,7 @@ metadata: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize name: leader-election-role + namespace: system rules: - apiGroups: - "" diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml index 043347846..aea998a25 100644 --- a/config/rbac/leader_election_role_binding.yaml +++ b/config/rbac/leader_election_role_binding.yaml @@ -5,6 +5,7 @@ metadata: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize name: leader-election-rolebinding + namespace: system roleRef: apiGroup: rbac.authorization.k8s.io kind: Role diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 654f1c85f..d8649106b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -8,7 +8,10 @@ rules: - "" resources: - configmaps + - namespaces + - pods - secrets + - services verbs: - create - delete @@ -21,14 +24,11 @@ rules: - "" resources: - endpoints - - namespaces - nodes - persistentvolumeclaims - persistentvolumes - - pods - pods/status - replicationcontrollers - - services verbs: - get - list @@ -89,12 +89,23 @@ rules: - apps resources: - daemonsets + - statefulsets + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: - deployments - replicasets - - statefulsets verbs: + - create + - delete - get - list + - patch + - update - watch - apiGroups: - ate.dev @@ -155,9 +166,16 @@ rules: resources: - agentruntimes - agents + - branchclaims + - controllerepochs + - externaleffects + - promptattempts - providers + - publications - repositorymonitors - repositoryscans + - runtimepools + - runtimesessioncontrols - skills - substrateactorpools - tasks @@ -175,10 +193,17 @@ rules: resources: - agentruntimes/finalizers - agents/finalizers + - branchclaims/finalizers + - controllerepochs/finalizers + - externaleffects/finalizers - outboundaccesspolicies/finalizers + - promptattempts/finalizers - providers/finalizers + - publications/finalizers - repositorymonitors/finalizers - repositoryscans/finalizers + - runtimepools/finalizers + - runtimesessioncontrols/finalizers - skills/finalizers - substrateactorpools/finalizers - tasks/finalizers @@ -190,10 +215,17 @@ rules: resources: - agentruntimes/status - agents/status + - branchclaims/status + - controllerepochs/status + - externaleffects/status - outboundaccesspolicies/status + - promptattempts/status - providers/status + - publications/status - repositorymonitors/status - repositoryscans/status + - runtimepools/status + - runtimesessioncontrols/status - skills/status - substrateactorpools/status - tasks/status @@ -303,26 +335,19 @@ rules: - delete - get - list + - patch + - update - watch - apiGroups: - policy resources: - poddisruptionbudgets verbs: - - get - - list - - watch -- apiGroups: - - rbac.authorization.k8s.io - resources: - - clusterrolebindings - - rolebindings - - roles - verbs: - create - delete - get - list + - patch - update - watch - apiGroups: @@ -343,6 +368,18 @@ rules: - clusterroles verbs: - bind +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list + - update + - watch - apiGroups: - storage.k8s.io resources: diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml index 9840b45c7..f4ff5197e 100644 --- a/config/rbac/role_binding.yaml +++ b/config/rbac/role_binding.yaml @@ -1,10 +1,11 @@ apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: labels: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize name: manager-rolebinding + namespace: system roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole diff --git a/config/rbac/static_controller_cluster_role.yaml b/config/rbac/static_controller_cluster_role.yaml new file mode 100644 index 000000000..a8ed8a946 --- /dev/null +++ b/config/rbac/static_controller_cluster_role.yaml @@ -0,0 +1,46 @@ +# Static controllers need only two common cluster-scoped capabilities: +# reading namespace mode claims and binding the exact release worker roles into +# the selected workload namespace. Mode-specific cluster APIs are granted by +# the corresponding workload overlay, never by this common role. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: static-controller-cluster-role +rules: +- apiGroups: + - "" + resourceNames: + - system + resources: + - namespaces + verbs: + - get +- apiGroups: + - rbac.authorization.k8s.io + resourceNames: + - ai-worker-role + - vendor-worker-role + - container-worker-role + resources: + - clusterroles + verbs: + - bind +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/managed-by: kustomize + name: static-controller-cluster-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: static-controller-cluster-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/worker_role.yaml b/config/rbac/worker_role.yaml index d3484d307..432d2c131 100644 --- a/config/rbac/worker_role.yaml +++ b/config/rbac/worker_role.yaml @@ -107,9 +107,9 @@ rules: - pods/log verbs: - get -# code_exec Kubernetes backend: per-job ServiceAccounts. This ClusterRole is -# bound through a ClusterRoleBinding, so these create/delete permissions are -# cluster-wide and reserved for the AI worker trust tier. +# code_exec Kubernetes backend: per-job ServiceAccounts. Static installations +# bind this ClusterRole through a RoleBinding, so these create/delete +# permissions remain inside the selected workload namespace. - apiGroups: - "" resources: @@ -464,9 +464,10 @@ rules: - get --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: name: ai-worker-rolebinding + namespace: system roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole @@ -477,9 +478,10 @@ subjects: namespace: system --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: name: vendor-worker-rolebinding + namespace: system roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole @@ -490,9 +492,10 @@ subjects: namespace: system --- apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: name: container-worker-rolebinding + namespace: system roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole diff --git a/config/rbac/workspace_parameter_reader_role_binding.yaml b/config/rbac/workspace_parameter_reader_role_binding.yaml index fc9b0c347..9caffc2c7 100644 --- a/config/rbac/workspace_parameter_reader_role_binding.yaml +++ b/config/rbac/workspace_parameter_reader_role_binding.yaml @@ -1,10 +1,11 @@ apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: labels: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize name: workspace-parameter-reader-rolebinding + namespace: system roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole diff --git a/config/samples/core_v1alpha1_agent.yaml b/config/samples/core_v1alpha1_agent.yaml index a147c394b..2a6d8cc18 100644 --- a/config/samples/core_v1alpha1_agent.yaml +++ b/config/samples/core_v1alpha1_agent.yaml @@ -1,9 +1,20 @@ +# This Agent shows the future namespace-local external v2 selector shape from +# core_v1alpha1_agentruntime.yaml. Registration and conformance are available, +# but runtimeRef Task dispatch remains fail-closed until the external v2 +# dispatcher support boundary is enabled. Built-in Codex, Claude, Copilot, and +# OpenCode Agents use runtime.type instead. apiVersion: core.orka.ai/v1alpha1 kind: Agent metadata: labels: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize - name: agent-sample + name: external-v2-agent spec: - # TODO(user): Add fields here + model: + name: operator-reviewed-model + systemPrompt: + inline: "You are an operator-reviewed external coding assistant." + runtime: + runtimeRef: + name: sample-external-v2-runtime diff --git a/config/samples/core_v1alpha1_agent_claude.yaml b/config/samples/core_v1alpha1_agent_claude.yaml index 520c7129e..2da2bdc70 100644 --- a/config/samples/core_v1alpha1_agent_claude.yaml +++ b/config/samples/core_v1alpha1_agent_claude.yaml @@ -12,13 +12,15 @@ spec: # systemPrompt is injected via --system-prompt; can also use configMapRef systemPrompt: inline: "You are a helpful coding assistant." - # secretRef references a Secret containing ANTHROPIC_API_KEY - secretRef: - name: claude-api-key - # runtime marks this Agent for type: agent tasks using Claude Code CLI - # (mutually exclusive with providerRef, which is for type: ai tasks) + # Provider credentials are injected only by the central authenticated proxy; + # no provider Secret is referenced by an ACP Agent or delivered to its runtime. + # runtime.type selects the built-in Claude ACP profile backed exclusively by + # a controller-owned, digest-pinned orka.harness.v2 RuntimePool. There is no + # per-Task Job/image or legacy harness fallback. It is mutually exclusive + # with providerRef, which is for native type: ai tasks. runtime: - # type selects the CLI runtime: "claude", "copilot", "codex", or "opencode" + contractVersion: orka.harness.v2 + # type selects a supported ACP runtime profile. type: claude # defaultMaxTurns is the default max agent loop iterations per task defaultMaxTurns: 50 @@ -32,13 +34,3 @@ spec: - Bash - Glob - Grep - # execution sets default runtime isolation and placement for worker pods - # execution: - # runtimeClassName: gvisor - # nodeSelector: - # sandbox-runtime: gvisor - # tolerations: - # - key: sandbox-runtime - # operator: Equal - # value: gvisor - # effect: NoSchedule diff --git a/config/samples/core_v1alpha1_agent_codex.yaml b/config/samples/core_v1alpha1_agent_codex.yaml index 3f4a36490..acad3fd9a 100644 --- a/config/samples/core_v1alpha1_agent_codex.yaml +++ b/config/samples/core_v1alpha1_agent_codex.yaml @@ -10,9 +10,13 @@ spec: name: "gpt-5.4" systemPrompt: inline: "You are a helpful coding assistant." - secretRef: - name: codex-api-key + # Provider credentials are injected only by the central authenticated proxy; + # no provider Secret is referenced by an ACP Agent or delivered to its runtime. + # runtime.type selects the built-in Codex ACP profile backed exclusively by + # a controller-owned, digest-pinned orka.harness.v2 RuntimePool. There is no + # per-Task Job/image or legacy harness fallback. runtime: + contractVersion: orka.harness.v2 type: codex defaultMaxTurns: 50 defaultAllowBash: true diff --git a/config/samples/core_v1alpha1_agent_opencode.yaml b/config/samples/core_v1alpha1_agent_opencode.yaml index 4bd0ef25e..fb51c72b1 100644 --- a/config/samples/core_v1alpha1_agent_opencode.yaml +++ b/config/samples/core_v1alpha1_agent_opencode.yaml @@ -6,14 +6,26 @@ metadata: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize spec: + # OpenCode model IDs use provider/model form. model: - name: "kimi-k2" - systemPrompt: - inline: "You are a helpful coding assistant." - # The Secret must contain OPENAI_BASE_URL. Add OPENAI_API_KEY when authentication is required. - secretRef: - name: opencode-credentials + name: "openai/gpt-5.4" + # Reviewed operational ceilings; set these from the selected model's contract. + contextWindow: 32768 + maxTokens: 4096 + # Provider credentials are injected only by the central authenticated proxy; + # no provider Secret is referenced by an ACP Agent or delivered to its runtime. + # runtime.type selects the built-in OpenCode ACP profile backed exclusively by + # a controller-owned, digest-pinned orka.harness.v2 RuntimePool. There is no + # per-Task Job/image or legacy harness fallback. runtime: + contractVersion: orka.harness.v2 type: opencode defaultMaxTurns: 50 defaultAllowBash: true + defaultAllowedTools: + - read + - write + - edit + - bash + - glob + - grep diff --git a/config/samples/core_v1alpha1_agentruntime.yaml b/config/samples/core_v1alpha1_agentruntime.yaml index a0b4fd885..0e8a52783 100644 --- a/config/samples/core_v1alpha1_agentruntime.yaml +++ b/config/samples/core_v1alpha1_agentruntime.yaml @@ -1,26 +1,75 @@ +# External runtimes are operator-owned orka.harness.v2 services. Registration +# and strict workspace-governance conformance are available, but runtimeRef Task +# dispatch remains fail-closed until the external v2 dispatcher boundary is +# enabled. Prefer controller-owned RuntimePools for built-in Codex, Claude, +# Copilot, and OpenCode profiles. +# +# Before applying this object, create both referenced Secrets in this namespace. +# Each Secret must contain at least 32 bytes, set +# orka.ai/agent-runtime-auth: "true" +# and bind itself to this runtime and endpoint with +# orka.ai/agent-runtime-name: sample-external-v2-runtime +# orka.ai/agent-runtime-endpoint: http://sample-external-v2-runtime.default.svc.cluster.local:8080 +# The capability/profile values below are illustrative. They must exactly match +# the runtime's public /v2/capabilities and authenticated /v2/status responses. apiVersion: core.orka.ai/v1alpha1 kind: AgentRuntime metadata: labels: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize - name: sample-http-runtime + name: sample-external-v2-runtime spec: - # Namespace-local AgentRuntime facade for a remote execution backend. - # The endpoint may point at a generic HTTP runtime, AgentKit Serve adapter, - # Foundry adapter, or another backend implementing orka.harness.v1. - # The bearer token authenticates Orka to the runtime endpoint; it is not a - # downstream production tool credential. - contractVersion: orka.harness.v1 + contractVersion: orka.harness.v2 deployment: mode: external-endpoint - endpoint: http://sample-http-runtime.default.svc.cluster.local:8080 + endpoint: http://sample-external-v2-runtime.default.svc.cluster.local:8080 clientAuth: - bearerTokenSecretRef: - name: sample-http-runtime-token + controllerBearerTokenSecretRef: + name: sample-external-v2-runtime-controller-auth key: token + operationCapabilitySecretRef: + name: sample-external-v2-runtime-operation-auth + key: capability-secret capabilities: - toolExecutionModes: - - observed - supportsCancel: true - supportsRuntimeSessions: true + runtimeInstanceID: sample-external-v2-runtime-01 + profile: + digest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + digestSchemaVersion: 1 + acpProfile: acp.v1 + adapterName: operator-reviewed-adapter + adapterDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + providerKind: operator-managed + model: operator-reviewed-model + agentConfigurationDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + toolPolicyDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + approvalPolicyDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + mcpConfigurationDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 + workspaceIntent: read + proxyCredentialRole: operator-managed + proxyCredentialScope: external-runtime + resourceClass: external + limits: + maxResidentSessions: 10 + maxConcurrentPrompts: 4 + maxRequestBytes: 1048576 + maxEventLineBytes: 262144 + maxTerminalResultBytes: 1048576 + maxBufferedEvents: 4096 + maxUpdateEventsPerSecond: 100 + minPromptLeaseMillis: 5000 + maxPromptLeaseMillis: 120000 + maxPendingPermissions: 32 + maxWorkspaceDeltaBytes: 104857600 + supportsDrain: false + supportsPublicationFinalization: false + workspaceGovernance: + mode: strict-governed + trusted: false + orkaOwnedWorkspaceDeltas: true + promptScopedBrokerAuthorization: true + noDirectSCMPublication: true + orkaOwnedCleanRoomPublication: true + exactInstanceFencing: true + duplicateSafeMutations: true + cancellationSettlement: true diff --git a/config/samples/core_v1alpha1_agentruntime_agentkit.yaml b/config/samples/core_v1alpha1_agentruntime_agentkit.yaml deleted file mode 100644 index 7ffc602c6..000000000 --- a/config/samples/core_v1alpha1_agentruntime_agentkit.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: AgentRuntime -metadata: - labels: - app.kubernetes.io/name: orka - app.kubernetes.io/managed-by: kustomize - name: sample-agentkit-runtime -spec: - # Namespace-local facade for an operator-deployed AgentKit Serve adapter. - # Current AgentKit Serve Orka support is observed mode only; do not add brokered - # classes or supportsContinuation until AgentKit passes those conformance profiles. - # Adapter/runtime credentials live in the adapter deployment, not in Orka Tool - # credentials or the remote workload. - contractVersion: orka.harness.v1 - deployment: - mode: external-endpoint - endpoint: http://sample-agentkit-runtime.default.svc.cluster.local:8080 - clientAuth: - bearerTokenSecretRef: - name: sample-agentkit-runtime-token - key: token - capabilities: - toolExecutionModes: - - observed - supportsCancel: true - supportsRuntimeSessions: true diff --git a/config/samples/core_v1alpha1_agentruntime_foundry.yaml b/config/samples/core_v1alpha1_agentruntime_foundry.yaml deleted file mode 100644 index f98f1fe32..000000000 --- a/config/samples/core_v1alpha1_agentruntime_foundry.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: AgentRuntime -metadata: - labels: - app.kubernetes.io/name: orka - app.kubernetes.io/managed-by: kustomize - name: sample-foundry-runtime -spec: - # Namespace-local facade for the separately deployed orka-agents/agent-runtime-foundry - # Hosted Agents Responses adapter. Azure authentication belongs to the adapter deployment - # (for example, Workload Identity). Orka Tool credentials remain Orka-governed. - contractVersion: orka.harness.v1 - deployment: - mode: external-endpoint - endpoint: http://sample-foundry-runtime.default.svc.cluster.local:8080 - clientAuth: - bearerTokenSecretRef: - name: sample-foundry-runtime-token - key: token - capabilities: - # The adapter is observed-only by default. Advertise brokered classes only after - # enabling ORKA_FOUNDRY_BROKERED_TOOL_CLASSES and passing matching conformance probes. - toolExecutionModes: - - observed - supportsCancel: true - supportsRuntimeSessions: true diff --git a/config/samples/core_v1alpha1_task_agent.yaml b/config/samples/core_v1alpha1_task_agent.yaml index 11d93db23..78894fdfa 100644 --- a/config/samples/core_v1alpha1_task_agent.yaml +++ b/config/samples/core_v1alpha1_task_agent.yaml @@ -6,35 +6,23 @@ metadata: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize spec: - # type: agent delegates work to an external CLI runtime (e.g., Claude Code CLI or Codex CLI) + # type: agent uses a fenced ACP v2 RuntimeSession in a controller-owned pool. + # Provider authentication comes only through the central provider proxy; + # neither this Task nor the Agent references a provider credential Secret. type: agent - # agentRef references an Agent CRD that defines runtime type and credentials agentRef: name: claude-agent - # execution optionally overrides runtime isolation and placement for this task - # execution: - # runtimeClassName: gvisor - # nodeSelector: - # sandbox-runtime: gvisor - # tolerations: - # - key: sandbox-runtime - # operator: Equal - # value: gvisor - # effect: NoSchedule - # prompt is the instruction sent to the agent CLI - prompt: "Refactor the main.go file to use structured logging" - # agentRuntime allows task-level overrides of the Agent's runtime defaults + prompt: "Review main.go for structured logging opportunities. Do not modify files." + workspace: + intent: read + gitRepo: "https://github.com/example/my-project.git" + branch: "main" + # Optional for private repositories; used only by the clean-room clone path. + # readCredentialRef: + # name: my-project-read + # agentRuntime contains only task-level overrides of Agent runtime defaults. agentRuntime: - workspace: - # gitRepo is the repository URL to clone into the agent's workspace - gitRepo: "https://github.com/example/my-project.git" - # branch to checkout - branch: "main" - # maxTurns limits agent loop iterations (overrides Agent's defaultMaxTurns) maxTurns: 100 - # allowBash enables bash command execution (overrides Agent's defaultAllowBash) allowBash: true - # timeout is the maximum duration before the task is terminated timeout: "30m" - # priority controls queue ordering (0-1000, higher = more urgent) priority: 500 diff --git a/config/samples/core_v1alpha1_task_agent_copilot.yaml b/config/samples/core_v1alpha1_task_agent_copilot.yaml deleted file mode 100644 index a7750f50b..000000000 --- a/config/samples/core_v1alpha1_task_agent_copilot.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: Task -metadata: - name: agent-task-copilot-example - labels: - app.kubernetes.io/name: orka - app.kubernetes.io/managed-by: kustomize -spec: - # type: agent delegates work to an external CLI runtime - type: agent - # Reference an Agent configured with runtime.type: copilot - agentRef: - name: copilot-agent - # Prompt sent to the Copilot CLI agent - prompt: "Add unit tests for the authentication middleware" - # Task-level runtime overrides (optional) - agentRuntime: - maxTurns: 30 - # Maximum time before the task is terminated - timeout: "20m" - priority: 500 diff --git a/config/samples/core_v1alpha1_task_agent_workspace.yaml b/config/samples/core_v1alpha1_task_agent_workspace.yaml index c523f027b..76aa27070 100644 --- a/config/samples/core_v1alpha1_task_agent_workspace.yaml +++ b/config/samples/core_v1alpha1_task_agent_workspace.yaml @@ -6,30 +6,42 @@ metadata: app.kubernetes.io/name: orka app.kubernetes.io/managed-by: kustomize spec: - # type: agent delegates work to an external CLI runtime + # type: agent uses the ACP v2 RuntimePool selected from the referenced Agent. type: agent - # Reference an Agent with runtime configured (e.g., claude-agent) agentRef: name: claude-agent - # Prompt for the agent to execute in the cloned workspace prompt: "Fix the failing CI tests in the api/ directory" - # Agent runtime overrides with workspace git clone configuration + # Repository input and delivery policy live at top-level spec.workspace. + workspace: + intent: write + # The clean-room workspace boundary clones this credential-free source URL. + gitRepo: "https://github.com/example/my-project.git" + branch: "main" + # Use ref instead of branch to pin a commit or tag. + # ref: "0123456789abcdef0123456789abcdef01234567" + readCredentialRef: + name: my-project-source-read + # Publication is a separate repository/credential role. The credential is + # resolved only by the Workspace/Publisher and never enters the ACP runtime. + publicationGitRepo: "https://github.com/example/my-project.git" + publicationReadCredentialRef: + name: my-project-publication-read + key: token + publicationCredentialRef: + name: my-project-publication-write + key: token + forgeCredentialRef: + name: my-project-forge + key: token + pushBranch: "orka/fix-api-tests" + prBaseBranch: "main" + createPR: true + # subPath restricts the workspace root to a repository subdirectory. + # subPath: "services/api" + # agentRuntime contains only per-Task runtime overrides. agentRuntime: - workspace: - # gitRepo is the repository URL to clone into the workspace - gitRepo: "https://github.com/example/my-project.git" - # branch to checkout (optional, defaults to repo default branch) - branch: "feature/fix-tests" - # ref can be a specific commit SHA or tag (optional, mutually exclusive with branch) - # ref: "abc123" - # gitSecretRef references a Secret with git credentials for private repos - gitSecretRef: - name: git-credentials - # subPath restricts the workspace root to a subdirectory of the repo - # subPath: "services/api" maxTurns: 100 allowBash: true - # allowedTools overrides the Agent's defaultAllowedTools for this task allowedTools: - Read - Write diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 1a35ce3a4..d45c9ec8b 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -2,16 +2,14 @@ resources: - core_v1alpha1_task.yaml - core_v1alpha1_task_agent.yaml -- core_v1alpha1_task_agent_copilot.yaml - core_v1alpha1_task_agent_workspace.yaml - core_v1alpha1_tool.yaml - core_v1alpha1_agent.yaml - core_v1alpha1_agentruntime.yaml -- core_v1alpha1_agentruntime_agentkit.yaml -- core_v1alpha1_agentruntime_foundry.yaml - core_v1alpha1_agent_claude.yaml - core_v1alpha1_agent_codex.yaml - core_v1alpha1_agent_opencode.yaml +- core_v1alpha1_outboundaccesspolicy.yaml - gateway_v1alpha1_gatewayclass.yaml - gateway_v1alpha1_gateway.yaml - gateway_v1alpha1_gatewaybinding.yaml @@ -21,4 +19,5 @@ resources: - workspace_v1alpha1_executionworkspace.yaml - fake.workspace_v1alpha1_fakeproviderconfig.yaml - fake.workspace_v1alpha1_fakepoolparameters.yaml + # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/scm-egress-proxy/README.md b/config/scm-egress-proxy/README.md new file mode 100644 index 000000000..08a1bc571 --- /dev/null +++ b/config/scm-egress-proxy/README.md @@ -0,0 +1,21 @@ +# SCM egress proxy + +This package is included by `config/default`. Before applying it, create the +shared Publisher/proxy authentication Secret in `orka-system`: + +```bash +token="$(openssl rand -hex 32)" +kubectl -n orka-system create secret generic scm-egress-proxy-auth \ + --from-literal=token="$token" +unset token +``` + +The token must contain 32-256 RFC 3986 unreserved characters (`A-Z`, `a-z`, +`0-9`, `-`, `.`, `_`, or `~`). It authenticates the Publisher to the proxy; it +is not an SCM credential. + +`deployment.yaml` allows only `github.com` plus `api.github.com`. Patch +`--allowed-hosts` and `--forge-api-base-url` together with the Publisher's +`ORKA_PUBLISHER_ALLOWED_SCM_HOSTS` and forge API URL when using GitHub +Enterprise or another reviewed forge endpoint. Hostnames are exact and +lower-case; wildcards and IP literals are rejected. diff --git a/config/scm-egress-proxy/deployment.yaml b/config/scm-egress-proxy/deployment.yaml new file mode 100644 index 000000000..60f61afe5 --- /dev/null +++ b/config/scm-egress-proxy/deployment.yaml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: scm-egress-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy + template: + metadata: + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy + orka.ai/network-role: scm-egress-proxy + spec: + serviceAccountName: scm-egress-proxy + automountServiceAccountToken: false + enableServiceLinks: false + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: proxy + image: controller:latest + imagePullPolicy: IfNotPresent + command: [/scm-egress-proxy] + args: + - --listen-address=:8080 + - --allowed-hosts=github.com + - --forge-api-base-url=https://api.github.com + - --token-file=/var/run/secrets/orka/scm-egress/token + - --max-request-header-bytes=32768 + - --max-response-header-bytes=65536 + - --max-request-bytes=4194304 + - --max-response-bytes=8388608 + - --max-tunnel-bytes=1073741824 + - --max-concurrent=8 + - --resolution-timeout=5s + - --connect-timeout=10s + - --response-header-timeout=30s + - --forward-timeout=2m + - --idle-timeout=30s + - --tunnel-timeout=10m + ports: + - name: http-proxy + containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 25m + memory: 32Mi + ephemeral-storage: 32Mi + limits: + cpu: 500m + memory: 256Mi + ephemeral-storage: 128Mi + readinessProbe: + httpGet: + path: /readyz + port: http-proxy + livenessProbe: + httpGet: + path: /healthz + port: http-proxy + volumeMounts: + - name: auth + mountPath: /var/run/secrets/orka/scm-egress/token + subPath: token + readOnly: true + volumes: + - name: auth + secret: + secretName: scm-egress-proxy-auth + defaultMode: 0440 + items: + - key: token + path: token diff --git a/config/scm-egress-proxy/kustomization.yaml b/config/scm-egress-proxy/kustomization.yaml new file mode 100644 index 000000000..7bc7ce24b --- /dev/null +++ b/config/scm-egress-proxy/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: system +resources: + - serviceaccount.yaml + - service.yaml + - deployment.yaml + - networkpolicy.yaml +images: + - name: controller + newName: ghcr.io/orka-agents/orka + newTag: latest diff --git a/config/scm-egress-proxy/networkpolicy.yaml b/config/scm-egress-proxy/networkpolicy.yaml new file mode 100644 index 000000000..8a0ae6671 --- /dev/null +++ b/config/scm-egress-proxy/networkpolicy.yaml @@ -0,0 +1,70 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: scm-egress-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy + policyTypes: [Ingress, Egress] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: workspace-publisher + ports: + - protocol: TCP + port: 8080 + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - {protocol: UDP, port: 53} + - {protocol: TCP, port: 53} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 0.0.0.0/8 + - 10.0.0.0/8 + - 100.64.0.0/10 + - 127.0.0.0/8 + - 169.254.0.0/16 + - 172.16.0.0/12 + - 192.0.0.0/24 + - 192.0.2.0/24 + - 192.168.0.0/16 + - 198.18.0.0/15 + - 198.51.100.0/24 + - 203.0.113.0/24 + - 224.0.0.0/4 + - 240.0.0.0/4 + ports: + - {protocol: TCP, port: 443} + - to: + - ipBlock: + cidr: ::/0 + except: + - ::/128 + - ::1/128 + - 64:ff9b::/96 + - 64:ff9b:1::/48 + - 100::/64 + - 2001::/32 + - 2001:db8::/32 + - 2002::/16 + - fc00::/7 + - fe80::/10 + - ff00::/8 + ports: + - {protocol: TCP, port: 443} diff --git a/config/scm-egress-proxy/service.yaml b/config/scm-egress-proxy/service.yaml new file mode 100644 index 000000000..e794bb329 --- /dev/null +++ b/config/scm-egress-proxy/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: scm-egress-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy +spec: + selector: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy + ports: + - name: http-proxy + port: 8080 + targetPort: http-proxy diff --git a/config/scm-egress-proxy/serviceaccount.yaml b/config/scm-egress-proxy/serviceaccount.yaml new file mode 100644 index 000000000..744c2bafc --- /dev/null +++ b/config/scm-egress-proxy/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: scm-egress-proxy + labels: + app.kubernetes.io/name: orka + app.kubernetes.io/component: scm-egress-proxy +automountServiceAccountToken: false diff --git a/config/vekil-ingress/kustomization.yaml b/config/vekil-ingress/kustomization.yaml new file mode 100644 index 000000000..b90ff79ed --- /dev/null +++ b/config/vekil-ingress/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - networkpolicy.yaml diff --git a/config/vekil-ingress/networkpolicy.yaml b/config/vekil-ingress/networkpolicy.yaml new file mode 100644 index 000000000..b77cf4896 --- /dev/null +++ b/config/vekil-ingress/networkpolicy.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: orka-provider-auth-proxy-only + namespace: vekil-system + labels: + app.kubernetes.io/managed-by: orka +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: vekil + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: orka-system + podSelector: + matchLabels: + orka.ai/network-role: provider-auth-proxy + ports: + - protocol: TCP + port: 1337 diff --git a/docs/adr/0001-execution-workspace-default-provider.md b/docs/adr/0001-execution-workspace-default-provider.md index 41d60d268..db10e919a 100644 --- a/docs/adr/0001-execution-workspace-default-provider.md +++ b/docs/adr/0001-execution-workspace-default-provider.md @@ -1,5 +1,9 @@ -# Use an explicit default provider for Execution Workspaces +# ADR 0001: Use an explicit default provider for Execution Workspaces -When a Task requests an Execution Workspace without setting `spec.execution.workspace.provider`, Orka resolves the provider from an operator-configured Default Workspace Provider and falls back to `agent-sandbox` for compatibility. Orka does not infer the provider from installed cluster components because ambient detection is ambiguous when multiple providers are installed, stale CRDs remain, or RBAC hides provider resources. +## Status -Standard Worker Execution is not a provider. Tasks that do not request an Execution Workspace keep the existing direct Kubernetes worker Job path. +Superseded by the ACP core RuntimePool cutover. + +The earlier API allowed a Task to select an execution-workspace provider through `spec.execution.workspace.provider`. The current built-in agent path rejects `Task.spec.execution.workspace` and never auto-detects or defaults an upstream provider. + +Current agent repository input and publication policy belongs at top-level `Task.spec.workspace`. Future agent-sandbox or Substrate integration must be explicit, operator-configured, and implemented behind the `orka.harness.v2` RuntimeSession lifecycle; ambient provider discovery remains disallowed. diff --git a/docs/adr/0002-provider-neutral-execution-workspace-status.md b/docs/adr/0002-provider-neutral-execution-workspace-status.md index 2fa5f0e84..7e4b3f6ff 100644 --- a/docs/adr/0002-provider-neutral-execution-workspace-status.md +++ b/docs/adr/0002-provider-neutral-execution-workspace-status.md @@ -1,5 +1,13 @@ -# Report provider-neutral Execution Workspace status +# ADR 0002: Report provider-neutral Execution Workspace status -Tasks that use an Execution Workspace should report a safe, provider-neutral lifecycle summary instead of exposing provider-native objects such as Substrate Actor snapshots or daemon URLs. Workers should emit workspace lifecycle updates through a small authenticated internal Task status endpoint because workers own provider lifecycle operations in the wrapper-first model. The controller records validation and lock failures directly, but it should not poll provider-native resources for normal workspace progress. +## Status -Intermediate workspace status updates are best effort. The Task result path remains the source of command completion, while requested cleanup state must still be reached for a workspace-backed Task to succeed. +Superseded for built-in agent Tasks by ACP v2 execution and delivery status. + +The earlier prototype reported worker-owned upstream workspace lifecycle. Current ACP agent Tasks expose provider-neutral control state through: + +- `Task.status.execution` for the fenced attempt, RuntimePool, RuntimeSession, prompt, and outcome; +- `Task.status.delivery` for workspace validation, clean-room publication, verification, and PR receipts; +- `RuntimePool.status` for lifecycle, admission, exact instance, capacity, and pressure. + +Future execution-workspace providers must project into these Orka-owned surfaces and must not expose provider-native snapshot URIs, daemon URLs, credentials, or mutable child-controlled Git state. diff --git a/docs/adr/0003-use-provider-route-for-workspace-daemon.md b/docs/adr/0003-use-provider-route-for-workspace-daemon.md index bf8346ede..3920f701d 100644 --- a/docs/adr/0003-use-provider-route-for-workspace-daemon.md +++ b/docs/adr/0003-use-provider-route-for-workspace-daemon.md @@ -1,3 +1,9 @@ -# Use the provider route for Workspace Daemon calls +# ADR 0003: Use the provider route for Workspace Daemon calls -For Substrate-backed Execution Workspaces, Orka workers should call the Workspace Daemon through Substrate's router and actor DNS route instead of calling provider-native worker pod IPs directly. This keeps Orka coupled to the provider's stable routing abstraction rather than transient actor placement details. +## Status + +Deferred with the Substrate ACP integration. + +The earlier worker-based Substrate prototype used the provider router and Actor DNS route instead of transient worker Pod IPs. The current ACP RuntimePool path does not call a Substrate workspace daemon. + +If an Actor-backed `orka.harness.v2` supervisor is implemented, it should still use the provider's stable authenticated route rather than provider-native Pod placement details. That routing choice must not weaken exact runtime-instance fencing or expose Git/provider credentials to the wrong process tree. diff --git a/docs/adr/0004-use-minimal-substrate-control-client.md b/docs/adr/0004-use-minimal-substrate-control-client.md index 5637de7a2..2fecda954 100644 --- a/docs/adr/0004-use-minimal-substrate-control-client.md +++ b/docs/adr/0004-use-minimal-substrate-control-client.md @@ -1,3 +1,7 @@ -# Use a minimal Substrate control API client +# ADR 0004: Use a minimal Substrate control API client -Orka should generate or vendor only the small Substrate API surface needed for actor lifecycle operations instead of importing the full `github.com/agent-substrate/substrate` Go module. Orka needs narrow gRPC clients for the public control API and, when implementing checkpoint/restore features, the specific Ateom/AteomHerder checkpoint services. Avoiding the full module reduces Kubernetes dependency churn and cloud-provider transitive dependencies while still keeping capability-specific proto clients available inside `internal/substratepb`. +## Status + +Deferred with the Substrate ACP integration. + +A future Actor-backed RuntimeSession provider should continue to generate or vendor only the narrow Substrate lifecycle API surface it needs, rather than importing the full Substrate module. Any checkpoint/restore client must remain capability-specific and must not imply ACP prompt replay, provider-session restore, or publication from an Actor-controlled workspace. diff --git a/docs/adr/0005-require-explicit-substrate-api-trust.md b/docs/adr/0005-require-explicit-substrate-api-trust.md index 91b3d371d..cbc5c701a 100644 --- a/docs/adr/0005-require-explicit-substrate-api-trust.md +++ b/docs/adr/0005-require-explicit-substrate-api-trust.md @@ -1,5 +1,9 @@ -# Require explicit trust for the Substrate control API +# ADR 0005: Require explicit trust for the Substrate control API -Orka should connect to the Substrate control API with explicit TLS trust configuration instead of normalizing insecure TLS verification. Local kind setups may opt into insecure mode for development, but production configuration should provide a CA or equivalent trust material for lifecycle calls that create, resume, suspend, and delete Substrate Actors. +## Status -The Substrate API trust setting belongs to provider installation/configuration, not to individual Tasks. Tasks select a Workspace Provider and Template; they do not choose whether provider control-plane TLS is trusted. +Accepted as a requirement for any future Substrate ACP provider; not active in the current Kubernetes RuntimePool path. + +A future integration must connect to Substrate with explicit TLS trust material. Local kind evaluation may opt into insecure verification, but production configuration must provide a reviewed CA or equivalent trust anchor for Actor lifecycle calls. + +Trust configuration belongs to the operator-managed provider/runtime profile, never an individual Task. Tasks do not choose whether control-plane TLS is trusted. diff --git a/docs/adr/0006-use-wrapper-first-execution-workspace-providers.md b/docs/adr/0006-use-wrapper-first-execution-workspace-providers.md index 7db4d5d0c..8e493bbb2 100644 --- a/docs/adr/0006-use-wrapper-first-execution-workspace-providers.md +++ b/docs/adr/0006-use-wrapper-first-execution-workspace-providers.md @@ -1,11 +1,17 @@ -# Use wrapper-first Execution Workspace providers +# ADR 0006: Use wrapper-first Execution Workspace providers Date: 2026-05-21 ## Status -Superseded as the target architecture by ADR 0012 and ADR 0014. Retained as historical and transitional context for the legacy direct-provider path. +Superseded by the ACP core RuntimePool cutover. -Orka should add new Execution Workspace providers behind the existing worker-wrapper path before moving provider lifecycle ownership into the controller. The controller validates, locks, and creates the normal worker Job; the outer worker claims the selected provider, stages the inner worker, runs it inside the workspace, reports provider-neutral status, and applies cleanup. This preserves Orka's existing worker auth, result submission, artifact upload, and agent runtime behavior while isolating provider-specific lifecycle code behind `WorkspaceExecutor`. +## Original decision -Controller-direct execution can be reconsidered after Substrate is stable enough to justify tighter lifecycle orchestration and stronger controller-owned observability. +The original execution-workspace prototype placed provider ownership in a per-Task worker path: the controller created a worker Job, the worker claimed an upstream workspace, staged another worker process, and handled result submission and cleanup. + +## Superseding decision + +Built-in `type: agent` Tasks now use only `orka.harness.v2` RuntimePools and private RuntimeSessions. There is no per-Task agent Job or worker-based fallback. Top-level `Task.spec.workspace` defines verified repository input and clean-room publication policy; `Task.spec.execution.workspace` is rejected by the ACP core runtime. + +Future agent-sandbox or Substrate support must host one RuntimeSession behind the same v2 lifecycle. Orka remains authoritative for attempt/session fences, prompt leases, cancellation, transcript/finalization, workspace deltas, publication, and delivery receipts. Source-read, target-read, target-write, and forge credentials remain outside the ACP process tree. diff --git a/docs/adr/0007-substrate-actor-pool-oversubscription.md b/docs/adr/0007-substrate-actor-pool-oversubscription.md index cf7618b1f..b5dcd7f07 100644 --- a/docs/adr/0007-substrate-actor-pool-oversubscription.md +++ b/docs/adr/0007-substrate-actor-pool-oversubscription.md @@ -1,9 +1,11 @@ -# Model Substrate oversubscription as controller-owned actor pools +# ADR 0007: Model Substrate oversubscription as controller-owned actor pools -Substrate oversubscription should be exposed through an Orka-owned actor pool abstraction rather than by overloading per-Task workspace fields. A pool represents a bounded Substrate WorkerPool plus a target actor density, such as 250 stateful actors across 8 workers, and the controller reconciles pool membership, queued claims, and cleanup pressure against that budget. Tasks still select an Execution Workspace provider and template; they may reference a pool, but they should not choose individual workers or actor pods. +## Status -The pool controller should own scheduling and juggling decisions. It can pre-create or retain suspended actors, resume actors when workers have capacity, suspend idle actors to free workers, and use Substrate `ListActors` and `ListWorkers` to publish density and placement health. Workers continue to own command execution through the wrapper-first path, so result submission, artifact upload, token handling, and provider-neutral Task status stay unchanged. +Deferred and superseded in part by the ACP RuntimePool model. -The first implementation slice should keep Task status provider-neutral: surface density, placement, and latency, but do not expose raw Substrate snapshot URIs, daemon URLs, or tokens. Later snapshot restore work can reuse the vendored checkpoint/restore clients, but restoring arbitrary snapshots should be a pool/controller action with explicit template compatibility checks instead of a worker-side shortcut. +The current first-release pool is a controller-owned logical ACP `RuntimePool` with one active Kubernetes Pod, bounded resident RuntimeSessions, bounded concurrent prompts, drain, and scale-to-zero behavior. Tasks do not choose individual workers or Pods. -This keeps oversubscription an operator-controlled capacity feature, avoids encoding provider-native pod choices into user Tasks, and leaves room for MCP tool-actors to reuse the same pool machinery when Orka adds durable tool-hosting actors. +A future Substrate implementation may map that logical pool to a bounded WorkerPool and Actor density, but each Actor must host one fenced `orka.harness.v2` RuntimeSession. Orka remains authoritative for queueing, admission, prompt leases, outcome classification, validation, publication reservations, and cleanup. Actor suspension or juggling must not occur while prompt, validation, publication, finalization, or Session lease work is active. + +Provider-native density and placement may be summarized safely in pool status. Raw snapshot URIs, daemon URLs, tokens, and arbitrary restore controls must not appear in Task status. diff --git a/docs/adr/0008-runtime-session-internal-store-first.md b/docs/adr/0008-runtime-session-internal-store-first.md index f5e518a7d..ddded1be8 100644 --- a/docs/adr/0008-runtime-session-internal-store-first.md +++ b/docs/adr/0008-runtime-session-internal-store-first.md @@ -4,25 +4,41 @@ Date: 2026-06-11 ## Status -Accepted for the first frontier implementation wave. +Superseded for ACP control authority by the Kubernetes hard cutover. Retained as +the historical record for why RuntimeSession was not initially exposed as a +public process-management CRD. ## Context -The remaining frontier introduces backend-neutral runtime sessions that can be claimed, reused, released, retained, suspended, and deleted. The lifecycle must support non-Substrate providers first while keeping Agent Substrate optional. Runtime sessions require strict namespace ownership and cleanup semantics, but the first provider still needs to prove the turn protocol and conformance suite before operators need a public CRD surface. +The remaining frontier introduces backend-neutral runtime sessions that can be claimed, reused, released, retained, suspended, and deleted. The lifecycle must support non-Substrate providers first while keeping Agent Substrate optional. Runtime sessions require strict namespace ownership, exact instance fencing, and cleanup semantics. The Kubernetes RuntimePool provider and v2 conformance suite now supply the first implementation seam without requiring a public RuntimeSession CRD. ## Decision -Start with an internal RuntimeSession state model and persistence boundary, then add a CRD only after the non-Substrate provider and cleanup loop have stable status requirements. +The original decision was to start with an internal RuntimeSession state model +and persistence boundary, then add a CRD only after the non-Substrate provider +and cleanup loop had stable status requirements. -The frozen state machine lives in `internal/harness` so the controller/provider implementation can share validation. Public API/CLI visibility can read from the internal store initially and later migrate to a CRD-backed implementation without changing the turn protocol. +The frozen state machine still lives in `internal/harness/v2` so the controller +and supervisor share validation. The hard cutover now makes +`RuntimeSessionControl` and the other ACP control CRDs authoritative through +status `resourceVersion` CAS, with coordination Leases for controller epoch and +session mutation ownership. SQLite remains the payload store for transcripts, +SessionTurns, deferred outbox projections, and artifacts. ## Consequences -- Provider integration can ship behind feature gates without expanding the Kubernetes API surface prematurely. -- Namespace ownership, cleanup policies, and transition validation are testable before persistence is introduced. -- Operator visibility is initially API/CLI-driven rather than `kubectl get runtimesessions`. -- A future CRD migration must preserve IDs, owner metadata, cleanup policy, active task, provider, phase, idle timeout, and max lifetime. +- Provider-private process details remain inside the runtime; the public control + record stores only Orka-owned lifecycle, fences, leases, and safe receipts. +- Namespace ownership, cleanup policy, and transition validation are enforced + by Kubernetes-authoritative records rather than SQLite control rows. +- Operator visibility is available through Task/RuntimePool status, the API/CLI, + and the ACP control CRDs. +- Cross-store finalization must preserve session UID/generation, + pool/runtime/controller fences, the active attempt/prompt, publication state, + transcript continuity, and the exact deferred outbox projection. ## Revisit -Revisit once the non-Substrate provider passes conformance and the cleanup controller needs watch/reconcile semantics that are awkward for an internal store. +Any future public process-detail API must remain a projection of this authority. +It must not introduce prompt replay, split authority between SQLite and +Kubernetes, or weaken v2 duplicate/fencing rules. diff --git a/docs/adr/0009-agentkit-byoa-harness-runtime-boundary.md b/docs/adr/0009-agentkit-byoa-harness-runtime-boundary.md deleted file mode 100644 index 610a7ce40..000000000 --- a/docs/adr/0009-agentkit-byoa-harness-runtime-boundary.md +++ /dev/null @@ -1,51 +0,0 @@ -# ADR 0009: Register bring-your-own agent runtimes through AgentRuntime - -Date: 2026-06-26 - -## Status - -Accepted for the first bring-your-own agent runtime implementation slice. - -## Context - -Orka routes `type: agent` tasks through `AgentRuntime`, an Orka-facing runtime contract backed by `orka.harness.v1`. The remote execution backend may be a generic self-hosted HTTP runtime, AgentKit Serve, Azure AI Foundry hosted agents, or a future backend. Orka should not parse backend authoring formats or accept arbitrary per-Agent images and commands. - -Backend-specific protocol skins are adapter-owned and live outside the Orka source tree. Orka keeps only the provider-neutral protocol, public Go aliases, conformance checks, reference fixtures, and facade samples. Orka needs a stable Kubernetes-native boundary: the `AgentRuntime` registry, readiness/conformance, runtime routing, task provenance, capability tiers, and brokered-governance hooks. - -## Decision - -Orka consumes only the `orka.harness.v1` endpoint for bring-your-own agent runtimes. - -For the first milestone, Orka supports `AgentRuntime.spec.deployment.mode: external-endpoint`. The runtime is pre-deployed outside the `AgentRuntime` controller, and Orka validates the endpoint before any `Task` may route to it. Managed adapter Deployments are explicitly deferred until the protocol seam is proven. - -`AgentRuntime` is **namespaced in this first slice**. Issue #160 describes the long-term cluster-scoped, admin-owned registry, but the external-endpoint milestone intentionally mirrors the existing namespaced `Provider` governance pattern so namespace owners can validate the protocol seam without introducing cluster-wide admission or runtime allowlists in the same PR. Admin governance is still required for Secret use: harness bearer-token Secrets must explicitly opt in with `orka.ai/agent-runtime-auth: "true"`, may scope themselves to a single runtime with `orka.ai/agent-runtime-name`, and must bind themselves to the intended endpoint with `orka.ai/agent-runtime-endpoint`. A future follow-up can add a cluster-scoped registry or namespace allowlist once managed/dedicated-image runtimes and the BYO trust tier are designed. - -Agents select remote execution backends with `spec.runtime.runtimeRef`. Built-in CLI runtimes continue to use `spec.runtime.type: codex|claude|copilot` and the shared harness-wrapper endpoint. An Agent must set exactly one of `type` or `runtimeRef`. - -Tool execution has capability tiers: - -- **Observed mode:** the remote execution backend may use its own internal tools; Orka records lifecycle, output, and terminal result from harness frames, but cannot govern backend-internal side effects. -- **Brokered mode:** brokered governance is the target model. Remote backends request Orka Tool execution, and Orka owns authorization, approval, idempotency-key injection, credential resolution, execution/brokering, events, lineage, and audit. Remote backends must not receive production Orka Tool credentials. - -Fibey remains one acceptance scenario, but the demo should show the same Orka API and approval UX across generic HTTP, AgentKit Serve, and Foundry-backed `AgentRuntime` facades. Foundry and AgentKit are backend options, not the architecture. - -## Consequences - -- Orka has a first-class `AgentRuntime` registry and readiness condition without exposing arbitrary Agent image/command fields. -- `AgentRuntime` status carries sanitized observed capabilities and `observedGeneration`, which lets task routing fail closed on non-ready or stale runtime definitions. -- Harness bearer token Secrets must opt in with `orka.ai/agent-runtime-auth: "true"`, may scope themselves to one runtime with `orka.ai/agent-runtime-name`, and must bind to the intended endpoint with `orka.ai/agent-runtime-endpoint`, preventing AgentRuntime authors from using the controller as a generic Secret exfiltration path. -- Built-in CLI runtime behavior remains backward compatible. -- External runtimes can prove the cross-repo contract without placing provider-specific implementations in the Orka repository or making Orka own Deployment, ServiceAccount, NetworkPolicy, image policy, or secret-delivery hardening. -- Brokered Orka Tool execution is opt-in by capability and policy; observed mode must not be marketed as full brokered governance. - -## External adapter references - -- Microsoft Foundry Hosted Agents (Responses API): [`orka-agents/agent-runtime-foundry`](https://github.com/orka-agents/agent-runtime-foundry) -- Microsoft Foundry Agent Service classic (Threads/Runs API): [`orka-agents/agent-runtime-foundry-classic`](https://github.com/orka-agents/agent-runtime-foundry-classic) - -## References - -- `docs/development/harness-protocol-mvp.md` -- `docs/development/harness-conformance.md` -- `internal/harness/` -- Planned issue direction: bring-your-own agent runtimes as governed `AgentRuntime` entries rather than free-form per-Agent images. diff --git a/docs/adr/0009-defer-runtime-session-ui-until-public-api.md b/docs/adr/0009-defer-runtime-session-ui-until-public-api.md index 21562a7f4..955748e96 100644 --- a/docs/adr/0009-defer-runtime-session-ui-until-public-api.md +++ b/docs/adr/0009-defer-runtime-session-ui-until-public-api.md @@ -4,7 +4,7 @@ Date: 2026-06-13 ## Status -Accepted. +Accepted; updated for the ACP v2 cutover. ## Context @@ -13,13 +13,13 @@ fork) is shipping in the web UI. Its final phase asks whether the UI should also surface RuntimeSession lifecycle (claim, reuse, release, retain, suspend, delete). -Per ADR 0008, RuntimeSession persistence starts as an internal `internal/harness` -state model with **no public CRD or HTTP API**. A scan of the server routes -(`internal/api/server.go`) confirms there is currently no -`runtimesession`/`runtime-session` endpoint — `RuntimeSession*` types exist only -in `internal/harness` as the turn-protocol contract. The only session-scoped HTTP -surfaces are the conversation-session endpoints (`/api/v1/sessions/...`), which -are unrelated to runtime sessions. +The ACP hard cutover now stores RuntimeSession control, ownership, lifecycle, +and mutation-lease state in Kubernetes-authoritative +`RuntimeSessionControl` records and coordination Leases. The public HTTP surface +still exposes Task execution/delivery projections and read-only RuntimePool +endpoints rather than direct RuntimeSession mutation. Conversation session +endpoints (`/api/v1/sessions/...`) remain a separate canonical-transcript +surface backed by SQLite payload storage. The UI follow-up plan is explicit that the UI must not invent backend behavior or call endpoints that do not exist. @@ -31,13 +31,14 @@ public runtime-session API exists. No runtime-session API client, hook, route, o component ships in this follow-up. This ADR is the documented follow-up that the plan requires. -When a public API does land (reading from the internal store first, then a -CRD-backed implementation per ADR 0008), add a feature-gated runtime-session view -that surfaces, per session: +When a public read API does land, it must project the Kubernetes-authoritative +control record rather than create a second SQLite authority. Add a +feature-gated runtime-session view that surfaces, per session: - runtime session id - namespace -- provider +- RuntimePool and exact runtime instance identity +- provider/model profile - state / phase - active task (linkable to task detail) - idle age (and idle timeout) @@ -54,14 +55,13 @@ execution-event surfaces already use. - No UI depends on unimplemented runtime-session backend behavior; nothing calls a nonexistent endpoint. -- The field list above is fixed up front, matching the migration-preserving fields - in ADR 0008, so a future implementation has a clear target. +- The field list above is fixed up front and must remain a safe projection of + `RuntimeSessionControl`, RuntimePool identity, and non-secret transcript data. - When the API ships, this is additive UI work behind a capability check rather than a redesign. ## Revisit -Revisit when the non-Substrate provider passes conformance and a public -runtime-session read API (internal-store-backed or CRD-backed) is exposed under -`/api/v1`. At that point, implement the feature-gated view described above and -update the UI guide (`website/docs/guides/ui.md`). +Revisit when a public RuntimeSession read API is exposed under `/api/v1`. At +that point, implement the feature-gated view described above and update the UI +guide (`website/docs/guides/ui.md`). diff --git a/docs/adr/0016-harness-migration-strategy.md b/docs/adr/0016-harness-migration-strategy.md new file mode 100644 index 000000000..40e3ad4fc --- /dev/null +++ b/docs/adr/0016-harness-migration-strategy.md @@ -0,0 +1,80 @@ +# ADR 0016: Select full active coexistence for the harness v1/v2 migration + +Date: 2026-08-05 + +## Status + +Superseded by ADR 0018 on 2026-08-07. This record is retained as the historical +full-active-coexistence decision; it is no longer an implementation or release +contract. + +Previously accepted (engineering). Product and operations countersign are recorded in the +release tracker before the first dual release ships; implementation may proceed +on this record, but canary admission (Phase 7 of the plan) must not open without +the countersign. + +## Context + +The `acp` line is an intentional v2-only hard cutover: the turn-oriented +harness-wrapper path (`workers/harness`, `cmd/orka-agent-harness-wrapper`, +`internal/controller/harness_wrapper.go`) exists only on `origin/main` +(`21f8ef15`), while the ACP v2 RuntimePool path exists only on `acp` +(`77bdc4db`). The two controllers cannot run concurrently against one Task +population, the v2 CRD prunes v1-only fields, `runtime.type: opencode` exists in +both baselines with incompatible configuration contracts, and the wrapper keeps +active turns in process memory. Installations with in-flight v1 work therefore +have no safe upgrade to v2 today. + +`docs/harness-v1-v2-coexistence-plan.md` (Revision 4) evaluates three +strategies and specifies the full-coexistence design. Its Phase 0 gate requires +one strategy to be selected before implementation. + +## Decision + +Select **full active coexistence** (plan §2.3 option 3) over blue/green +replacement (option 1) and the zero-active-state in-place bridge (option 2). + +Decision drivers, recorded per the plan's quantitative gate: + +- **Continuity:** in-flight v1 Tasks, wrapper turns, and v1 Sessions must be + preserved and remain continuable; Session continuity is mandatory for the + primary installation, which is sufficient under the plan's "one high-value + installation" rule. Blue/green abandons in-flight runtime state; the + zero-active bridge requires a full drain window. +- **Canaries:** v2 must be provable with same-cluster canaries under the same + controller ownership scope before v1 admission closes. +- **Downtime:** near-zero; no maintenance window long enough for a full drain + is available on the primary installation. +- **External usage:** external v1 `runtimeRef` runtimes and legacy v1 OpenCode + Agents are assumed present (fail-closed assumption); both are preserved + during the window under the compatibility policy. +- **Rollback:** consequential external effects (publication, PRs) require the + checkpointed in-place rollback and coordinated restore procedures of plan + §14; Helm rollback alone is insufficient in every option. +- **Cost:** full coexistence carries the highest build and retirement cost; + that cost is accepted and time-bounded below. +- **Operator-supplied at rollout:** affected cluster/customer counts and + active-turn volume are installation-specific and are captured in the + pre-upgrade inventory report (plan §15) rather than in this record. + +Compatibility window: the owner is the repository owner (sozercan). Target +retirement: v1 admission moves to `drain-only` by default two minor releases +after the first dual release and the v1 data plane is removed one release +later, with a maximum window of six months from the first dual release unless +this ADR is superseded. + +Blue/green replacement and the zero-active-state bridge are rejected for this +line, and their reduced plans are not produced. Per plan §2.3 this selection +scopes all later phases to the full-coexistence design. + +## Consequences + +- The remainder of `docs/harness-v1-v2-coexistence-plan.md` is the implementation + contract; ADR 0017 records its architecture. +- The v1 execution plane is restored and maintained for the bounded window, + including build, scanning, and CI lanes for both image families. +- Until the compatibility release and its preflight tooling exist, simultaneous + v1 and v2 operation still requires separate clusters or non-overlapping + control planes. +- Retirement is a gated, staged operation (plan §12 Phase 8, §15), not a + side effect of the window expiring. diff --git a/docs/adr/0017-harness-coexistence-architecture.md b/docs/adr/0017-harness-coexistence-architecture.md new file mode 100644 index 000000000..e7089b62d --- /dev/null +++ b/docs/adr/0017-harness-coexistence-architecture.md @@ -0,0 +1,113 @@ +# ADR 0017: Harness v1/v2 coexistence architecture and ownership contracts + +Date: 2026-08-05 + +## Status + +Superseded by ADR 0018 on 2026-08-07. This record is retained as the historical +architecture for the rejected shared-population design; it is no longer an +implementation or release contract. + +Previously accepted. Implements the strategy selected in ADR 0016; the normative +specification is `docs/harness-v1-v2-coexistence-plan.md` (Revision 4). Where +this record and the plan disagree, the plan governs. + +## Context + +One compatibility release must run harness v1 (turn-oriented wrapper and +external v1 endpoints) and harness v2 (ACP RuntimePools) against the same Task +population without inference, fallback, cross-dispatch, or protocol mutation, +and must retire v1 as a separately gated operation. + +## Decision + +**Ownership.** One controller binary and exactly one controller ownership +scope reconcile agent Tasks. Leader election is mandatory. The global Lease is +`orka-system/orka-agent-execution` and never varies by release or namespace. +Every legacy `03b49a10.orka.ai` Lease is enumerated, acquired in deterministic +order, and continuously renewed as a migration fence recorded in +`AgentExecutionControl.status.ownership`; loss of any fence closes readiness +and stops every mutating runnable. While SQLite is the payload store the +controller runs exactly one Pod, `Recreate` rollout, and a process-lifetime +exclusive filesystem lock; every mutating SQLite-backed endpoint and background +writer is leader-gated. Kubernetes control CRDs and Leases are authoritative +for lifecycle, fences, and CAS; SQLite persists payloads, ledgers, outbox +projections, and artifacts only (extends ADR 0008). + +**API surface.** `core.orka.ai/v1alpha1` remains the only Kubernetes API +version; harness v1/v2 are protocol values. A bridge CRD wave installs the +full v1+v2 field superset with variant fields as optional pointers, no +protocol defaults, and discriminator CEL; enforcement admission then ratchets: +new built-in Agents require an immutable `spec.runtime.contractVersion` +(`orka.harness.v1|orka.harness.v2`), `AgentRuntime.spec.contractVersion` +accepts both values and is immutable, and unchanged historical v1 objects +(including legacy Task `spec.agentRuntime.workspace` fields and +`status.harnessRuntime`) round-trip without pruning. A missing selector is +never interpreted as either protocol; `runtime.type: opencode` is never +protocol evidence. + +**Binding.** Every executable agent Task gets, before any executor side +effect, a write-once, immutable, uncached compare-if-absent +`status.agentExecutionBinding` that freezes protocol, backend, mode revision, +policy identity, Agent/runtime UIDs, and a content-addressed immutable +execution snapshot of every non-secret executable input. Dispatchers build +requests only from the snapshot. Snapshot bodies are encrypted at rest, +reference-retained, and exported only through a privileged audited operation. +Adoption of pre-existing Tasks consults v2 authoritative stores first, then +durable v1 evidence, and quarantines ambiguity immutably +(`agentExecutionQuarantine`); proven no-route-state deletion records immutable +`UnboundNoExecution`. There is no fallback in either direction, ever. + +**Dispatch.** Two isolated leader-gated dispatchers consume durable demand +carrying the binding digest: `HarnessV1Dispatcher` (durable attempt state +machine, wrapper admission ledger closing only pre-first-frame ambiguity, +`SubmittedUnknown`/`OutcomeUnknown` without replay) and the existing +`ACPDispatcher` (unchanged v2 fencing, publication, external-effect, and +`OutcomeUnknown` semantics; RuntimePool side effects only after binding). +External v2 `runtimeRef` dispatch stays fail-closed. + +**Sessions.** Every agent Session durably records lineage (namespace UID, +Session UID, contract version, lineage generation, runtime identity, snapshot +digest, provenance) claimed atomically with the Session lease. Implicit +cross-protocol continuation is rejected; migration is an explicit +transcript-bootstrap creating a new Session UID. Terminal settlement is one +atomic order: validate Kubernetes authority → commit SQLite payload plus +inactive outbox → CAS Kubernetes control and release the lease → activate the +projection. Ambiguity settles the Session `ReconciliationBlocked`, never +`Available`. + +**Modes and admission.** Backend admission modes (`enabled → closing → +drain-only → disabled`) live in the singleton `AgentExecutionControl` with +UID/generation/modeRevision CAS and durable binding reservations; the +controller-serialized closing barrier is the linearization point. New v1 +admission additionally requires an admin-owned `AgentExecutionPolicy` bound by +UID/generation/digest. Fail-closed admission (parameterized +ValidatingAdmissionPolicy plus `failurePolicy: Fail` webhooks) is served by a +separate stateless replicated `orka-admission` Deployment, never by the +singleton controller Pod; narrowly scoped API-server match conditions exempt +only cleanup-safe controller writes. Quarantine and blocked Sessions exit only +through automatic receipt-based recovery or an immutable, fenced, admin-only +`AgentExecutionAdjudication`; original evidence is never rewritten. + +**OpenCode.** Legacy v1 OpenCode Agents are preserved for the window +(sealed-inventory adoption only; no new v1 OpenCode bindings); new OpenCode is +v2-only through the managed digest-pinned RuntimePool profile with +provider-qualified model IDs and reviewed limits. v1→v2 OpenCode migration +creates a new Agent UID and new Session lineage; nothing is patched in place. +Omitted versus explicit-empty tool allowlists remain distinct security states +everywhere. + +## Consequences + +- Protocol choice is always an explicit, durable, immutable fact; every + executor record carries the binding digest, and cross-dispatch is + structurally impossible rather than merely avoided. +- The plan's schemas (§6.2–§6.5, §7.7, §9.3), verification matrix (§13), + rollback checkpoints (§14), and retirement proofs (§15) are binding release + gates; `scripts/upgrade-orka-crds.sh` remains the v2-only hard-cutover tool + and is not the coexistence migration path. +- V1 is a compatibility trust tier: no strict-governance claims, no new direct + publication, credential-free public-read-only new workloads, warning + events/metrics on every admitted v1 Task. +- The wrapper Pod template is never mutated while active turns exist without a + completed durable drain. diff --git a/docs/adr/0018-static-harness-modes-and-namespace-isolation.md b/docs/adr/0018-static-harness-modes-and-namespace-isolation.md new file mode 100644 index 000000000..47ac5887c --- /dev/null +++ b/docs/adr/0018-static-harness-modes-and-namespace-isolation.md @@ -0,0 +1,114 @@ +# ADR 0018: Use static harness modes and namespace-isolated control planes + +Date: 2026-08-07 + +## Status + +Accepted. Supersedes ADR 0016 and ADR 0017. The normative rollout and +verification contract is `docs/harness-v1-v2-coexistence-plan.md` Revision 8. + +## Context + +ADR 0016 selected full active coexistence, and ADR 0017 placed harness v1 and +harness v2 behind one controller ownership scope and one Task population. That +design safely preserved ambiguous in-flight state, but required three new CRDs, +dynamic backend modes, immutable route bindings and snapshots, legacy +classification, quarantine and adjudication, binding reservations, and a +cluster-global ownership fence. + +The actual product requirement is narrower: keep a legacy v1 installation +available while a new v2 installation is proven on the same Kubernetes +cluster. Existing work does not need to change protocols, and a Session does +not need to continue across protocols. Kubernetes namespaces can provide the +ownership boundary if controller caches, RBAC, storage, Leases, endpoints, and +data planes are isolated with them. + +CRDs remain cluster-scoped. Separate releases therefore cannot independently +evolve incompatible CRD schemas, even when every custom resource is +namespaced. + +## Decision + +Run harness v1 and harness v2 as two independent Orka installations. Every +controller requires exactly one static startup mode: + +- `harness-v1` enables the legacy wrapper path and does not enable ACP agent + execution; +- `harness-v2` enables ACP RuntimePool/RuntimeSession execution and does not + enable the legacy wrapper path. + +`dual`, `auto`, and `harness-v1-drain` are not modes. Mode is not a runtime +setting and cannot be changed in place. + +Every controller requires a non-empty `--watch-namespace`. That namespace must +carry `orka.ai/controller-mode` with the exact controller mode. A missing or +mismatched claim fails startup. Leader election remains mandatory, but its +Lease is scoped to the watched namespace rather than a cluster-global harness +ownership namespace. + +Same-cluster coexistence requires different: + +- release names and controller namespaces; +- watched workload namespaces; +- API Services and producer endpoints; +- ServiceAccounts and namespace-enforcing RBAC; +- leader-election Leases; +- SQLite stores, PVCs, backups, and Secrets; +- wrapper, worker, publisher, proxy, and runtime data-plane resources. + +The v2 installation is the sole owner of cluster-scoped gateway and +workspace-provider reconcilers. Cluster-scoped CRDs and common admission +resources have one designated platform owner and are not independently owned by +both Helm releases. + +One shared CRD bundle preserves the supported v1 and v2 object shapes. Contract +selectors may remain where needed to discriminate that schema, but a selector +must match controller mode and cannot route a Task to another protocol. +`runtime.type: opencode` is not a protocol selector. + +There is no supported migration between protocols: + +- no Task, Agent, or AgentRuntime changes its execution protocol; +- no Session or transcript continues under the other protocol; +- no controller cancels, settles, finalizes, publishes, or cleans up work from + the other installation; +- no controller database, PVC, ledger, or runtime store is reused under the + other mode. + +Creating an object in the other installation creates new work with a new +namespace, UID, Session lineage, attempt history, and external-effect history. + +The architecture does not include `AgentExecutionControl`, +`AgentExecutionPolicy`, or `AgentExecutionAdjudication`. It also does not +include shared-population binding, classification, quarantine, adjudication, +or dynamic backend-mode machinery. + +V1 draining is operational: stop its ingress and producers, revoke create +permission, inventory and settle existing v1 work, then remove the v1 release. +The controller does not acquire a third drain mode. + +## Consequences + +- V1 and v2 can be canaried on one cluster without sharing execution ownership + or state. +- The three coexistence CRDs and most shared-population coordination machinery + are unnecessary. +- Protocol selection is obvious from the installation endpoint, watched + namespace, and static mode. +- Mode changes require a new namespace and installation. This deliberately + trades in-place migration for a smaller safety surface. +- Pre-static implicit-v2 installations likewise require a fresh static-v2 + release because their in-flight attempts lack the new execution authority; + supported upgrades begin only after a release declares its static mode. +- Operators temporarily manage two endpoints, stores, data planes, and backup + sets. +- Existing v1 Tasks and Sessions remain on v1 until they finish or are + canceled. They cannot be resumed on v2. +- Rollback redirects future submissions; it does not convert accepted v2 work + into v1 work. +- A designated platform owner must coordinate every CRD upgrade and preserve + both shapes for the full compatibility window. +- Namespace-scoped caches without namespace-enforcing RBAC are insufficient; + isolation tests must prove cross-plane reads and writes are forbidden. +- Historical v1 objects may outlive the v1 data plane and keep the shared + schema broad until retention and archival requirements are complete. diff --git a/docs/development/acp-crd-hard-cutover.md b/docs/development/acp-crd-hard-cutover.md new file mode 100644 index 000000000..13d486bad --- /dev/null +++ b/docs/development/acp-crd-hard-cutover.md @@ -0,0 +1,218 @@ +# ACP v2 CRD hard cutover + +`scripts/upgrade-orka-crds.sh` is the only supported helper for replacing a +cluster that may still contain `orka.harness.v1` AgentRuntime state. It is a +fail-closed gate, not a migration tool. It never migrates or deletes +AgentRuntime, Agent, or GatewayBinding objects. + +Do not store backups, verification markers, or copied SQLite data in the +repository. They may contain sensitive operational data. + +## 1. Create and verify both backups + +Create a consistent backup of the controller SQLite/PVC state. A CSI snapshot, +offline PVC archive, raw SQLite backup, or provider snapshot receipt is +acceptable as the `--sqlite-backup` file, but the underlying backup must be +restore-tested or independently inspected before its marker is written. A +snapshot receipt file must identify the immutable snapshot that was tested. + +Export the pre-cutover CR inventory as one Kubernetes JSON List before deleting +or migrating legacy objects: + +```bash +set -Eeuo pipefail +umask 077 + +context=sertac-aks +backup_dir=/absolute/secure/path/orka-acp-cutover +install -d -m 0700 "$backup_dir" +chmod 0700 "$backup_dir" + +cluster_uid="$( + kubectl --context "$context" get namespace kube-system \ + -o jsonpath='{.metadata.uid}' +)" +api_server_identity_sha256="$( + kubectl --context "$context" config view --minify --flatten -o json \ + | jq -c ' + .clusters + | if length == 1 then + .[0].cluster + | { + server: (.server // null), + certificateAuthorityData: (.["certificate-authority-data"] // null), + insecureSkipTLSVerify: (.["insecure-skip-tls-verify"] // null), + tlsServerName: (.["tls-server-name"] // null), + proxyURL: (.["proxy-url"] // null), + disableCompression: (.["disable-compression"] // null) + } + else + error("expected exactly one target cluster") + end + ' \ + | shasum -a 256 \ + | awk '{print tolower($1)}' +)" + +cr_tmp="$(mktemp "$backup_dir/.orka-crs.XXXXXX")" +cleanup_cr_tmp() { + rm -f "$cr_tmp" +} +trap cleanup_cr_tmp EXIT + +if ! kubectl --context "$context" get \ + agentruntimes.core.orka.ai,agents.core.orka.ai,gatewaybindings.gateway.orka.ai \ + --all-namespaces -o json >"$cr_tmp"; then + echo "CR inventory capture failed; refusing to create a backup" >&2 + exit 1 +fi +if ! jq -e '.kind == "List" and (.items | type == "array")' \ + "$cr_tmp" >/dev/null; then + echo "CR inventory validation failed; refusing to create a backup" >&2 + exit 1 +fi +chmod 0600 "$cr_tmp" +mv "$cr_tmp" "$backup_dir/orka-crs.json" +trap - EXIT +``` + +After verifying each backup, create a digest-bound operator attestation. Use +`kind=sqlite-pvc` for the SQLite/PVC artifact and `kind=orka-crs` for the CR +inventory: + +```bash +write_verified_marker() { + kind="$1" + backup="$2" + marker="$3" + digest="$(shasum -a 256 "$backup" | awk '{print tolower($1)}')" + verified_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + cat >"$marker" < Booting -> Ready -> TurnRunning -> Idle -> Releasing -> Deleted - | | +-> Retained - | | +-> Suspended - | +-> Deleting -> Deleted - +-> Failed/Unhealthy -> Deleting -``` - -Supported states are `Pending`, `Booting`, `Ready`, `TurnRunning`, `Idle`, `Releasing`, `Retained`, `Suspended`, `Deleting`, `Deleted`, `Failed`, and `Unhealthy`. Runtime sessions require namespace, session name, provider, cleanup policy, and owner metadata. Cleanup policies are `delete`, `retain`, and provider-capability-gated `suspend`. - -### Security requirements - -- Harness control calls are namespace-scoped and authenticated by Orka; per-turn credentials must be short-lived and scoped to the task/session. -- Raw secrets, raw TxTokens, environment dumps, cookies, API keys, and JWTs must not appear in **persisted or observable** surfaces: Task status, persisted annotations, execution events/frames, logs, or trace output. Resolved literal credentials MAY be carried in the in-memory `StartTurnRequest.input.env` solely as the controller-to-wrapper delivery channel (see "Required turn fields"); the wrapper must not log the request and should drop `input.env` from retained turn state once child env is materialized. Raw TxTokens are disallowed even on the delivery channel — use owner-referenced child Secrets and fail-closed TTS exchanges. Confidentiality of the delivery channel in transit (TLS/mTLS) is a deployment-posture concern. -- Cross-namespace runtime reuse is denied by ownership validation. -- Lifecycle transitions and cleanup failures must be evented with safe metadata only. - -### Conformance contract - -The reusable conformance suite in `internal/harness/harnesstest` verifies health, capabilities, successful turns, failed turns, cancellation, invalid/unknown frames, redaction, and client timeout behavior against any provider factory. The fake harness server covers success, failure, delayed output, long-running turns, cancellation, invalid frames, and secret-looking output. diff --git a/docs/development/post-p0-evented-runtime-checklist.md b/docs/development/post-p0-evented-runtime-checklist.md index f05291471..962b5f32d 100644 --- a/docs/development/post-p0-evented-runtime-checklist.md +++ b/docs/development/post-p0-evented-runtime-checklist.md @@ -16,7 +16,7 @@ This checklist maps the P0 wave plans in `~/Downloads/orka-p0-wave-plans/` and t | Public task SSE stream | Implemented + tested | Replay, live polling, heartbeat, terminal `stream_complete`, and reconnect tests. | | Controller lifecycle producers | Implemented + tested | Task controller emits lifecycle events; focused controller event tests exist, envtest full suite requires envtest binaries. | | AI worker producers | Implemented + tested | `workers/ai` event tests cover model/tool/result/context events and redaction. | -| Agent CLI producers | Implemented + tested | agent runtime event tests cover command/runtime lifecycle. | +| ACP runtime producers | Foundation implemented + tested | Dispatcher, RuntimePool, v2 supervisor, and delivery state tests cover the session-centric path; full live/restart/publication acceptance remains required. | | Container/general worker producer | Implemented + tested | worker/common and general worker event/result tests cover basic lifecycle. | | Redaction/truncation | Implemented + tested | Worker-side and store-side redaction tests cover bearer/JWT/API key/cookie/GitHub/Anthropic/OpenAI/Txn token patterns. | | No Agent Substrate dependency | Implemented | Event store, APIs, trace, session aggregation, fork, and approvals use normal Task/Job event streams. | @@ -47,7 +47,7 @@ This checklist maps the P0 wave plans in `~/Downloads/orka-p0-wave-plans/` and t | F4 | Integrate one high-risk action | Deferred integration | First target is PR creation/merge; API/read model is ready for worker/tool integration. | | F5 | CLI approvals | Implemented | `orka task approvals/approve/decline`. | | G1-G2 | Event metrics/SLO hooks | Implemented baseline | Metric names and safe labels are documented; append-time idempotent pair derivation can be expanded as producers stabilize. | -| H | Harness protocol prep | Documented | See `docs/development/harness-protocol-mvp.md`. | +| H | ACP runtime protocol | Superseded by v2 | The turn-oriented prototype was removed. The current contract is `orka.harness.v2`; see `website/docs/development/agent-runtime-adapter-contract.md`. | ## Release notes draft diff --git a/docs/development/remaining-frontier-readiness.md b/docs/development/remaining-frontier-readiness.md index ba12dadb6..e2b598b75 100644 --- a/docs/development/remaining-frontier-readiness.md +++ b/docs/development/remaining-frontier-readiness.md @@ -1,6 +1,6 @@ # Remaining frontier readiness checklist -Date: 2026-06-11 +Date: 2026-07-25 This checklist gates implementation of the backend-neutral resident runtime frontier on top of the post-P0 evented runtime worktree. @@ -11,24 +11,26 @@ This checklist gates implementation of the backend-neutral resident runtime fron | Fork/checkpoint MVP | Ready | Event-sequence fork API and CLI are present; physical snapshot fork remains deferred. | | Durable approvals MVP | Ready for API/read model | Approval event read model and decision endpoints exist; first high-risk tool integration is still deferred. | | Event metrics | Ready baseline | Append/list/stream/redaction metrics exist with low-cardinality labels. | -| Harness protocol DTOs | Ready | `internal/harness` defines `orka.harness.v1` DTOs and validation. | -| Event mapping | Ready for conformance | `internal/harness.MapFrameToExecutionEvent` maps frames to existing execution events. | -| Runtime lifecycle state machine | Ready foundation | `internal/harness` validates RuntimeSession states and transitions. | -| Tool execution modes | Ready contract | Observed and brokered modes plus brokered idempotency key are defined. | -| Security requirements | Ready contract | Protocol docs and mapper enforce no raw secrets in persisted events. | -| Conformance fixture | Ready foundation | `internal/harness/harnesstest` fake server and suite cover MVP provider behavior. | -| Non-Substrate provider | Not implemented | The first Kubernetes Service/sidecar provider should be built after the conformance suite is adopted by controller integration. | -| RuntimeSession persistence/API | Deferred | ADR 0008 selects internal-store-first; persistent implementation follows provider integration. | -| Resident daemon/process | Deferred | Requires runtime session claim/release and provider implementation. | -| Substrate provider | Deferred optional | Must remain provider-scoped and pass the same conformance suite. | +| ACP v2 DTOs | Ready foundation | `internal/harness/v2` defines the session-centric `orka.harness.v2` request, event, lifecycle, duplicate, capability, and fencing contracts. | +| Event mapping | Ready foundation | ACP v2 prompt events are bounded and mapped into Orka-owned execution/result state; runtime diagnostics are not canonical Task authority. | +| Runtime lifecycle state machine | Ready foundation | `internal/harness/v2` validates RuntimeSession states from `creating` through validation/publication/finalization and deletion. | +| Prompt-scoped broker authority | Implemented foundation | Each RuntimeSession exposes a credential-protected loopback MCP endpoint. The controller revalidates Task, attempt, prompt lease, exact runtime fences, tool policy, approval evidence, and consequential-effect identity for every call. | +| Security requirements | Implemented foundation | V2 fences, operation capabilities, bounded diagnostics, private session identities, the central provider proxy, clean-room Git credential separation, and artifact/credential brokers are modeled and tested. | +| Conformance fixture | Ready foundation | `internal/harness/v2/conformance` and `conformancetest` cover v2 identity, endpoints, duplicates, fencing, cancellation, and governance claims. | +| Kubernetes RuntimePool provider | Implemented foundation | Controller-owned RuntimePools, exact-Pod routing, atomic status reservations, bounded-wait queue promotion, drain, scale-to-zero, and Codex/Claude/Copilot ACP images exist; full live acceptance remains required. | +| Kubernetes control authority | Implemented foundation | `ControllerEpoch`, `PromptAttempt`, `RuntimeSessionControl`, `BranchClaim`, `Publication`, and `ExternalEffect` status plus coordination Leases are authoritative. SQLite retains transcript/SessionTurn, deferred outbox, and artifact payloads rather than ACP control authority. | +| RuntimeSession persistence/API | Implemented foundation | Kubernetes control records and Leases fence restart/takeover recovery; SQLite persists transcript/session-turn payloads behind those fences. Full live restart/takeover acceptance remains required. | +| External v2 runtime dispatch | Deferred | Registration, probing, and conformance are available, but `runtimeRef` Task planning remains fail-closed until the external v2 dispatcher support boundary is enabled. | +| Resident supervisor/process | Implemented foundation | The ACP supervisor hosts multiple private RuntimeSessions with bounded prompt concurrency and cleanup rules. | +| Substrate provider | Deferred | Must host one RuntimeSession per Actor behind the same v2 lifecycle and pass the same conformance, crash, credential, and publication gates. | | Snapshot-aware fork | Deferred | Logical fork remains available; physical clone/snapshot capability contract follows provider support. | -No missing required contract blocks Wave 1 conformance/client/fake-harness work. Wave 2+ implementation should start by wiring a non-Substrate provider to the frozen DTOs and conformance suite. +The v2 contract and Kubernetes RuntimePool foundation have replaced the earlier turn-oriented frontier. The remaining release gate is the required live acceptance matrix: digest verification, Codex then Claude execution, continuation, active-prompt cancellation/timeout, restart and replacement recovery, clean-room publication/PR reconciliation, drain/scale-to-zero, and cleanup. Focused verification for the readiness layer: ```bash -go test ./internal/harness/... -run 'DTO|Protocol|RuntimeSession|MapFrame|FakeHarness|Conformance' -v +go test ./internal/harness/v2/... ./internal/acp/... -run 'Protocol|RuntimeSession|Conformance|Fence|Duplicate|Cancel' -v go test ./internal/api -run 'Task.*Trace|Session.*Event|Fork|Approval|Event' -v go test ./internal/store/sqlite -run ExecutionEvent -v ``` diff --git a/docs/development/repository-security-scanning-implementation-plan.md b/docs/development/repository-security-scanning-implementation-plan.md index 1dae86c89..54bd516a8 100644 --- a/docs/development/repository-security-scanning-implementation-plan.md +++ b/docs/development/repository-security-scanning-implementation-plan.md @@ -10,7 +10,7 @@ This plan keeps Orka's existing architecture: - Scan, validation, and patch work continue to run as Kubernetes-backed `Task` resources. - Dynamic security data remains in SQLite. - Detailed outputs continue to flow through task artifacts. -- Credentials, transaction metadata, and authorization remain governed by existing Orka worker, Secret, RBAC, OIDC, and context-token controls. +- Credentials, transaction metadata, and authorization remain governed by Orka Secret roles, ACP operation fences, RBAC, OIDC, and context-token controls. ## Target Outcomes @@ -39,7 +39,7 @@ Orka already has: - SQLite-backed scan runs, threat models, findings, and patch proposals. - Artifact contracts for threat models, findings, validation output, and patch diffs. - Human-in-the-loop patch generation and PR creation. -- Worker isolation through non-root pods, read-only root filesystem, dropped capabilities, seccomp, optional runtime class, and writable `/tmp`, `/home/worker`, and `/workspace`. +- Native mapper/container isolation plus ACP RuntimePools with private RuntimeSessions, digest-pinned profiles, no Git credentials, and separate clean-room publication. Key gaps to close: diff --git a/docs/harness-v1-v2-coexistence-plan.md b/docs/harness-v1-v2-coexistence-plan.md new file mode 100644 index 000000000..cbd07f780 --- /dev/null +++ b/docs/harness-v1-v2-coexistence-plan.md @@ -0,0 +1,469 @@ +# Orka Harness v1/v2 Isolated Coexistence Plan + +**Status:** Accepted — Revision 8 +**Prepared:** August 7, 2026 +**Supersedes:** Revision 7 + +## 1. Decision + +Harness v1 and harness v2 may run on the same Kubernetes cluster only as two +independent Orka installations with disjoint namespace ownership. Each +controller starts in exactly one static mode: + +- `harness-v1` serves the legacy turn-oriented wrapper contract; +- `harness-v2` serves the ACP RuntimePool and RuntimeSession contract. + +There is no `dual`, `auto`, or `harness-v1-drain` mode. A running installation +does not switch modes. Tasks, Agents, AgentRuntimes, and Sessions do not migrate +between modes, and neither controller falls back to the other protocol. + +```text +one Kubernetes cluster + + v1 release v2 release + ----------------------------- ----------------------------- + release/watch namespace: release/watch namespace: + orka-v1-system orka-v2-system + mode: harness-v1 mode: harness-v2 + own Lease/PVC/Secrets/Service own Lease/PVC/Secrets/Service + harness wrapper ACP RuntimePools/runtime namespace + + shared, platform-owned CRD schemas +``` + +The shared Kubernetes API server, cluster nodes, and centrally managed CRD +schemas are infrastructure coupling. They are not execution-plane coupling. + +## 2. Why this replaces active coexistence + +The previous plan put both protocols behind one controller and one Task +population. Making that safe required dynamic backend modes, route bindings, +classification, binding reservations, quarantine, adjudication, global Lease +fencing, and cross-protocol retirement state. + +Namespace isolation removes the ambiguous ownership that those mechanisms +addressed. Protocol choice is a property of an installation and its namespace, +not a per-Task routing decision. Consequently, this plan does not introduce: + +- `AgentExecutionControl`; +- `AgentExecutionPolicy`; +- `AgentExecutionAdjudication`; +- dynamic `enabled`, `closing`, `drain-only`, or `disabled` backend modes; +- cross-protocol Task binding, classification, quarantine, or adjudication; +- transcript-bootstrap migration or cross-protocol Session lineage. + +Harness-specific safety remains within each path. In particular, ACP v2 keeps +its RuntimeSession fencing, prompt-attempt, publication, external-effect, and +`OutcomeUnknown` rules. Harness v1 must not replay an ambiguously accepted +turn. + +## 3. Required invariants + +### 3.1 Static controller identity + +Every controller process requires: + +```text +--controller-mode=harness-v1|harness-v2 +--watch-namespace= +--leader-elect=true +``` + +`ORKA_CONTROLLER_MODE` is the environment equivalent of +`--controller-mode`. Invalid or omitted modes fail startup. Cluster-wide watch +scope fails startup. + +The watched namespace must carry the matching administrative claim: + +```yaml +metadata: + labels: + orka.ai/controller-mode: harness-v1 # or harness-v2 +``` + +A missing or mismatched label fails startup. The label is an installation +identity, not a runtime switch. Changing it in place is unsupported; create a +new namespace and installation instead. + +Namespace bootstrap is fail-closed and write-once with respect to that claim: + +- if the namespace is absent, create it with `orka.ai/controller-mode` in the + same Kubernetes API write, before creating Secrets or workloads; +- if it already exists, proceed only when its exact name and mode claim match; + same-mode reuse preserves the claim and any unrelated labels; +- never adopt or relabel an existing unlabeled or opposite-mode namespace; +- if another installer wins the create race, reread the namespace and proceed + only when the resulting identity is an exact same-mode match. + +Canonical script-based installs enforce this contract through +`scripts/lib/ensure-static-mode-namespace.sh`. Additional namespace metadata +may converge only after an atomic test confirms that the mode claim is still +unchanged. + +The ordinary leader-election ID may be the same in both installations because +each Lease lives in its controller's distinct watched namespace. There is no +cluster-global harness ownership Lease and no legacy-Lease acquisition bridge. + +### 3.2 Disjoint ownership + +The two releases must have: + +- different Helm release names; +- different controller release namespaces; +- different, non-empty watched namespaces; +- different controller Services and API endpoints; +- different ServiceAccounts, Secrets, RBAC bindings, and leader-election + Leases; +- different SQLite databases and PVCs; +- different worker and harness data-plane resources; +- for v2, a runtime namespace not used by another Orka installation; +- NetworkPolicies that do not admit traffic from the other execution plane. + +The packaged Helm and Kustomize installations use the controller release +namespace as the watched namespace. This keeps the controller, workload +objects, namespaced RBAC, Lease, and data plane inside one ownership boundary. +The v2 runtime namespace remains separate. Custom packaging must preserve the +same isolation if it separates the controller and watched namespaces. + +Neither controller may list, watch, mutate, finalize, or clean up namespaced +objects in the other controller's watch namespace. RBAC is the enforcement +boundary; cache configuration alone is insufficient. + +Cluster-scoped CRDs and admission resources have one designated platform +owner. Cluster-scoped reconcilers, including gateway and workspace-provider +infrastructure, are owned by the `harness-v2` installation and are not started +by `harness-v1`. Common admission protections are installed once. No two Helm +releases independently own the same cluster-scoped admission object. + +### 3.3 One protocol per installation + +In `harness-v1` mode: + +- agent Tasks may use only the v1 contract; +- the legacy dispatcher and wrapper client are enabled; +- ACP RuntimePool dispatch, runtime reconciliation, and ACP cleanup are not + execution alternatives; +- cluster-scoped gateway/workspace controllers are disabled. + +In `harness-v2` mode: + +- agent Tasks may use only the v2 contract; +- the ACP dispatcher and RuntimePool/RuntimeSession controllers are enabled; +- the v1 dispatcher and wrapper path are not started; +- missing ACP capacity or configuration fails closed without v1 fallback. + +Native AI and container Task paths remain governed by their existing +namespace-scoped contracts. They do not authorize cross-namespace access. + +An Agent or AgentRuntime contract selector, where retained to discriminate the +shared CRD schema, must match the controller mode. It does not override the +mode or select a second dispatcher. `runtime.type: opencode` is never protocol +evidence. + +### 3.4 No migration or shared continuation + +The following operations are unsupported: + +- changing an installation from `harness-v1` to `harness-v2` in place, or the + reverse; +- reusing a controller database, PVC, runtime store, or wrapper ledger under + the other mode; +- changing a Task, Agent, or AgentRuntime protocol to make existing work run + through the other installation; +- continuing a v1 Session in v2 or a v2 Session in v1; +- importing a transcript as continuation of the original Session lineage; +- allowing one controller to cancel, finalize, publish, or clean up work owned + by the other. + +An operator may create a new object in the other installation, but it is new +work with a new namespace, UID, Session lineage, attempt identity, and external +effect history. Copying prompts or non-secret configuration does not preserve +execution identity. + +The static `harness-v2` release also does not adopt a pre-static, implicit-v2 +controller installation in place. Those installations can have accepted ACP +attempts without the immutable execution authority required by this design. +Settle or retire that installation, preserve its state under its existing +owner, and install static `harness-v2` as a new release and namespace. + +### 3.5 Harness v1 recovery boundaries + +The v1 compatibility installation keeps request execution authority separate +from terminal-settlement authority: + +- submitting, replaying, or recovering terminal output requires the frozen + provider `CredentialRefs` used by the original request; +- acknowledging an already durable settlement reconstructs only the frozen + wrapper endpoint/authentication client and the stored turn, request-digest, + and terminal-receipt fences; it does not reread provider credentials; +- wrapper authentication rotation still fails closed against the frozen + Secret UID, resourceVersion, and key. + +Frame polling is bounded by the immutable request deadline. Persisted or +wrapper-ledger terminal evidence always wins first. Once the deadline passes, +the controller durably enters `CancelRequested`, retries `CancelTurn`, and +drains brokered tool-call reservations without starting new effects. If no +authoritative terminal evidence appears within the bounded cancellation +settlement window, the attempt becomes `OutcomeUnknown`; it is never replayed. + +Deterministic frame identity, sequence, approval, continuation, frozen-tool, +and input-authority violations become `ProtocolViolation`/`OutcomeUnknown`. +Transport, event-journal, Kubernetes-read, and external-effect-store errors +remain retryable because they do not prove a permanent protocol violation. + +The built-in wrapper advertises and enforces `MaxConcurrentTurns=1`. Controller +startup and Helm validation therefore require exactly one harness v1 dispatcher +worker; parallel dispatch is rejected as an unsupported configuration. + +## 4. Shared API and CRD contract + +CRDs are cluster-scoped even when their custom resources are namespaced. One +platform owner therefore installs and upgrades a schema bundle that can store +both the supported v1 and v2 object shapes without pruning either. + +Requirements: + +- keep one Kubernetes API version unless an independently justified API + migration introduces conversion; +- preserve all v1 and v2 fields needed during the compatibility window; +- use structural schema and contract-specific validation where the two shapes + differ; +- do not default an omitted protocol based on runtime type or observed state; +- verify stored v1 and v2 fixtures round-trip through spec and status updates; +- apply CRD upgrades once, before either release that requires them; +- install every additional Orka release with `--skip-crds` or the equivalent + GitOps ownership rule. + +The shared schema is not permission for mixed execution. Controller mode, +namespace claim, and RBAC determine which installation may act on an object. + +The three `AgentExecution*` CRDs from the superseded design are not part of +this architecture. Test clusters that already installed unreleased versions of +those CRDs must remove their instances before deleting the definitions; CRD +deletion is cluster-wide and destructive to instances of that kind. + +## 5. Deployment topology + +### 5.1 Platform-owned wave + +Before installing either controller: + +1. back up existing CRDs and custom resources; +2. apply the reviewed v1/v2-compatible CRD bundle through the designated + platform owner; +3. wait for every CRD to become `Established`; +4. verify maximum-shape v1 and v2 fixtures and status updates on a real + supported Kubernetes version; +5. install shared admission resources once, after their serving endpoints are + ready. + +Helm does not update files from `crds/` during `helm upgrade`. Every upgrade +that changes schemas requires the explicit CRD-first wave. + +### 5.2 Harness v1 release + +The v1 release is an isolated compatibility installation. It requires: + +- `controller.mode: harness-v1` (rendering + `--controller-mode=harness-v1`); +- a dedicated, labeled, non-empty watch namespace; +- the reviewed digest-pinned wrapper image and its private Service; +- separate wrapper bearer-auth and rotatable TLS Secrets, plus a dedicated + durable ledger; +- its own controller store, backups, ServiceAccount, and API endpoint; +- no ACP RuntimePool data plane. + +Wrapper upgrades still require a successful wrapper drain before changing its +Pod template. That drain protects v1 turn state; it is not a third controller +mode and does not open migration to v2. + +The bearer Secret is immutable while v1 work exists because bindings freeze +its UID and resourceVersion. Certificate renewal changes only the separate TLS +Secret and follows the same drained Pod-template rollover; it must never mutate +the bearer authority as a side effect. + +### 5.3 Harness v2 release + +The v2 release is a fresh installation. It requires: + +- `controller.mode: harness-v2` (rendering + `--controller-mode=harness-v2`); +- a dedicated, labeled, non-empty watch namespace; +- its own controller and runtime namespaces; +- digest-pinned ACP runtime images; +- the authenticated provider proxy, SCM proxy, and clean-room Publisher where + the selected workflow requires them; +- its own controller store, backups, ServiceAccount, and API endpoint; +- no harness v1 wrapper data plane. + +New producers select the v2 endpoint and namespace explicitly. Existing v1 +objects are not copied or adopted. + +An older controller that implicitly enabled ACP but did not declare +`--controller-mode=harness-v2` is not an upgrade source. Helm and the canonical +direct-Kustomize deployment preflight reject that in-place transition before +mutating workloads. Once a release already declares the exact static mode and +watch namespace, ordinary same-mode upgrades remain supported. + +### 5.4 Cross-plane references + +User-authored Task, Agent, AgentRuntime, Provider, Session, Tool, Skill, and +credential references stay within the installation's owned namespace unless a +separate API contract explicitly defines a platform-owned reference. A +reference into the other execution plane is rejected, not proxied. + +V2 controller-owned RuntimePool resources may live in its configured runtime +namespace. That is an internal v2 relationship and does not give v1 any access +to the runtime namespace. + +## 6. Rollout + +1. Inventory the current v1 installation, including active wrapper turns, + Tasks, Sessions, producers, stores, Secrets, and backups. +2. Atomically establish a dedicated v1 watch namespace with the `harness-v1` + claim; reject any preexisting namespace without that exact identity. +3. Upgrade the v1 installation to the static-mode compatibility release + without changing its protocol or object identities. Follow the v1 wrapper + drain procedure for any wrapper Pod-template change. +4. Apply the shared CRD bundle through the single platform owner. +5. Atomically create a distinct v2 watch namespace with the `harness-v2` claim + and create a distinct v2 runtime namespace; never adopt or relabel an + existing unlabeled or opposite-mode watch namespace. +6. Install the v2 release with a unique name, endpoint, RBAC, storage, and + `harness-v2` mode. +7. Prove with RBAC and runtime tests that neither controller can observe or + mutate the other's namespace. +8. Run v2 canaries as newly created v2 Agents, Tasks, and Sessions. +9. Route new producers to the v2 endpoint. Leave existing v1 work on v1. + +At no point does rollout patch v1 objects into v2 objects or run both modes in +one controller. + +## 7. V1 drain and retirement + +There is no dynamic drain mode. Draining is an operational procedure: + +1. stop v1 API ingress and every internal and external v1 Task producer; +2. revoke or suspend permissions that can create new v1 agent Tasks; +3. record a cutoff time and inventory all active, queued, finalizing, and + cleanup-relevant v1 work; +4. allow proven v1 work to finish on the v1 installation; +5. cancel work only through v1 and preserve `OutcomeUnknown` where acceptance + cannot be disproved; +6. repeat uncached inventory until no active turn, Task, Session settlement, + finalizer, or wrapper-ledger cleanup remains; +7. back up retained v1 history and stores; +8. uninstall v1 workloads and revoke v1 credentials; +9. remove v1 PVCs or historical fields only under a separate reviewed + retention and data-destruction decision. + +The v2 release continues independently throughout v1 retirement. + +## 8. Rollback and recovery + +Rollback changes where future work is submitted; it does not move existing +work. + +If a v2 rollout must stop: + +- stop new submissions to the v2 endpoint; +- let accepted v2 work settle, cancel it through v2, or retain its existing + unknown-outcome classification; +- submit any replacement work as new v1 work only if the v1 installation is + still intentionally open; +- retain the shared superset CRDs while either object shape exists. + +Each installation has its own coordinated recovery point. A recovery must pair +that installation's Kubernetes identities with its controller database, PVCs, +ledgers, artifacts, and Secrets. Never restore a v1 data set into v2 or a v2 +data set into v1. Plain YAML export does not preserve UIDs and is not sufficient +to resume UID-bound execution. + +## 9. Verification and release gates + +### 9.1 Configuration + +- missing, empty, `dual`, `auto`, `harness-v1-drain`, and unknown modes fail; +- an empty watch namespace fails; +- a missing or mismatched namespace mode label fails; +- fresh bootstrap creates the namespace and mode claim in one write before any + Secret or workload write; +- exact same-mode namespace reuse is idempotent and does not rewrite the mode + claim; +- unlabeled and opposite-mode namespaces are rejected without mutation; +- a namespace create race succeeds only after rereading an exact same-mode + identity; +- leader election is required and its Lease is in the watched namespace; +- mode-incompatible wrapper or ACP configuration fails rendering or startup; +- implicit or legacy v2 controllers are rejected as in-place static-v2 upgrade + sources; +- changing the namespace claim does not cause a running installation to adopt + opposite-mode work. + +### 9.2 Isolation + +- v1 and v2 releases use different namespaces, SAs, Leases, PVCs, Services, + Secrets, and endpoints; +- each controller receives `Forbidden` when attempting reads or writes in the + other watch namespace; +- v1 does not start ACP or cluster-scoped gateway/workspace reconcilers; +- v2 does not start the v1 dispatcher; +- each controller restart, upgrade, and uninstall leaves the other healthy; +- no cluster-scoped admission resource is multiply owned. + +### 9.3 API compatibility + +- stored v1 and v2 Agent, AgentRuntime, Task, and Session fixtures survive the + shared CRD apply and status update; +- contract-specific invalid combinations are rejected; +- a contract that conflicts with controller mode fails closed; +- no `AgentExecutionControl`, `AgentExecutionPolicy`, or + `AgentExecutionAdjudication` CRD is installed; +- deleting one Helm release does not delete shared CRDs or the other release's + objects. + +### 9.4 Runtime behavior + +- a v1 Task executes only through the wrapper; +- a v2 Task executes only through ACP RuntimePools; +- unavailable v2 capacity never falls back to v1; +- the shipped v1 controller and wrapper both enforce a single concurrent turn; +- a same-name object in the other namespace is unrelated and cannot continue + the original Task or Session; +- cross-plane cancellation, cleanup, publication, and Session continuation are + rejected; +- v1 wrapper and v2 controller restart tests preserve their own protocol's + duplicate and unknown-outcome invariants. +- v1 settlement acknowledgement survives provider-credential removal while + wrapper-auth rotation remains fail-closed; +- v1 request deadlines durably request cancellation, stop new brokered effects, + and reach `OutcomeUnknown` only after the bounded settlement window; +- deterministic v1 frame-authority violations terminalize as protocol + violations while transport and durable-store failures remain retryable. + +### 9.5 Retirement + +- v1 producers and create permissions are closed before the drain inventory; +- repeated inventory proves zero active and cleanup-relevant v1 state; +- removing v1 workloads and credentials does not change v2 readiness or work; +- retained historical v1 objects remain readable under the shared schema. + +## 10. Definition of done + +This replacement plan is complete when: + +1. the controller accepts exactly the two static modes, requires a matching + non-empty namespace claim, and deployment paths establish that claim + atomically without adopting or relabeling an existing namespace; +2. v1 and v2 run in disjoint namespaces with enforced RBAC, storage, Lease, + Service, Secret, and network boundaries; +3. mode-specific controller registration makes cross-dispatch impossible; +4. the shared CRD bundle round-trips supported v1 and v2 shapes without the + three `AgentExecution*` CRDs; +5. Helm and Kustomize deployment paths document one CRD owner and distinct + release/watch/runtime namespaces; +6. no supported workflow migrates or continues a Task, AgentRuntime, or + Session across protocols; +7. the two-release, rollback, and v1-retirement verification gates pass on a + real cluster; +8. ADR 0018 and operator documentation describe the shipped behavior. diff --git a/docs/pr-monitor-clawsweeper-parity-plan.md b/docs/pr-monitor-clawsweeper-parity-plan.md index 471b4d89d..860215e3c 100644 --- a/docs/pr-monitor-clawsweeper-parity-plan.md +++ b/docs/pr-monitor-clawsweeper-parity-plan.md @@ -1,5 +1,10 @@ # PR Monitor to ClawSweeper Parity Plan +> Historical scope note: this plan describes repository-monitor application +> state. Its SQLite source-of-truth statements do not apply to ACP runtime +> control records. The ACP hard cutover makes control CRD status and Kubernetes +> Leases authoritative while retaining monitor history in SQLite. + This document describes how to evolve Orka's current PR monitor into an Orka-native maintainer automation system with capabilities comparable to ClawSweeper. @@ -618,8 +623,9 @@ Input: - branch write mode - constraints on files, tools, and allowed mutation -The repair task may use an agent runtime workspace. It should not directly -merge or close a PR. +The repair Task may use top-level `spec.workspace` with `intent: write`, separate +read/publication credentials, and an Orka-owned publication branch. The ACP child +must not push, merge, or close a PR directly. ### Repair Task Output diff --git a/examples/bring-your-own-agent-runtime-demo/README.md b/examples/bring-your-own-agent-runtime-demo/README.md deleted file mode 100644 index c8cc52b75..000000000 --- a/examples/bring-your-own-agent-runtime-demo/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Bring-your-own AgentRuntime demo - -This package is the canonical entry point for the provider-neutral remote-runtime demo. It points at the runnable manifests used by the current repo-owned implementation and keeps the story independent of any one backend. - -```text -Orka API + governance plane - -> namespace-local AgentRuntime facade - -> remote execution backend adapter/runtime - -> Orka-brokered Tool CRDs -``` - -## What is runnable from this repository - -| Scenario | Path | Credentials | Brokered tools | -| --- | --- | --- | --- | -| Fibey observed + backend switch facades | `../fibey-custom-agent-demo` | Harness bearer token only for generic HTTP; adapter credentials for optional backends | Optional; default observed | -| Support escalation brokered read | `../support-escalation-runtime-demo` | Harness bearer token only | `support-ticket-lookup` read tool | - -AgentKit Serve adapter changes are intentionally not in this repository. The Foundry facade targets the separately maintained Microsoft Foundry Hosted Agents Responses adapter [`orka-agents/agent-runtime-foundry`](https://github.com/orka-agents/agent-runtime-foundry); Foundry and AgentKit samples remain namespace-local facades that point at operator-provided adapter Services. - -## Run the generic HTTP demo - -Build and load the reference generic HTTP harness fixture: - -```bash -docker build -t ghcr.io/orka-agents/orka/example-echo-harness:latest -f examples/harness/echo/Dockerfile . -kind load docker-image ghcr.io/orka-agents/orka/example-echo-harness:latest --name -``` - -Create the runtime bearer Secret out of band; do not commit real values. The Secret must contain a data key named `token`, must be labeled `orka.ai/agent-runtime-auth=true`, and must be annotated with the exact runtime endpoint: - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: support-http-runtime-token - labels: - orka.ai/agent-runtime-auth: "true" - orka.ai/agent-runtime-name: support-http-runtime - annotations: - orka.ai/agent-runtime-endpoint: http://support-http-runtime.default.svc.cluster.local:8080 -stringData: - token: -``` - -Apply and run: - -```bash -kubectl apply -k examples/support-escalation-runtime-demo -kubectl wait --for=condition=Ready agentruntime/support-http-runtime --timeout=60s -kubectl get task support-escalation-demo -o yaml -``` - -Expected flow: - -1. `AgentRuntime/support-http-runtime` probes `/v1/health`, `/v1/capabilities`, observed turn conformance, and the advertised brokered read profile. -2. `Task/support-escalation-demo` starts a remote turn through the namespace-local facade. -3. The runtime emits `ToolCallRequested` for `support-ticket-lookup`. -4. Orka validates `Task.spec.agentRuntime.allowedTools`, loads the same-namespace `Tool`, resolves any downstream credentials inside Orka, executes it, records brokered events, and returns a `ToolCallResult` via `/v1/turns/{turnID}/continue`. -5. The runtime completes and Orka stores the result. - -## Approval-gated write variant - -To exercise a write tool, add a `Tool` with `spec.brokeredToolClass: write`, include it in `Task.spec.agentRuntime.allowedTools`, and run the generic fixture with: - -```yaml -env: -- name: ORKA_REMOTE_HTTP_RUNTIME_BEHAVIOR - value: approval-tool -- name: ORKA_REMOTE_HTTP_RUNTIME_WRITE_TOOL_NAME - value: -``` - -The expected UX is unchanged across backends: - -```bash -orka task approvals -orka task approve -orka task result -``` - -Orka, not the remote runtime, creates the canonical `ApprovalRequested` event and executes the approved tool exactly once unless a prior execution ledger entry has an unknown outcome, in which case it fails closed instead of replaying a consequential side effect. - -## Backend switching - -Use `../fibey-custom-agent-demo/switch-backend.sh` to patch only `Task.spec.agentRef.name` between namespace-local facades: - -```bash -examples/fibey-custom-agent-demo/switch-backend.sh http -examples/fibey-custom-agent-demo/switch-backend.sh foundry -``` - -The workflow, Tool CRDs, approval UX, and task/result APIs remain Orka-owned. Remote backends receive safe tool schemas and scoped turn metadata only; they do not receive downstream Tool credentials. - -## Troubleshooting - -| Symptom | Likely cause | Fix | -| --- | --- | --- | -| `AgentRuntime` Ready=False mentioning `orka.ai/agent-runtime-auth` | bearer Secret missing opt-in label | add `orka.ai/agent-runtime-auth: "true"` | -| Ready=False endpoint binding error | Secret annotation does not match `spec.deployment.endpoint` | update `orka.ai/agent-runtime-endpoint` | -| Ready=False brokered class missing | runtime did not advertise a required `brokeredToolClasses` value | fix adapter capabilities or narrow `spec.capabilities` | -| Task fails `tool not allowed` | remote requested a tool not in `Task.spec.agentRuntime.allowedTools` | add the intended tool or reject the backend behavior | -| Task waits for approval | write tool requested and no human decision exists | use `orka task approvals` then approve/decline | -| Outcome unknown for write tool | controller saw a pre-execution ledger entry without terminal result | inspect downstream idempotency target; do not blindly replay | -| Result data truncated in parent summary | structured data exceeded `wait_for_tasks` bounds | store large payloads as artifacts and pass references | diff --git a/examples/bring-your-own-agent-runtime-demo/SECURITY.md b/examples/bring-your-own-agent-runtime-demo/SECURITY.md deleted file mode 100644 index e0dd52830..000000000 --- a/examples/bring-your-own-agent-runtime-demo/SECURITY.md +++ /dev/null @@ -1,29 +0,0 @@ -# Security model for bring-your-own AgentRuntime demos - -Remote execution backends are workload substrates, not governance authorities. - -> Remote agents may ask; Orka decides and executes. - -## Invariants - -- `AgentRuntime.spec.deployment.endpoint` must not contain credentials. -- Runtime bearer tokens live in Kubernetes Secrets and must opt in with `orka.ai/agent-runtime-auth=true`. -- Runtime auth Secrets are bound to the expected `AgentRuntime` name and endpoint. -- Remote runtimes receive safe tool schemas only: name, description, brokered class, and JSON parameters. -- Remote runtimes never receive Tool CRD execution URLs, auth Secret refs, headers, bearer tokens, kubeconfigs, or approval bypass credentials. -- Orka validates allowed tools/classes before every brokered call. -- Orka creates canonical approval events for write tools and verifies exact argument/spec digests before execution. -- Brokered write execution records a pre-execution ledger entry; unresolved prior executions fail closed instead of duplicating consequential side effects. - -## Demo-only controls - -The checked-in generic HTTP fixture uses bearer-token authentication and allows `http://` cluster-local service URLs for kind/local demos. Production adapters should run behind TLS or private networking and may add mTLS or signed short-lived turn credentials. - -## Do not commit - -- runtime bearer token values; -- Foundry credentials; -- AgentKit credentials; -- downstream tool API keys; -- raw transcripts or auth headers; -- kubeconfigs or service-account tokens. diff --git a/examples/fibey-custom-agent-demo/README.md b/examples/fibey-custom-agent-demo/README.md deleted file mode 100644 index 1e2b41a94..000000000 --- a/examples/fibey-custom-agent-demo/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Fibey bring-your-own agent runtime demo - -This demo exercises the first bring-your-own agent runtime slice: Orka registers a namespace-local `AgentRuntime` facade for a remote execution backend, then an `Agent` routes `type: agent` work to it with `spec.runtime.runtimeRef`. - -The checked-in backend is a deterministic generic HTTP harness fixture. It advertises `runtimeName: fibey-http-runtime`, supports `orka.harness.v1`, and runs in `observed` tool mode by default. AgentKit Serve and Foundry should plug in by swapping only the backend Service/adapter endpoint and `AgentRuntime` facade, not the Orka workflow. - -## Backend facades - -| Facade | Backend | Credentials | -| --- | --- | --- | -| `fibey-http-runtime` | Generic mock/self-hosted HTTP runtime | Harness bearer token only | -| `fibey-agentkit-runtime` | AgentKit Serve adapter | Adapter/runtime config only | -| `fibey-foundry-runtime` | [`orka-agents/agent-runtime-foundry`](https://github.com/orka-agents/agent-runtime-foundry) | Harness bearer Secret plus adapter Azure identity; no Orka Tool production credentials | - -`fibey-agentkit-runtime` is intentionally observed-only in the checked-in demo: it should show `toolExecutionModes: [observed]`, `supportsCancel: true`, and `supportsRuntimeSessions: true`, with no `brokeredToolClasses` or `supportsContinuation`. AgentKit brokered read/write/coordination exist only for deployments that explicitly enable those conformance-gated profiles. - -`fibey-foundry-runtime` is also observed-only by default. It targets a deployed Microsoft Foundry Hosted Agent through the dedicated Responses endpoint derived from `ORKA_FOUNDRY_PROJECT_ENDPOINT` and `ORKA_FOUNDRY_AGENT_NAME`. The adapter uses `DefaultAzureCredential`; Foundry access tokens and downstream Orka Tool credentials are never stored in the `AgentRuntime`. Brokered read/write are opt-in through `ORKA_FOUNDRY_BROKERED_TOOL_CLASSES` and must match the facade's advertised capabilities. - -All facades are namespace-local `AgentRuntime` objects. Remote execution backends do **not** receive production Orka Tool credentials. In brokered mode, remote backends request tools and Orka owns authorization, approvals, idempotency, credential resolution, execution/brokering, events, lineage, and audit. - -## Build/load the generic HTTP fixture image for kind - -```bash -docker build -t ghcr.io/orka-agents/orka/example-echo-harness:latest -f examples/harness/echo/Dockerfile . -kind load docker-image ghcr.io/orka-agents/orka/example-echo-harness:latest --name -``` - -The fixture can run scripted behaviors through `ORKA_REMOTE_HTTP_RUNTIME_BEHAVIOR`: - -- `success` — return a final result; -- `read-tool` — emit a brokered read-only tool request and observed result frame; -- `approval-tool` — emit an approval-pending frame and resume on `/v1/turns/{turnID}/continue`; -- `failure` — fail deterministically; -- `timeout` — emit a retryable timeout failure; -- `cancellation` — wait until the turn is cancelled. - -The default demo uses `success`/observed mode so it can run without external credentials or brokered-tool infrastructure. - -## Apply the demo - -```bash -kubectl apply -k examples/fibey-custom-agent-demo -kubectl wait --for=condition=Ready agentruntime/fibey-http-runtime --timeout=60s -kubectl get task fibey-quincy-north-alert -o yaml -``` - -Expected flow: - -1. `AgentRuntime/fibey-http-runtime` reads only a harness token Secret labeled `orka.ai/agent-runtime-auth: "true"`, scoped with `orka.ai/agent-runtime-name`, and endpoint-bound with `orka.ai/agent-runtime-endpoint` before probing `/v1/health` and `/v1/capabilities` and becoming Ready. -2. `Agent/fibey-remote-http` selects the runtime by `runtimeRef`. -3. `Task/fibey-quincy-north-alert` starts a harness turn against the generic HTTP runtime endpoint. -4. The task timeline shows `TurnStarted`, `RuntimeOutput`, and `TurnCompleted` frames mapped into native Orka execution events. - -A successful Task should include `status.harnessRuntime.runtimeRefName: fibey-http-runtime`, proving the resolved runtime target was frozen for the accepted turn. - -## Swapping backends - -To test AgentKit Serve or Foundry, keep the Orka workflow and tool policy the same. Replace only: - -- the backend Deployment/Service or external endpoint; -- the harness bearer-token Secret binding; -- the namespace-local `AgentRuntime` facade used by `Agent.spec.runtime.runtimeRef`. - -Optional facade manifests are checked in but not included in the default `kustomization.yaml` because they require separately deployed adapters: - -For a local/kind AgentKit observed-mode demo with no model credentials, build and -load an AgentKit test-agent image from the AgentKit Serve checkout, then deploy -the offline echo fixture Service used only for readiness/conformance demos. The -AgentKitfile used for this image must declare the Orka per-turn env names that the -controller sends, because AgentKit rejects undeclared `input.env` values. Add -these entries to the test AgentKitfile before building: - -```yaml -env: - - name: ORKA_CONTROLLER_URL - - name: ORKA_RESULT_ENDPOINT - - name: ORKA_PARENT_TASK - - name: ORKA_PRIOR_TASK - - name: ORKA_PRIOR_TASK_NAMESPACE - - name: ORKA_COORDINATION_DEPTH -``` - -Then build/load/apply: - -```bash -# From /path/to/agentkit.serve: -make build-agentkit build-serve build-test-agent AGENT_IMAGE=hello-agent:test -kind load docker-image hello-agent:test --name - -# From this Orka checkout: -kubectl apply -f examples/fibey-custom-agent-demo/secret-agentkit.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agentkit-runtime-offline.example.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agentruntime-agentkit.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agent-agentkit.yaml -kubectl wait --for=condition=Ready agentruntime/fibey-agentkit-runtime --timeout=60s -``` - -The example deployment sets `AGENTKIT_PROTOCOL=orka`, reads -`AGENTKIT_AUTH_TOKEN` from the Orka client-auth Secret, and sets -`AGENTKIT_ORKA_OFFLINE_ECHO=1` so the AgentRuntime readiness probe and demo task -complete without live provider credentials. Remove `AGENTKIT_ORKA_OFFLINE_ECHO` -and provide normal model/runtime credentials for production AgentKit services. - -```bash -# AgentKit Serve observed-mode facade; requires a Service named fibey-agentkit-runtime. -kubectl apply -f examples/fibey-custom-agent-demo/secret-agentkit.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agentruntime-agentkit.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agent-agentkit.yaml -kubectl wait --for=condition=Ready agentruntime/fibey-agentkit-runtime --timeout=60s - -# Foundry Hosted Agents facade; requires a Service named fibey-foundry-runtime. -# Build/deploy github.com/orka-agents/agent-runtime-foundry with a Foundry project endpoint, -# Hosted Agent name, adapter bearer token, and an Azure SDK identity such as Workload Identity. -# The checked-in facade is observed-only. Enable ORKA_FOUNDRY_BROKERED_TOOL_CLASSES -# and update the facade only after the Hosted Agent passes matching conformance probes. -kubectl apply -f examples/fibey-custom-agent-demo/secret-foundry.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agentruntime-foundry.yaml -kubectl apply -f examples/fibey-custom-agent-demo/agent-foundry.yaml -kubectl wait --for=condition=Ready agentruntime/fibey-foundry-runtime --timeout=60s -``` - -Run the same task against another backend by changing only `spec.agentRef.name`, for example: - -```bash -examples/fibey-custom-agent-demo/switch-backend.sh agentkit -examples/fibey-custom-agent-demo/switch-backend.sh foundry -examples/fibey-custom-agent-demo/switch-backend.sh http -``` - -The script validates the selected `AgentRuntime` and `Agent`, then patches only -the Task's `spec.agentRef.name`. - -Brokered mode is used only when the selected runtime advertises brokered capabilities and the task/agent exposes allowed tools. Current AgentKit Serve facades do not advertise brokered mode, so AgentKit-owned tools remain internal to AgentKit and Orka observes only lifecycle/output frames. Orka-owned side-effect tools stay behind Orka brokered governance; production tool credentials are not handed to the remote backend. diff --git a/examples/fibey-custom-agent-demo/agent-agentkit.yaml b/examples/fibey-custom-agent-demo/agent-agentkit.yaml deleted file mode 100644 index e15741a76..000000000 --- a/examples/fibey-custom-agent-demo/agent-agentkit.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Optional Agent that selects the AgentKit Serve backend facade. -apiVersion: core.orka.ai/v1alpha1 -kind: Agent -metadata: - name: fibey-remote-agentkit -spec: - runtime: - runtimeRef: - name: fibey-agentkit-runtime - systemPrompt: - inline: | - You are Fibey's AgentKit-backed incident scout. Produce a concise dossier and request Orka-brokered tools for evidence or consequential actions. diff --git a/examples/fibey-custom-agent-demo/agent-foundry.yaml b/examples/fibey-custom-agent-demo/agent-foundry.yaml deleted file mode 100644 index c921ab367..000000000 --- a/examples/fibey-custom-agent-demo/agent-foundry.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Optional Agent that selects the Microsoft Foundry Hosted Agents Responses facade. -apiVersion: core.orka.ai/v1alpha1 -kind: Agent -metadata: - name: fibey-remote-foundry -spec: - runtime: - runtimeRef: - name: fibey-foundry-runtime - systemPrompt: - inline: | - You are Fibey's Foundry Hosted Agent incident scout. Produce a concise dossier. Request Orka-brokered tools only when the selected runtime advertises them. diff --git a/examples/fibey-custom-agent-demo/agent.yaml b/examples/fibey-custom-agent-demo/agent.yaml deleted file mode 100644 index 4ce44ec4f..000000000 --- a/examples/fibey-custom-agent-demo/agent.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: Agent -metadata: - name: fibey-remote-http -spec: - runtime: - runtimeRef: - name: fibey-http-runtime - systemPrompt: - inline: | - You are Fibey's custom incident scout. Produce a concise dossier and propose any side-effecting work order instead of executing it directly. diff --git a/examples/fibey-custom-agent-demo/agentkit-runtime-offline.example.yaml b/examples/fibey-custom-agent-demo/agentkit-runtime-offline.example.yaml deleted file mode 100644 index 137908ada..000000000 --- a/examples/fibey-custom-agent-demo/agentkit-runtime-offline.example.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Optional local/kind AgentKit Serve deployment for the Fibey AgentKit facade. -# Build and load an AgentKit test-agent image named hello-agent:test, then apply -# this manifest with secret-agentkit.yaml, agentruntime-agentkit.yaml, and -# agent-agentkit.yaml. The offline echo fixture is for conformance/demo only; do -# not use AGENTKIT_ORKA_OFFLINE_ECHO in production AgentKit deployments. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: fibey-agentkit-runtime -spec: - replicas: 1 - selector: - matchLabels: - app: fibey-agentkit-runtime - template: - metadata: - labels: - app: fibey-agentkit-runtime - spec: - containers: - - name: agentkit - image: hello-agent:test - imagePullPolicy: IfNotPresent - env: - - name: AGENTKIT_PROTOCOL - value: orka - - name: AGENTKIT_BIND - value: 0.0.0.0 - - name: AGENTKIT_ORKA_OFFLINE_ECHO - value: "1" - - name: AGENTKIT_AUTH_TOKEN - valueFrom: - secretKeyRef: - name: fibey-agentkit-runtime-token - key: token - ports: - - name: http - containerPort: 8080 ---- -apiVersion: v1 -kind: Service -metadata: - name: fibey-agentkit-runtime -spec: - selector: - app: fibey-agentkit-runtime - ports: - - name: http - port: 8080 - targetPort: http diff --git a/examples/fibey-custom-agent-demo/agentruntime-agentkit.yaml b/examples/fibey-custom-agent-demo/agentruntime-agentkit.yaml deleted file mode 100644 index 8178fc79b..000000000 --- a/examples/fibey-custom-agent-demo/agentruntime-agentkit.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# Optional namespace-local facade for an AgentKit Serve adapter implementing orka.harness.v1. -# Current AgentKit Serve Orka support is observed mode only; brokered tool classes -# stay unadvertised until AgentKit passes the corresponding conformance profiles. -# Apply this with secret-agentkit.yaml and point an Agent runtimeRef at fibey-agentkit-runtime -# after deploying the adapter Service named fibey-agentkit-runtime. -apiVersion: core.orka.ai/v1alpha1 -kind: AgentRuntime -metadata: - name: fibey-agentkit-runtime -spec: - contractVersion: orka.harness.v1 - deployment: - mode: external-endpoint - endpoint: http://fibey-agentkit-runtime.default.svc.cluster.local:8080 - clientAuth: - bearerTokenSecretRef: - name: fibey-agentkit-runtime-token - key: token - capabilities: - toolExecutionModes: - - observed - supportsCancel: true - supportsRuntimeSessions: true diff --git a/examples/fibey-custom-agent-demo/agentruntime-foundry.yaml b/examples/fibey-custom-agent-demo/agentruntime-foundry.yaml deleted file mode 100644 index 7cc76fe1f..000000000 --- a/examples/fibey-custom-agent-demo/agentruntime-foundry.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Optional namespace-local facade for github.com/orka-agents/agent-runtime-foundry. -# The checked-in facade matches the Hosted Agents adapter's observed-only default. -# Apply this with secret-foundry.yaml and point an Agent runtimeRef at fibey-foundry-runtime -# after deploying the adapter Service named fibey-foundry-runtime. -apiVersion: core.orka.ai/v1alpha1 -kind: AgentRuntime -metadata: - name: fibey-foundry-runtime -spec: - contractVersion: orka.harness.v1 - deployment: - mode: external-endpoint - endpoint: http://fibey-foundry-runtime.default.svc.cluster.local:8080 - clientAuth: - bearerTokenSecretRef: - name: fibey-foundry-runtime-token - key: token - capabilities: - toolExecutionModes: - - observed - supportsCancel: true - supportsRuntimeSessions: true diff --git a/examples/fibey-custom-agent-demo/agentruntime.yaml b/examples/fibey-custom-agent-demo/agentruntime.yaml deleted file mode 100644 index 1cd83083c..000000000 --- a/examples/fibey-custom-agent-demo/agentruntime.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: AgentRuntime -metadata: - name: fibey-http-runtime -spec: - contractVersion: orka.harness.v1 - deployment: - mode: external-endpoint - endpoint: http://fibey-http-runtime.default.svc.cluster.local:8080 - clientAuth: - bearerTokenSecretRef: - name: fibey-http-runtime-token - key: token - capabilities: - toolExecutionModes: - - observed - supportsCancel: true - supportsRuntimeSessions: true diff --git a/examples/fibey-custom-agent-demo/kustomization.yaml b/examples/fibey-custom-agent-demo/kustomization.yaml deleted file mode 100644 index 6bae51397..000000000 --- a/examples/fibey-custom-agent-demo/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -resources: -- secret.yaml -- mock-http-runtime-service.yaml -- agentruntime.yaml -- agent.yaml -- task.yaml diff --git a/examples/fibey-custom-agent-demo/mock-http-runtime-service.yaml b/examples/fibey-custom-agent-demo/mock-http-runtime-service.yaml deleted file mode 100644 index a8f166d43..000000000 --- a/examples/fibey-custom-agent-demo/mock-http-runtime-service.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: fibey-http-runtime - labels: - app.kubernetes.io/name: fibey-http-runtime -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: fibey-http-runtime - template: - metadata: - labels: - app.kubernetes.io/name: fibey-http-runtime - spec: - containers: - - name: harness - image: ghcr.io/orka-agents/orka/example-echo-harness:latest - imagePullPolicy: IfNotPresent - env: - - name: ORKA_REMOTE_HTTP_RUNTIME_ADDR - value: :8080 - - name: ORKA_REMOTE_HTTP_RUNTIME_NAME - value: fibey-http-runtime - - name: ORKA_REMOTE_HTTP_RUNTIME_BEARER_TOKEN - valueFrom: - secretKeyRef: - name: fibey-http-runtime-token - key: token - ports: - - name: http - containerPort: 8080 - readinessProbe: - httpGet: - path: /v1/health - port: http - periodSeconds: 3 ---- -apiVersion: v1 -kind: Service -metadata: - name: fibey-http-runtime -spec: - selector: - app.kubernetes.io/name: fibey-http-runtime - ports: - - name: http - port: 8080 - targetPort: http diff --git a/examples/fibey-custom-agent-demo/secret-agentkit.yaml b/examples/fibey-custom-agent-demo/secret-agentkit.yaml deleted file mode 100644 index eb3e38386..000000000 --- a/examples/fibey-custom-agent-demo/secret-agentkit.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Optional harness bearer token for the AgentKit Serve adapter facade. -# This authenticates Orka to the adapter endpoint; it is not a downstream production tool credential. -apiVersion: v1 -kind: Secret -metadata: - name: fibey-agentkit-runtime-token - annotations: - orka.ai/agent-runtime-endpoint: http://fibey-agentkit-runtime.default.svc.cluster.local:8080 - labels: - orka.ai/agent-runtime-auth: "true" - orka.ai/agent-runtime-name: fibey-agentkit-runtime -stringData: - token: mock-token diff --git a/examples/fibey-custom-agent-demo/secret-foundry.yaml b/examples/fibey-custom-agent-demo/secret-foundry.yaml deleted file mode 100644 index 1664eb501..000000000 --- a/examples/fibey-custom-agent-demo/secret-foundry.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Optional harness bearer token for the Foundry adapter facade. -# This authenticates Orka to the adapter endpoint; Foundry authentication uses the adapter deployment's Azure identity. -apiVersion: v1 -kind: Secret -metadata: - name: fibey-foundry-runtime-token - annotations: - orka.ai/agent-runtime-endpoint: http://fibey-foundry-runtime.default.svc.cluster.local:8080 - labels: - orka.ai/agent-runtime-auth: "true" - orka.ai/agent-runtime-name: fibey-foundry-runtime -stringData: - token: mock-token diff --git a/examples/fibey-custom-agent-demo/secret.yaml b/examples/fibey-custom-agent-demo/secret.yaml deleted file mode 100644 index 4d235db17..000000000 --- a/examples/fibey-custom-agent-demo/secret.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: fibey-http-runtime-token - annotations: - orka.ai/agent-runtime-endpoint: http://fibey-http-runtime.default.svc.cluster.local:8080 - labels: - orka.ai/agent-runtime-auth: "true" - orka.ai/agent-runtime-name: fibey-http-runtime -stringData: - token: mock-token diff --git a/examples/fibey-custom-agent-demo/switch-backend.sh b/examples/fibey-custom-agent-demo/switch-backend.sh deleted file mode 100755 index fcfb41345..000000000 --- a/examples/fibey-custom-agent-demo/switch-backend.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat >&2 <<'USAGE' -Usage: switch-backend.sh [task-name] [namespace] - -Patches the Fibey demo Task to use the Agent bound to the selected namespace-local -AgentRuntime facade. The selected AgentRuntime/Agent manifests must already be -applied. This changes only the Orka Agent reference; workflow input and Tool -policy remain Orka-owned. -USAGE -} - -backend="${1:-}" -task_name="${2:-fibey-quincy-north-alert}" -namespace="${3:-default}" - -case "${backend}" in - http) - agent="fibey-remote-http" - runtime="fibey-http-runtime" - ;; - agentkit) - agent="fibey-remote-agentkit" - runtime="fibey-agentkit-runtime" - ;; - foundry) - agent="fibey-remote-foundry" - runtime="fibey-foundry-runtime" - ;; - -h|--help|help|"") - usage - exit 0 - ;; - *) - echo "unknown backend: ${backend}" >&2 - usage - exit 2 - ;; -esac - -kubectl -n "${namespace}" get agentruntime "${runtime}" >/dev/null -kubectl -n "${namespace}" get agent "${agent}" >/dev/null -kubectl -n "${namespace}" patch task "${task_name}" --type merge \ - -p "{\"spec\":{\"agentRef\":{\"name\":\"${agent}\"}}}" - -echo "${task_name} now targets ${agent} (${runtime}) in namespace ${namespace}" diff --git a/examples/fibey-custom-agent-demo/task.yaml b/examples/fibey-custom-agent-demo/task.yaml deleted file mode 100644 index 787f755d8..000000000 --- a/examples/fibey-custom-agent-demo/task.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: Task -metadata: - name: fibey-quincy-north-alert -spec: - type: agent - agentRef: - name: fibey-remote-http - prompt: | - Quincy North alert: pump telemetry is anomalous after overnight maintenance. - Investigate likely cause, summarize evidence, and propose the safest next action. diff --git a/examples/github-cicd/README.md b/examples/github-cicd/README.md index 3118638cc..aeb2ce0c5 100644 --- a/examples/github-cicd/README.md +++ b/examples/github-cicd/README.md @@ -1,61 +1,66 @@ # GitHub CI/CD Integration -This maintained example shows how to use Orka's multi-agent coordination to automate a GitHub PR workflow: +This example shows how to use Orka's multi-agent coordination with the ACP v2 workspace boundary: -1. An AI coordinator delegates code changes to a Claude Code runtime agent. -2. The runtime agent pushes a feature branch. -3. The coordinator opens a PR with Orka's built-in `create_pull_request` tool. -4. The coordinator waits for CI and merges with `auto_merge_pull_request`. -5. If CI fails, the coordinator loops back with fix feedback on the same branch. +1. An AI coordinator delegates a write-intent Task to a Claude ACP runtime. +2. The runtime edits the verified workspace but never receives Git credentials or publishes directly. +3. Orka's separate Workspace/Publisher prepares and verifies the branch update. +4. The coordinator opens a PR with `create_pull_request`, waits for CI, and merges with `auto_merge_pull_request`. +5. If CI fails, the coordinator delegates a focused repair against the same claimed branch. -## How It Works +## Credential roles -1. A **coordinator agent** (AI type with coordination enabled) receives a task -2. It delegates work to a **Claude Code agent** via `delegate_task`, including workspace details for clone, push, and PR creation -3. It uses built-in GitHub coordination tools to create the PR and auto-merge when checks pass -4. On CI failure, it can delegate a follow-up fix using `prior_task` +The example uses three independent credential roles: + +- `claude-credentials` — provider/proxy access for the Claude ACP runtime; +- `repository-read` — clone/read credential used only by the clean-room workspace boundary; +- `repository-publish` — branch/forge credential used only by the Workspace/Publisher and GitHub coordination tools. + +Neither Git Secret is delivered to the ACP process tree. ## Files | File | Description | -|------|-------------| -| `agents.yaml` | Coordinator and Claude Code agent definitions | -| `secret.yaml` | Example `git-credentials` Secret for clone/push/PR auth | -| `task.yaml` | Sample task to trigger the workflow | -| `github-actions-webhook.yaml` | Optional GitHub Actions workflow that triggers a branch-fix agent task on CI failure | +| --- | --- | +| `agents.yaml` | Coordinator and Claude Agent definitions with ACP-safe prompts | +| `secret.yaml` | Example read and publication credential Secrets | +| `task.yaml` | Sample coordinator Task | +| `github-actions-webhook.yaml` | Optional workflow that creates a direct ACP write Task after CI failure | ## Setup ```bash -# Update `spec.providerRef.name` in agents.yaml to match your Provider CRD. -# Edit secret.yaml and replace the token value. +# Update spec.providerRef.name in agents.yaml to match your Provider CRD. +# Replace the placeholder values in secret.yaml before applying it. kubectl apply -f examples/github-cicd/secret.yaml -# Create Claude runtime credentials if you do not already have them +# Create the Claude provider/proxy credential separately. kubectl create secret generic claude-credentials \ --from-literal=ANTHROPIC_API_KEY=sk-ant-your-key -# Deploy the example kubectl apply -k examples/github-cicd - -# Submit a task kubectl apply -f examples/github-cicd/task.yaml ``` -Before running the task, edit `task.yaml` and replace the placeholder repository details: +Before running the Task, edit `task.yaml` and replace: -- `gitRepo` -- `branch` -- `gitSecretRef` -- `pushBranch` +- `gitRepo` and `publicationGitRepo`; +- `branch` and `pushBranch`; +- `readCredentialRef` and `publicationCredentialRef`. -## Optional GitHub Actions Integration +A branch update is successful only when the child Task has a terminal verified `status.delivery` receipt. The ACP child reporting that it changed files is not proof of publication. -The `github-actions-webhook.yaml` file is a workflow you can copy into `.github/workflows/` in a repository. When a CI job fails, it creates a direct `type: agent` task for `claude-coder` to investigate and push a fix to the same branch. +## Optional GitHub Actions integration + +Copy `github-actions-webhook.yaml` into `.github/workflows/` in a repository. On CI failure it creates a direct `type: agent` Task with top-level `workspace.intent: write`. The runtime edits the checkout; the Workspace/Publisher owns the exact-ref push. Configure these repository secrets: -- `ORKA_API_URL` -- `ORKA_TOKEN` +- `ORKA_API_URL`; +- `ORKA_TOKEN`. + +The Orka Task namespace must contain the `repository-read` and `repository-publish` Secrets referenced by the payload. -And make sure the Orka namespace already has a `git-credentials` Secret that matches the name used in the workflow payload. +:::caution Current write-path limitation +This worktree still fails non-empty workspace deltas closed until dispatcher-to-publisher delivery is fully wired. Treat this example as the ACP v2 manifest shape, and require a verified delivery receipt in live testing. +::: diff --git a/examples/github-cicd/agents.yaml b/examples/github-cicd/agents.yaml index 1fab09c16..2bca80e46 100644 --- a/examples/github-cicd/agents.yaml +++ b/examples/github-cicd/agents.yaml @@ -1,4 +1,5 @@ -# Claude Code agent that writes code and pushes a feature branch. +# Claude ACP agent. It edits the materialized workspace but never commits, +# pushes, or creates pull requests; the Workspace/Publisher owns delivery. apiVersion: core.orka.ai/v1alpha1 kind: Agent metadata: @@ -17,20 +18,16 @@ spec: - Grep systemPrompt: inline: | - You are a software developer. When given a task: - 1. Work in the repository and branch provided through workspace config. - 2. Implement the requested changes cleanly. - 3. Run relevant tests if they exist. - 4. Commit with a descriptive message. - 5. Push to the configured pushBranch. - 6. Report what changed and any test results. - - Do not create or merge pull requests yourself. - The coordinator handles GitHub workflow steps with Orka's built-in tools. + You are a software developer. Work only in the workspace supplied by Orka. + Implement the requested changes and run focused tests when available. + Do not commit, change Git remotes/configuration, push, or create a pull request. + Orka validates the final tree and publishes it through a separate clean-room service. + Report changed files and test results. secretRef: name: claude-credentials --- -# Coordinator agent that orchestrates the full CI/CD flow with built-in PR tools. +# Coordinator agent that delegates implementation and uses Orka's GitHub tools +# only after the child Task has a verified branch-delivery receipt. apiVersion: core.orka.ai/v1alpha1 kind: Agent metadata: @@ -42,26 +39,25 @@ spec: name: claude-sonnet-4-20250514 systemPrompt: inline: | - You are a CI/CD coordinator. You manage the full lifecycle of code changes: + You are a CI/CD coordinator. - 1. Read the repository details from the task prompt: - - gitRepo - - branch - - gitSecretRef - - pushBranch - 2. Delegate the implementation to the claude-coder agent with that workspace config. - 3. Wait for the coder to finish. - 4. Create a pull request with create_pull_request. - Remember the PR number after the first PR is opened. + 1. Read these values from the task prompt: + - gitRepo and branch + - readCredentialRef + - publicationGitRepo and publicationCredentialRef + - pushBranch and prBaseBranch + 2. Delegate implementation to claude-coder with workspace.intent = write and + every workspace field above. The coder edits files only; Orka publishes. + 3. Wait for the child Task. Continue only when its delivery status proves the + branch was independently verified. + 4. Create the pull request with create_pull_request, using the child task name, + pushBranch, and prBaseBranch. 5. Call auto_merge_pull_request to wait for CI and merge automatically. - 6. If auto_merge_pull_request reports ci_failed or timeout: - - Summarize the failure for the coder. - - Delegate a fix to claude-coder using prior_task and the same pushBranch. - - Wait for the fix to finish. - - Reuse the existing PR and call auto_merge_pull_request again. - 7. Report the final PR URL and merge outcome. + 6. If CI fails or times out, summarize the failure and delegate a focused repair + against the same claimed publication branch, then wait and verify delivery again. + 7. Report the final PR URL, verified branch SHA, and merge outcome. - Use Orka's built-in PR tools rather than shelling out to GitHub CLI. + Never ask the ACP runtime to commit, push, run gh, or create the PR itself. coordination: enabled: true maxConcurrentChildren: 3 diff --git a/examples/github-cicd/github-actions-webhook.yaml b/examples/github-cicd/github-actions-webhook.yaml index 7124f222c..090bac798 100644 --- a/examples/github-cicd/github-actions-webhook.yaml +++ b/examples/github-cicd/github-actions-webhook.yaml @@ -1,11 +1,12 @@ # .github/workflows/ci-feedback.yml -# Copy this to your repository to trigger Orka auto-fix on CI failure +# Copy this to your repository to trigger an Orka ACP repair Task on CI failure. # -# Required secrets: -# ORKA_API_URL: URL of your Orka API (e.g., https://orka.example.com) -# ORKA_TOKEN: ServiceAccount token for Orka API auth -# Cluster prerequisite: -# - git-credentials Secret exists in the task namespace and contains a GitHub token +# Required repository secrets: +# ORKA_API_URL: URL of your Orka API (for example, https://orka.example.com) +# ORKA_TOKEN: ServiceAccount/OIDC token for Orka API authentication +# Cluster prerequisites: +# - repository-read Secret exists in the Task namespace +# - repository-publish Secret exists in the Task namespace name: CI Feedback to Orka on: workflow_run: @@ -17,32 +18,32 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'failure' }} runs-on: ubuntu-latest steps: - - name: Get failure logs + - name: Get failure context id: logs run: | - echo "run_id=${{ github.event.workflow_run.id }}" >> $GITHUB_OUTPUT - echo "branch=${{ github.event.workflow_run.head_branch }}" >> $GITHUB_OUTPUT - echo "sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT - echo "repo=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "branch=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT" + echo "sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" + echo "repo=${{ github.repository }}" >> "$GITHUB_OUTPUT" - - name: Trigger Orka fix agent + - name: Trigger Orka repair agent run: | - curl -s -X POST "${{ secrets.ORKA_API_URL }}/api/v1/tasks" \ + curl -sS -X POST "${{ secrets.ORKA_API_URL }}/api/v1/tasks" \ -H "Authorization: Bearer ${{ secrets.ORKA_TOKEN }}" \ -H "Content-Type: application/json" \ -d '{ "type": "agent", "agentRef": {"name": "claude-coder"}, - "prompt": "CI failed on branch ${{ steps.logs.outputs.branch }} (commit ${{ steps.logs.outputs.sha }}). Check the CI failures, fix the code, and push to the same branch.", - "agentRuntime": { - "workspace": { - "gitRepo": "https://github.com/${{ steps.logs.outputs.repo }}.git", - "branch": "${{ steps.logs.outputs.branch }}", - "gitSecretRef": { - "name": "git-credentials" - } - } + "prompt": "CI failed on branch ${{ steps.logs.outputs.branch }} at ${{ steps.logs.outputs.sha }}. Fix only the CI failure. Do not commit or push; Orka owns publication.", + "workspace": { + "intent": "write", + "gitRepo": "https://github.com/${{ steps.logs.outputs.repo }}.git", + "branch": "${{ steps.logs.outputs.branch }}", + "readCredentialRef": {"name": "repository-read"}, + "publicationGitRepo": "https://github.com/${{ steps.logs.outputs.repo }}.git", + "publicationCredentialRef": {"name": "repository-publish"}, + "pushBranch": "${{ steps.logs.outputs.branch }}" }, - "timeout": "15m", + "agentRuntime": {"maxTurns": 40}, + "timeout": "20m", "priority": 900 }' diff --git a/examples/github-cicd/secret.yaml b/examples/github-cicd/secret.yaml index bad4f2a0b..643c42b6b 100644 --- a/examples/github-cicd/secret.yaml +++ b/examples/github-cicd/secret.yaml @@ -1,7 +1,18 @@ apiVersion: v1 kind: Secret metadata: - name: git-credentials + name: repository-read type: Opaque stringData: - token: "ghp_YOUR_TOKEN_HERE" # needs repo, pull_request, and workflow scopes + # Use a read-only, repository-scoped token. + token: "github_pat_READ_ONLY_PLACEHOLDER" +--- +apiVersion: v1 +kind: Secret +metadata: + name: repository-publish +type: Opaque +stringData: + # Use a separately scoped token permitted to update the publication branch + # and create/reconcile pull requests. + token: "github_pat_PUBLICATION_PLACEHOLDER" diff --git a/examples/github-cicd/task.yaml b/examples/github-cicd/task.yaml index dc18e2e68..d43faa313 100644 --- a/examples/github-cicd/task.yaml +++ b/examples/github-cicd/task.yaml @@ -10,15 +10,19 @@ spec: prompt: | Implement unit tests for the authentication module. - Repository details: + Repository details for the delegated ACP write Task: - gitRepo: https://github.com/myorg/myrepo.git - branch: main - - gitSecretRef: git-credentials + - readCredentialRef: repository-read + - publicationGitRepo: https://github.com/myorg/myrepo.git + - publicationCredentialRef: repository-publish - pushBranch: chore/add-auth-tests + - prBaseBranch: main Requirements: - Cover the authentication module's happy path and failure path. - Run the relevant test suite before handing work back. + - Require a verified branch-delivery receipt before opening the PR. - Open a PR and auto-merge it once CI passes. timeout: 45m priority: 800 diff --git a/examples/github-label-trigger/README.md b/examples/github-label-trigger/README.md index 465f39bea..3d9b9aaea 100644 --- a/examples/github-label-trigger/README.md +++ b/examples/github-label-trigger/README.md @@ -1,9 +1,9 @@ # GitHub Label Trigger Example -This example wires GitHub labels to Orka runtime-agent Tasks. +This example wires GitHub labels to Orka ACP `type: agent` Tasks. 1. Deploy an Agent that can work in a git workspace. -2. Configure the controller with a webhook secret, default Agent, and git credentials Secret. +2. Configure the controller with a webhook secret, default Agent, and the label-trigger compatibility Git Secret. 3. Add labels such as `agent:implement`, `agent:update-branch`, `agent:review`, or `agent:to-issues` to issues/PRs. ## Secrets @@ -15,10 +15,10 @@ kubectl create secret generic github-webhook-secret \ --from-literal=secret='' kubectl create secret generic git-credentials \ - --from-literal=token='' + --from-literal=token='' ``` -The GitHub token should have only the repository permissions required by the actions you allow. Runtime agents should leave workspace changes uncommitted; Orka finalization commits and pushes configured branches. +The compatibility setting maps this Secret to the Task read role and, for write labels, the publication role. The ACP runtime never receives it. Runtime agents must leave changes uncommitted; the separate Workspace/Publisher prepares, publishes, and verifies configured branches. For strict least privilege with different read and publication Secrets, create Tasks through an API/workflow that sets both top-level references explicitly. ## Controller env diff --git a/examples/harness/echo/Dockerfile b/examples/harness/echo/Dockerfile deleted file mode 100644 index ae5b0354e..000000000 --- a/examples/harness/echo/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM golang:1.26-alpine AS build -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o /out/orka-example-echo-harness ./examples/harness/echo - -FROM gcr.io/distroless/static:nonroot -COPY --from=build /out/orka-example-echo-harness /orka-example-echo-harness -USER 65532:65532 -EXPOSE 8090 -ENTRYPOINT ["/orka-example-echo-harness"] diff --git a/examples/harness/echo/main.go b/examples/harness/echo/main.go deleted file mode 100644 index 5bdc12572..000000000 --- a/examples/harness/echo/main.go +++ /dev/null @@ -1,638 +0,0 @@ -package main - -import ( - "crypto/subtle" - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/orka-agents/orka/internal/harness" -) - -const ( - behaviorSuccess = "success" - behaviorReadTool = "read-tool" - behaviorApprovalTool = "approval-tool" - behaviorFailure = "failure" - behaviorTimeout = "timeout" - behaviorCancellation = "cancellation" - remoteRuntimeNameEnv = "ORKA_REMOTE_HTTP_RUNTIME_NAME" - remoteRuntimeBearerEnv = "ORKA_REMOTE_HTTP_RUNTIME_BEARER_TOKEN" - remoteRuntimeAddrEnv = "ORKA_REMOTE_HTTP_RUNTIME_ADDR" - remoteRuntimeScriptEnv = "ORKA_REMOTE_HTTP_RUNTIME_BEHAVIOR" - remoteRuntimeReadToolEnv = "ORKA_REMOTE_HTTP_RUNTIME_READ_TOOL_NAME" - remoteRuntimeWriteToolEnv = "ORKA_REMOTE_HTTP_RUNTIME_WRITE_TOOL_NAME" - remoteRuntimeBrokeredOnlyEnv = "ORKA_REMOTE_HTTP_RUNTIME_BROKERED_ONLY" - brokeredReadCallID = "tool-read-1" - brokeredWriteCallID = "tool-write-1" -) - -type server struct { - runtimeName string - bearerValue string - behavior string - mu sync.Mutex - turns map[harness.HarnessTurnID]*turnState - completedTurns map[harness.HarnessTurnID]struct{} -} - -type turnState struct { - request harness.StartTurnRequest - cancelled chan struct{} - continued chan struct{} - onceCancel sync.Once - onceCont sync.Once - results []harness.ToolCallResult -} - -func main() { - addr := firstNonBlank(os.Getenv(remoteRuntimeAddrEnv), os.Getenv("ORKA_EXAMPLE_HARNESS_ADDR"), ":8090") - runtimeName := firstNonBlank( - os.Getenv(remoteRuntimeNameEnv), - os.Getenv("ORKA_EXAMPLE_HARNESS_RUNTIME_NAME"), - "orka-generic-http-runtime", - ) - behavior := normalizeBehavior(firstNonBlank( - os.Getenv(remoteRuntimeScriptEnv), - os.Getenv("ORKA_EXAMPLE_HARNESS_BEHAVIOR"), - behaviorSuccess, - )) - s := &server{ - runtimeName: runtimeName, - bearerValue: strings.TrimSpace(firstNonBlank( - os.Getenv(remoteRuntimeBearerEnv), - os.Getenv("ORKA_EXAMPLE_HARNESS_BEARER_TOKEN"), - )), - behavior: behavior, - turns: map[harness.HarnessTurnID]*turnState{}, - completedTurns: map[harness.HarnessTurnID]struct{}{}, - } - mux := http.NewServeMux() - mux.HandleFunc(harness.HealthPath, s.health) - mux.HandleFunc(harness.CapabilitiesPath, s.capabilities) - mux.HandleFunc(harness.TurnsPath, s.startTurn) - mux.HandleFunc(harness.TurnsPath+"/", s.turn) - mux.HandleFunc("/lookup", s.supportLookup) - log.Printf("generic HTTP AgentRuntime fixture listening on %s (runtime=%s behavior=%s)", addr, runtimeName, behavior) - log.Fatal(http.ListenAndServe(addr, mux)) -} - -func (s *server) supportLookup(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - incident, _ := body["incident"].(string) - if strings.TrimSpace(incident) == "" { - incident = "unknown" - } - harness.WriteJSON(w, http.StatusOK, map[string]any{ - "success": true, - "data": map[string]any{ - "incident": incident, - "status": "investigating", - "source": "mock-support-tool", - }, - }) -} - -func firstNonBlank(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func envBool(name string) bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func normalizeBehavior(value string) string { - switch strings.TrimSpace(value) { - case behaviorReadTool, behaviorApprovalTool, behaviorFailure, behaviorTimeout, behaviorCancellation: - return strings.TrimSpace(value) - default: - return behaviorSuccess - } -} - -func (s *server) health(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - harness.WriteJSON(w, http.StatusOK, harness.HealthResponse{ - Version: harness.ProtocolVersion, - Status: harness.HealthStatusOK, - Ready: true, - CheckedAt: time.Now().UTC(), - Metadata: map[string]string{ - "runtime": s.runtimeName, - "backend": "generic-http", - }, - }) -} - -func (s *server) capabilities(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - modes := []harness.ToolExecutionMode{harness.ToolExecutionModeObserved} - classes := []harness.BrokeredToolClass(nil) - brokeredOnly := envBool(remoteRuntimeBrokeredOnlyEnv) - if brokeredOnly { - modes = nil - } - if s.behavior == behaviorReadTool { - modes = append(modes, harness.ToolExecutionModeBrokered) - classes = append(classes, harness.BrokeredToolClassRead) - } - if s.behavior == behaviorApprovalTool { - modes = append(modes, harness.ToolExecutionModeBrokered) - classes = append(classes, harness.BrokeredToolClassWrite) - } - harness.WriteJSON(w, http.StatusOK, harness.CapabilitiesResponse{ - Version: harness.ProtocolVersion, - ProtocolVersion: harness.ProtocolVersion, - Transport: harness.HTTPTransport, - RuntimeName: s.runtimeName, - RuntimeVersion: "generic-http-fixture", - ProviderKind: harness.ProviderKindRemote, - ToolExecutionModes: modes, - BrokeredToolClasses: classes, - SupportsCancel: true, - SupportsRuntimeSessions: true, - SupportsContinuation: s.behavior == behaviorReadTool || s.behavior == behaviorApprovalTool, - SupportsArtifacts: true, - MaxConcurrentTurns: 1, - MaxTurnSeconds: 600, - MaxOutputBytes: 1 << 20, - Metadata: map[string]string{ - "backend": "generic-http", - "behavior": s.behavior, - }, - }) -} - -func (s *server) authorized(w http.ResponseWriter, r *http.Request) bool { - if strings.TrimSpace(s.bearerValue) == "" { - return true - } - got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(s.bearerValue)) != 1 { - harness.WriteError(w, http.StatusUnauthorized, "unauthorized") - return false - } - return true -} - -func (s *server) startTurn(w http.ResponseWriter, r *http.Request) { - if !s.authorized(w, r) { - return - } - if r.Method != http.MethodPost { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - var request harness.StartTurnRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - harness.WriteError(w, http.StatusBadRequest, "invalid JSON request") - return - } - if err := request.Validate(); err != nil { - harness.WriteError(w, http.StatusBadRequest, err.Error()) - return - } - eventStreamPath, err := harness.EventStreamPath(request.TurnID) - if err != nil { - harness.WriteError(w, http.StatusBadRequest, err.Error()) - return - } - turn := &turnState{request: request, cancelled: make(chan struct{}), continued: make(chan struct{})} - s.mu.Lock() - if _, completed := s.completedTurns[request.TurnID]; completed { - s.mu.Unlock() - harness.WriteError(w, http.StatusConflict, "turn already completed") - return - } - if _, exists := s.turns[request.TurnID]; exists { - s.mu.Unlock() - harness.WriteError(w, http.StatusConflict, "turn already exists") - return - } - s.turns[request.TurnID] = turn - s.mu.Unlock() - harness.WriteJSON(w, http.StatusAccepted, harness.StartTurnResponse{ - Version: harness.ProtocolVersion, - Accepted: true, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - CorrelationID: request.CorrelationID, - EventStreamPath: eventStreamPath, - }) -} - -func (s *server) turn(w http.ResponseWriter, r *http.Request) { - if !s.authorized(w, r) { - return - } - turnID, resource, err := harness.ParseTurnResourcePath(r.URL.EscapedPath()) - if err != nil { - harness.WriteError(w, http.StatusNotFound, "not found") - return - } - s.mu.Lock() - turn := s.turns[turnID] - s.mu.Unlock() - if turn == nil { - harness.WriteError(w, http.StatusNotFound, "turn not found") - return - } - switch resource { - case harness.TurnResourceEvents: - if r.Method != http.MethodGet { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - s.streamEvents(w, r, turn) - case harness.TurnResourceContinue: - if r.Method != http.MethodPost { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - s.continueTurn(w, r, turn) - case harness.TurnResourceCancel: - s.cancelTurn(w, r, turn) - default: - harness.WriteError(w, http.StatusNotFound, "not found") - } -} - -func (s *server) streamEvents(w http.ResponseWriter, r *http.Request, turn *turnState) { - afterSeq := int64(0) - if raw := strings.TrimSpace(r.URL.Query().Get("afterSeq")); raw != "" { - _, _ = fmt.Sscanf(raw, "%d", &afterSeq) - } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - write := func(frame harness.HarnessEventFrame) bool { - if frame.Seq <= afterSeq { - return true - } - return harness.WriteSSEFrame(w, frame) == nil - } - frames := s.initialFrames(turn) - if framesHaveTerminal(frames) { - s.markCompleted(turn.request.TurnID) - } - for _, frame := range frames { - if !write(frame) { - return - } - } - if turn.request.ToolExecutionMode != harness.ToolExecutionModeBrokered && - (s.behavior == behaviorReadTool || s.behavior == behaviorApprovalTool) { - _ = harness.WriteSSEDone(w) - return - } - switch s.behavior { - case behaviorReadTool: - if afterSeq >= 5 { - _ = harness.WriteSSEDone(w) - return - } - select { - case <-turn.continued: - continuedFrames := s.continuedReadFrames(turn) - if framesHaveTerminal(continuedFrames) { - s.markCompleted(turn.request.TurnID) - } - for _, frame := range continuedFrames { - if !write(frame) { - return - } - } - _ = harness.WriteSSEDone(w) - case <-turn.cancelled: - s.markCompleted(turn.request.TurnID) - _ = write(frame(turn.request, 4, harness.FrameTurnCancelled, "turn cancelled", nil)) - _ = harness.WriteSSEDone(w) - case <-r.Context().Done(): - return - } - case behaviorApprovalTool: - if afterSeq >= 6 { - _ = harness.WriteSSEDone(w) - return - } - select { - case <-turn.continued: - continuedFrames := s.continuedFrames(turn) - if framesHaveTerminal(continuedFrames) { - s.markCompleted(turn.request.TurnID) - } - for _, frame := range continuedFrames { - if !write(frame) { - return - } - } - _ = harness.WriteSSEDone(w) - case <-turn.cancelled: - s.markCompleted(turn.request.TurnID) - _ = write(frame(turn.request, 5, harness.FrameTurnCancelled, "turn cancelled", nil)) - _ = harness.WriteSSEDone(w) - case <-r.Context().Done(): - return - } - case behaviorCancellation: - select { - case <-turn.cancelled: - s.markCompleted(turn.request.TurnID) - _ = write(frame(turn.request, 2, harness.FrameTurnCancelled, "turn cancelled", nil)) - _ = harness.WriteSSEDone(w) - case <-r.Context().Done(): - return - } - default: - _ = harness.WriteSSEDone(w) - } -} - -func (s *server) markCompleted(turnID harness.HarnessTurnID) { - s.mu.Lock() - s.completedTurns[turnID] = struct{}{} - s.mu.Unlock() -} - -func framesHaveTerminal(frames []harness.HarnessEventFrame) bool { - for _, frame := range frames { - switch frame.Type { - case harness.FrameTurnCompleted, harness.FrameTurnFailed, harness.FrameTurnCancelled: - return true - } - } - return false -} - -func (s *server) initialFrames(turn *turnState) []harness.HarnessEventFrame { - request := turn.request - start := frame(request, 1, harness.FrameTurnStarted, "turn started", nil) - switch s.behavior { - case behaviorFailure: - failed := frame(request, 2, harness.FrameTurnFailed, "turn failed", nil) - failed.Failed = &harness.TurnFailed{Reason: "simulated_failure", Message: "generic HTTP runtime simulated failure"} - failed.Error = &harness.ErrorInfo{Code: "simulated_failure", Message: "generic HTTP runtime simulated failure"} - return []harness.HarnessEventFrame{start, failed} - case behaviorTimeout: - failed := frame(request, 2, harness.FrameTurnFailed, "turn timeout", nil) - failed.Failed = &harness.TurnFailed{ - Reason: "timeout", - Message: "generic HTTP runtime simulated timeout", - Retryable: true, - } - failed.Error = &harness.ErrorInfo{Code: "timeout", Message: "generic HTTP runtime simulated timeout", Retryable: true} - return []harness.HarnessEventFrame{start, failed} - case behaviorCancellation: - return []harness.HarnessEventFrame{start} - case behaviorReadTool: - if request.ToolExecutionMode != harness.ToolExecutionModeBrokered { - return observedSuccessFrames(request, start) - } - output := runtimeOutput(request, 2, "generic HTTP runtime requesting read-only tool") - toolName := brokeredToolNameForRequest(request, defaultReadToolName()) - if !requestIncludesBrokeredToolSchema(request, toolName) { - failed := missingToolSchemaFrame(request, 3) - return []harness.HarnessEventFrame{start, output, failed} - } - tool := toolRequested(request, 3, toolName, brokeredReadCallID, `{"incident":"quincy-north"}`) - return []harness.HarnessEventFrame{start, output, tool} - case behaviorApprovalTool: - if request.ToolExecutionMode != harness.ToolExecutionModeBrokered { - return observedSuccessFrames(request, start) - } - output := runtimeOutput(request, 2, "generic HTTP runtime requesting approval-gated tool") - toolName := brokeredToolNameForRequest(request, defaultWriteToolName()) - if !requestIncludesBrokeredToolSchema(request, toolName) { - failed := missingToolSchemaFrame(request, 3) - return []harness.HarnessEventFrame{start, output, failed} - } - - tool := toolRequested( - request, - 3, - toolName, - brokeredWriteCallID, - `{"incident":"quincy-north","action":"dispatch technician"}`, - ) - waiting := frame(request, 4, harness.FrameRuntimeLog, "waiting for Orka brokered approval", nil) - waiting.Content = json.RawMessage(fmt.Sprintf(`{"status":"waiting_for_orka_approval","targetTool":%q}`, toolName)) - return []harness.HarnessEventFrame{start, output, tool, waiting} - default: - return observedSuccessFrames(request, start) - } -} - -func missingToolSchemaFrame(request harness.StartTurnRequest, seq int64) harness.HarnessEventFrame { - const message = "brokered tool schema was not supplied by Orka" - failed := frame(request, seq, harness.FrameTurnFailed, "brokered tool schema missing", nil) - failed.Failed = &harness.TurnFailed{Reason: "missing_tool_schema", Message: message} - failed.Error = &harness.ErrorInfo{Code: "missing_tool_schema", Message: message} - return failed -} - -func requestIncludesBrokeredToolSchema(request harness.StartTurnRequest, name string) bool { - name = strings.TrimSpace(name) - for _, definition := range request.Input.Tools { - if strings.TrimSpace(definition.Name) == name && definition.BrokeredClass != "" { - return true - } - } - return false -} - -func brokeredToolNameForRequest(request harness.StartTurnRequest, fallback string) string { - if class := strings.TrimSpace(request.Metadata["brokeredToolClass"]); class != "" { - return "conformance_" + class - } - return fallback -} - -func defaultReadToolName() string { - return firstNonBlank(os.Getenv(remoteRuntimeReadToolEnv), "read_incident") -} - -func defaultWriteToolName() string { - return firstNonBlank(os.Getenv(remoteRuntimeWriteToolEnv), "dispatch_work_order") -} - -func observedSuccessFrames( - request harness.StartTurnRequest, - start harness.HarnessEventFrame, -) []harness.HarnessEventFrame { - output := runtimeOutput(request, 2, "echo: "+request.Input.Prompt) - completed := frame(request, 3, harness.FrameTurnCompleted, "turn completed", &harness.TurnCompleted{ - Result: "ok", - FinalEventSeq: 3, - }) - return []harness.HarnessEventFrame{start, output, completed} -} - -func (s *server) continuedReadFrames(turn *turnState) []harness.HarnessEventFrame { - request := turn.request - result := frame(request, 4, harness.FrameToolResultReceived, "brokered read-only tool result received", nil) - result.ToolName = brokeredToolNameForRequest(request, defaultReadToolName()) - result.ToolCallID = brokeredReadCallID - if len(turn.results) > 0 { - if turn.results[0].Error != nil { - result.Error = turn.results[0].Error - encoded, _ := json.Marshal(map[string]any{"error": turn.results[0].Error}) - result.Content = encoded - } else { - result.Content = turn.results[0].Output - } - } - completed := frame(request, 5, harness.FrameTurnCompleted, "turn completed", &harness.TurnCompleted{ - Result: "read-only investigation complete", - FinalEventSeq: 5, - }) - return []harness.HarnessEventFrame{result, completed} -} - -func (s *server) continuedFrames(turn *turnState) []harness.HarnessEventFrame { - request := turn.request - result := frame(request, 5, harness.FrameToolResultReceived, "brokered approval-gated tool result received", nil) - result.ToolName = brokeredToolNameForRequest(request, defaultWriteToolName()) - result.ToolCallID = brokeredWriteCallID - if len(turn.results) > 0 { - if turn.results[0].Error != nil { - result.Error = turn.results[0].Error - encoded, _ := json.Marshal(map[string]any{"error": turn.results[0].Error}) - result.Content = encoded - } else { - result.Content = turn.results[0].Output - } - } else { - result.Error = &harness.ErrorInfo{Code: "missing_tool_result", Message: "continue request had no tool result"} - result.Content = json.RawMessage(`{"error":{"code":"missing_tool_result"}}`) - } - completed := frame(request, 6, harness.FrameTurnCompleted, "turn completed", &harness.TurnCompleted{ - Result: "approval-gated action completed", - FinalEventSeq: 6, - }) - return []harness.HarnessEventFrame{result, completed} -} - -func runtimeOutput(request harness.StartTurnRequest, seq int64, text string) harness.HarnessEventFrame { - output := frame(request, seq, harness.FrameRuntimeOutput, "runtime output", nil) - output.ContentText = text - output.Content = json.RawMessage(fmt.Sprintf(`{"message":%q}`, text)) - return output -} - -func toolRequested( - request harness.StartTurnRequest, - seq int64, - name string, - toolCallID string, - args string, -) harness.HarnessEventFrame { - tool := frame(request, seq, harness.FrameToolCallRequested, "brokered tool requested", nil) - tool.ToolName = name - tool.ToolCallID = toolCallID - tool.Content = json.RawMessage(args) - return tool -} - -func (s *server) continueTurn(w http.ResponseWriter, r *http.Request, turn *turnState) { - var request harness.ContinueTurnRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - harness.WriteError(w, http.StatusBadRequest, "invalid JSON request") - return - } - if err := request.Validate(); err != nil { - harness.WriteError(w, http.StatusBadRequest, err.Error()) - return - } - if request.RuntimeSessionID != turn.request.RuntimeSessionID || - request.TurnID != turn.request.TurnID || - request.CorrelationID != turn.request.CorrelationID { - harness.WriteError(w, http.StatusBadRequest, "continue request does not match started turn") - return - } - turn.results = append([]harness.ToolCallResult(nil), request.ToolResults...) - turn.onceCont.Do(func() { close(turn.continued) }) - harness.WriteJSON(w, http.StatusAccepted, harness.ContinueTurnResponse{ - Version: harness.ProtocolVersion, - Accepted: true, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - CorrelationID: request.CorrelationID, - Message: "continue accepted", - }) -} - -func (s *server) cancelTurn(w http.ResponseWriter, r *http.Request, turn *turnState) { - if r.Method != http.MethodPost { - harness.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - var request harness.CancelTurnRequest - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - harness.WriteError(w, http.StatusBadRequest, "invalid JSON request") - return - } - if err := request.Validate(); err != nil { - harness.WriteError(w, http.StatusBadRequest, err.Error()) - return - } - if request.RuntimeSessionID != turn.request.RuntimeSessionID || request.TurnID != turn.request.TurnID { - harness.WriteError(w, http.StatusBadRequest, "cancel request does not match started turn") - return - } - turn.onceCancel.Do(func() { close(turn.cancelled) }) - harness.WriteJSON(w, http.StatusAccepted, harness.CancelTurnResponse{ - Version: harness.ProtocolVersion, - Accepted: true, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - CorrelationID: request.CorrelationID, - Message: "cancel accepted", - }) -} - -func frame( - request harness.StartTurnRequest, - seq int64, - typ harness.FrameType, - summary string, - completed *harness.TurnCompleted, -) harness.HarnessEventFrame { - metadata := map[string]string{"backend": "generic-http"} - return harness.HarnessEventFrame{ - Version: harness.ProtocolVersion, - Type: typ, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - CorrelationID: request.CorrelationID, - Seq: seq, - CreatedAt: time.Now().UTC(), - Summary: summary, - Completed: completed, - Metadata: metadata, - } -} diff --git a/examples/harness/echo/main_test.go b/examples/harness/echo/main_test.go deleted file mode 100644 index d4a03d277..000000000 --- a/examples/harness/echo/main_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/orka-agents/orka/internal/harness" - "github.com/orka-agents/orka/internal/harness/conformance" -) - -func TestEchoHarnessCancelEndpointMatchesCapabilities(t *testing.T) { - s := newTestServer(behaviorSuccess) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - client, err := harness.NewClient(srv.URL) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - ctx := context.Background() - caps, err := client.Capabilities(ctx) - if err != nil { - t.Fatalf("Capabilities() error = %v", err) - } - if !caps.SupportsCancel { - t.Fatal("generic HTTP runtime should advertise cancellation") - } - request := validStartTurnRequest() - if _, err := client.StartTurn(ctx, request); err != nil { - t.Fatalf("StartTurn() error = %v", err) - } - cancelled, err := client.CancelTurn(ctx, harness.CancelTurnRequest{ - Version: harness.ProtocolVersion, - Namespace: request.Namespace, - TaskName: request.TaskName, - SessionName: request.SessionName, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - CorrelationID: request.CorrelationID, - Reason: "test", - }) - if err != nil { - t.Fatalf("CancelTurn() error = %v", err) - } - if !cancelled.Accepted || - cancelled.TurnID != request.TurnID || - cancelled.RuntimeSessionID != request.RuntimeSessionID { - t.Fatalf("CancelTurn() = %#v", cancelled) - } -} - -func TestEchoHarnessRejectsDuplicateStartTurn(t *testing.T) { - s := newTestServer(behaviorSuccess) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - client, err := harness.NewClient(srv.URL) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - request := validStartTurnRequest() - if _, err := client.StartTurn(context.Background(), request); err != nil { - t.Fatalf("first StartTurn() error = %v", err) - } - _, err = client.StartTurn(context.Background(), request) - if err == nil || !strings.Contains(err.Error(), "turn already exists") { - t.Fatalf("second StartTurn() error = %v, want duplicate rejection", err) - } -} - -func TestEchoHarnessRejectsCompletedStartTurn(t *testing.T) { - s := newTestServer(behaviorSuccess) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - client, err := harness.NewClient(srv.URL) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - request := validStartTurnRequest() - if _, err := client.StartTurn(context.Background(), request); err != nil { - t.Fatalf("StartTurn() error = %v", err) - } - if err := client.StreamFrames( - context.Background(), - request.TurnID, - 0, - func(harness.HarnessEventFrame) error { return nil }, - ); err != nil { - t.Fatalf("StreamFrames() error = %v", err) - } - _, err = client.StartTurn(context.Background(), request) - if err == nil || !strings.Contains(err.Error(), "turn already completed") { - t.Fatalf("completed StartTurn() error = %v, want tombstone rejection", err) - } -} - -func TestSupportLookupEndpoint(t *testing.T) { - s := newTestServer(behaviorSuccess) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - resp, err := http.Post(srv.URL+"/lookup", "application/json", bytes.NewBufferString(`{"incident":"case-1"}`)) - if err != nil { - t.Fatalf("POST /lookup: %v", err) - } - defer resp.Body.Close() //nolint:errcheck - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } - var body map[string]any - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - if body["success"] != true { - t.Fatalf("body = %#v", body) - } -} - -func TestGenericHTTPRuntimeApprovalContinuation(t *testing.T) { - s := newTestServer(behaviorApprovalTool) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - client, err := harness.NewClient(srv.URL) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - request := validStartTurnRequest() - request.ToolExecutionMode = harness.ToolExecutionModeBrokered - request.Input.Tools = []harness.ToolDefinition{{ - Name: defaultWriteToolName(), - BrokeredClass: harness.BrokeredToolClassWrite, - Parameters: json.RawMessage(`{"type":"object"}`), - }} - if _, err := client.StartTurn(context.Background(), request); err != nil { - t.Fatalf("StartTurn() error = %v", err) - } - firstCtx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - var firstFrames []harness.HarnessEventFrame - _ = client.StreamFrames(firstCtx, request.TurnID, 0, func(frame harness.HarnessEventFrame) error { - firstFrames = append(firstFrames, frame) - return nil - }) - if len(firstFrames) != 4 || - firstFrames[2].Type != harness.FrameToolCallRequested || - firstFrames[len(firstFrames)-1].Type != harness.FrameRuntimeLog { - t.Fatalf("first frames = %#v, want tool request followed by waiting diagnostic", firstFrames) - } - continued, err := client.ContinueTurn(context.Background(), harness.ContinueTurnRequest{ - Version: harness.ProtocolVersion, - Namespace: request.Namespace, - TaskName: request.TaskName, - SessionName: request.SessionName, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - CorrelationID: request.CorrelationID, - ToolResults: []harness.ToolCallResult{{ - Version: harness.ProtocolVersion, - RuntimeSessionID: request.RuntimeSessionID, - TurnID: request.TurnID, - ToolCallID: "tool-write-1", - IdempotencyKey: harness.ToolRequestIdempotencyKey(request.RuntimeSessionID, request.TurnID, "tool-write-1"), - Approved: true, - Output: json.RawMessage(`{"success":true,"data":{"dispatched":true}}`), - }}, - }) - if err != nil { - t.Fatalf("ContinueTurn() error = %v", err) - } - if !continued.Accepted { - t.Fatalf("ContinueTurn() = %#v, want accepted", continued) - } - var finalFrames []harness.HarnessEventFrame - if err := client.StreamFrames(context.Background(), request.TurnID, 4, func(frame harness.HarnessEventFrame) error { - finalFrames = append(finalFrames, frame) - return nil - }); err != nil { - t.Fatalf("StreamFrames(after continue) error = %v", err) - } - if len(finalFrames) != 2 || - finalFrames[0].Type != harness.FrameToolResultReceived || - finalFrames[1].Type != harness.FrameTurnCompleted { - t.Fatalf("final frames = %#v, want tool result then completion", finalFrames) - } -} - -func TestGenericHTTPRuntimePassesBrokeredReadConformance(t *testing.T) { - s := newTestServer(behaviorReadTool) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - observed := conformance.Check(context.Background(), conformance.Target{BaseURL: srv.URL, ProbeTurn: true}) - if !observed.Passed { - t.Fatalf("observed conformance failed: %v", observed.Failures) - } - result := conformance.Check(context.Background(), conformance.Target{BaseURL: srv.URL, ProbeBrokeredRead: true}) - if !result.Passed { - t.Fatalf("conformance failed: %v", result.Failures) - } -} - -func TestGenericHTTPRuntimePassesBrokeredWriteConformance(t *testing.T) { - s := newTestServer(behaviorApprovalTool) - srv := httptest.NewServer(s.handler()) - defer srv.Close() - observed := conformance.Check(context.Background(), conformance.Target{BaseURL: srv.URL, ProbeTurn: true}) - if !observed.Passed { - t.Fatalf("observed conformance failed: %v", observed.Failures) - } - result := conformance.Check(context.Background(), conformance.Target{BaseURL: srv.URL, ProbeBrokeredWrite: true}) - if !result.Passed { - t.Fatalf("conformance failed: %v", result.Failures) - } -} - -func newTestServer(behavior string) *server { - return &server{ - runtimeName: "orka-generic-http-runtime", - behavior: normalizeBehavior(behavior), - turns: map[harness.HarnessTurnID]*turnState{}, - completedTurns: map[harness.HarnessTurnID]struct{}{}, - } -} - -func (s *server) handler() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc(harness.HealthPath, s.health) - mux.HandleFunc(harness.CapabilitiesPath, s.capabilities) - mux.HandleFunc(harness.TurnsPath, s.startTurn) - mux.HandleFunc(harness.TurnsPath+"/", s.turn) - mux.HandleFunc("/lookup", s.supportLookup) - return mux -} - -func validStartTurnRequest() harness.StartTurnRequest { - return harness.StartTurnRequest{ - Version: harness.ProtocolVersion, - Namespace: "default", - TaskName: "task", - SessionName: "session", - RuntimeSessionID: "runtime", - TurnID: "turn", - CorrelationID: "corr", - Deadline: time.Now().UTC().Add(time.Minute), - AuthIdentity: harness.AuthIdentity{Subject: "user:test"}, - Input: harness.TurnInput{Prompt: "hello"}, - } -} diff --git a/examples/iterative-review/coder-agent.yaml b/examples/iterative-review/coder-agent.yaml index 7d52465a3..8d77f376a 100644 --- a/examples/iterative-review/coder-agent.yaml +++ b/examples/iterative-review/coder-agent.yaml @@ -20,5 +20,7 @@ spec: inline: | You are a coding agent. Write clean, well-tested code. If you receive FEEDBACK FROM REVIEW, address all feedback items. + Do not commit, change Git configuration/remotes, push, or create a pull request. + Orka validates and publishes the final tree through a separate clean-room service. secretRef: name: claude-credentials diff --git a/examples/iterative-review/coordinator-agent.yaml b/examples/iterative-review/coordinator-agent.yaml index 34d707399..5c92fec6c 100644 --- a/examples/iterative-review/coordinator-agent.yaml +++ b/examples/iterative-review/coordinator-agent.yaml @@ -11,26 +11,30 @@ spec: inline: | You are a coordinator agent. Follow this protocol: 1. READ the repository details from the task prompt: - - gitRepo - - branch - - gitSecretRef - - pushBranch - 2. DELEGATE implementation to the coder agent with that workspace config. - The coder must work on the requested pushBranch. + - gitRepo and branch + - readCredentialRef + - publicationGitRepo and publicationCredentialRef + - pushBranch and prBaseBranch + 2. DELEGATE implementation to the coder agent with workspace.intent = write + and the complete source/publication configuration. The coder edits files; + Orka's clean-room publisher owns commit creation and branch delivery. 3. WAIT for the coder's result. 4. DELEGATE review to the reviewer agent with prior_task = the coder task name. 5. WAIT for the reviewer's verdict. 6. IF verdict == "CHANGES_NEEDED" AND iteration < 3: DELEGATE fix to the coder agent with prior_task + feedback. - Reuse the same workspace details and pushBranch. + Reuse the same workspace details and claimed pushBranch. + Require a verified delivery receipt before continuing. Go to step 3. - 7. IF verdict == "APPROVED": + 7. IF verdict == "APPROVED" and the latest coder delivery is verified: Call create_pull_request with: - task_name = the coder task name - head_branch = pushBranch from the task prompt - base_branch = branch from the task prompt - title/body that summarize the completed work - 8. REPORT the final result with the PR URL. + 8. REPORT the final result with the PR URL and verified branch SHA. + + Never ask an ACP runtime to commit, push, run gh, or create the PR itself. coordination: enabled: true maxDepth: 3 diff --git a/examples/iterative-review/iterative-task.yaml b/examples/iterative-review/iterative-task.yaml index 08bdd64b8..539f7b00c 100644 --- a/examples/iterative-review/iterative-task.yaml +++ b/examples/iterative-review/iterative-task.yaml @@ -12,8 +12,11 @@ spec: Repository details for delegated agent tasks: - gitRepo: https://github.com/example/api.git - branch: main - - gitSecretRef: git-credentials + - readCredentialRef: repository-read + - publicationGitRepo: https://github.com/example/api.git + - publicationCredentialRef: repository-publish - pushBranch: feature/jwt-auth + - prBaseBranch: main Requirements: - Login endpoint with username/password diff --git a/examples/iterative-review/reviewer-agent.yaml b/examples/iterative-review/reviewer-agent.yaml index 9d14373b0..5f22e88bf 100644 --- a/examples/iterative-review/reviewer-agent.yaml +++ b/examples/iterative-review/reviewer-agent.yaml @@ -16,7 +16,7 @@ spec: name: claude-sonnet-4-20250514 systemPrompt: inline: | - You are a code reviewer. Review the code changes in the workspace. + You are a code reviewer. Review the workspace supplied by Orka without modifying it. Respond with a clear verdict: - "APPROVED" if the code is ready - "CHANGES_NEEDED" with specific feedback if changes are required diff --git a/examples/support-escalation-runtime-demo/README.md b/examples/support-escalation-runtime-demo/README.md deleted file mode 100644 index 89474fdcd..000000000 --- a/examples/support-escalation-runtime-demo/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Support escalation bring-your-own AgentRuntime demo - -This non-Fibey scenario validates the generic bring-your-own agent runtime story in a support escalation domain. - -The workflow is intentionally the same shape as the Fibey demo: - -```text -Task/support-escalation-demo - -> Agent/support-remote-investigator - -> AgentRuntime/support-http-runtime - -> generic HTTP remote execution backend - -> Orka-brokered Tool/support-ticket-lookup -``` - -Security invariant: the remote runtime does not receive support-system credentials. It can request `support-ticket-lookup`; Orka validates policy, executes the Tool CRD, records events, and returns only the brokered result. - -## Run - -Build/load the generic HTTP fixture image first: - -```bash -docker build -t ghcr.io/orka-agents/orka/example-echo-harness:latest -f examples/harness/echo/Dockerfile . -kind load docker-image ghcr.io/orka-agents/orka/example-echo-harness:latest --name -``` - -Apply the demo: - -1. Create a per-cluster runtime bearer Secret named `support-http-runtime-token` - with data key `token`, label `orka.ai/agent-runtime-auth: "true"`, label - `orka.ai/agent-runtime-name: support-http-runtime`, and annotation - `orka.ai/agent-runtime-endpoint: http://support-http-runtime.default.svc.cluster.local:8080`. - Generate the bearer value outside the repository; do not commit it. -2. Apply the demo: - - ```bash - kubectl apply -k examples/support-escalation-runtime-demo - kubectl wait --for=condition=Ready agentruntime/support-http-runtime --timeout=60s - kubectl get task support-escalation-demo -o yaml - ``` - -The checked-in `Tool/support-ticket-lookup` points at the included mock `support-tool` service. Replace that service with a real read-only support lookup service for a live demo. The AgentRuntime and Orka task flow remain unchanged when swapping backends or domains. - -## Brokered write variant - -To exercise approval-gated writes in this domain, add a write-class Tool such as `support-escalate-case`, include it in `Task.spec.agentRuntime.allowedTools`, and set the fixture behavior to `approval-tool`. Orka will emit `ApprovalRequested`, execute the write Tool only after approval, and continue the remote runtime with the approved/declined result. diff --git a/examples/support-escalation-runtime-demo/agent.yaml b/examples/support-escalation-runtime-demo/agent.yaml deleted file mode 100644 index 6f21fe144..000000000 --- a/examples/support-escalation-runtime-demo/agent.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: Agent -metadata: - name: support-remote-investigator -spec: - runtime: - runtimeRef: - name: support-http-runtime - systemPrompt: - inline: | - You are a support escalation investigator. Use Orka-brokered tools for evidence gathering and summarize the customer's likely issue and next safe action. diff --git a/examples/support-escalation-runtime-demo/agentruntime.yaml b/examples/support-escalation-runtime-demo/agentruntime.yaml deleted file mode 100644 index b87257682..000000000 --- a/examples/support-escalation-runtime-demo/agentruntime.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: AgentRuntime -metadata: - name: support-http-runtime -spec: - contractVersion: orka.harness.v1 - deployment: - mode: external-endpoint - endpoint: http://support-http-runtime.default.svc.cluster.local:8080 - clientAuth: - bearerTokenSecretRef: - name: support-http-runtime-token - key: token - capabilities: - toolExecutionModes: - - observed - - brokered - brokeredToolClasses: - - read - supportsCancel: true - supportsRuntimeSessions: true - supportsContinuation: true diff --git a/examples/support-escalation-runtime-demo/kustomization.yaml b/examples/support-escalation-runtime-demo/kustomization.yaml deleted file mode 100644 index 0888f4dc7..000000000 --- a/examples/support-escalation-runtime-demo/kustomization.yaml +++ /dev/null @@ -1,7 +0,0 @@ -resources: -- mock-http-runtime-service.yaml -- mock-support-tool-service.yaml -- tools.yaml -- agentruntime.yaml -- agent.yaml -- task.yaml diff --git a/examples/support-escalation-runtime-demo/mock-http-runtime-service.yaml b/examples/support-escalation-runtime-demo/mock-http-runtime-service.yaml deleted file mode 100644 index a93010ad1..000000000 --- a/examples/support-escalation-runtime-demo/mock-http-runtime-service.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: support-http-runtime - labels: - app.kubernetes.io/name: support-http-runtime -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: support-http-runtime - template: - metadata: - labels: - app.kubernetes.io/name: support-http-runtime - spec: - containers: - - name: harness - image: ghcr.io/orka-agents/orka/example-echo-harness:latest - imagePullPolicy: IfNotPresent - env: - - name: ORKA_REMOTE_HTTP_RUNTIME_ADDR - value: :8080 - - name: ORKA_REMOTE_HTTP_RUNTIME_NAME - value: support-http-runtime - # Switch to read-tool or approval-tool to exercise brokered read/write protocol paths. - - name: ORKA_REMOTE_HTTP_RUNTIME_BEHAVIOR - value: read-tool - - name: ORKA_REMOTE_HTTP_RUNTIME_READ_TOOL_NAME - value: support-ticket-lookup - - name: ORKA_REMOTE_HTTP_RUNTIME_BEARER_TOKEN - valueFrom: - secretKeyRef: - name: support-http-runtime-token - key: token - ports: - - name: http - containerPort: 8080 - readinessProbe: - httpGet: - path: /v1/health - port: http - periodSeconds: 3 ---- -apiVersion: v1 -kind: Service -metadata: - name: support-http-runtime -spec: - selector: - app.kubernetes.io/name: support-http-runtime - ports: - - name: http - port: 8080 - targetPort: http diff --git a/examples/support-escalation-runtime-demo/mock-support-tool-service.yaml b/examples/support-escalation-runtime-demo/mock-support-tool-service.yaml deleted file mode 100644 index bf04552f0..000000000 --- a/examples/support-escalation-runtime-demo/mock-support-tool-service.yaml +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: support-tool - labels: - app.kubernetes.io/name: support-tool -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: support-tool - template: - metadata: - labels: - app.kubernetes.io/name: support-tool - spec: - containers: - - name: support-tool - image: ghcr.io/orka-agents/orka/example-echo-harness:latest - imagePullPolicy: IfNotPresent - env: - - name: ORKA_REMOTE_HTTP_RUNTIME_ADDR - value: :8080 - - name: ORKA_REMOTE_HTTP_RUNTIME_NAME - value: support-tool - ports: - - name: http - containerPort: 8080 - readinessProbe: - httpGet: - path: /v1/health - port: http - periodSeconds: 3 ---- -apiVersion: v1 -kind: Service -metadata: - name: support-tool -spec: - selector: - app.kubernetes.io/name: support-tool - ports: - - name: http - port: 8080 - targetPort: http diff --git a/examples/support-escalation-runtime-demo/task.yaml b/examples/support-escalation-runtime-demo/task.yaml deleted file mode 100644 index 789f6b015..000000000 --- a/examples/support-escalation-runtime-demo/task.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: core.orka.ai/v1alpha1 -kind: Task -metadata: - name: support-escalation-demo -spec: - type: agent - agentRef: - name: support-remote-investigator - agentRuntime: - allowedTools: - - support-ticket-lookup - prompt: | - Customer ACME-42 reports intermittent checkout failures after a payment gateway migration. - Gather evidence through brokered support tools and recommend the safest next action. diff --git a/examples/support-escalation-runtime-demo/tools.yaml b/examples/support-escalation-runtime-demo/tools.yaml deleted file mode 100644 index fae8053bb..000000000 --- a/examples/support-escalation-runtime-demo/tools.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Example brokered read Tool CRD. Replace the URL with a real support/ticket lookup service. -# Remote runtimes only request this tool; Orka owns execution and any downstream credentials. -apiVersion: core.orka.ai/v1alpha1 -kind: Tool -metadata: - name: support-ticket-lookup -spec: - description: Look up sanitized support ticket and service health evidence for a customer escalation. - brokeredToolClass: read - parameters: - type: object - properties: - incident: - type: string - description: Support incident or customer identifier. - required: - - incident - http: - url: http://support-tool.default.svc.cluster.local:8080/lookup - method: POST diff --git a/go.mod b/go.mod index e8df108d1..ac925c399 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.61.0 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/distribution/reference v0.6.0 github.com/github/copilot-sdk/go v0.1.25 github.com/go-logr/logr v1.4.4 github.com/gofiber/fiber/v3 v3.4.0 @@ -31,6 +32,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.45.0 go.opentelemetry.io/otel/trace v1.45.0 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.org/x/time v0.15.0 google.golang.org/grpc v1.83.0 @@ -39,7 +41,9 @@ require ( k8s.io/api v0.36.3 k8s.io/apiextensions-apiserver v0.36.3 k8s.io/apimachinery v0.36.3 + k8s.io/apiserver v0.36.3 k8s.io/client-go v0.36.3 + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 modernc.org/sqlite v1.55.0 sigs.k8s.io/agent-sandbox v0.5.4 sigs.k8s.io/controller-runtime v0.24.1 @@ -125,6 +129,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -159,7 +164,6 @@ require ( golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/tools v0.47.0 // indirect @@ -169,12 +173,10 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiserver v0.36.3 // indirect k8s.io/component-base v0.36.3 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/streaming v0.36.3 // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 09d49d68a..b0be7872d 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= @@ -236,6 +238,8 @@ github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/openai/openai-go/v3 v3.50.0 h1:CXn+C8a10oQiI5CMyMbCiykhITVhVxhdHX8j3CfLa2U= github.com/openai/openai-go/v3 v3.50.0/go.mod h1:Ogjo0gDct+Jm7yCqaCjLGQGygeV8xNfNHV1/yKvCji0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= diff --git a/hack/agent-substrate/README.md b/hack/agent-substrate/README.md new file mode 100644 index 000000000..0bd1d2bc0 --- /dev/null +++ b/hack/agent-substrate/README.md @@ -0,0 +1,107 @@ +# Agent Substrate evaluation patches + +Orka pins Agent Substrate at +`b80031d260959b1fc5c6f61e3099fe2a6d368af1` for local/CI evaluation. The +installer clones that immutable revision and applies the reviewed patches in +this directory before building Substrate. These are evaluation-only +compatibility and hardening patches; Orka does not install or manage Agent +Substrate in production. Patch-set changes are not rolled through a live worker +fleet: the retained-cluster marker binds the exact patch blobs and requires a +full cluster recreate, so mixed-version snapshot readers and rollback migration +are intentionally outside this local/CI evaluation contract. + +Each patch is fail-closed: + +- `scripts/agent-substrate-e2e.sh` verifies the exact upstream Git blob for + every existing source file the patch changes. +- The patch must apply with `--whitespace=error-all` and reverse-apply cleanly. +- The parsed patch path set must exactly match its declared scope. +- Focused upstream package tests run before the Kind cluster is created. +- A different `SUBSTRATE_REF` is rejected unless the blobs and patches are + explicitly reviewed and updated together. + +## Patch set + +### `atelet-root-supervisor-capabilities.patch` + +Scopes `CAP_SETUID` and `CAP_SETGID` to the Orka workspace-agent supervisor and +keeps its extracted root filesystem traversable after the supervisor drops task +commands to UID/GID 1000. + +### `atenet-router-authorization-redaction.patch` + +Makes `atenet-router` request logging allowlist-only. Request metadata retains +only method, sanitized path, host/authority, and request ID; every other header +is discarded before its normal or `RawValue` content is read. Request-target +parsing keeps only the escaped path, including for legal absolute-form proxy +requests. Host/authority values are parsed independently and reject userinfo, +paths, queries, and fragments, so query credentials and absolute-URI userinfo +cannot reach logs or the status recorder. Upstream tests cover +`Authorization`, `Proxy-Authorization`, `Txn-Token`, `Cookie`, `X-API-Key`, +unknown `RawValue` headers, and query credentials. The patch also lowers Envoy's +`ext_proc`, router, and upstream component logging from debug to info in both +the static install manifest and the programmatic runner so the sidecar cannot +log raw request header tuples before the application-level allowlist boundary. + +### `ateom-runsc-delete-recovery.patch` + +Hardens the pinned `ateom-gvisor` checkpoint cleanup path: + +- prepares a durable recovery directory and points the ordinary checkpoint path + at it before `runsc checkpoint` writes any bytes; +- pins `flate-best-speed` so the stopped sandbox produces exactly one statefile, + then validates that file with runsc's own `statefile` reader before cleanup; +- keeps committed recovery in its original one-file format, then materializes a + separate upload view with a hard link to `checkpoint.img` plus explicitly + marked `pages.img`/`pages_meta.img` compatibility files. Restore removes only + those exact markers (including a surviving half of an interrupted cleanup) + while preserving native multi-file snapshots even when pages are empty; +- reconciles an interrupted commit by checking the pause-container state: a + valid statefile plus `stopped` is committed in place, while absent/partial + bytes may be discarded only when the sandbox is still `created` or `running`; +- trusts checkpoint bytes only after atomically writing a deterministic commit + inventory covering the statefile mode, size, and SHA-256 digest; +- stages the atomic commit temporary beside (not inside) the inventoried + recovery directory, so a crash cannot turn its orphan into an extra artifact; +- runs every direct `runsc list` under the PID-1 child-reaper read lock and + parses `runsc state` JSON from stdout separately from stderr diagnostics; +- restores the worker Pod network before fallible `runsc` state/delete cleanup + and treats an already-restored interface as an idempotent retry state; +- converges across partial container deletion by reusing the durable checkpoint + instead of checkpointing the stopped sandbox again; +- retries a failed `runsc delete --force` a bounded four times; +- after every failed delete, runs `runsc list --quiet` and accepts only an exact, + verified absence of the target container; +- fails closed if checkpoint/container/network state cannot be verified; +- includes deterministic fake-`runsc` tests for crash-after-checkpoint recovery, + two-call cleanup after retry exhaustion, exit-128-after-removal, transient + retry success, persistent failure, and verification failure. + +With `SUBSTRATE_E2E_EXTENDED=1`, the live E2E deletes an assigned worker and +verifies store/Deployment replacement, then installs a temporary fail-once +`runsc` delegator on a live replacement worker. The Actor must suspend through +the reviewed retry path, the original binary must be restored, and a subsequent +direct Actor lifecycle must route, execute, suspend, and delete without leaving +any Actor in `STATUS_SUSPENDING`. + +## Validation + +Fast repository checks: + +```bash +bash scripts/tests/agent-substrate-patches-test.sh +bash -n scripts/agent-substrate-e2e.sh hack/demos/cluster/install-substrate.sh +``` + +The complete Linux/Kind validation is destructive to a same-named Kind cluster: + +```bash +PATH="$(go env GOPATH)/bin:$PATH" \ +SUBSTRATE_E2E_EXTENDED=1 \ +KEEP_CLUSTER=1 \ +bash scripts/agent-substrate-e2e.sh +``` + +Review and update the source blob constants in `scripts/agent-substrate-e2e.sh` +only after inspecting the replacement upstream source and regenerating the +corresponding patch. diff --git a/hack/agent-substrate/atenet-router-authorization-redaction.patch b/hack/agent-substrate/atenet-router-authorization-redaction.patch new file mode 100644 index 000000000..d222e5825 --- /dev/null +++ b/hack/agent-substrate/atenet-router-authorization-redaction.patch @@ -0,0 +1,311 @@ +diff --git a/cmd/servers/atenet/app/router/envoyrunner.go b/cmd/servers/atenet/app/router/envoyrunner.go +index 8d38be29..593f4a9e 100644 +--- a/cmd/servers/atenet/app/router/envoyrunner.go ++++ b/cmd/servers/atenet/app/router/envoyrunner.go +@@ -170,7 +170,7 @@ func (r *envoyrunner) reconcileEnvoyDeployment(ctx context.Context) error { + "-c", + "/etc/envoy/envoy.yaml", + "--component-log-level", +- "upstream:debug,router:debug,ext_proc:debug", ++ "upstream:info,router:info,ext_proc:info", + }, + Ports: []corev1.ContainerPort{ + { +diff --git a/cmd/servers/atenet/app/router/extproc_in.go b/cmd/servers/atenet/app/router/extproc_in.go +index 31751184..7271d8cf 100644 +--- a/cmd/servers/atenet/app/router/extproc_in.go ++++ b/cmd/servers/atenet/app/router/extproc_in.go +@@ -17,20 +17,77 @@ package router + import ( + "fmt" + "net" ++ "net/url" + "strings" + + "github.com/agent-substrate/substrate/internal/resources" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + ) + ++const ( ++ invalidRequestPath = "[invalid]" ++ invalidRequestAuthority = "[invalid]" ++) ++ + type requestMetadata struct { + headers map[string]string + path string + host string + } + ++func isSafeRequestMetadataHeader(key string) bool { ++ switch key { ++ case ":method", ":path", ":authority", "host", "x-request-id": ++ return true ++ default: ++ return false ++ } ++} ++ ++func sanitizeRequestPath(value string) string { ++ if value == "*" { ++ return value ++ } ++ requestURI, err := url.ParseRequestURI(value) ++ if err != nil { ++ return invalidRequestPath ++ } ++ if requestURI.Opaque != "" { ++ // ParseRequestURI intentionally stores an absolute-form request target in ++ // Opaque. Parse it as a URL only to recover the escaped path; userinfo, ++ // authority, query, and fragment values remain excluded from metadata. ++ requestURI, err = url.Parse(value) ++ if err != nil || requestURI.Scheme == "" || requestURI.Host == "" { ++ return invalidRequestPath ++ } ++ } ++ path := requestURI.EscapedPath() ++ if path == "" { ++ return "/" ++ } ++ return path ++} ++ ++func sanitizeRequestAuthority(value string) string { ++ if value == "" { ++ return "" ++ } ++ authority, err := url.Parse("//" + value) ++ if err != nil || authority.Host == "" || authority.User != nil || authority.Path != "" || ++ authority.RawPath != "" || authority.RawQuery != "" || authority.Fragment != "" { ++ return invalidRequestAuthority ++ } ++ return authority.Host ++} ++ + func (m *requestMetadata) String() string { +- return fmt.Sprintf("%+v", *m) ++ return fmt.Sprintf( ++ "method=%q path=%q host=%q request_id=%q", ++ m.headers[":method"], ++ m.path, ++ m.host, ++ m.headers["x-request-id"], ++ ) + } + + func newRequestMetadata(headers []*corev3.HeaderValue) *requestMetadata { +@@ -40,18 +97,24 @@ func newRequestMetadata(headers []*corev3.HeaderValue) *requestMetadata { + + for _, h := range headers { + k := strings.ToLower(h.Key) ++ if !isSafeRequestMetadataHeader(k) { ++ continue ++ } ++ + val := h.Value + if val == "" && len(h.RawValue) > 0 { + val = string(h.RawValue) + } +- +- headersMap[k] = val + if k == ":path" { ++ val = sanitizeRequestPath(val) + path = val + } + if k == ":authority" || k == "host" { ++ val = sanitizeRequestAuthority(val) + host = val + } ++ ++ headersMap[k] = val + } + + return &requestMetadata{ +diff --git a/cmd/servers/atenet/app/router/extproc_in_test.go b/cmd/servers/atenet/app/router/extproc_in_test.go +index 09bb9a4c..e6d2e1e1 100644 +--- a/cmd/servers/atenet/app/router/extproc_in_test.go ++++ b/cmd/servers/atenet/app/router/extproc_in_test.go +@@ -15,14 +15,15 @@ + package router + + import ( +- "fmt" + "reflect" ++ "strings" + "testing" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + ) + + func TestExtractMetadata(t *testing.T) { ++ absoluteFormTarget := "https://example.com/api/v1/a%2Fb?sample=placeholder" + tests := []struct { + name string + headers []*corev3.HeaderValue +@@ -31,13 +32,15 @@ func TestExtractMetadata(t *testing.T) { + wantHost string + }{ + { +- name: "basic path and authority", ++ name: "basic safe routing metadata", + headers: []*corev3.HeaderValue{ ++ {Key: ":method", Value: "GET"}, + {Key: ":path", Value: "/api/v1/test"}, + {Key: ":authority", Value: "example.com"}, + {Key: "X-Request-ID", Value: "req-123"}, + }, + wantHeaders: map[string]string{ ++ ":method": "GET", + ":path": "/api/v1/test", + ":authority": "example.com", + "x-request-id": "req-123", +@@ -76,29 +79,62 @@ func TestExtractMetadata(t *testing.T) { + wantHost: "authority.com", + }, + { +- name: "no authority or host headers", ++ name: "arbitrary headers are omitted", + headers: []*corev3.HeaderValue{ + {Key: ":path", Value: "/api/v1/test"}, + {Key: "x-something-else", Value: "custom-value"}, ++ {Key: "X-Request-ID", RawValue: []byte("req-from-raw-value")}, + }, + wantHeaders: map[string]string{ +- ":path": "/api/v1/test", +- "x-something-else": "custom-value", ++ ":path": "/api/v1/test", ++ "x-request-id": "req-from-raw-value", + }, + wantPath: "/api/v1/test", + wantHost: "", + }, + { +- name: "headers are lowercased", ++ name: "credential headers and query values are omitted", + headers: []*corev3.HeaderValue{ +- {Key: "UPPER-KEY", Value: "UPPER-VALUE"}, +- {Key: "camelCaseKey", Value: "camelValue"}, ++ {Key: ":method", Value: "POST"}, ++ {Key: ":path", RawValue: []byte("/api/v1/test?token=test-token-placeholder")}, ++ {Key: ":authority", Value: "example.com"}, ++ {Key: "X-Request-ID", Value: "req-credential-test"}, ++ {Key: "Authorization", Value: "Bearer test-auth-token"}, ++ {Key: "Proxy-Authorization", RawValue: []byte("Basic dummy")}, ++ {Key: "Txn-Token", Value: "gateway-token"}, ++ {Key: "Cookie", RawValue: []byte("session=sample")}, ++ {Key: "X-API-Key", Value: "fake"}, ++ {Key: "X-Unrecognized-Raw", RawValue: []byte("not-a-real")}, + }, + wantHeaders: map[string]string{ +- "upper-key": "UPPER-VALUE", +- "camelcasekey": "camelValue", ++ ":method": "POST", ++ ":path": "/api/v1/test", ++ ":authority": "example.com", ++ "x-request-id": "req-credential-test", + }, +- wantPath: "", ++ wantPath: "/api/v1/test", ++ wantHost: "example.com", ++ }, ++ { ++ name: "absolute-form request target preserves only escaped path", ++ headers: []*corev3.HeaderValue{ ++ {Key: ":path", Value: absoluteFormTarget}, ++ }, ++ wantHeaders: map[string]string{ ++ ":path": "/api/v1/a%2Fb", ++ }, ++ wantPath: "/api/v1/a%2Fb", ++ wantHost: "", ++ }, ++ { ++ name: "invalid request target fails closed", ++ headers: []*corev3.HeaderValue{ ++ {Key: ":path", Value: "not a valid request URI"}, ++ }, ++ wantHeaders: map[string]string{ ++ ":path": invalidRequestPath, ++ }, ++ wantPath: invalidRequestPath, + wantHost: "", + }, + } +@@ -120,18 +156,56 @@ func TestExtractMetadata(t *testing.T) { + } + } + +-func TestRequestMetadata_String(t *testing.T) { ++func TestSanitizeRequestAuthority(t *testing.T) { ++ userinfoFixture := string([]byte{0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2d, 0x75, 0x73, 0x65, 0x72, 0x3a, 0x73, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2d, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x40, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d}) ++ for _, test := range []struct { ++ name string ++ value string ++ want string ++ }{ ++ {name: "host", value: "example.com", want: "example.com"}, ++ {name: "host with port", value: "example.com:8443", want: "example.com:8443"}, ++ {name: "userinfo", value: userinfoFixture, want: invalidRequestAuthority}, ++ {name: "query", value: "example.com?sample=placeholder", want: invalidRequestAuthority}, ++ } { ++ t.Run(test.name, func(t *testing.T) { ++ if got := sanitizeRequestAuthority(test.value); got != test.want { ++ t.Fatalf("sanitizeRequestAuthority() = %q, want %q", got, test.want) ++ } ++ }) ++ } ++} ++ ++func TestRequestMetadataStringOnlyIncludesSafeRoutingMetadata(t *testing.T) { + headers := []*corev3.HeaderValue{ +- {Key: ":path", Value: "/api/v1/test"}, ++ {Key: ":method", Value: "POST"}, ++ {Key: ":path", RawValue: []byte("/api/v1/test")}, + {Key: ":authority", Value: "example.com"}, ++ {Key: "X-Request-ID", RawValue: []byte("req-123")}, ++ {Key: "Authorization", Value: "Bearer secret-token"}, ++ {Key: "Proxy-Authorization", RawValue: []byte("Basic decoy-token")}, ++ {Key: "Txn-Token", Value: "matrix_qa_e2ee_cli_gateway"}, ++ {Key: "Cookie", RawValue: []byte("session=changeme")}, ++ {Key: "X-API-Key", Value: "redacted"}, ++ {Key: "X-Unrecognized-Raw", RawValue: []byte("token-oversized")}, + } +- m := newRequestMetadata(headers) +- str := m.String() +- if str == "" { +- t.Errorf("expected non-empty string from String()") ++ metadata := newRequestMetadata(headers) ++ got := metadata.String() ++ want := `method="POST" path="/api/v1/test" host="example.com" request_id="req-123"` ++ if got != want { ++ t.Fatalf("String() = %q, want %q", got, want) + } +- if !reflect.DeepEqual(str, fmt.Sprintf("%+v", *m)) { +- t.Errorf("String() = %q, want %q", str, fmt.Sprintf("%+v", *m)) ++ for _, sensitive := range []string{ ++ "secret-token", ++ "decoy-token", ++ "matrix_qa_e2ee_cli_gateway", ++ "changeme", ++ "redacted", ++ "token-oversized", ++ } { ++ if strings.Contains(got, sensitive) { ++ t.Fatalf("String() leaked sensitive value %q: %q", sensitive, got) ++ } + } + } + +diff --git a/manifests/ate-install/atenet-router.yaml b/manifests/ate-install/atenet-router.yaml +index e309cad0..74486a9b 100644 +--- a/manifests/ate-install/atenet-router.yaml ++++ b/manifests/ate-install/atenet-router.yaml +@@ -146,7 +146,7 @@ spec: + - "-c" + - "/etc/envoy/envoy.yaml" + - "--component-log-level" +- - "upstream:debug,router:debug,ext_proc:debug" ++ - "upstream:info,router:info,ext_proc:info" + ports: + - name: http + containerPort: 8080 diff --git a/hack/agent-substrate/ateom-runsc-delete-recovery.patch b/hack/agent-substrate/ateom-runsc-delete-recovery.patch new file mode 100644 index 000000000..6f8fa6ada --- /dev/null +++ b/hack/agent-substrate/ateom-runsc-delete-recovery.patch @@ -0,0 +1,2018 @@ +diff --git a/cmd/servers/ateom-gvisor/ateom-gvisor.go b/cmd/servers/ateom-gvisor/ateom-gvisor.go +index 7d79dd0a..65b719ed 100644 +--- a/cmd/servers/ateom-gvisor/ateom-gvisor.go ++++ b/cmd/servers/ateom-gvisor/ateom-gvisor.go +@@ -16,12 +16,19 @@ package main + + import ( + "context" ++ "crypto/sha256" ++ "encoding/json" ++ "errors" + "flag" + "fmt" ++ "io" + "log/slog" + "net" + "os" ++ "path/filepath" + "runtime" ++ "sort" ++ "strings" + "sync" + "time" + +@@ -270,58 +277,833 @@ func (s *AteomService) CheckpointWorkload(ctx context.Context, req *ateompb.Chec + if err := os.MkdirAll(checkpointPath, 0o700); err != nil { + return nil, fmt.Errorf("while creating checkpoint directory: %w", err) + } ++ recoveryPath := filepath.Join( ++ ateompath.PIDFileDir(req.GetActorTemplateNamespace(), req.GetActorTemplateName(), req.GetActorId()), ++ checkpointRecoveryDirName, ++ ) ++ applicationContainers := make([]string, 0, len(req.GetSpec().GetContainers())) ++ for _, ctr := range req.GetSpec().GetContainers() { ++ applicationContainers = append(applicationContainers, ctr.GetName()) ++ } ++ ++ if err := checkpointAndCleanup( ++ ctx, ++ rcmd, ++ applicationContainers, ++ checkpointPath, ++ recoveryPath, ++ s.restorePodNetwork, ++ ); err != nil { ++ return nil, err ++ } ++ ++ s.actorLogger.EmitLifecycleLog("Actor checkpointed", req.GetActorId(), req.GetActorTemplateName(), req.GetActorTemplateNamespace()) ++ ++ return nil, nil ++} ++ ++const ( ++ checkpointRecoveryDirName = ".checkpoint-delete-recovery-v1" ++ checkpointRecoveryManifestName = ".checkpoint-delete-recovery-containers" ++ checkpointRecoveryCommitName = ".checkpoint-delete-recovery-committed" ++ checkpointRecoveryPreparePrefix = ".checkpoint-delete-recovery-prepare-" ++ checkpointRecoveryCommitPrefix = ".checkpoint-delete-recovery-commit-" ++ checkpointPagesCompatibilityMarker = "orka-compressed-pages-placeholder-v1\n" ++ checkpointPagesMetadataCompatibilityMarker = "orka-compressed-pages-metadata-placeholder-v1\n" ++) ++ ++type checkpointRuntime interface { ++ cmdCheckpoint(context.Context, string, string) error ++ cmdValidateCheckpoint(context.Context, string) error ++ containerStatus(context.Context, string) (string, error) ++ cmdState(context.Context, string) error ++ cmdDelete(context.Context, string) error ++ containerNames(context.Context) (map[string]struct{}, error) ++} ++ ++type podNetworkOps struct { ++ podEth0Present func(context.Context) (bool, error) ++ interiorEth0Present func(context.Context) (bool, error) ++ moveInteriorEth0ToPod func(context.Context) error ++} ++ ++func checkpointAndCleanup( ++ ctx context.Context, ++ rcmd checkpointRuntime, ++ applicationContainers []string, ++ checkpointPath string, ++ recoveryPath string, ++ restoreNetwork func(context.Context) error, ++) error { ++ orderedContainers, expectedContainers, err := expectedCheckpointContainers(applicationContainers) ++ if err != nil { ++ return fmt.Errorf("invalid checkpoint container set: %w", err) ++ } ++ ++ presentContainers, err := rcmd.containerNames(ctx) ++ if err != nil { ++ return fmt.Errorf("failed closed while listing containers before checkpoint cleanup: %w", err) ++ } ++ if err := validatePresentContainers(presentContainers, expectedContainers); err != nil { ++ return err ++ } ++ ++ recoveryState, err := inspectCheckpointRecovery(recoveryPath, expectedContainers) ++ if err != nil { ++ return fmt.Errorf("failed closed while validating checkpoint recovery: %w", err) ++ } ++ switch recoveryState { ++ case checkpointRecoveryCommitted: ++ if err := restoreCheckpointRecovery(checkpointPath, recoveryPath, expectedContainers); err != nil { ++ return fmt.Errorf("failed closed while restoring checkpoint recovery: %w", err) ++ } ++ case checkpointRecoveryPrepared: ++ // A prepared recovery has no atomic commit record. Reconcile it against ++ // runsc itself before deciding whether bytes are canonical: ++ // * a parseable single-file checkpoint plus a stopped sandbox is a ++ // completed checkpoint whose commit record was interrupted; ++ // * a running/created sandbox is still safe to checkpoint again, so ++ // discard any absent or partial bytes; ++ // * an invalid checkpoint paired with a stopped sandbox is uncertain ++ // and must fail closed without deleting containers. ++ checkpointErr := rcmd.cmdValidateCheckpoint(ctx, recoveryPath) ++ status, statusErr := rcmd.containerStatus(ctx, "pause") ++ if statusErr != nil { ++ return fmt.Errorf("failed closed while reconciling prepared checkpoint state: %w", statusErr) ++ } ++ switch status { ++ case "stopped": ++ if checkpointErr != nil { ++ return fmt.Errorf("failed closed: stopped sandbox has an invalid uncommitted checkpoint: %w", checkpointErr) ++ } ++ if err := commitCheckpointRecovery(recoveryPath, expectedContainers); err != nil { ++ return fmt.Errorf("failed closed while committing recovered checkpoint: %w", err) ++ } ++ if err := restoreCheckpointRecovery(checkpointPath, recoveryPath, expectedContainers); err != nil { ++ return fmt.Errorf("failed closed while restoring recovered checkpoint: %w", err) ++ } ++ case "created", "running": ++ if !sameContainerSet(presentContainers, expectedContainers) { ++ return fmt.Errorf( ++ "failed closed: partial container state with an uncommitted checkpoint (present=%s expected=%s)", ++ formatContainerSet(presentContainers), ++ formatContainerSet(expectedContainers), ++ ) ++ } ++ if err := resetPreparedCheckpointRecovery(checkpointPath, recoveryPath, expectedContainers); err != nil { ++ return fmt.Errorf("failed closed while resetting uncommitted checkpoint recovery: %w", err) ++ } ++ if err := checkpointFreshSandbox(ctx, rcmd, checkpointPath, recoveryPath, expectedContainers); err != nil { ++ return err ++ } ++ default: ++ return fmt.Errorf("failed closed: unsupported pause container state %q while reconciling checkpoint", status) ++ } ++ case checkpointRecoveryAbsent: ++ if !sameContainerSet(presentContainers, expectedContainers) { ++ return fmt.Errorf( ++ "failed closed: partial container state without complete checkpoint recovery (present=%s expected=%s)", ++ formatContainerSet(presentContainers), ++ formatContainerSet(expectedContainers), ++ ) ++ } ++ if err := checkpointFreshSandbox(ctx, rcmd, checkpointPath, recoveryPath, expectedContainers); err != nil { ++ return err ++ } ++ default: ++ return fmt.Errorf("failed closed: unknown checkpoint recovery state %d", recoveryState) ++ } ++ ++ // Restore the worker Pod network before fallible runsc cleanup. The operation ++ // is state-aware so a retry accepts eth0 only when it is already in the Pod ++ // namespace and absent from the interior namespace. ++ if err := restoreNetwork(ctx); err != nil { ++ return fmt.Errorf("while restoring worker Pod network: %w", err) ++ } + +- // Checkpoint pause container (root of the sandbox) ++ presentContainers, err = rcmd.containerNames(ctx) ++ if err != nil { ++ return fmt.Errorf("failed closed while listing containers before state inspection: %w", err) ++ } ++ if err := validatePresentContainers(presentContainers, expectedContainers); err != nil { ++ return err ++ } ++ ++ // Check state only for containers that remain. A previous cleanup attempt may ++ // already have removed an application container or the pause container. ++ for _, containerName := range orderedContainers { ++ if _, present := presentContainers[containerName]; !present { ++ continue ++ } ++ if err := rcmd.cmdState(ctx, containerName); err != nil { ++ return fmt.Errorf("while checking state of %q container: %w", containerName, err) ++ } ++ } ++ ++ // Delete all application containers that remain. ++ for _, containerName := range orderedContainers[1:] { ++ if _, present := presentContainers[containerName]; !present { ++ continue ++ } ++ if err := rcmd.cmdDelete(ctx, containerName); err != nil { ++ return fmt.Errorf("while deleting %q application container: %w", containerName, err) ++ } ++ } ++ ++ // Delete the pause container last when it remains. ++ if _, present := presentContainers["pause"]; present { ++ if err := rcmd.cmdDelete(ctx, "pause"); err != nil { ++ return fmt.Errorf("while deleting pause container: %w", err) ++ } ++ } ++ ++ presentContainers, err = rcmd.containerNames(ctx) ++ if err != nil { ++ return fmt.Errorf("failed closed while verifying checkpoint cleanup: %w", err) ++ } ++ if err := validatePresentContainers(presentContainers, expectedContainers); err != nil { ++ return err ++ } ++ if len(presentContainers) != 0 { ++ return fmt.Errorf("failed closed: containers remain after checkpoint cleanup: %s", formatContainerSet(presentContainers)) ++ } ++ if err := materializeCheckpointTransport(checkpointPath, recoveryPath); err != nil { ++ return fmt.Errorf("failed closed while materializing checkpoint transport: %w", err) ++ } ++ ++ // Keep the recovery copy until atelet uploads the snapshot and resets the ++ // actor directories. This also makes a retry after an upload failure safe. ++ return nil ++} ++ ++func checkpointFreshSandbox( ++ ctx context.Context, ++ rcmd checkpointRuntime, ++ checkpointPath string, ++ recoveryPath string, ++ expectedContainers map[string]struct{}, ++) error { ++ // Compression is pinned in cmdCheckpoint so runsc produces one statefile ++ // that its own statefile command can validate before the stopped sandbox is ++ // treated as canonical. ++ if err := prepareCheckpointRecovery(checkpointPath, recoveryPath, expectedContainers); err != nil { ++ return fmt.Errorf("failed closed while preparing durable checkpoint recovery: %w", err) ++ } + if err := rcmd.cmdCheckpoint(ctx, "pause", checkpointPath); err != nil { +- return nil, fmt.Errorf("while checkpointing pause: %w", err) ++ return fmt.Errorf("while checkpointing pause into durable recovery: %w", err) + } ++ if err := rcmd.cmdValidateCheckpoint(ctx, recoveryPath); err != nil { ++ return fmt.Errorf("failed closed while validating runsc checkpoint: %w", err) ++ } ++ status, err := rcmd.containerStatus(ctx, "pause") ++ if err != nil { ++ return fmt.Errorf("failed closed while verifying checkpointed sandbox state: %w", err) ++ } ++ if status != "stopped" { ++ return fmt.Errorf("failed closed: checkpointed pause container state is %q, want stopped", status) ++ } ++ if err := commitCheckpointRecovery(recoveryPath, expectedContainers); err != nil { ++ return fmt.Errorf("failed closed while committing durable checkpoint recovery: %w", err) ++ } ++ return nil ++} + +- // Check state of all containers to mimic containerd. +- // +- // Without this, `runsc delete` occasionally throws an error. +- if err := rcmd.cmdState(ctx, "pause"); err != nil { +- return nil, fmt.Errorf("while checking state of pause container: %w", err) ++func expectedCheckpointContainers(applicationContainers []string) ([]string, map[string]struct{}, error) { ++ ordered := make([]string, 0, len(applicationContainers)+1) ++ ordered = append(ordered, "pause") ++ expected := map[string]struct{}{"pause": {}} ++ for _, containerName := range applicationContainers { ++ if containerName == "" { ++ return nil, nil, fmt.Errorf("application container name is empty") ++ } ++ if containerName == "pause" { ++ return nil, nil, fmt.Errorf("application container uses reserved name %q", containerName) ++ } ++ if _, duplicate := expected[containerName]; duplicate { ++ return nil, nil, fmt.Errorf("duplicate application container name %q", containerName) ++ } ++ expected[containerName] = struct{}{} ++ ordered = append(ordered, containerName) + } +- for _, ctr := range req.GetSpec().GetContainers() { +- if err := rcmd.cmdState(ctx, ctr.GetName()); err != nil { +- return nil, fmt.Errorf("while deleting %q application container: %w", ctr.GetName(), err) ++ return ordered, expected, nil ++} ++ ++func validatePresentContainers(present, expected map[string]struct{}) error { ++ for containerName := range present { ++ if _, ok := expected[containerName]; !ok { ++ return fmt.Errorf("failed closed: unexpected runsc container %q is present", containerName) + } + } ++ return nil ++} + +- // Delete all application containers +- for _, ctr := range req.GetSpec().GetContainers() { +- if err := rcmd.cmdDelete(ctx, ctr.GetName()); err != nil { +- return nil, fmt.Errorf("while deleting %q application container: %w", ctr.GetName(), err) ++func sameContainerSet(left, right map[string]struct{}) bool { ++ if len(left) != len(right) { ++ return false ++ } ++ for name := range left { ++ if _, ok := right[name]; !ok { ++ return false + } + } ++ return true ++} ++ ++func formatContainerSet(containers map[string]struct{}) string { ++ names := make([]string, 0, len(containers)) ++ for name := range containers { ++ names = append(names, name) ++ } ++ sort.Strings(names) ++ return strings.Join(names, ",") ++} ++ ++func checkpointRecoveryManifest(expected map[string]struct{}) []byte { ++ return []byte(formatContainerSet(expected) + "\n") ++} ++ ++type checkpointRecoveryState int ++ ++const ( ++ checkpointRecoveryAbsent checkpointRecoveryState = iota ++ checkpointRecoveryPrepared ++ checkpointRecoveryCommitted ++) ++ ++func inspectCheckpointRecovery(recoveryPath string, expected map[string]struct{}) (checkpointRecoveryState, error) { ++ info, err := os.Lstat(recoveryPath) ++ if errors.Is(err, os.ErrNotExist) { ++ return checkpointRecoveryAbsent, nil ++ } ++ if err != nil { ++ return checkpointRecoveryAbsent, fmt.Errorf("while stating recovery directory: %w", err) ++ } ++ if !info.IsDir() { ++ return checkpointRecoveryAbsent, fmt.Errorf("recovery path %q is not a directory", recoveryPath) ++ } + +- // Delete pause container +- if err := rcmd.cmdDelete(ctx, "pause"); err != nil { +- return nil, fmt.Errorf("while deleting pause container: %w", err) ++ manifestPath := filepath.Join(recoveryPath, checkpointRecoveryManifestName) ++ manifestInfo, err := os.Lstat(manifestPath) ++ if err != nil { ++ return checkpointRecoveryAbsent, fmt.Errorf("while stating recovery manifest: %w", err) ++ } ++ if !manifestInfo.Mode().IsRegular() { ++ return checkpointRecoveryAbsent, fmt.Errorf("recovery manifest is not a regular file") ++ } ++ manifest, err := os.ReadFile(manifestPath) ++ if err != nil { ++ return checkpointRecoveryAbsent, fmt.Errorf("while reading recovery manifest: %w", err) ++ } ++ if string(manifest) != string(checkpointRecoveryManifest(expected)) { ++ return checkpointRecoveryAbsent, fmt.Errorf("recovery manifest does not match requested containers") + } + +- // Yoink eth0 back to the pod netns. +- podNetNS, err := netns.Get() ++ commitPath := filepath.Join(recoveryPath, checkpointRecoveryCommitName) ++ commitInfo, err := os.Lstat(commitPath) ++ if errors.Is(err, os.ErrNotExist) { ++ return checkpointRecoveryPrepared, nil ++ } + if err != nil { +- return nil, fmt.Errorf("while getting pod netns: %w", err) ++ return checkpointRecoveryAbsent, fmt.Errorf("while stating recovery commit: %w", err) + } +- err = netNSDo(ctx, s.interiorNetNS, func(ctx context.Context) error { +- eth0Link, err := netlink.LinkByName("eth0") ++ if !commitInfo.Mode().IsRegular() { ++ return checkpointRecoveryAbsent, fmt.Errorf("recovery commit is not a regular file") ++ } ++ commit, err := os.ReadFile(commitPath) ++ if err != nil { ++ return checkpointRecoveryAbsent, fmt.Errorf("while reading recovery commit: %w", err) ++ } ++ currentCommit, err := checkpointRecoveryCommit(recoveryPath) ++ if err != nil { ++ return checkpointRecoveryAbsent, err ++ } ++ if string(commit) != string(currentCommit) { ++ return checkpointRecoveryAbsent, fmt.Errorf("recovery checkpoint integrity does not match its commit record") ++ } ++ return checkpointRecoveryCommitted, nil ++} ++ ++func validateCheckpointRecovery(recoveryPath string, expected map[string]struct{}) (bool, error) { ++ state, err := inspectCheckpointRecovery(recoveryPath, expected) ++ if err != nil { ++ return false, err ++ } ++ return state == checkpointRecoveryCommitted, nil ++} ++ ++type checkpointRecoveryArtifact struct { ++ Path string `json:"path"` ++ Type string `json:"type"` ++ Mode uint32 `json:"mode"` ++ Size int64 `json:"size,omitempty"` ++ SHA256 string `json:"sha256,omitempty"` ++} ++ ++type checkpointRecoveryCommitRecord struct { ++ Version int `json:"version"` ++ Artifacts []checkpointRecoveryArtifact `json:"artifacts"` ++} ++ ++func checkpointRecoveryCommit(recoveryPath string) ([]byte, error) { ++ artifacts := make([]checkpointRecoveryArtifact, 0) ++ directories := []string{recoveryPath} ++ checkpointPaths := make([]string, 0, 1) ++ err := filepath.WalkDir(recoveryPath, func(path string, entry os.DirEntry, walkErr error) error { ++ if walkErr != nil { ++ return walkErr ++ } ++ relative, err := filepath.Rel(recoveryPath, path) + if err != nil { +- return fmt.Errorf("while acquiring eth0 in interior netns: %w", err) ++ return fmt.Errorf("while resolving recovery artifact path: %w", err) ++ } ++ if relative == "." { ++ return nil + } +- if err := netlink.LinkSetNsFd(eth0Link, int(podNetNS)); err != nil { +- return fmt.Errorf("while sending eth0 back to pod netns: %w", err) ++ relative = filepath.ToSlash(relative) ++ if relative == checkpointRecoveryCommitName { ++ return nil ++ } ++ ++ info, err := entry.Info() ++ if err != nil { ++ return fmt.Errorf("while stating recovery artifact %q: %w", relative, err) ++ } ++ mode := info.Mode() ++ if mode&os.ModeSymlink != 0 { ++ return fmt.Errorf("recovery artifact %q is a symlink", relative) ++ } ++ if mode.IsDir() { ++ directories = append(directories, path) ++ artifacts = append(artifacts, checkpointRecoveryArtifact{ ++ Path: relative, ++ Type: "directory", ++ Mode: uint32(mode.Perm()), ++ }) ++ return nil ++ } ++ if !mode.IsRegular() { ++ return fmt.Errorf("recovery artifact %q is not a regular file or directory", relative) ++ } ++ ++ file, err := os.Open(path) ++ if err != nil { ++ return fmt.Errorf("while opening recovery artifact %q: %w", relative, err) ++ } ++ if err := file.Sync(); err != nil { ++ _ = file.Close() ++ return fmt.Errorf("while syncing recovery artifact %q: %w", relative, err) ++ } ++ digest := sha256.New() ++ size, err := io.Copy(digest, file) ++ if err != nil { ++ _ = file.Close() ++ return fmt.Errorf("while hashing recovery artifact %q: %w", relative, err) ++ } ++ if err := file.Close(); err != nil { ++ return fmt.Errorf("while closing recovery artifact %q: %w", relative, err) ++ } ++ artifacts = append(artifacts, checkpointRecoveryArtifact{ ++ Path: relative, ++ Type: "file", ++ Mode: uint32(mode.Perm()), ++ Size: size, ++ SHA256: fmt.Sprintf("%x", digest.Sum(nil)), ++ }) ++ if relative != checkpointRecoveryManifestName { ++ checkpointPaths = append(checkpointPaths, relative) + } + return nil + }) + if err != nil { +- return nil, fmt.Errorf("while restoring eth0 in interior netns: %w", err) ++ return nil, fmt.Errorf("while inventorying recovery artifacts: %w", err) ++ } ++ sort.Strings(checkpointPaths) ++ if len(checkpointPaths) != 1 || checkpointPaths[0] != "checkpoint.img" { ++ return nil, fmt.Errorf( ++ "recovery checkpoint artifact set is %v, want exactly [checkpoint.img]", ++ checkpointPaths, ++ ) + } + +- s.actorLogger.EmitLifecycleLog("Actor checkpointed", req.GetActorId(), req.GetActorTemplateName(), req.GetActorTemplateNamespace()) ++ // Sync directory entries from the leaves up before publishing the commit ++ // record. A matching record therefore covers every regular file and directory ++ // that runsc returned, not only checkpoint.img. ++ sort.Slice(directories, func(i, j int) bool { ++ leftDepth := strings.Count(filepath.Clean(directories[i]), string(filepath.Separator)) ++ rightDepth := strings.Count(filepath.Clean(directories[j]), string(filepath.Separator)) ++ if leftDepth == rightDepth { ++ return directories[i] < directories[j] ++ } ++ return leftDepth > rightDepth ++ }) ++ for _, directory := range directories { ++ handle, err := os.Open(directory) ++ if err != nil { ++ return nil, fmt.Errorf("while opening recovery directory for sync: %w", err) ++ } ++ if err := handle.Sync(); err != nil { ++ _ = handle.Close() ++ return nil, fmt.Errorf("while syncing recovery directory: %w", err) ++ } ++ if err := handle.Close(); err != nil { ++ return nil, fmt.Errorf("while closing recovery directory: %w", err) ++ } ++ } + +- return nil, nil ++ sort.Slice(artifacts, func(i, j int) bool { ++ return artifacts[i].Path < artifacts[j].Path ++ }) ++ commit, err := json.Marshal(checkpointRecoveryCommitRecord{ ++ Version: 1, ++ Artifacts: artifacts, ++ }) ++ if err != nil { ++ return nil, fmt.Errorf("while encoding recovery commit: %w", err) ++ } ++ return append(commit, '\n'), nil ++} ++ ++func writeAtomicRecoveryFile(directory, temporaryDirectory, name, prefix string, data []byte) (retErr error) { ++ temporary, err := os.CreateTemp(temporaryDirectory, prefix) ++ if err != nil { ++ return fmt.Errorf("while creating temporary recovery file: %w", err) ++ } ++ temporaryPath := temporary.Name() ++ defer func() { ++ _ = temporary.Close() ++ if retErr != nil { ++ _ = os.Remove(temporaryPath) ++ } ++ }() ++ if err := temporary.Chmod(0o600); err != nil { ++ return fmt.Errorf("while setting temporary recovery file permissions: %w", err) ++ } ++ if _, err := temporary.Write(data); err != nil { ++ return fmt.Errorf("while writing temporary recovery file: %w", err) ++ } ++ if err := temporary.Sync(); err != nil { ++ return fmt.Errorf("while syncing temporary recovery file: %w", err) ++ } ++ if err := temporary.Close(); err != nil { ++ return fmt.Errorf("while closing temporary recovery file: %w", err) ++ } ++ if err := os.Rename(temporaryPath, filepath.Join(directory, name)); err != nil { ++ return fmt.Errorf("while committing recovery file: %w", err) ++ } ++ if err := syncDirectory(directory); err != nil { ++ return err ++ } ++ if filepath.Clean(temporaryDirectory) != filepath.Clean(directory) { ++ if err := syncDirectory(temporaryDirectory); err != nil { ++ return err ++ } ++ } ++ return nil ++} ++ ++func syncDirectory(directory string) error { ++ handle, err := os.Open(directory) ++ if err != nil { ++ return fmt.Errorf("while opening recovery directory for sync: %w", err) ++ } ++ defer handle.Close() ++ if err := handle.Sync(); err != nil { ++ return fmt.Errorf("while syncing recovery directory: %w", err) ++ } ++ return nil ++} ++ ++func prepareCheckpointRecovery(checkpointPath, recoveryPath string, expected map[string]struct{}) (retErr error) { ++ if _, err := os.Lstat(recoveryPath); err == nil { ++ return fmt.Errorf("refusing to replace existing recovery path %q", recoveryPath) ++ } else if !errors.Is(err, os.ErrNotExist) { ++ return fmt.Errorf("while checking recovery path: %w", err) ++ } ++ parent := filepath.Dir(recoveryPath) ++ if err := os.MkdirAll(parent, 0o700); err != nil { ++ return fmt.Errorf("while creating recovery parent directory: %w", err) ++ } ++ stagingPath, err := os.MkdirTemp(parent, checkpointRecoveryPreparePrefix) ++ if err != nil { ++ return fmt.Errorf("while creating recovery staging directory: %w", err) ++ } ++ published := false ++ defer func() { ++ if !published || retErr != nil { ++ _ = os.RemoveAll(stagingPath) ++ _ = os.RemoveAll(recoveryPath) ++ } ++ }() ++ if err := writeAtomicRecoveryFile( ++ stagingPath, ++ stagingPath, ++ checkpointRecoveryManifestName, ++ checkpointRecoveryPreparePrefix, ++ checkpointRecoveryManifest(expected), ++ ); err != nil { ++ return fmt.Errorf("while writing recovery manifest: %w", err) ++ } ++ if err := os.Rename(stagingPath, recoveryPath); err != nil { ++ return fmt.Errorf("while publishing prepared recovery directory: %w", err) ++ } ++ published = true ++ parentHandle, err := os.Open(parent) ++ if err != nil { ++ return fmt.Errorf("while opening recovery parent for sync: %w", err) ++ } ++ if err := parentHandle.Sync(); err != nil { ++ _ = parentHandle.Close() ++ return fmt.Errorf("while syncing recovery parent: %w", err) ++ } ++ if err := parentHandle.Close(); err != nil { ++ return fmt.Errorf("while closing recovery parent: %w", err) ++ } ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ return fmt.Errorf("while clearing checkpoint path: %w", err) ++ } ++ if err := os.Symlink(recoveryPath, checkpointPath); err != nil { ++ return fmt.Errorf("while linking checkpoint path to recovery: %w", err) ++ } ++ return nil ++} ++ ++func commitCheckpointRecovery(recoveryPath string, expected map[string]struct{}) error { ++ state, err := inspectCheckpointRecovery(recoveryPath, expected) ++ if err != nil { ++ return err ++ } ++ if state != checkpointRecoveryPrepared { ++ return fmt.Errorf("recovery state is %d, want prepared", state) ++ } ++ commit, err := checkpointRecoveryCommit(recoveryPath) ++ if err != nil { ++ return err ++ } ++ if err := writeAtomicRecoveryFile( ++ recoveryPath, ++ filepath.Dir(recoveryPath), ++ checkpointRecoveryCommitName, ++ checkpointRecoveryCommitPrefix, ++ commit, ++ ); err != nil { ++ return err ++ } ++ state, err = inspectCheckpointRecovery(recoveryPath, expected) ++ if err != nil { ++ return err ++ } ++ if state != checkpointRecoveryCommitted { ++ return fmt.Errorf("recovery state is %d after commit, want committed", state) ++ } ++ return nil ++} ++ ++func resetPreparedCheckpointRecovery(checkpointPath, recoveryPath string, expected map[string]struct{}) error { ++ state, err := inspectCheckpointRecovery(recoveryPath, expected) ++ if err != nil { ++ return err ++ } ++ if state != checkpointRecoveryPrepared { ++ return fmt.Errorf("recovery state is %d, want prepared", state) ++ } ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ return fmt.Errorf("while clearing ordinary checkpoint path: %w", err) ++ } ++ if err := os.RemoveAll(recoveryPath); err != nil { ++ return fmt.Errorf("while clearing prepared recovery path: %w", err) ++ } ++ return nil ++} ++ ++func restoreCheckpointRecovery(checkpointPath, recoveryPath string, expected map[string]struct{}) error { ++ valid, err := validateCheckpointRecovery(recoveryPath, expected) ++ if err != nil { ++ return err ++ } ++ if !valid { ++ return fmt.Errorf("checkpoint recovery is not committed") ++ } ++ return materializeCheckpointTransport(checkpointPath, recoveryPath) ++} ++ ++func materializeCheckpointTransport(checkpointPath, recoveryPath string) error { ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ return fmt.Errorf("while clearing checkpoint transport path: %w", err) ++ } ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ return fmt.Errorf("while creating checkpoint transport path: %w", err) ++ } ++ if err := os.Link( ++ filepath.Join(recoveryPath, "checkpoint.img"), ++ filepath.Join(checkpointPath, "checkpoint.img"), ++ ); err != nil { ++ return fmt.Errorf("while linking committed checkpoint into transport view: %w", err) ++ } ++ compatibilityArtifacts := map[string]string{ ++ "pages.img": checkpointPagesCompatibilityMarker, ++ "pages_meta.img": checkpointPagesMetadataCompatibilityMarker, ++ } ++ for artifact, marker := range compatibilityArtifacts { ++ if err := writeAtomicRecoveryFile( ++ checkpointPath, ++ checkpointPath, ++ artifact, ++ ".checkpoint-transport-", ++ []byte(marker), ++ ); err != nil { ++ return fmt.Errorf("while creating checkpoint transport artifact %s: %w", artifact, err) ++ } ++ } ++ return nil ++} ++ ++func (s *AteomService) restorePodNetwork(ctx context.Context) error { ++ ops := podNetworkOps{ ++ podEth0Present: func(context.Context) (bool, error) { ++ return linkPresent("eth0") ++ }, ++ interiorEth0Present: func(ctx context.Context) (bool, error) { ++ var present bool ++ err := netNSDo(ctx, s.interiorNetNS, func(context.Context) error { ++ var err error ++ present, err = linkPresent("eth0") ++ return err ++ }) ++ return present, err ++ }, ++ moveInteriorEth0ToPod: func(ctx context.Context) error { ++ podNetNS, err := netns.Get() ++ if err != nil { ++ return fmt.Errorf("while getting pod netns: %w", err) ++ } ++ defer podNetNS.Close() ++ return netNSDo(ctx, s.interiorNetNS, func(context.Context) error { ++ eth0Link, err := netlink.LinkByName("eth0") ++ if err != nil { ++ return fmt.Errorf("while acquiring eth0 in interior netns: %w", err) ++ } ++ if err := netlink.LinkSetNsFd(eth0Link, int(podNetNS)); err != nil { ++ return fmt.Errorf("while sending eth0 back to pod netns: %w", err) ++ } ++ return nil ++ }) ++ }, ++ } ++ return ensurePodNetwork(ctx, ops) ++} ++ ++func ensurePodNetwork(ctx context.Context, ops podNetworkOps) error { ++ podPresent, interiorPresent, err := readPodNetworkState(ctx, ops) ++ if err != nil { ++ return err ++ } ++ switch { ++ case podPresent && !interiorPresent: ++ return nil ++ case !podPresent && interiorPresent: ++ if err := ops.moveInteriorEth0ToPod(ctx); err != nil { ++ return fmt.Errorf("while moving eth0 from interior to pod netns: %w", err) ++ } ++ podPresent, interiorPresent, err = readPodNetworkState(ctx, ops) ++ if err != nil { ++ return fmt.Errorf("failed closed while verifying restored eth0 location: %w", err) ++ } ++ if !podPresent || interiorPresent { ++ return fmt.Errorf( ++ "failed closed: restored eth0 location is uncertain (pod=%t interior=%t)", ++ podPresent, ++ interiorPresent, ++ ) ++ } ++ return nil ++ default: ++ return fmt.Errorf( ++ "failed closed: eth0 location is uncertain (pod=%t interior=%t)", ++ podPresent, ++ interiorPresent, ++ ) ++ } ++} ++ ++func readPodNetworkState(ctx context.Context, ops podNetworkOps) (bool, bool, error) { ++ podPresent, err := ops.podEth0Present(ctx) ++ if err != nil { ++ return false, false, fmt.Errorf("while checking eth0 in pod netns: %w", err) ++ } ++ interiorPresent, err := ops.interiorEth0Present(ctx) ++ if err != nil { ++ return false, false, fmt.Errorf("while checking eth0 in interior netns: %w", err) ++ } ++ return podPresent, interiorPresent, nil ++} ++ ++func linkPresent(name string) (bool, error) { ++ _, err := netlink.LinkByName(name) ++ if err == nil { ++ return true, nil ++ } ++ var notFound netlink.LinkNotFoundError ++ if errors.As(err, ¬Found) { ++ return false, nil ++ } ++ return false, err ++} ++ ++func prepareCheckpointRestore(checkpointDir string) error { ++ type compatibilityArtifact struct { ++ name string ++ marker string ++ } ++ artifacts := []compatibilityArtifact{ ++ {name: "pages.img", marker: checkpointPagesCompatibilityMarker}, ++ {name: "pages_meta.img", marker: checkpointPagesMetadataCompatibilityMarker}, ++ } ++ present := make([]bool, len(artifacts)) ++ compatibility := make([]bool, len(artifacts)) ++ for index, artifact := range artifacts { ++ path := filepath.Join(checkpointDir, artifact.name) ++ info, err := os.Lstat(path) ++ if errors.Is(err, os.ErrNotExist) { ++ continue ++ } ++ if err != nil { ++ return fmt.Errorf("while stating %s: %w", artifact.name, err) ++ } ++ if !info.Mode().IsRegular() { ++ return fmt.Errorf("checkpoint artifact %s must be a regular file", artifact.name) ++ } ++ present[index] = true ++ if info.Size() == int64(len(artifact.marker)) { ++ content, err := os.ReadFile(path) ++ if err != nil { ++ return fmt.Errorf("while reading %s: %w", artifact.name, err) ++ } ++ compatibility[index] = string(content) == artifact.marker ++ } ++ } ++ ++ anyCompatibility := compatibility[0] || compatibility[1] ++ if anyCompatibility { ++ for index, artifact := range artifacts { ++ if present[index] && !compatibility[index] { ++ return fmt.Errorf("checkpoint mixes compatibility and native page artifacts") ++ } ++ if !present[index] { ++ continue ++ } ++ if err := os.Remove(filepath.Join(checkpointDir, artifact.name)); err != nil { ++ return fmt.Errorf("while removing %s compatibility file: %w", artifact.name, err) ++ } ++ } ++ return nil ++ } ++ ++ if present[0] != present[1] { ++ return fmt.Errorf("native checkpoint has only one of pages.img/pages_meta.img") ++ } ++ // Both absent is a native compressed snapshot. Both present is a native ++ // uncompressed snapshot, even when one file is legitimately empty. ++ return nil + } + + func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.RestoreWorkloadRequest) (*ateompb.RestoreWorkloadResponse, error) { +@@ -336,6 +1118,11 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore + // * All OCI bundles are set up, including for "pause" container. + // * Checkpoint downloaded and placed on disk + ++ checkpointDir := ateompath.CheckpointDir(req.GetActorTemplateNamespace(), req.GetActorTemplateName(), req.GetActorId()) ++ if err := prepareCheckpointRestore(checkpointDir); err != nil { ++ return nil, fmt.Errorf("while preparing checkpoint files for restore: %w", err) ++ } ++ + // Move pod eth0 into interior netns + eth0Link, err := netlink.LinkByName("eth0") + if err != nil { +@@ -384,8 +1171,6 @@ func (s *AteomService) RestoreWorkload(ctx context.Context, req *ateompb.Restore + actorID: req.GetActorId(), + } + +- checkpointDir := ateompath.CheckpointDir(req.GetActorTemplateNamespace(), req.GetActorTemplateName(), req.GetActorId()) +- + // Create and restore pause container + if err := rcmd.cmdCreate(ctx, os.Stdout, "pause"); err != nil { + return nil, fmt.Errorf("while creating pause container: %w", err) +diff --git a/cmd/servers/ateom-gvisor/runsc.go b/cmd/servers/ateom-gvisor/runsc.go +index 6db499a5..00763bfc 100644 +--- a/cmd/servers/ateom-gvisor/runsc.go ++++ b/cmd/servers/ateom-gvisor/runsc.go +@@ -15,12 +15,18 @@ + package main + + import ( ++ "bufio" ++ "bytes" + "context" ++ "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" ++ "path/filepath" ++ "strings" ++ "time" + + "github.com/agent-substrate/substrate/internal/ateompath" + ) +@@ -116,6 +122,7 @@ func (r *runsc) cmdCheckpoint(ctx context.Context, containerName, checkpointPath + "-root", ateompath.RunSCStateDir(r.actorTemplateNamespace, r.actorTemplateName, r.actorID), + "checkpoint", + "-image-path", checkpointPath, ++ "-compression=flate-best-speed", + containerName, // Name of the container + ) + cmd.Stdout = os.Stdout +@@ -127,6 +134,64 @@ func (r *runsc) cmdCheckpoint(ctx context.Context, containerName, checkpointPath + return nil + } + ++func (r *runsc) cmdValidateCheckpoint(ctx context.Context, checkpointPath string) error { ++ reapLock.RLock() ++ defer reapLock.RUnlock() ++ ++ slog.InfoContext(ctx, "About to validate runsc checkpoint") ++ cmd := exec.CommandContext( ++ ctx, ++ r.path, ++ "-log-format", "json", ++ "--alsologtostderr", ++ "-root", ateompath.RunSCStateDir(r.actorTemplateNamespace, r.actorTemplateName, r.actorID), ++ "statefile", ++ filepath.Join(checkpointPath, "checkpoint.img"), ++ ) ++ cmd.Stdout = io.Discard ++ cmd.Stderr = io.Discard ++ if err := cmd.Run(); err != nil { ++ return fmt.Errorf("while validating runsc checkpoint statefile: %w", err) ++ } ++ return nil ++} ++ ++func (r *runsc) containerStatus(ctx context.Context, containerName string) (string, error) { ++ reapLock.RLock() ++ defer reapLock.RUnlock() ++ ++ var stdout bytes.Buffer ++ var stderr bytes.Buffer ++ cmd := exec.CommandContext( ++ ctx, ++ r.path, ++ "-log-format", "json", ++ "--alsologtostderr", ++ "-root", ateompath.RunSCStateDir(r.actorTemplateNamespace, r.actorTemplateName, r.actorID), ++ "state", ++ containerName, ++ ) ++ cmd.Stdout = &stdout ++ cmd.Stderr = &stderr ++ if err := cmd.Run(); err != nil { ++ detail := strings.TrimSpace(stderr.String()) ++ if detail != "" { ++ return "", fmt.Errorf("while reading runsc container state: %w: %s", err, detail) ++ } ++ return "", fmt.Errorf("while reading runsc container state: %w", err) ++ } ++ var state struct { ++ Status string `json:"status"` ++ } ++ if err := json.Unmarshal(stdout.Bytes(), &state); err != nil { ++ return "", fmt.Errorf("while parsing runsc container state: %w", err) ++ } ++ if state.Status == "" { ++ return "", fmt.Errorf("runsc container state omitted status") ++ } ++ return strings.ToLower(state.Status), nil ++} ++ + // We take a checkpoint only of the root container of the sandbox, but we need + // to call restore on each container, using the same checkpoint. + func (r *runsc) cmdRestore(ctx context.Context, out io.Writer, containerName, checkpointPath string) error { +@@ -163,33 +228,140 @@ func (r *runsc) cmdRestore(ctx context.Context, out io.Writer, containerName, ch + return nil + } + ++const ( ++ runscDeleteAttempts = 4 ++ runscDeleteRetryDelay = 100 * time.Millisecond ++) ++ + func (r *runsc) cmdDelete(ctx context.Context, containerName string) error { + reapLock.RLock() + defer reapLock.RUnlock() + +- // token := rand.Text() +- // logFile := "/tmp/runsc.delete." + token + ".log" ++ var lastErr error ++ for attempt := 1; attempt <= runscDeleteAttempts; attempt++ { ++ var output bytes.Buffer ++ cmd := exec.CommandContext( ++ ctx, ++ r.path, ++ "-log-format", "json", ++ "--alsologtostderr", ++ // "-debug", ++ "-root", ateompath.RunSCStateDir(r.actorTemplateNamespace, r.actorTemplateName, r.actorID), ++ "delete", ++ "-force", ++ containerName, ++ ) ++ cmd.Stdout = &output ++ cmd.Stderr = &output ++ ++ runErr := cmd.Run() ++ if runErr != nil { ++ lastErr = fmt.Errorf("while running `runsc delete`: %w", runErr) ++ if detail := strings.TrimSpace(output.String()); detail != "" { ++ lastErr = fmt.Errorf("%w: %s", lastErr, detail) ++ } ++ } else { ++ lastErr = nil ++ } ++ ++ present, err := r.containerPresentLocked(ctx, containerName) ++ if err != nil { ++ outcome := "runsc delete exited successfully" ++ if lastErr != nil { ++ outcome = lastErr.Error() ++ } ++ return fmt.Errorf("%s; failed closed while verifying container absence: %w", outcome, err) ++ } ++ if !present { ++ if lastErr != nil { ++ slog.WarnContext(ctx, "runsc delete returned an error after removing the container; accepting verified absence", ++ slog.String("container", containerName), ++ slog.Int("attempt", attempt), ++ slog.Any("err", lastErr)) ++ } ++ return nil ++ } ++ if lastErr == nil { ++ lastErr = fmt.Errorf("runsc delete exited successfully but container remains present") ++ } ++ if attempt == runscDeleteAttempts { ++ break ++ } ++ ++ slog.WarnContext(ctx, "runsc delete did not remove the container; retrying", ++ slog.String("container", containerName), ++ slog.Int("attempt", attempt), ++ slog.Any("err", lastErr)) ++ timer := time.NewTimer(runscDeleteRetryDelay * time.Duration(attempt)) ++ select { ++ case <-ctx.Done(): ++ if !timer.Stop() { ++ select { ++ case <-timer.C: ++ default: ++ } ++ } ++ return fmt.Errorf("%w; delete retry canceled: %v", lastErr, ctx.Err()) ++ case <-timer.C: ++ } ++ } ++ ++ return fmt.Errorf("%w after %d attempts; container is still present", lastErr, runscDeleteAttempts) ++} ++ ++func (r *runsc) containerPresentLocked(ctx context.Context, containerName string) (bool, error) { ++ containers, err := r.containerNamesLocked(ctx) ++ if err != nil { ++ return false, err ++ } ++ _, present := containers[containerName] ++ return present, nil ++} + ++func (r *runsc) containerNames(ctx context.Context) (map[string]struct{}, error) { ++ reapLock.RLock() ++ defer reapLock.RUnlock() ++ return r.containerNamesLocked(ctx) ++} ++ ++func (r *runsc) containerNamesLocked(ctx context.Context) (map[string]struct{}, error) { ++ var stdout bytes.Buffer ++ var stderr bytes.Buffer + cmd := exec.CommandContext( + ctx, + r.path, + "-log-format", "json", + "--alsologtostderr", +- // "-debug", + "-root", ateompath.RunSCStateDir(r.actorTemplateNamespace, r.actorTemplateName, r.actorID), +- "delete", +- "-force", +- containerName, ++ "list", ++ "--quiet", + ) +- cmd.Stdout = os.Stdout +- cmd.Stderr = os.Stderr +- +- err := cmd.Run() +- if err != nil { +- return fmt.Errorf("while running `runsc delete`: %w", err) ++ cmd.Stdout = &stdout ++ cmd.Stderr = &stderr ++ if err := cmd.Run(); err != nil { ++ detail := strings.TrimSpace(stderr.String()) ++ if detail != "" { ++ return nil, fmt.Errorf("while running `runsc list --quiet`: %w: %s", err, detail) ++ } ++ return nil, fmt.Errorf("while running `runsc list --quiet`: %w", err) + } + +- return nil ++ containers := make(map[string]struct{}) ++ scanner := bufio.NewScanner(&stdout) ++ for scanner.Scan() { ++ containerName := strings.TrimSpace(scanner.Text()) ++ if containerName == "" { ++ continue ++ } ++ if _, duplicate := containers[containerName]; duplicate { ++ return nil, fmt.Errorf("duplicate container %q in `runsc list --quiet` output", containerName) ++ } ++ containers[containerName] = struct{}{} ++ } ++ if err := scanner.Err(); err != nil { ++ return nil, fmt.Errorf("while reading `runsc list --quiet` output: %w", err) ++ } ++ return containers, nil + } + + func (r *runsc) cmdState(ctx context.Context, containerName string) error { +diff --git a/cmd/servers/ateom-gvisor/runsc_test.go b/cmd/servers/ateom-gvisor/runsc_test.go +new file mode 100644 +index 00000000..187aee4f +--- /dev/null ++++ b/cmd/servers/ateom-gvisor/runsc_test.go +@@ -0,0 +1,855 @@ ++// Copyright 2026 Google LLC ++// ++// Licensed under the Apache License, Version 2.0 (the "License"); ++// you may not use this file except in compliance with the License. ++// You may obtain a copy of the License at ++// ++// http://www.apache.org/licenses/LICENSE-2.0 ++// ++// Unless required by applicable law or agreed to in writing, software ++// distributed under the License is distributed on an "AS IS" BASIS, ++// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++// See the License for the specific language governing permissions and ++// limitations under the License. ++ ++package main ++ ++import ( ++ "context" ++ "errors" ++ "os" ++ "path/filepath" ++ "slices" ++ "strconv" ++ "strings" ++ "testing" ++ "time" ++) ++ ++func writeFakeRunsc(t *testing.T, mode string, presentContainers ...string) (string, string) { ++ t.Helper() ++ ++ stateDir := t.TempDir() ++ if err := os.WriteFile(filepath.Join(stateDir, "mode"), []byte(mode), 0o600); err != nil { ++ t.Fatalf("write mode: %v", err) ++ } ++ if len(presentContainers) > 0 { ++ present := strings.Join(presentContainers, "\n") + "\n" ++ if err := os.WriteFile(filepath.Join(stateDir, "present"), []byte(present), 0o600); err != nil { ++ t.Fatalf("write present state: %v", err) ++ } ++ } ++ if err := os.WriteFile(filepath.Join(stateDir, "status"), []byte("running\n"), 0o600); err != nil { ++ t.Fatalf("write status: %v", err) ++ } ++ ++ script := filepath.Join(stateDir, "runsc") ++ const body = `#!/bin/sh ++set -eu ++state_dir="${RUNSC_FAKE_STATE:?}" ++mode="$(cat "$state_dir/mode")" ++command="" ++image_path="" ++previous="" ++last="" ++for arg in "$@"; do ++ if [ "$previous" = "-image-path" ]; then ++ image_path="$arg" ++ fi ++ case "$arg" in ++ checkpoint|delete|list|state|statefile) ++ command="$arg" ++ ;; ++ esac ++ previous="$arg" ++ last="$arg" ++done ++container="$last" ++ ++container_present() { ++ [ -f "$state_dir/present" ] && grep -Fxq "$1" "$state_dir/present" ++} ++ ++remove_container() { ++ temporary="$state_dir/present.tmp" ++ if [ -f "$state_dir/present" ]; then ++ grep -Fxv "$1" "$state_dir/present" > "$temporary" || true ++ mv "$temporary" "$state_dir/present" ++ fi ++} ++ ++increment_counter() { ++ counter_file="$state_dir/$1" ++ count=0 ++ if [ -f "$counter_file" ]; then ++ count="$(cat "$counter_file")" ++ fi ++ count=$((count + 1)) ++ printf '%s' "$count" > "$counter_file" ++ printf '%s' "$count" ++} ++ ++case "$command" in ++ checkpoint) ++ count="$(increment_counter checkpoint-count)" ++ printf '%s\n' "$@" > "$state_dir/checkpoint-args" ++ mkdir -p "$image_path" ++ if [ "$mode" = "checkpoint-partial-once" ] && [ "$count" -eq 1 ]; then ++ printf 'partial-snapshot\n' > "$image_path/checkpoint.img" ++ printf 'running\n' > "$state_dir/status" ++ printf 'simulated interrupted checkpoint\n' >&2 ++ exit 74 ++ fi ++ printf 'snapshot-%s\n' "$count" > "$image_path/checkpoint.img" ++ printf 'stopped\n' > "$state_dir/status" ++ ;; ++ delete) ++ count="$(increment_counter delete-count-$container)" ++ case "$mode" in ++ absent-after-error) ++ remove_container "$container" ++ printf 'simulated delete exit 128\n' >&2 ++ exit 128 ++ ;; ++ retry-then-success) ++ if [ "$count" -eq 1 ]; then ++ printf 'simulated transient delete exit 128\n' >&2 ++ exit 128 ++ fi ++ remove_container "$container" ++ ;; ++ persistent-error|list-error) ++ printf 'simulated persistent delete exit 128\n' >&2 ++ exit 128 ++ ;; ++ two-call-recovery) ++ if [ "$container" = "app-b" ] && [ "$count" -le 4 ]; then ++ printf 'simulated persistent app-b delete exit 128\n' >&2 ++ exit 128 ++ fi ++ remove_container "$container" ++ ;; ++ success|checkpoint-partial-once|state-warning) ++ remove_container "$container" ++ ;; ++ success-but-present) ++ ;; ++ *) ++ printf 'unexpected fake runsc mode %s\n' "$mode" >&2 ++ exit 65 ++ ;; ++ esac ++ ;; ++ list) ++ if [ "$mode" = "list-error" ]; then ++ printf 'simulated list failure\n' >&2 ++ exit 2 ++ fi ++ if [ -f "$state_dir/present" ]; then ++ cat "$state_dir/present" ++ fi ++ ;; ++ state) ++ if ! container_present "$container"; then ++ printf 'container %s is absent\n' "$container" >&2 ++ exit 3 ++ fi ++ if [ "$mode" = "state-warning" ]; then ++ printf 'simulated runsc state warning\n' >&2 ++ fi ++ status="$(cat "$state_dir/status")" ++ printf '{"id":"%s","status":"%s"}\n' "$container" "$status" ++ ;; ++ statefile) ++ if ! grep -Eq '^snapshot-[0-9]+$' "$container"; then ++ printf 'simulated invalid statefile\n' >&2 ++ exit 75 ++ fi ++ printf 'valid statefile\n' ++ ;; ++ *) ++ printf 'unexpected fake runsc command\n' >&2 ++ exit 64 ++ ;; ++esac ++` ++ if err := os.WriteFile(script, []byte(body), 0o700); err != nil { ++ t.Fatalf("write fake runsc: %v", err) ++ } ++ t.Setenv("RUNSC_FAKE_STATE", stateDir) ++ return script, stateDir ++} ++ ++func testRunsc(path string) *runsc { ++ return &runsc{ ++ path: path, ++ actorTemplateNamespace: "test-namespace", ++ actorTemplateName: "test-template", ++ actorID: "test-actor", ++ } ++} ++ ++func counterValue(t *testing.T, stateDir, name string) int { ++ t.Helper() ++ data, err := os.ReadFile(filepath.Join(stateDir, name)) ++ if os.IsNotExist(err) { ++ return 0 ++ } ++ if err != nil { ++ t.Fatalf("read counter %s: %v", name, err) ++ } ++ count, err := strconv.Atoi(string(data)) ++ if err != nil { ++ t.Fatalf("parse counter %s: %v", name, err) ++ } ++ return count ++} ++ ++func presentContainerSet(t *testing.T, stateDir string) map[string]struct{} { ++ t.Helper() ++ data, err := os.ReadFile(filepath.Join(stateDir, "present")) ++ if os.IsNotExist(err) { ++ return map[string]struct{}{} ++ } ++ if err != nil { ++ t.Fatalf("read present containers: %v", err) ++ } ++ containers := make(map[string]struct{}) ++ for _, name := range strings.Fields(string(data)) { ++ containers[name] = struct{}{} ++ } ++ return containers ++} ++ ++func TestCmdDeleteAcceptsVerifiedAbsenceAfterError(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "absent-after-error", "workspace") ++ if err := testRunsc(path).cmdDelete(context.Background(), "workspace"); err != nil { ++ t.Fatalf("cmdDelete() error = %v, want verified absence success", err) ++ } ++ if got := counterValue(t, stateDir, "delete-count-workspace"); got != 1 { ++ t.Fatalf("delete attempts = %d, want 1", got) ++ } ++} ++ ++func TestCmdDeleteRetriesWhileContainerRemains(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "retry-then-success", "workspace") ++ if err := testRunsc(path).cmdDelete(context.Background(), "workspace"); err != nil { ++ t.Fatalf("cmdDelete() error = %v, want retry success", err) ++ } ++ if got := counterValue(t, stateDir, "delete-count-workspace"); got != 2 { ++ t.Fatalf("delete attempts = %d, want 2", got) ++ } ++} ++ ++func TestCmdDeleteFailsClosedWhenContainerRemains(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "persistent-error", "workspace") ++ err := testRunsc(path).cmdDelete(context.Background(), "workspace") ++ if err == nil { ++ t.Fatal("cmdDelete() error = nil, want persistent failure") ++ } ++ if !strings.Contains(err.Error(), "container is still present") { ++ t.Fatalf("cmdDelete() error = %q, want presence postcondition", err) ++ } ++ if got := counterValue(t, stateDir, "delete-count-workspace"); got != runscDeleteAttempts { ++ t.Fatalf("delete attempts = %d, want %d", got, runscDeleteAttempts) ++ } ++} ++ ++func TestCmdDeleteFailsClosedWhenSuccessLeavesContainerPresent(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "success-but-present", "workspace") ++ err := testRunsc(path).cmdDelete(context.Background(), "workspace") ++ if err == nil { ++ t.Fatal("cmdDelete() error = nil, want verified-presence failure") ++ } ++ if !strings.Contains(err.Error(), "exited successfully but container remains present") { ++ t.Fatalf("cmdDelete() error = %q, want successful-exit presence failure", err) ++ } ++ if got := counterValue(t, stateDir, "delete-count-workspace"); got != runscDeleteAttempts { ++ t.Fatalf("delete attempts = %d, want %d", got, runscDeleteAttempts) ++ } ++} ++ ++func TestCmdDeleteFailsClosedWhenAbsenceCannotBeVerified(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "list-error", "workspace") ++ err := testRunsc(path).cmdDelete(context.Background(), "workspace") ++ if err == nil { ++ t.Fatal("cmdDelete() error = nil, want verification failure") ++ } ++ if !strings.Contains(err.Error(), "failed closed while verifying container absence") { ++ t.Fatalf("cmdDelete() error = %q, want verification failure context", err) ++ } ++ if got := counterValue(t, stateDir, "delete-count-workspace"); got != 1 { ++ t.Fatalf("delete attempts = %d, want 1", got) ++ } ++} ++ ++func TestContainerNamesWaitsForReaperReadLock(t *testing.T) { ++ path, _ := writeFakeRunsc(t, "success", "pause") ++ rcmd := testRunsc(path) ++ reapLock.Lock() ++ locked := true ++ defer func() { ++ if locked { ++ reapLock.Unlock() ++ } ++ }() ++ result := make(chan error, 1) ++ go func() { ++ _, err := rcmd.containerNames(context.Background()) ++ result <- err ++ }() ++ select { ++ case err := <-result: ++ t.Fatalf("containerNames() returned before reaper lock release: %v", err) ++ case <-time.After(50 * time.Millisecond): ++ } ++ reapLock.Unlock() ++ locked = false ++ select { ++ case err := <-result: ++ if err != nil { ++ t.Fatalf("containerNames() error = %v", err) ++ } ++ case <-time.After(2 * time.Second): ++ t.Fatal("containerNames() remained blocked after reaper lock release") ++ } ++} ++ ++func TestContainerStatusIgnoresStderrDiagnostics(t *testing.T) { ++ path, _ := writeFakeRunsc(t, "state-warning", "pause") ++ status, err := testRunsc(path).containerStatus(context.Background(), "pause") ++ if err != nil { ++ t.Fatalf("containerStatus() error = %v", err) ++ } ++ if status != "running" { ++ t.Fatalf("containerStatus() = %q, want running", status) ++ } ++} ++ ++func TestCmdCheckpointUsesStoppedSingleFileProtocol(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "success", "pause") ++ rcmd := testRunsc(path) ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ if err := rcmd.cmdCheckpoint(context.Background(), "pause", checkpointPath); err != nil { ++ t.Fatalf("cmdCheckpoint() error = %v", err) ++ } ++ args, err := os.ReadFile(filepath.Join(stateDir, "checkpoint-args")) ++ if err != nil { ++ t.Fatalf("read checkpoint args: %v", err) ++ } ++ fields := strings.Fields(string(args)) ++ if !slices.Contains(fields, "-compression=flate-best-speed") { ++ t.Fatalf("checkpoint args = %q, want pinned single-file compression", string(args)) ++ } ++ if slices.Contains(fields, "-leave-running") { ++ t.Fatalf("checkpoint args = %q, must keep the sandbox stopped", string(args)) ++ } ++ status, err := rcmd.containerStatus(context.Background(), "pause") ++ if err != nil { ++ t.Fatalf("containerStatus() error = %v", err) ++ } ++ if status != "stopped" { ++ t.Fatalf("checkpointed status = %q, want stopped", status) ++ } ++ if err := rcmd.cmdValidateCheckpoint(context.Background(), checkpointPath); err != nil { ++ t.Fatalf("cmdValidateCheckpoint() error = %v", err) ++ } ++} ++ ++func TestCheckpointRecoveryRejectsUnexpectedOrCorruptArtifacts(t *testing.T) { ++ path, _ := writeFakeRunsc(t, "success", "pause", "app") ++ rcmd := testRunsc(path) ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ recoveryPath := filepath.Join(t.TempDir(), checkpointRecoveryDirName) ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("create checkpoint directory: %v", err) ++ } ++ _, expected, err := expectedCheckpointContainers([]string{"app"}) ++ if err != nil { ++ t.Fatalf("expectedCheckpointContainers() error = %v", err) ++ } ++ if err := prepareCheckpointRecovery(checkpointPath, recoveryPath, expected); err != nil { ++ t.Fatalf("prepareCheckpointRecovery() error = %v", err) ++ } ++ if err := rcmd.cmdCheckpoint(context.Background(), "pause", checkpointPath); err != nil { ++ t.Fatalf("cmdCheckpoint() error = %v", err) ++ } ++ if err := rcmd.cmdValidateCheckpoint(context.Background(), recoveryPath); err != nil { ++ t.Fatalf("cmdValidateCheckpoint() error = %v", err) ++ } ++ ++ extraPath := filepath.Join(recoveryPath, "unexpected.img") ++ if err := os.WriteFile(extraPath, []byte("unexpected\n"), 0o600); err != nil { ++ t.Fatalf("write unexpected checkpoint artifact: %v", err) ++ } ++ if err := commitCheckpointRecovery(recoveryPath, expected); err == nil || ++ !strings.Contains(err.Error(), "want exactly [checkpoint.img]") { ++ t.Fatalf("commit with unexpected artifact error = %v, want single-file recovery rejection", err) ++ } ++ if err := os.Remove(extraPath); err != nil { ++ t.Fatalf("remove unexpected checkpoint artifact: %v", err) ++ } ++ if err := commitCheckpointRecovery(recoveryPath, expected); err != nil { ++ t.Fatalf("commitCheckpointRecovery() error = %v", err) ++ } ++ if valid, err := validateCheckpointRecovery(recoveryPath, expected); err != nil || !valid { ++ t.Fatalf("committed recovery = valid:%t error:%v, want valid", valid, err) ++ } ++ if err := materializeCheckpointTransport(checkpointPath, recoveryPath); err != nil { ++ t.Fatalf("materializeCheckpointTransport() error = %v", err) ++ } ++ recoveryInfo, err := os.Stat(filepath.Join(recoveryPath, "checkpoint.img")) ++ if err != nil { ++ t.Fatalf("stat recovery checkpoint: %v", err) ++ } ++ transportInfo, err := os.Stat(filepath.Join(checkpointPath, "checkpoint.img")) ++ if err != nil { ++ t.Fatalf("stat transport checkpoint: %v", err) ++ } ++ if !os.SameFile(recoveryInfo, transportInfo) { ++ t.Fatal("transport checkpoint is not linked to committed recovery") ++ } ++ expectedMarkers := map[string]string{ ++ "pages.img": checkpointPagesCompatibilityMarker, ++ "pages_meta.img": checkpointPagesMetadataCompatibilityMarker, ++ } ++ for artifact, marker := range expectedMarkers { ++ content, err := os.ReadFile(filepath.Join(checkpointPath, artifact)) ++ if err != nil { ++ t.Fatalf("read transport artifact %s: %v", artifact, err) ++ } ++ if string(content) != marker { ++ t.Fatalf("transport artifact %s = %q, want compatibility marker", artifact, content) ++ } ++ if _, err := os.Stat(filepath.Join(recoveryPath, artifact)); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("recovery unexpectedly contains transport artifact %s: %v", artifact, err) ++ } ++ } ++ ++ checkpointPath = filepath.Join(recoveryPath, "checkpoint.img") ++ if err := os.WriteFile(checkpointPath, []byte("truncated\n"), 0o600); err != nil { ++ t.Fatalf("corrupt checkpoint statefile: %v", err) ++ } ++ if valid, err := validateCheckpointRecovery(recoveryPath, expected); err == nil || valid || ++ !strings.Contains(err.Error(), "integrity") { ++ t.Fatalf("corrupted recovery = valid:%t error:%v, want integrity failure", valid, err) ++ } ++} ++ ++func TestCheckpointRecoveryReconcilesPreparationBeforeCheckpoint(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "success", "pause", "app") ++ rcmd := testRunsc(path) ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ recoveryPath := filepath.Join(t.TempDir(), checkpointRecoveryDirName) ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("create checkpoint directory: %v", err) ++ } ++ _, expected, err := expectedCheckpointContainers([]string{"app"}) ++ if err != nil { ++ t.Fatalf("expectedCheckpointContainers() error = %v", err) ++ } ++ if err := prepareCheckpointRecovery(checkpointPath, recoveryPath, expected); err != nil { ++ t.Fatalf("prepareCheckpointRecovery() error = %v", err) ++ } ++ ++ // Simulate a crash after durable preparation but before runsc starts, then ++ // atelet clearing and recreating only the ordinary checkpoint path. ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ t.Fatalf("clear ordinary checkpoint path: %v", err) ++ } ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("recreate ordinary checkpoint path: %v", err) ++ } ++ if err := checkpointAndCleanup( ++ context.Background(), ++ rcmd, ++ []string{"app"}, ++ checkpointPath, ++ recoveryPath, ++ func(context.Context) error { return nil }, ++ ); err != nil { ++ t.Fatalf("checkpointAndCleanup() preparation recovery error = %v", err) ++ } ++ if got := counterValue(t, stateDir, "checkpoint-count"); got != 1 { ++ t.Fatalf("checkpoint attempts = %d, want one fresh checkpoint after preparation recovery", got) ++ } ++ if valid, err := validateCheckpointRecovery(recoveryPath, expected); err != nil || !valid { ++ t.Fatalf("checkpoint recovery after preparation retry = valid:%t error:%v, want committed", valid, err) ++ } ++} ++ ++func TestCheckpointRecoveryReconcilesUncommittedSuccessfulCheckpoint(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "success", "pause", "app") ++ rcmd := testRunsc(path) ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ recoveryPath := filepath.Join(t.TempDir(), checkpointRecoveryDirName) ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("create checkpoint directory: %v", err) ++ } ++ _, expected, err := expectedCheckpointContainers([]string{"app"}) ++ if err != nil { ++ t.Fatalf("expectedCheckpointContainers() error = %v", err) ++ } ++ ++ // Simulate a crash after runsc returned success but before the service could ++ // atomically write the digest-backed commit record. The sandbox is stopped, ++ // so the retry must validate the runsc statefile and commit the existing ++ // snapshot rather than attempting an unsafe second checkpoint. ++ if err := prepareCheckpointRecovery(checkpointPath, recoveryPath, expected); err != nil { ++ t.Fatalf("prepareCheckpointRecovery() error = %v", err) ++ } ++ if err := rcmd.cmdCheckpoint(context.Background(), "pause", checkpointPath); err != nil { ++ t.Fatalf("cmdCheckpoint() error = %v", err) ++ } ++ if state, err := inspectCheckpointRecovery(recoveryPath, expected); err != nil || state != checkpointRecoveryPrepared { ++ t.Fatalf("recovery before commit = state:%d error:%v, want prepared", state, err) ++ } ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ t.Fatalf("clear ordinary checkpoint path: %v", err) ++ } ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("recreate ordinary checkpoint path: %v", err) ++ } ++ if err := checkpointAndCleanup( ++ context.Background(), ++ rcmd, ++ []string{"app"}, ++ checkpointPath, ++ recoveryPath, ++ func(context.Context) error { return nil }, ++ ); err != nil { ++ t.Fatalf("checkpointAndCleanup() uncommitted recovery error = %v", err) ++ } ++ if got := counterValue(t, stateDir, "checkpoint-count"); got != 1 { ++ t.Fatalf("checkpoint attempts = %d, want validated uncommitted snapshot reuse", got) ++ } ++ checkpoint, err := os.ReadFile(filepath.Join(checkpointPath, "checkpoint.img")) ++ if err != nil { ++ t.Fatalf("read recovered checkpoint: %v", err) ++ } ++ if got, want := string(checkpoint), "snapshot-1\n"; got != want { ++ t.Fatalf("recovered checkpoint = %q, want %q", got, want) ++ } ++ if valid, err := validateCheckpointRecovery(recoveryPath, expected); err != nil || !valid { ++ t.Fatalf("checkpoint recovery after uncommitted retry = valid:%t error:%v, want committed", valid, err) ++ } ++} ++ ++func TestCheckpointRecoveryReconcilesInterruptedWrite(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "checkpoint-partial-once", "pause", "app") ++ rcmd := testRunsc(path) ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ recoveryPath := filepath.Join(t.TempDir(), checkpointRecoveryDirName) ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("create checkpoint directory: %v", err) ++ } ++ networkRestores := 0 ++ restoreNetwork := func(context.Context) error { ++ networkRestores++ ++ return nil ++ } ++ ++ err := checkpointAndCleanup( ++ context.Background(), ++ rcmd, ++ []string{"app"}, ++ checkpointPath, ++ recoveryPath, ++ restoreNetwork, ++ ) ++ if err == nil || !strings.Contains(err.Error(), "exit status 74") { ++ t.Fatalf("first checkpointAndCleanup() error = %v, want interrupted checkpoint exit", err) ++ } ++ if networkRestores != 0 { ++ t.Fatalf("network restores after interrupted checkpoint = %d, want 0", networkRestores) ++ } ++ if got, want := formatContainerSet(presentContainerSet(t, stateDir)), "app,pause"; got != want { ++ t.Fatalf("present containers after interrupted checkpoint = %q, want %q", got, want) ++ } ++ _, expected, err := expectedCheckpointContainers([]string{"app"}) ++ if err != nil { ++ t.Fatalf("expectedCheckpointContainers() error = %v", err) ++ } ++ if state, err := inspectCheckpointRecovery(recoveryPath, expected); err != nil || state != checkpointRecoveryPrepared { ++ t.Fatalf("interrupted recovery = state:%d error:%v, want prepared", state, err) ++ } ++ ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ t.Fatalf("clear ordinary checkpoint path: %v", err) ++ } ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("recreate ordinary checkpoint path: %v", err) ++ } ++ if err := checkpointAndCleanup( ++ context.Background(), ++ rcmd, ++ []string{"app"}, ++ checkpointPath, ++ recoveryPath, ++ restoreNetwork, ++ ); err != nil { ++ t.Fatalf("second checkpointAndCleanup() error = %v, want fresh checkpoint success", err) ++ } ++ if got := counterValue(t, stateDir, "checkpoint-count"); got != 2 { ++ t.Fatalf("checkpoint attempts = %d, want retry after discarding partial bytes", got) ++ } ++ if networkRestores != 1 { ++ t.Fatalf("network restores after retry = %d, want 1", networkRestores) ++ } ++ checkpoint, err := os.ReadFile(filepath.Join(checkpointPath, "checkpoint.img")) ++ if err != nil { ++ t.Fatalf("read recovered checkpoint: %v", err) ++ } ++ if got, want := string(checkpoint), "snapshot-2\n"; got != want { ++ t.Fatalf("recovered checkpoint = %q, want %q", got, want) ++ } ++ if valid, err := validateCheckpointRecovery(recoveryPath, expected); err != nil || !valid { ++ t.Fatalf("checkpoint recovery after interrupted write = valid:%t error:%v, want committed", valid, err) ++ } ++} ++ ++func TestCheckpointCleanupConvergesAfterDeleteRetryExhaustion(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "two-call-recovery", "pause", "app-a", "app-b") ++ rcmd := testRunsc(path) ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ recoveryPath := filepath.Join(t.TempDir(), checkpointRecoveryDirName) ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("create checkpoint directory: %v", err) ++ } ++ ++ podHasEth0 := false ++ interiorHasEth0 := true ++ networkMoves := 0 ++ restoreNetwork := func(ctx context.Context) error { ++ return ensurePodNetwork(ctx, podNetworkOps{ ++ podEth0Present: func(context.Context) (bool, error) { ++ return podHasEth0, nil ++ }, ++ interiorEth0Present: func(context.Context) (bool, error) { ++ return interiorHasEth0, nil ++ }, ++ moveInteriorEth0ToPod: func(context.Context) error { ++ networkMoves++ ++ podHasEth0 = true ++ interiorHasEth0 = false ++ return nil ++ }, ++ }) ++ } ++ ++ err := checkpointAndCleanup( ++ context.Background(), ++ rcmd, ++ []string{"app-a", "app-b"}, ++ checkpointPath, ++ recoveryPath, ++ restoreNetwork, ++ ) ++ if err == nil { ++ t.Fatal("first checkpointAndCleanup() error = nil, want exhausted delete retries") ++ } ++ if !strings.Contains(err.Error(), "container is still present") { ++ t.Fatalf("first checkpointAndCleanup() error = %q, want exhausted presence postcondition", err) ++ } ++ if got := counterValue(t, stateDir, "delete-count-app-b"); got != runscDeleteAttempts { ++ t.Fatalf("first-call app-b delete attempts = %d, want %d", got, runscDeleteAttempts) ++ } ++ if got, want := formatContainerSet(presentContainerSet(t, stateDir)), "app-b,pause"; got != want { ++ t.Fatalf("present containers after first call = %q, want %q", got, want) ++ } ++ if networkMoves != 1 || !podHasEth0 || interiorHasEth0 { ++ t.Fatalf("network after first call = moves:%d pod:%t interior:%t, want 1,true,false", networkMoves, podHasEth0, interiorHasEth0) ++ } ++ if got := counterValue(t, stateDir, "checkpoint-count"); got != 1 { ++ t.Fatalf("checkpoint attempts after first call = %d, want 1", got) ++ } ++ ++ // Atelet removes and recreates the normal checkpoint directory before every ++ // RPC retry. The second call must repopulate it from recovery rather than ++ // checkpointing a workload whose app-a container is already absent. ++ if err := os.RemoveAll(checkpointPath); err != nil { ++ t.Fatalf("clear checkpoint directory: %v", err) ++ } ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("recreate checkpoint directory: %v", err) ++ } ++ ++ if err := checkpointAndCleanup( ++ context.Background(), ++ rcmd, ++ []string{"app-a", "app-b"}, ++ checkpointPath, ++ recoveryPath, ++ restoreNetwork, ++ ); err != nil { ++ t.Fatalf("second checkpointAndCleanup() error = %v, want recovery success", err) ++ } ++ if got := counterValue(t, stateDir, "checkpoint-count"); got != 1 { ++ t.Fatalf("checkpoint attempts after second call = %d, want preserved first checkpoint", got) ++ } ++ if got := counterValue(t, stateDir, "delete-count-app-b"); got != runscDeleteAttempts+1 { ++ t.Fatalf("total app-b delete attempts = %d, want %d", got, runscDeleteAttempts+1) ++ } ++ if got := len(presentContainerSet(t, stateDir)); got != 0 { ++ t.Fatalf("present container count after recovery = %d, want 0", got) ++ } ++ if networkMoves != 1 { ++ t.Fatalf("network moves after second call = %d, want already-restored no-op", networkMoves) ++ } ++ checkpoint, err := os.ReadFile(filepath.Join(checkpointPath, "checkpoint.img")) ++ if err != nil { ++ t.Fatalf("read restored checkpoint: %v", err) ++ } ++ if got, want := string(checkpoint), "snapshot-1\n"; got != want { ++ t.Fatalf("restored checkpoint = %q, want %q", got, want) ++ } ++ if valid, err := validateCheckpointRecovery(recoveryPath, map[string]struct{}{ ++ "pause": {}, ++ "app-a": {}, ++ "app-b": {}, ++ }); err != nil || !valid { ++ t.Fatalf("checkpoint recovery after success = valid:%t error:%v, want retained valid copy", valid, err) ++ } ++} ++ ++func TestCheckpointCleanupFailsClosedOnPartialStateWithoutRecovery(t *testing.T) { ++ path, stateDir := writeFakeRunsc(t, "success", "pause", "app-b") ++ checkpointPath := filepath.Join(t.TempDir(), "checkpoint") ++ recoveryPath := filepath.Join(t.TempDir(), checkpointRecoveryDirName) ++ if err := os.MkdirAll(checkpointPath, 0o700); err != nil { ++ t.Fatalf("create checkpoint directory: %v", err) ++ } ++ networkCalled := false ++ err := checkpointAndCleanup( ++ context.Background(), ++ testRunsc(path), ++ []string{"app-a", "app-b"}, ++ checkpointPath, ++ recoveryPath, ++ func(context.Context) error { ++ networkCalled = true ++ return nil ++ }, ++ ) ++ if err == nil { ++ t.Fatal("checkpointAndCleanup() error = nil, want partial-state failure") ++ } ++ if !strings.Contains(err.Error(), "partial container state without complete checkpoint recovery") { ++ t.Fatalf("checkpointAndCleanup() error = %q, want fail-closed partial-state context", err) ++ } ++ if got := counterValue(t, stateDir, "checkpoint-count"); got != 0 { ++ t.Fatalf("checkpoint attempts = %d, want 0 before uncertain state is rejected", got) ++ } ++ if networkCalled { ++ t.Fatal("network restoration ran for uncertain partial state") ++ } ++} ++ ++func TestEnsurePodNetworkFailsClosedOnUncertainLocation(t *testing.T) { ++ for _, test := range []struct { ++ name string ++ pod bool ++ interior bool ++ }{ ++ {name: "missing from both namespaces", pod: false, interior: false}, ++ {name: "present in both namespaces", pod: true, interior: true}, ++ } { ++ t.Run(test.name, func(t *testing.T) { ++ moved := false ++ err := ensurePodNetwork(context.Background(), podNetworkOps{ ++ podEth0Present: func(context.Context) (bool, error) { ++ return test.pod, nil ++ }, ++ interiorEth0Present: func(context.Context) (bool, error) { ++ return test.interior, nil ++ }, ++ moveInteriorEth0ToPod: func(context.Context) error { ++ moved = true ++ return nil ++ }, ++ }) ++ if err == nil || !strings.Contains(err.Error(), "failed closed") { ++ t.Fatalf("ensurePodNetwork() error = %v, want fail-closed uncertainty", err) ++ } ++ if moved { ++ t.Fatal("ensurePodNetwork() moved eth0 from an uncertain state") ++ } ++ }) ++ } ++} ++ ++func TestPrepareCheckpointRestoreRemovesMarkedCompatibilityFiles(t *testing.T) { ++ dir := t.TempDir() ++ if err := os.WriteFile(filepath.Join(dir, "checkpoint.img"), []byte("checkpoint\n"), 0o600); err != nil { ++ t.Fatalf("write checkpoint: %v", err) ++ } ++ compatibility := map[string]string{ ++ "pages.img": checkpointPagesCompatibilityMarker, ++ "pages_meta.img": checkpointPagesMetadataCompatibilityMarker, ++ } ++ for name, marker := range compatibility { ++ if err := os.WriteFile(filepath.Join(dir, name), []byte(marker), 0o600); err != nil { ++ t.Fatalf("write %s: %v", name, err) ++ } ++ } ++ if err := prepareCheckpointRestore(dir); err != nil { ++ t.Fatalf("prepareCheckpointRestore() error = %v", err) ++ } ++ for name := range compatibility { ++ if _, err := os.Stat(filepath.Join(dir, name)); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("compatibility file %s still exists: %v", name, err) ++ } ++ } ++} ++ ++func TestPrepareCheckpointRestoreRecoversPartialCompatibilityCleanup(t *testing.T) { ++ dir := t.TempDir() ++ if err := os.WriteFile( ++ filepath.Join(dir, "pages_meta.img"), ++ []byte(checkpointPagesMetadataCompatibilityMarker), ++ 0o600, ++ ); err != nil { ++ t.Fatalf("write surviving compatibility marker: %v", err) ++ } ++ if err := prepareCheckpointRestore(dir); err != nil { ++ t.Fatalf("prepareCheckpointRestore() partial cleanup error = %v", err) ++ } ++ if _, err := os.Stat(filepath.Join(dir, "pages_meta.img")); !errors.Is(err, os.ErrNotExist) { ++ t.Fatalf("surviving compatibility file still exists: %v", err) ++ } ++} ++ ++func TestPrepareCheckpointRestorePreservesNativeMultiFileSnapshot(t *testing.T) { ++ dir := t.TempDir() ++ for _, file := range []struct { ++ name string ++ data string ++ }{ ++ {name: "checkpoint.img", data: "checkpoint\n"}, ++ {name: "pages.img", data: ""}, ++ {name: "pages_meta.img", data: "metadata\n"}, ++ } { ++ if err := os.WriteFile(filepath.Join(dir, file.name), []byte(file.data), 0o600); err != nil { ++ t.Fatalf("write %s: %v", file.name, err) ++ } ++ } ++ if err := prepareCheckpointRestore(dir); err != nil { ++ t.Fatalf("prepareCheckpointRestore() error = %v", err) ++ } ++ if info, err := os.Stat(filepath.Join(dir, "pages.img")); err != nil || info.Size() != 0 { ++ t.Fatalf("native empty pages.img = info:%v error:%v, want preserved empty file", info, err) ++ } ++ if info, err := os.Stat(filepath.Join(dir, "pages_meta.img")); err != nil || info.Size() == 0 { ++ t.Fatalf("native pages_meta.img = info:%v error:%v, want preserved nonempty file", info, err) ++ } ++} ++ ++func TestPrepareCheckpointRestoreRejectsPartialNativeState(t *testing.T) { ++ dir := t.TempDir() ++ if err := os.WriteFile(filepath.Join(dir, "pages.img"), []byte("native-pages\n"), 0o600); err != nil { ++ t.Fatalf("write pages.img: %v", err) ++ } ++ if err := prepareCheckpointRestore(dir); err == nil { ++ t.Fatal("prepareCheckpointRestore() error = nil, want missing native metadata failure") ++ } ++} diff --git a/hack/demos/README.md b/hack/demos/README.md index dcc8047e6..c690b5831 100644 --- a/hack/demos/README.md +++ b/hack/demos/README.md @@ -6,8 +6,9 @@ This directory contains a small `demo-magic` kit for showing Orka in six ways: - `20-manual-workflow.sh`: explicit coordinator Task CR for a focused Vekil metrics first-PR workflow - `30-cron-workflow.sh`: scheduled runtime task with recurring child runs - `40-security-scanning.sh`: repository scan -> findings -> patch -> PR -- `60-agent-sandbox.sh`: three turns share a single SandboxClaim via `sessionRef` (scout -> builder -> CI fixup, same workspace) -- `70-agent-substrate.sh`: a real gpt-5.5 codex agent in a gVisor Actor (Agent Substrate) clones a repo, edits it, and opens a PR; a second task reuses the warm workspace with no cold start +- `50-kontxt.sh`: workload SA token -> in-cluster TTS -> request-scoped TxToken -> Orka API call (one identity, two outcomes) +- `60-agent-sandbox.sh`: archived execution-workspace prototype; not a current ACP v2 path +- `70-agent-substrate.sh`: archived Substrate prototype; requires an Actor-backed v2 supervisor before it is supported again There is also: @@ -21,7 +22,7 @@ There is also: - Optional: an upstream `demo-magic.sh` if you want to override the vendored fallback - A demo namespace that is not the controller namespace - A Provider CRD and runtime credential Secret in that demo namespace -- A git credential Secret in that demo namespace for clone, push, and PR creation +- Separate read/clone and publication/forge credential Secrets for any ACP workspace demo. The archived 60/70 scripts still assume one broad Git Secret and must be migrated before use. The demo scripts include a lightweight `demo-magic.sh` fallback at `hack/demos/lib/demo-magic.sh`, so no separate checkout is required. If you prefer the upstream `demo-magic` behavior, set `DEMO_MAGIC_PATH` to your local checkout. If `DEMO_MAGIC_PATH` points at a missing file, the scripts ignore it and use the vendored fallback. @@ -119,7 +120,7 @@ spec: YAML ``` -Create the runtime credential Secret for the Agent runtime. Codex accepts `OPENAI_API_KEY` or `CODEX_API_KEY`; Copilot accepts `GITHUB_TOKEN`; Claude accepts `ANTHROPIC_API_KEY`. +Create the provider/proxy credential Secret for the ACP runtime. Keep this role separate from both Git read and publication credentials. Codex accepts `OPENAI_API_KEY` or `CODEX_API_KEY`; Copilot accepts `GITHUB_TOKEN`; Claude accepts `ANTHROPIC_API_KEY`. ```bash # Codex example: @@ -133,12 +134,15 @@ kubectl -n "$DEMO_NAMESPACE" create secret generic "$DEMO_RUNTIME_SECRET_REF" \ # --dry-run=client -o yaml | kubectl apply -f - ``` -Create the git credential Secret used for clone, push, and PR creation: +For current ACP manifests, create separate repository credentials. The clean-room workspace boundary uses the read Secret; the Workspace/Publisher alone uses the publication Secret: ```bash -kubectl -n "$DEMO_NAMESPACE" create secret generic "$DEMO_GIT_SECRET_REF" \ - --from-literal=username='' \ - --from-literal=password='' \ +kubectl -n "$DEMO_NAMESPACE" create secret generic repository-read \ + --from-literal=token='' \ + --dry-run=client -o yaml | kubectl apply -f - + +kubectl -n "$DEMO_NAMESPACE" create secret generic repository-publish \ + --from-literal=token='' \ --dry-run=client -o yaml | kubectl apply -f - ``` @@ -283,11 +287,10 @@ source hack/demos/cluster/demo-env.sh make demo-cluster-up-all-down # tear it all down ``` -Notes: `install-agent-sandbox.sh` runs **last** in the bootstrap because it sets -the controller's default workspace provider to `agent-sandbox` (Demo 60 relies -on that default). Demo 70 sets `provider: substrate` explicitly, while the -model-backed demos continue to use their normal ServiceAccount authentication, -so the scenarios coexist safely. +Notes: the agent-sandbox/Substrate bootstrap and demos are retained only for +prototype archaeology. Current ACP v2 validation must not set a default execution +workspace provider or rely on a per-Task worker path. kontxt's `enforce` mode only +gates requests carrying a `Txn-Token`, so the other demos remain independent. Known flake (Demo 70): the warm-reuse Task occasionally fails during workspace release with a gVisor `RestoreWorkload: ... eth0: Link not found` daemon error diff --git a/hack/demos/RECORDING.md b/hack/demos/RECORDING.md index 7a39470a9..e8cadf679 100644 --- a/hack/demos/RECORDING.md +++ b/hack/demos/RECORDING.md @@ -1,5 +1,7 @@ # Demo recording design +> **ACP v2 cutover:** the Demo 60/70 storyboards below capture a retired execution-workspace prototype. Current built-in agent Tasks use RuntimePools, top-level `spec.workspace`, and the separate Workspace/Publisher. Keep these storyboards archived until the scripts and manifests are rebuilt around an ACP v2 supervisor; they are not release evidence. + This is the design doc for turning `hack/demos/` from a presenter rehearsal kit into a small, tasteful library of recorded terminal demos. The goal is to publish a handful of artifacts that hold up on the README, in the docs, and on @@ -69,8 +71,9 @@ Six demos total. Four exist; two are new. | 20 | YAML workflow | `20-manual-workflow.sh` | exists, needs polish | Same payload, declarative `Task` CR — GitOps-friendly | | 30 | Scheduled workflow | `30-cron-workflow.sh` | exists, needs polish | Cron-scheduled stale-PR triage report | | 40 | Security remediation | `40-security-scanning.sh` | exists, needs polish | Finding → patch proposal → reviewable PR | -| 60 | **Agent sandbox workspaces** | `60-agent-sandbox.sh` | **new** | One session, two agents, three turns — Scout, Builder, and a CI fixup share one warm sandbox | -| 70 | **Agent Substrate workspaces** | `70-agent-substrate.sh` | **new** | Real gpt-5.5 agent in a gVisor Actor clones + edits + opens a PR; warm reuse with no cold start | +| 50 | **Kontxt transaction tokens** | `50-kontxt.sh` | **new** | Caller Pod proves identity → kontxt mints TxToken → Orka stamps immutable provenance | +| 60 | **Agent sandbox workspaces** | `60-agent-sandbox.sh` | archived prototype | Requires a future sandbox-backed ACP v2 supervisor | +| 70 | **Agent Substrate workspaces** | `70-agent-substrate.sh` | archived prototype | Requires a future Actor-backed ACP v2 supervisor and clean-room publication | Demos 50 and 60 are designed in [§7](#7-new-scenario-storyboards). @@ -568,7 +571,7 @@ the successful Task and renders: ╰─────────────────────────────────────────────────────────────╯ ``` -### Demo 60 — Agent sandbox workspaces (`60-agent-sandbox.sh`) +### Demo 60 — Agent sandbox workspaces (`60-agent-sandbox.sh`, archived prototype) **Why this matters.** The other demos all show one-shot work. Demo 60 shows *continuity*: a coding session's repo, dependency cache, and built artifacts @@ -687,7 +690,7 @@ by an order of magnitude. --- -### Demo 70 — Agent Substrate workspaces (`70-agent-substrate.sh`) +### Demo 70 — Agent Substrate workspaces (`70-agent-substrate.sh`, archived prototype) **Why this matters.** Orka's execution workspace is provider-neutral. Demo 60 shows agent-sandbox; Demo 70 shows the *same Orka agent Task API* backed by a @@ -716,8 +719,7 @@ demo-magic cluster. Stand it up with `make demo-substrate-up` `scripts/agent-substrate-e2e.sh` standup (`KEEP_CLUSTER=1`) — Substrate control plane in `ate-system`, a `WorkerPool` + gVisor `ActorTemplate` (`orka-codex-ci` in `ate-demo`), Orka wired with `--substrate-*` flags; (2) builds a -**codex-capable Actor image** (the production `agent-harness-wrapper` — daemon + -codex CLI + git) and points the ActorTemplate at it; (3) deploys the **vekil** +**prototype codex-capable Actor image** (workspace daemon + Codex CLI + git; not a supported ACP v2 runtime image) and points the ActorTemplate at it; (3) deploys the **vekil** model proxy (one-time GitHub **device-code** login — the operator completes it from the pod logs, since a plain `gho_` gh token has no Copilot entitlement); (4) creates the model Secret (endpoint → vekil) and the git Secret. Requires @@ -729,15 +731,14 @@ comes from `GIT_TOKEN`/`GITHUB_TOKEN` or the local `gh` CLI. | # | Beat | What the audience sees | |---|------|------------------------| -| 1 | Cold | A Task with `provider: substrate` + `reusePolicy: session` + `sessionRef.create: true`. A fresh gVisor Actor; a real `gpt-5.5` agent clones the repo, edits a file, stops. Orka pushes the branch; the demo opens a real PR. `status.executionWorkspace.provider == substrate`. | -| 2 | PR | The demo opens the pull request via `gh` (the agent edited only — clean exit; Orka pushed). The real PR URL appears. | -| 3 | Warm | A second Task, same `sessionRef` (`create: false`). Reattaches the retained workspace: `status.executionWorkspace.reused == true` — repo already cloned, no cold start. A follow-up commit lands on the same PR. | +| 1 | Cold | Archived prototype: a fresh gVisor Actor hosted the agent. A revived ACP v2 version must start from a sanitized source artifact and leave publication to the Workspace/Publisher. | +| 2 | PR | Future v2 version requires an independently verified publisher receipt and Orka-owned PR reconciliation. | +| 3 | Warm | Future v2 version must resume one fenced RuntimeSession without replaying a prompt or bypassing validation/publication barriers. | -**Clean-exit contract (load-bearing).** The agent **edits files only** and -stops; Orka's `pushBranch` pushes the branch; the **demo script** opens/updates -the PR via `gh`. If the agent runs post-edit commands itself (git status, a PR -curl), a nonzero one makes the codex CLI exit 1 even though the work succeeded — -so the prompt forbids it. +**Clean-exit contract (load-bearing).** The ACP child edits files only. A +revived demo must freeze and validate the RuntimeSession, upload a durable delta, +and require the separate Workspace/Publisher to prepare, publish, verify, and +reconcile the PR. Runtime-local Git state is never publication authority. **gVisor contract (load-bearing).** The Task sets `ORKA_CODEX_DISABLE_SANDBOX=true`. Codex's inner bubblewrap sandbox cannot nest @@ -870,18 +871,18 @@ spec: - name: file_write - name: code_exec systemPrompt: | - You implement changes proposed by the scout. You read - /workspace/scout-report.md, apply the changes, run tests via - code_exec, and use the in-workspace git CLI (cloned and authenticated - by the agent runtime) to push branches and open pull requests - against sozercan/vekil. + You implement changes proposed by the scout. Read the supplied report, + edit the verified workspace, and run focused tests. Do not commit, alter + Git configuration/remotes, push, or create a pull request; Orka owns + clean-room publication. ``` **Tool-name caveat.** Built-in Orka tools verified against `internal/tools/common_constants.go` and `workers/ai/main_test.go`: `file_read`, `file_write`, `code_exec`, `web_search`, `web_fetch` are -real. There is *no* `open_pr` built-in tool — PR creation is done by the -agent runtime using `git` + `gh` (or the GitHub HTTP API) from inside the +real. There is *no* `open_pr` built-in tool. Under ACP v2, PR creation is an +Orka-owned Workspace/Publisher or governed GitHub-tool operation, never a +runtime-local `git`/forge action inside the sandbox workspace. The scout/builder split is enforced by `file_write` + `code_exec` *presence on builder, absence on scout*, not by an `open_pr` tool. @@ -924,15 +925,11 @@ Prompt files in `hack/demos/prompts/`: issue #77 (Prometheus metrics gap). Profile what's missing. Write your proposal to `/workspace/scout-report.md` with: counter names, where they go, test outline. Do not modify any vekil source."* -- `sandbox-turn-2-builder.txt` — *"Read `/workspace/scout-report.md`. - Implement the counters and tests in the vekil checkout under - `/workspace/vekil`. Push the branch with `git` and open a pull request - against sozercan/vekil (using `gh pr create`) with title 'Add Prometheus - /metrics endpoint (closes #77)'."* -- `sandbox-turn-3-fixup.txt` — *"CI on the open PR flagged that - `metrics_handler_test.go` is missing a test for the `error_total` - counter. Add it. Push as a fixup commit on the same branch. Do not - open a new PR."* +- `sandbox-turn-2-builder.txt` — must be rewritten to edit and test only. + A future ACP v2 demo supplies a verified workspace artifact and asks the + Workspace/Publisher to prepare, publish, verify, and reconcile the PR. +- `sandbox-turn-3-fixup.txt` — must be rewritten as another fenced write Task + against the claimed branch baseline; the ACP child must not commit or publish. ### Substrate manifests (Demo 70) @@ -946,12 +943,13 @@ demo only applies the Orka `Agent` + two `Task`s and opens the PR. All carry `codex`, model `gpt-5.5`. A real model run: `secretRef` (NOT `providerRef` — mutually exclusive with `runtime`) points at a Secret carrying `OPENAI_BASE_URL` (→ the in-cluster vekil proxy) + a placeholder - `OPENAI_API_KEY`. The system prompt tells the agent to edit files only and - stop (Orka pushes; the demo opens the PR). + `OPENAI_API_KEY`. The system prompt tells the agent to edit files only and stop. A revived v2 + demo requires the Workspace/Publisher to prepare, publish, verify, and reconcile the PR. - **Task** (`render_substrate_task `) — agent Task whose `execution.workspace` selects `provider: substrate` with - `templateRef` → `ate-demo/orka-codex-ci`, plus `agentRuntime.workspace` - (`gitRepo`, `branch`, `pushBranch`, `gitSecretRef`) and + `templateRef` → `ate-demo/orka-codex-ci`, plus top-level `spec.workspace` + (`intent`, `gitRepo`, `branch`, `readCredentialRef`, `publicationGitRepo`, + `publicationCredentialRef`, and `pushBranch`) and `env: ORKA_CODEX_DISABLE_SANDBOX=true` (gVisor is the sandbox). `reusePolicy` defaults to `session`; `cleanupPolicy` is `retain` for session tasks so the workspace stays warm. The 3rd arg sets `sessionRef.create`. @@ -1175,8 +1173,9 @@ and by the payoff cards): | 20 | "GitOps workflow" | "Same workflow from YAML. The agent isn't magic — it's a CR." | | 30 | "Scheduled work" | "Recurring AI triage queue — same auditable Task model, just add a `schedule:`." | | 40 | "Security remediation" | "Finding → patch proposal → reviewable PR. No human triage required." | -| 60 | "Warm agent sandboxes" | "One session, two agents, three turns. Scout, Builder, CI fixup — one warm workspace." | -| 70 | "Agent Substrate workspaces" | "A real agent clones, edits, and opens a PR from inside a gVisor sandbox — then reuses the warm workspace with no cold start." | +| 50 | "Kontxt transaction tokens" | "Zero-secret caller, one-shot transaction token, sealed Kubernetes provenance." | +| 60 | "Warm agent sandboxes" | "Archived until agent-sandbox hosts an ACP v2 RuntimeSession without weakening workspace governance." | +| 70 | "Agent Substrate workspaces" | "Archived until an Actor-backed ACP v2 supervisor and clean-room publication path are implemented." | --- @@ -1230,12 +1229,10 @@ writing the relevant render functions: as a placeholder. The implementer must inspect what `00-preflight.sh` applies (or what `cluster/cluster-up.sh` installs) and use that exact name in the scout/builder Agent specs. -- **`gh` is preinstalled in the sandbox image.** The builder prompt - assumes `gh pr create` works inside the sandbox. Phase 1's - `cluster/templates/orka-live-template.yaml` is the implementer's - source of truth — make sure the template's image bundles `git` + `gh` - + a writable workspace. If not, either add them to the template image - or swap the builder prompt to use the GitHub REST API (`curl + jq`). +- **SCM publication is outside the sandbox.** A future template must not + depend on Git publication tools or credentials inside the ACP process tree. + The Workspace/Publisher owns clone credentials, deterministic commit creation, + exact-ref publication, independent verification, and PR reconciliation. - **Sandbox claim name shape.** §7.5's `payoff_card_sandbox` extracts the claim name from `completed in sandbox workspace ` — that line is verified, but the literal claim *name* (`orka-vekil-metrics-77` in the @@ -1265,8 +1262,8 @@ writing the relevant render functions: are needed. - `install-agent-sandbox.sh` — installs the upstream `agent-sandbox` operator via its published manifests, then applies - `cluster/templates/orka-live-template.yaml` (a `SandboxTemplate` - containing the agent CLI runtime image + git/gh + workspace dirs). + `cluster/templates/orka-live-template.yaml` (an archived `SandboxTemplate`; + replace it with an ACP v2 supervisor image before reviving the demo). - `cluster-down.sh` — `kind delete cluster --name orka-demo`. - [ ] Makefile — add `demo-record-%`, `demo-record-hero`, `demo-record-all`, `demo-diff`, `demo-images`, **`demo-cluster-up`**, **`demo-cluster-down`** diff --git a/hack/demos/cluster/cluster-down.sh b/hack/demos/cluster/cluster-down.sh index 46918985c..7e133c4a5 100755 --- a/hack/demos/cluster/cluster-down.sh +++ b/hack/demos/cluster/cluster-down.sh @@ -4,6 +4,10 @@ set -Eeuo pipefail cluster_name="${ORKA_DEMO_CLUSTER:-orka-demo}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../../.." && pwd)" +# shellcheck source=scripts/lib/kind-local-registry.sh +. "${repo_root}/scripts/lib/kind-local-registry.sh" if kind get clusters | grep -qx "${cluster_name}"; then printf '==> Deleting kind cluster %s\n' "${cluster_name}" >&2 @@ -11,3 +15,5 @@ if kind get clusters | grep -qx "${cluster_name}"; then else printf '==> kind cluster %s not found; nothing to do\n' "${cluster_name}" >&2 fi + +orka_kind_registry_stop "${cluster_name}" diff --git a/hack/demos/cluster/cluster-up.sh b/hack/demos/cluster/cluster-up.sh index 826727373..619baf500 100755 --- a/hack/demos/cluster/cluster-up.sh +++ b/hack/demos/cluster/cluster-up.sh @@ -9,11 +9,14 @@ set -Eeuo pipefail cluster_name="${ORKA_DEMO_CLUSTER:-orka-demo}" img="${ORKA_DEMO_IMAGE:-orka-demo:dev}" +publisher_img="${ORKA_DEMO_PUBLISHER_IMAGE:-orka-workspace-publisher:demo}" namespace="${ORKA_NAMESPACE:-orka-system}" demo_namespace="${DEMO_NAMESPACE:-demo-magic}" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/../../.." && pwd)" +# shellcheck source=scripts/lib/kind-local-registry.sh +. "${repo_root}/scripts/lib/kind-local-registry.sh" log() { printf '==> %s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } @@ -21,6 +24,9 @@ die() { printf 'error: %s\n' "$*" >&2; exit 1; } command -v kind >/dev/null 2>&1 || die "missing required command: kind" command -v docker >/dev/null 2>&1 || die "missing required command: docker" command -v kubectl >/dev/null 2>&1 || die "missing required command: kubectl" +command -v curl >/dev/null 2>&1 || die "missing required command: curl" +command -v jq >/dev/null 2>&1 || die "missing required command: jq" +[[ "${namespace}" == "orka-system" ]] || die "demo cluster currently requires ORKA_NAMESPACE=orka-system" if kind get clusters | grep -qx "${cluster_name}"; then log "kind cluster ${cluster_name} already exists; reusing" @@ -32,30 +38,35 @@ fi log "Selecting kubectl context kind-${cluster_name}" kubectl config use-context "kind-${cluster_name}" >/dev/null +orka_kind_registry_start "${cluster_name}" + log "Building controller image ${img}" (cd "${repo_root}" && make docker-build IMG="${img}") +log "Building workspace publisher image ${publisher_img}" +(cd "${repo_root}" && make docker-build-workspace-publisher WORKSPACE_PUBLISHER_IMG="${publisher_img}") log "Loading ${img} into kind/${cluster_name}" kind load docker-image "${img}" --name "${cluster_name}" +manager_ref="$(orka_kind_registry_push "${img}" "orka/controller")" +publisher_ref="$(orka_kind_registry_push "${publisher_img}" "orka/workspace-publisher")" -log "Ensuring namespace ${namespace} and ${demo_namespace}" +log "Ensuring namespaces ${namespace}, ${demo_namespace}, and vekil-system" kubectl create namespace "${namespace}" --dry-run=client -o yaml | kubectl apply -f - kubectl create namespace "${demo_namespace}" --dry-run=client -o yaml | kubectl apply -f - +kubectl create namespace vekil-system --dry-run=client -o yaml | kubectl apply -f - -log "Deploying Orka (namespace ${namespace}, image ${img})" -if [[ "${namespace}" == "orka-system" ]]; then - (cd "${repo_root}" && make deploy IMG="${img}") -else - (cd "${repo_root}" && make manifests kustomize) - tmp_config="$(mktemp -d)" - cp -R "${repo_root}/config" "${tmp_config}/config" - (cd "${tmp_config}/config/manager" && "${repo_root}/bin/kustomize" edit set image controller="${img}") - perl -0pi -e "s#--controller-url=http://orka-api\.orka-system\.svc:8080#--controller-url=http://orka-api.${namespace}.svc:8080#g" \ - "${tmp_config}/config/manager/manager.yaml" - (cd "${tmp_config}/config/default" && "${repo_root}/bin/kustomize" edit set namespace "${namespace}") - "${repo_root}/bin/kustomize" build "${tmp_config}/config/default" | kubectl apply -f - - rm -rf "${tmp_config}" -fi +log "Installing ACP v2 CRDs" +(cd "${repo_root}" && make install) + +log "Deploying Orka (namespace ${namespace}, image ${manager_ref})" +placeholder_digest="sha256:$(printf '0%.0s' {1..64})" +(cd "${repo_root}" && make deploy \ + IMG="${manager_ref}" \ + WORKSPACE_PUBLISHER_IMG="${publisher_ref}" \ + ACP_CODEX_RUNTIME_IMG="example.invalid/orka/acp-codex@${placeholder_digest}" \ + ACP_CLAUDE_RUNTIME_IMG="example.invalid/orka/acp-claude@${placeholder_digest}" \ + ACP_COPILOT_RUNTIME_IMG="example.invalid/orka/acp-copilot@${placeholder_digest}" \ + ACP_OPENCODE_RUNTIME_IMG="example.invalid/orka/acp-opencode@${placeholder_digest}") log "Waiting for orka-controller-manager rollout" kubectl -n "${namespace}" rollout status deployment/orka-controller-manager --timeout=300s diff --git a/hack/demos/cluster/install-demo-model.sh b/hack/demos/cluster/install-demo-model.sh index 9464f346a..b8f294af2 100755 --- a/hack/demos/cluster/install-demo-model.sh +++ b/hack/demos/cluster/install-demo-model.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Provision the model Provider + secrets the model-backed demos (10/20/30/40) -# need, pointing them at the in-cluster vekil proxy. The workspace demos -# (50/60/70) bring their own model wiring; this script covers the SDLC demos. +# need, pointing them at the in-cluster vekil proxy. This script covers the +# active SDLC demos only; archived execution-workspace demos 60/70 are not +# configured here. # # What it creates in the demo namespace (DEMO_NAMESPACE, default demo-magic): # - a Provider CR (DEMO_PROVIDER_REF) used by the type: ai coordinator in @@ -9,8 +10,8 @@ # (demo 10 requires Opus). The provider api-key is a placeholder; vekil # holds the real Copilot session. # - the provider api-key Secret (DEMO_PROVIDER_SECRET_REF). -# - the runtime Secret (DEMO_RUNTIME_SECRET_REF) for the CLI agents: -# OPENAI_BASE_URL -> vekil /v1 + placeholder OPENAI_API_KEY (=> codex). +# - the ACP provider credential Secret (DEMO_RUNTIME_SECRET_REF): +# OPENAI_BASE_URL -> vekil /v1 + placeholder OPENAI_API_KEY (=> Codex). # - a git Secret (DEMO_GIT_SECRET_REF) with username/password (PR demos) AND # a token key (demo 30 reads GH_TOKEN from the 'token' key). Token from # GIT_TOKEN/GITHUB_TOKEN or the local gh CLI; never printed. @@ -39,8 +40,6 @@ command -v jq >/dev/null 2>&1 || die "missing required command: jq" orka_namespace="${ORKA_NAMESPACE:-orka-system}" controller_deployment="${ORKA_CONTROLLER_DEPLOYMENT:-orka-controller-manager}" -harness_wrapper_deployment="${ORKA_HARNESS_WRAPPER_DEPLOYMENT:-orka-agent-harness-wrapper}" -codex_image="${DEMO_HARNESS_WRAPPER_IMAGE:-localhost:${KIND_REGISTRY_PORT:-5001}/orka/agent-harness-wrapper:demo}" ai_image="${DEMO_AI_WORKER_IMAGE:-localhost:${KIND_REGISTRY_PORT:-5001}/orka/ai-worker:demo}" general_image="${DEMO_GENERAL_WORKER_IMAGE:-localhost:${KIND_REGISTRY_PORT:-5001}/orka/general-worker:demo}" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" @@ -56,7 +55,6 @@ require_image_source() { die "${label} build is disabled but ${image_var} is not set to an existing image" fi } -require_image_source "harness wrapper" "${DEMO_BUILD_CODEX_IMAGE:-1}" DEMO_HARNESS_WRAPPER_IMAGE require_image_source "AI worker" "${DEMO_BUILD_AI_IMAGE:-1}" DEMO_AI_WORKER_IMAGE require_image_source "general worker" "${DEMO_BUILD_GENERAL_IMAGE:-1}" DEMO_GENERAL_WORKER_IMAGE if [[ "${needs_docker}" == "1" ]]; then @@ -110,8 +108,8 @@ spec: defaultModel: ${provider_model} YAML -# --- Runtime Secret (CLI agents: codex via vekil /v1) ----------------------- -log "Creating runtime Secret ${demo_namespace}/${runtime_secret} (endpoint -> vekil)" +# --- ACP provider credential Secret (Codex via vekil /v1) ------------------ +log "Creating ACP provider credential Secret ${demo_namespace}/${runtime_secret} (endpoint -> vekil)" kubectl -n "${demo_namespace}" create secret generic "${runtime_secret}" \ --from-literal=OPENAI_BASE_URL="${vekil_url}" \ --from-literal=OPENAI_API_KEY=proxy-placeholder \ @@ -135,20 +133,14 @@ else log " kubectl -n ${demo_namespace} create secret generic ${git_secret} --from-literal=username=oauth2 --from-literal=password= --from-literal=token=" fi -# --- Git-capable codex worker image ----------------------------------------- -# The model-backed demos (10/20/30/40) run the agent directly in the worker pod -# (no sandbox/substrate workspace), so the worker image itself must contain git -# to clone the repo. The Substrate e2e deploys Orka with the STRIPPED codex -# image (workers/harness/Dockerfile = distroless, NO git), -# which fails these demos with "git: executable not found". Build the PRODUCTION -# codex image (workers/harness/Dockerfile has git + codex) and repoint the -# harness wrapper deployment at it. +# Built-in coding-agent runtimes are controller-owned, digest-pinned ACP +# RuntimePools. This demo helper intentionally does not build, repoint, or set +# process-wide sandbox environment on those runtimes. Install Orka with the +# desired immutable Codex/Claude/Copilot runtime images before running agent demos. + # build_and_repoint_worker