From 91165e31b9a3dcb9358d85908d08c38ec75d8a98 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 18 Apr 2026 13:53:51 +0000 Subject: [PATCH] [infra/beads-server] Wire BeadBoard to claude-agent-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context BeadBoard is the Next.js task visualization dashboard shipped in this stack. We want users to trigger headless Claude agent runs directly from a beads task row — "one-click dispatch" — instead of copy-pasting `bd` IDs into a terminal. The agent runs in-cluster as claude-agent-service (see stacks/claude-agent-service/), protected by a bearer token in Vault at secret/claude-agent-service/api_bearer_token. For BeadBoard to POST to /execute we need the service URL and the bearer token available inside the pod as env vars. The URL is static (cluster DNS); the token must come through External Secrets Operator so rotation in Vault propagates without re-applying Terraform. Secondary cleanup: the container was still pinned to :latest which violates the 8-char-SHA convention and causes stale pulls through the registry cache (see .claude/CLAUDE.md, Docker images). The image tag is now variable-driven; the GHA pipeline will override the default once it publishes the first SHA. ## This change - Adds an ExternalSecret `beadboard-agent-service` in the `beads-server` namespace, mirroring the pattern in stacks/claude-agent-service/main.tf (same Vault path `secret/claude-agent-service`, same `vault-kv` ClusterSecretStore, same 15m refresh). Exposes exactly one key: `api_bearer_token`. - Adds two env vars to the `beadboard` container: - `CLAUDE_AGENT_SERVICE_URL` — static cluster URL (`http://claude-agent-service.claude-agent.svc.cluster.local:8080`) - `CLAUDE_AGENT_BEARER_TOKEN` — `secret_key_ref` pointing at the ESO-managed Secret, key `api_bearer_token` - Adds `reloader.stakater.com/auto = "true"` on the Deployment's top-level metadata — matches the convention used by rybbit, claude-memory, onlyoffice. When ESO refreshes the K8s Secret because Vault rotated the token, Reloader restarts the pod so the new token is picked up (env vars are read once at boot). - Adds `variable "beadboard_image_tag"` (default `"latest"`, with a one-line comment flagging the temporary default). The image reference now interpolates `${var.beadboard_image_tag}`. No tfvars file is touched — orchestrator will flip the default to the first real 8-char SHA once GHA publishes it. ## What is NOT in this change - No GHA workflow additions. The pipeline that builds `registry.viktorbarzin.me:5050/beadboard` lives in the BeadBoard repo and is out of scope here. - No Vault-side changes. `secret/claude-agent-service/api_bearer_token` already exists (it powers the claude-agent-service deployment itself). - No Terraform `apply`. Orchestrator applies. ## Data flow Vault (secret/claude-agent-service) │ refresh every 15m ▼ ESO → K8s Secret `beadboard-agent-service` (beads-server ns) │ envFrom.secretKeyRef ▼ BeadBoard pod (CLAUDE_AGENT_BEARER_TOKEN env) │ Authorization: Bearer ▼ claude-agent-service.claude-agent.svc:8080 /execute On Vault rotation: ESO picks up new value at next refresh → K8s Secret data changes → Reloader sees annotation + referenced Secret changed → rolling-recreates the beadboard pod with the new token. ## Test Plan ### Automated - `terraform fmt -recursive stacks/beads-server/` — clean (formatted the file once; subsequent run is a no-op). - `terraform -chdir=stacks/beads-server validate` (after `terraform init -backend=false`) — `Success! The configuration is valid`. The 14 "Deprecated Resource" warnings are pre-existing (`kubernetes_namespace` vs `_v1` etc.) and unrelated to this change. ### Manual Verification 1. Orchestrator applies: `scripts/tg -chdir=stacks/beads-server apply` 2. Verify the ExternalSecret synced: `kubectl -n beads-server get externalsecret beadboard-agent-service` Expected: `Ready=True`, `SyncedAt` recent. 3. Verify the K8s Secret exists with one key: `kubectl -n beads-server get secret beadboard-agent-service -o jsonpath='{.data.api_bearer_token}' | base64 -d | head -c 8` Expected: first 8 chars of the bearer token. 4. Verify the deployment picked up the env vars: `kubectl -n beads-server get deploy beadboard -o yaml | grep -A2 CLAUDE_AGENT` Expected: both env entries present, bearer via `secretKeyRef`. 5. Verify the reloader annotation is on the Deployment metadata: `kubectl -n beads-server get deploy beadboard -o jsonpath='{.metadata.annotations.reloader\.stakater\.com/auto}'` Expected: `true`. 6. Verify the image tag resolved to the variable default (for now): `kubectl -n beads-server get deploy beadboard -o jsonpath='{.spec.template.spec.containers[0].image}'` Expected: `registry.viktorbarzin.me:5050/beadboard:latest` (will become `...:` once `beadboard_image_tag` default is updated). 7. Smoke-test the env var inside the pod: `kubectl -n beads-server exec deploy/beadboard -- sh -c 'printenv CLAUDE_AGENT_SERVICE_URL; printenv CLAUDE_AGENT_BEARER_TOKEN | head -c 8'` Expected: URL printed, first 8 chars of token printed. Co-Authored-By: Claude Opus 4.7 (1M context) --- stacks/beads-server/main.tf | 86 ++++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/stacks/beads-server/main.tf b/stacks/beads-server/main.tf index bd3ce735..b8af8c66 100644 --- a/stacks/beads-server/main.tf +++ b/stacks/beads-server/main.tf @@ -3,6 +3,12 @@ variable "tls_secret_name" { sensitive = true } +# Temporary default until GHA pipeline publishes the first 8-char SHA tag. +variable "beadboard_image_tag" { + type = string + default = "latest" +} + resource "kubernetes_namespace" "beads" { metadata { name = "beads-server" @@ -386,13 +392,13 @@ module "tls_secret" { } module "ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "proxied" - namespace = kubernetes_namespace.beads.metadata[0].name - name = "dolt-workbench" - tls_secret_name = var.tls_secret_name - protected = false - exclude_crowdsec = true + source = "../../modules/kubernetes/ingress_factory" + dns_type = "proxied" + namespace = kubernetes_namespace.beads.metadata[0].name + name = "dolt-workbench" + tls_secret_name = var.tls_secret_name + protected = false + exclude_crowdsec = true extra_annotations = { "gethomepage.dev/enabled" = "true" "gethomepage.dev/name" = "Dolt Workbench" @@ -463,6 +469,38 @@ resource "kubernetes_config_map" "beadboard_config" { } } +# Pulls the claude-agent-service bearer token from Vault so BeadBoard can +# dispatch agent jobs via the in-cluster HTTP API. +resource "kubernetes_manifest" "beadboard_agent_service_secret" { + manifest = { + apiVersion = "external-secrets.io/v1beta1" + kind = "ExternalSecret" + metadata = { + name = "beadboard-agent-service" + namespace = kubernetes_namespace.beads.metadata[0].name + } + spec = { + refreshInterval = "15m" + secretStoreRef = { + name = "vault-kv" + kind = "ClusterSecretStore" + } + target = { + name = "beadboard-agent-service" + } + data = [ + { + secretKey = "api_bearer_token" + remoteRef = { + key = "claude-agent-service" + property = "api_bearer_token" + } + }, + ] + } + } +} + resource "kubernetes_deployment" "beadboard" { metadata { name = "beadboard" @@ -471,6 +509,9 @@ resource "kubernetes_deployment" "beadboard" { app = "beadboard" tier = local.tiers.aux } + annotations = { + "reloader.stakater.com/auto" = "true" + } } spec { replicas = 1 @@ -507,13 +548,28 @@ resource "kubernetes_deployment" "beadboard" { container { name = "beadboard" - image = "registry.viktorbarzin.me:5050/beadboard:latest" + image = "registry.viktorbarzin.me:5050/beadboard:${var.beadboard_image_tag}" port { name = "http" container_port = 3000 } + env { + name = "CLAUDE_AGENT_SERVICE_URL" + value = "http://claude-agent-service.claude-agent.svc.cluster.local:8080" + } + + env { + name = "CLAUDE_AGENT_BEARER_TOKEN" + value_from { + secret_key_ref { + name = "beadboard-agent-service" + key = "api_bearer_token" + } + } + } + volume_mount { name = "beads-writable" mount_path = "/app/.beads" @@ -596,13 +652,13 @@ resource "kubernetes_service" "beadboard" { } module "beadboard_ingress" { - source = "../../modules/kubernetes/ingress_factory" - dns_type = "proxied" - namespace = kubernetes_namespace.beads.metadata[0].name - name = "beadboard" - tls_secret_name = var.tls_secret_name - protected = true - exclude_crowdsec = true + source = "../../modules/kubernetes/ingress_factory" + dns_type = "proxied" + namespace = kubernetes_namespace.beads.metadata[0].name + name = "beadboard" + tls_secret_name = var.tls_secret_name + protected = true + exclude_crowdsec = true extra_annotations = { "gethomepage.dev/enabled" = "true" "gethomepage.dev/name" = "BeadBoard"