diff --git a/.github/workflows/helm-validate.yml b/.github/workflows/helm-validate.yml index 1c78209..1141ecf 100644 --- a/.github/workflows/helm-validate.yml +++ b/.github/workflows/helm-validate.yml @@ -28,6 +28,8 @@ jobs: run: | helm lint deploy/helm/fuzeagent helm lint deploy/helm/fuzeagent -f deploy/helm/fuzeagent/values-prod.yaml + helm lint deploy/helm/a2a-shared + helm lint deploy/helm/a2a-shared -f deploy/helm/a2a-shared/values-prod.yaml - name: Install kubeconform run: | @@ -41,8 +43,16 @@ jobs: # -ignore-missing-schemas: Traefik / SealedSecret CRDs have no published # JSON schema; matches FuzeInfra's helm-validate + deploy-prod convention. for overlay in values.yaml values-prod.yaml; do - echo "::group::$overlay" + echo "::group::fuzeagent $overlay" helm template fuzeagent deploy/helm/fuzeagent -f "deploy/helm/fuzeagent/$overlay" \ | kubeconform -strict -summary -kubernetes-version 1.29.0 -ignore-missing-schemas echo "::endgroup::" done + # a2a-shared ships enabled:false (default/prod render nothing); the ci/ + # overlay exercises the enabled Deployment/Service/ConfigMap/Ingress path. + for overlay in values.yaml values-prod.yaml ci/enabled-values.yaml; do + echo "::group::a2a-shared $overlay" + helm template a2a-shared deploy/helm/a2a-shared -f "deploy/helm/a2a-shared/$overlay" \ + | kubeconform -strict -summary -kubernetes-version 1.29.0 -ignore-missing-schemas + echo "::endgroup::" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8574496..b18c55d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,9 @@ on: - 'hierarchy_endpoints.py' - 'requirements-hierarchy.txt' - 'deploy/helm/fuzeagent/**' + - 'deploy/helm/a2a-shared/**' + - 'agent-templates/a2a/**' + - 'agent-templates/contracts/a2a/**' - 'containers/**' - '.github/workflows/release.yml' @@ -97,6 +100,22 @@ jobs: cache-from: type=gha,scope=mcp cache-to: type=gha,mode=max,scope=mcp + - name: Build & push a2a-shared + # The ONE shared A2A server. Context = agent-templates/ (mirrors handoff_mcp); + # packages a2a/*.py + the frozen contract client. Chart ships enabled:false + # until the server + providesTo backfill land, so this image is tagged but + # not run in prod yet. + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: agent-templates + file: agent-templates/a2a/Dockerfile + push: true + tags: | + ghcr.io/izzywdev/fuzeagent-a2a:${{ steps.tag.outputs.sha }} + ghcr.io/izzywdev/fuzeagent-a2a:latest + cache-from: type=gha,scope=a2a + cache-to: type=gha,mode=max,scope=a2a + - name: Bump image tags in values-prod.yaml (GitOps) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -106,10 +125,12 @@ jobs: # Rewrites the four app-image `tag:` lines. Datastore images are single # `image:` strings (no tag: key), so they are untouched. sed -i -E "s/^(\s*tag:).*/\1 ${SHA}/" deploy/helm/fuzeagent/values-prod.yaml + # a2a-shared overlay has exactly one `tag:` key (the a2a image) — same rewrite. + sed -i -E "s/^(\s*tag:).*/\1 ${SHA}/" deploy/helm/a2a-shared/values-prod.yaml git config user.email "github-actions[bot]@users.noreply.github.com" git config user.name "github-actions[bot]" git checkout -b "${BRANCH}" - git add deploy/helm/fuzeagent/values-prod.yaml + git add deploy/helm/fuzeagent/values-prod.yaml deploy/helm/a2a-shared/values-prod.yaml git commit -m "release: fuzeagent images ${SHA} [skip ci]" || exit 0 git push origin "${BRANCH}" # Bot (GITHUB_TOKEN) creates PR; owner (GH_APPROVE_TOKEN) approves then admin-merges diff --git a/agent-templates/a2a/Dockerfile b/agent-templates/a2a/Dockerfile new file mode 100644 index 0000000..7a5b826 --- /dev/null +++ b/agent-templates/a2a/Dockerfile @@ -0,0 +1,61 @@ +# Shared A2A server image (the ONE server that fronts every tenant). +# +# Build context = agent-templates/ (mirrors orchestration/handoff_mcp/Dockerfile): +# docker build -f a2a/Dockerfile -t ghcr.io/izzywdev/fuzeagent-a2a:dev . # ctx = agent-templates/ +# +# Runtime surface is fixed by the FROZEN contract (agent-templates/contracts/a2a/v1): +# POST /rpc (JSON-RPC 2.0, binding.md §1) +# GET /.well-known/agent-card.json (unauthenticated card discovery) +# on the port from the values file (service.port, 8080). Identity/authZ are enforced +# callee-side (authz.md) from the OIDC bearer — never the request body. +# +# Entrypoint is agent-templates/a2a/runtime.py:main() -> `python -m a2a.runtime`, which +# build_from_env() composes from: +# A2A_VALUES_FILE JSON of the values-interface `a2a` block (mounted ConfigMap) +# A2A_REPOS_DIR tenant repo checkouts the LocalRepoResolver reads (/repos) +# AGENT_PROVIDER Managed-Agents provider id (default anthropic) +# HOST bind address (chart sets 0.0.0.0; server defaults to loopback) +# runtime imports `providers` (top-level) and the anthropic provider delegates to the +# `sync/` modules, so both packages are vendored below. +FROM python:3.12-slim + +WORKDIR /app + +# Runtime deps (server-owned set + `cryptography` for JWKS RS256/ES256 verification). +COPY a2a/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Typed wire/card models from the frozen contract — a2a/_contract.py imports +# `fuze_a2a_client` so request/response shapes cannot drift from the spec. +COPY contracts/a2a/v1/client/ /app/contract-client/ +RUN pip install --no-cache-dir /app/contract-client + +# The shared A2A server + its runtime deps within the templates tree: +# a2a/ the server itself (python -m a2a.runtime) +# providers/ `from providers import get_provider` (Managed-Agents binding) +# sync/ common/driver/role_loader the anthropic provider delegates to +COPY a2a/ /app/a2a/ +COPY providers/ /app/providers/ +COPY sync/ /app/sync/ + +# Run as a non-root user (defence in depth; also satisfies a runAsNonRoot +# PodSecurity policy). Code is read-only; config at /config, repo checkouts at /repos. +RUN useradd --system --uid 10001 --home-dir /app --shell /usr/sbin/nologin a2a \ + && chown -R a2a:a2a /app +USER 10001 + +# Only vars runtime.py / the provider actually read. HOST is set explicitly so the +# in-cluster Service can reach the pod (server defaults to loopback, CWE-605-safe). +# The listen PORT is NOT an env var — the server reads it from the values file +# (service.port), so image and chart stay consistent via one source. +ENV HOST=0.0.0.0 \ + A2A_VALUES_FILE=/config/values.json \ + A2A_REPOS_DIR=/repos \ + AGENT_PROVIDER=anthropic \ + FUZE_STATE_DIR=/state \ + PYTHONPATH=/app \ + PYTHONUNBUFFERED=1 + +EXPOSE 8080 + +CMD ["python", "-m", "a2a.runtime"] diff --git a/agent-templates/a2a/requirements.txt b/agent-templates/a2a/requirements.txt index 11f69f5..5830589 100644 --- a/agent-templates/a2a/requirements.txt +++ b/agent-templates/a2a/requirements.txt @@ -8,3 +8,8 @@ uvicorn>=0.30 PyJWT>=2.8 # Card schema/profile validation. jsonschema>=4.0 +# Asymmetric verification backend for PyJWT — runtime._build_verifier decodes RS256/ES256 +# JWKS-signed OIDC tokens, which PyJWT can only do with `cryptography` present. Added by +# devops for the deployed image (unit tests never construct the verifier, so main's +# server-owned deps omit it). See PR #85. +cryptography>=42 diff --git a/deploy/argocd/applications/a2a-shared.yaml b/deploy/argocd/applications/a2a-shared.yaml new file mode 100644 index 0000000..dc4e452 --- /dev/null +++ b/deploy/argocd/applications/a2a-shared.yaml @@ -0,0 +1,31 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: a2a-shared + namespace: argocd +spec: + # The ONE shared A2A server, lifecycled independently of the fuzeagent app + # (hybrid-Argo: one Application per independently-versioned workload). Uses the + # `fuzeagent` AppProject (authored in FuzeInfra) — this repo must NOT self-create + # a project granting the namespace. Auto-discovered by app-of-apps (recurse). + project: fuzeagent + source: + repoURL: https://github.com/izzywdev/FuzeAgent.git + targetRevision: main + path: deploy/helm/a2a-shared + helm: + valueFiles: + - values-prod.yaml + destination: + server: https://kubernetes.default.svc + # binding.md/card-projection.md §2 hardcode a2a-shared.fuzeagent.svc — the + # Service MUST live in the `fuzeagent` namespace. + namespace: fuzeagent + syncPolicy: + # Ships enabled:false → renders nothing until go-live (see chart README). + automated: + prune: true # stateless workload — safe to prune + selfHeal: true # prod is GitOps; out-of-band kubectl changes are reverted + syncOptions: + - CreateNamespace=true + - PrunePropagationPolicy=foreground diff --git a/deploy/helm/a2a-shared/Chart.yaml b/deploy/helm/a2a-shared/Chart.yaml new file mode 100644 index 0000000..616d73a --- /dev/null +++ b/deploy/helm/a2a-shared/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: a2a-shared +description: >- + The ONE shared A2A (agent-to-agent) server for the FuzeOne family. A single + Deployment/Service fronts every product and exec role; onboarding a repo is a + `a2a.tenants[]` entry (DATA), never a new chart or pod. Values honour the frozen + contract interface agent-templates/contracts/a2a/v1/schema/values-interface.schema.json. + Deployed onto the shared FuzeInfra Contabo k3s cluster (namespace `fuzeagent`) + via GitOps (Argo CD), in-cluster HTTP + Cloudflare-tunnel-only external surface. +type: application +version: 0.1.0 +# A2A protocol version served (binding.md — frozen for contract v1). +appVersion: "1.0" diff --git a/deploy/helm/a2a-shared/README.md b/deploy/helm/a2a-shared/README.md new file mode 100644 index 0000000..488d6fc --- /dev/null +++ b/deploy/helm/a2a-shared/README.md @@ -0,0 +1,75 @@ +# a2a-shared — the ONE shared A2A server (deploy runbook) + +A single Deployment/Service (`a2a-shared` in namespace `fuzeagent`) fronts **every** +tenant. Onboarding a repo is an `a2a.tenants[]` entry — **DATA, never a new chart or +pod**. The operator surface is frozen by the contract: +`agent-templates/contracts/a2a/v1/schema/values-interface.schema.json` +(`binding.md`, `card-projection.md`, `authz.md`). This chart implements exactly that +surface; deploy mechanics (`deploy.*`) are kept out of the `a2a` block so it stays +byte-conformant to the interface. + +- **In-cluster (default):** `http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc` + and `…/​.well-known/agent-card.json`. HTTP; identity is the OIDC bearer, never network + position (`authz.md §2`). +- **External (opt-in per tenant `external: true`):** `https://a2a..prod.fuzefront.com/rpc` + through the Cloudflare tunnel → Traefik (ClusterIP). Exec-tier tenants MUST be + `external: false` (`card-projection.md §5`). No LoadBalancer/NodePort surface. + +**Prod is GitOps** (Argo CD `a2a-shared` Application, `selfHeal: true`). Never +`kubectl apply/patch/edit` — change values in git and let Argo reconcile. + +## Ships DISABLED — go-live preconditions (out of this chart's scope) + +`values-prod.yaml` sets `a2a.enabled: false`, so the Argo app renders nothing until: + +1. **Server image exists** — `ghcr.io/izzywdev/fuzeagent-a2a` is built by `release.yml` + from `agent-templates/a2a/` (backend-engineer). The tag is auto-bumped in + `values-prod.yaml` on merge. +2. **`providesTo` backfill** — `authz.md §3` is fail-closed (absent `providesTo` == DENY). + Backfilling it on every served repo's manifest is a **precondition**, not a follow-up. +3. **Card-signing SealedSecret** — the Fuze profile requires non-empty `signatures[]` + (`card-projection.md §6`). + +## Go-live (single GitOps PR, human-gated) + +1. Provision the SealedSecrets in `deploy/contabo/sealed/` (synced by `fuzeagent-sealed`): + - `a2a-provider-anthropic` (key `api-key`) — Managed-Agents key, exported as + `ANTHROPIC_API_KEY` for session provisioning; set `deploy.providerApiKeySecretRef`. + - `a2a-repos-git` (key `token`) — token for cloning PRIVATE tenant repos in the + repo-sync init container; set `deploy.reposGitTokenSecretRef`. Omit for public repos. + - `a2a-mtls-ca` (key `ca.crt`) if in-cluster mTLS is enabled. + - Card-signing: the server reads `cardSigning.keyId` from the values doc; the JWS + signer injection is still a server-side TODO ("production injects a real JWS signer" + in `card_generator.py`), so no signing-key env is wired yet. + Seal with `scripts/seal-secret.sh` (same flow as handoff-mcp). +2. Provide the Managed-Agents id-state (agent/vault/memory/environment ids) as a ConfigMap + mounted at `FUZE_STATE_DIR=/state` — same mechanism as handoff-mcp — and set + `deploy.stateConfigMap`. +3. In `values-prod.yaml`: set `a2a.enabled: true`, uncomment `auth` (real family OIDC + issuer) + `cardSigning`, and add the `tenants[]` (only repos whose `providesTo` is + backfilled). Keep the single `tag:` line — `release.yml` owns it. +4. Merge → Argo syncs the Deployment/Service (+ the `values.json` ConfigMap the server + reads via `A2A_VALUES_FILE`, the repo-sync init container, per-external-tenant Ingress). + +### Runtime shape (matches the merged server, `agent-templates/a2a/`) +- Entrypoint `python -m a2a.runtime` → `build_from_env()` reads `A2A_VALUES_FILE` + (the `a2a` block as JSON), `A2A_REPOS_DIR=/repos`, `AGENT_PROVIDER`, `HOST`. +- The image vendors `a2a/` + `providers/` + `sync/` (runtime imports `providers`, whose + anthropic adapter delegates to the `sync/` modules) + the frozen `fuze_a2a_client`. +- Per-tenant card projection reads each repo's `.fuze/manifest.json` + `roles/` from + `/repos/`, populated by the `repo-sync` init container at each tenant `ref`. + +## Validate (matches `helm-validate.yml`) + +```bash +helm lint deploy/helm/a2a-shared +helm lint deploy/helm/a2a-shared -f deploy/helm/a2a-shared/values-prod.yaml +# enabled path (default/prod render nothing while gated off): +helm template a2a-shared deploy/helm/a2a-shared -f deploy/helm/a2a-shared/ci/enabled-values.yaml \ + | kubeconform -strict -summary -kubernetes-version 1.29.0 -ignore-missing-schemas +``` + +> Remaining server-side gap (tracked, non-blocking): a real JWS card signer injection +> (`card_generator.py` — "production injects a real JWS signer"). The health/readiness +> probe uses the server's `GET /healthz`. None block validation; the chart renders and +> passes kubeconform, and the image composes the app from the chart's `values.json`. diff --git a/deploy/helm/a2a-shared/ci/enabled-values.yaml b/deploy/helm/a2a-shared/ci/enabled-values.yaml new file mode 100644 index 0000000..34d74d2 --- /dev/null +++ b/deploy/helm/a2a-shared/ci/enabled-values.yaml @@ -0,0 +1,49 @@ +# CI-only overlay: exercises the ENABLED render path so helm lint + kubeconform +# actually validate the Deployment/Service/ConfigMap/Ingress (the shipped overlays +# default enabled:false and would render nothing). Not a deploy overlay. +a2a: + enabled: true + image: + repository: ghcr.io/izzywdev/fuzeagent-a2a + tag: "0.0.0-ci" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 8080 + protocolVersion: "1.0" + auth: + oidcIssuerUrl: https://issuer.example.com/application/o/a2a/ + audience: a2a + callerClaim: sub + mtls: + enabled: true + caSecretRef: { name: a2a-mtls-ca, key: ca.crt } + cardSigning: + keySecretRef: { name: a2a-card-signing, key: jws.key } + keyId: a2a-v1 + tenants: + - tenant: FuzePlan + repo: izzywdev/FuzePlan + ref: main + enabled: true + entryRole: product-manager + servingRoles: [product-manager, backend-engineer] + external: false + provider: + name: anthropic + environmentId: env-fuzeplan + apiKeySecretRef: { name: a2a-provider-anthropic, key: api-key } + vaultIds: [fuzeplan] + memoryResources: [handoff] + - tenant: FuzeSales + repo: izzywdev/FuzeSales + ref: main + enabled: true + external: true +deploy: + imagePullSecrets: + - name: ghcr-pull + # Exercise the init-container repo-sync + optional secret/state wiring in render. + reposGitTokenSecretRef: { name: a2a-repos-git, key: token } + providerApiKeySecretRef: { name: a2a-provider-anthropic, key: api-key } + stateConfigMap: a2a-state diff --git a/deploy/helm/a2a-shared/templates/NOTES.txt b/deploy/helm/a2a-shared/templates/NOTES.txt new file mode 100644 index 0000000..c83d64f --- /dev/null +++ b/deploy/helm/a2a-shared/templates/NOTES.txt @@ -0,0 +1,17 @@ +{{- if .Values.a2a.enabled }} +a2a-shared (the ONE shared A2A server) is ENABLED. + +In-cluster interface (frozen contract — card-projection.md §2): + http://a2a-shared.fuzeagent.svc.cluster.local:{{ .Values.a2a.service.port }}/rpc +Agent Card discovery (binding.md §1, unauthenticated): + http://a2a-shared.fuzeagent.svc.cluster.local:{{ .Values.a2a.service.port }}/.well-known/agent-card.json + +Tenants served (a2a.tenants[]): +{{- range .Values.a2a.tenants }} + - {{ .tenant }} ({{ .repo }}@{{ .ref | default "main" }}){{ if .external }} [external: https://a2a.{{ include "a2a.repoSlug" .repo }}.{{ $.Values.deploy.externalDomain }}/rpc]{{ end }} +{{- else }} + (none — add entries under a2a.tenants to onboard a repo; no new pod required) +{{- end }} +{{- else }} +a2a-shared is DISABLED (a2a.enabled=false). Set a2a.enabled=true (prod overlay) to deploy. +{{- end }} diff --git a/deploy/helm/a2a-shared/templates/_helpers.tpl b/deploy/helm/a2a-shared/templates/_helpers.tpl new file mode 100644 index 0000000..9fa06da --- /dev/null +++ b/deploy/helm/a2a-shared/templates/_helpers.tpl @@ -0,0 +1,35 @@ +{{/* +Fixed workload name. binding.md §1/§4 and card-projection.md §2 HARDCODE the +in-cluster interface URL `http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc`, +so the Service (and thus this name) MUST be `a2a-shared`. Do not derive it from +.Release.Name — the card projection is a frozen contract value. +*/}} +{{- define "a2a.name" -}} +a2a-shared +{{- end -}} + +{{/* Common labels. */}} +{{- define "a2a.labels" -}} +app.kubernetes.io/name: {{ include "a2a.name" . }} +app.kubernetes.io/part-of: fuzeagent +app.kubernetes.io/component: a2a-server +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }} +{{- end -}} + +{{/* Selector labels (stable across upgrades — never add version-bearing labels here). */}} +{{- define "a2a.selectorLabels" -}} +app.kubernetes.io/name: {{ include "a2a.name" . }} +app.kubernetes.io/component: a2a-server +{{- end -}} + +{{/* +Lowercased repo-name segment ("izzywdev/FuzePlan" -> "fuzeplan"), used as the +external host slug per card-projection.md §2: + https://a2a..prod.fuzefront.com/rpc +Argument: the tenant's `repo` string. +*/}} +{{- define "a2a.repoSlug" -}} +{{- $parts := splitList "/" . -}} +{{- last $parts | lower -}} +{{- end -}} diff --git a/deploy/helm/a2a-shared/templates/deployment.yaml b/deploy/helm/a2a-shared/templates/deployment.yaml new file mode 100644 index 0000000..d8f24bb --- /dev/null +++ b/deploy/helm/a2a-shared/templates/deployment.yaml @@ -0,0 +1,166 @@ +{{- if .Values.a2a.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "a2a.name" . }} + labels: + {{- include "a2a.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.deploy.replicas }} + selector: + matchLabels: + {{- include "a2a.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "a2a.selectorLabels" . | nindent 8 }} + annotations: + # Roll the pod when the values document (tenants/auth/etc.) changes — a + # ConfigMap update alone does not restart the pod. + checksum/values: {{ include (print $.Template.BasePath "/values-configmap.yaml") . | sha256sum }} + spec: + {{- with .Values.deploy.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + # Matches the image's non-root USER 10001 (agent-templates/a2a/Dockerfile). + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + {{- if .Values.a2a.tenants }} + # Repo checkout: runtime.py's LocalRepoResolver reads each tenant's projection + # inputs (.fuze/manifest.json + agent-templates/roles/) from A2A_REPOS_DIR/. + # The server comment states the chart provides this checkout out of band, so an + # init container shallow-clones each enabled tenant's repo at its GitOps `ref`. + # GitOps: the ref is the source of truth, never live-mutated in /repos. + initContainers: + - name: repo-sync + image: {{ .Values.deploy.gitImage }} + command: + - sh + - -c + - | + set -eu + {{- if .Values.deploy.reposGitTokenSecretRef }} + AUTH="${A2A_REPOS_GIT_TOKEN}@" + {{- else }} + AUTH="" + {{- end }} + {{- range .Values.a2a.tenants }} + {{- if .enabled }} + name="{{ include "a2a.repoSlug" .repo }}" + echo "cloning {{ .repo }}@{{ .ref | default "main" }} -> /repos/${name}" + rm -rf "/repos/${name}" + git clone --depth 1 --branch "{{ .ref | default "main" }}" \ + "https://${AUTH}github.com/{{ .repo }}.git" "/repos/${name}" + {{- end }} + {{- end }} + {{- if .Values.deploy.reposGitTokenSecretRef }} + env: + - name: A2A_REPOS_GIT_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.deploy.reposGitTokenSecretRef.name }} + key: {{ .Values.deploy.reposGitTokenSecretRef.key }} + {{- end }} + volumeMounts: + - name: repos + mountPath: /repos + {{- end }} + containers: + - name: {{ include "a2a.name" . }} + image: "{{ .Values.a2a.image.repository }}:{{ .Values.a2a.image.tag }}" + imagePullPolicy: {{ .Values.a2a.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + # Entrypoint is `python -m a2a.runtime` (the image CMD). The listen port comes + # from the values file (service.port), not an env var, so container port and + # the served port share one source. + ports: + - name: http + containerPort: {{ .Values.a2a.service.port }} + env: + # Bind all interfaces so the in-cluster Service reaches the pod (the server + # defaults to loopback). Everything else (auth, tenants, protocolVersion, + # card keyId) the server reads from A2A_VALUES_FILE — NOT per-var env. + - name: HOST + value: "0.0.0.0" + - name: A2A_VALUES_FILE + value: /config/values.json + - name: A2A_REPOS_DIR + value: /repos + - name: AGENT_PROVIDER + value: {{ .Values.deploy.agentProvider | quote }} + - name: FUZE_STATE_DIR + value: /state + - name: PYTHONUNBUFFERED + value: "1" + {{- with .Values.deploy.providerApiKeySecretRef }} + # Managed-Agents API key for session provisioning (sync/common.py _api_key()). + - name: ANTHROPIC_API_KEY + valueFrom: + secretKeyRef: + name: {{ .name }} + key: {{ .key }} + {{- end }} + # The server exposes GET /healthz (verified against the built image's + # composed Starlette routes: /rpc, /.well-known/agent-card.json, + # /extendedAgentCard, /healthz). + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 20 + periodSeconds: 20 + volumeMounts: + - name: values + mountPath: /config + readOnly: true + - name: repos + mountPath: /repos + {{- with .Values.deploy.stateConfigMap }} + - name: state + mountPath: /state + readOnly: true + {{- end }} + {{- if and .Values.a2a.auth .Values.a2a.auth.mtls }} + {{- if and .Values.a2a.auth.mtls.enabled .Values.a2a.auth.mtls.caSecretRef }} + - name: mtls-ca + mountPath: /etc/a2a/mtls + readOnly: true + {{- end }} + {{- end }} + resources: + {{- toYaml .Values.deploy.resources | nindent 12 }} + volumes: + - name: values + configMap: + name: {{ include "a2a.name" . }}-values + - name: repos + emptyDir: {} + {{- with .Values.deploy.stateConfigMap }} + - name: state + configMap: + name: {{ . }} + {{- end }} + {{- if and .Values.a2a.auth .Values.a2a.auth.mtls }} + {{- if and .Values.a2a.auth.mtls.enabled .Values.a2a.auth.mtls.caSecretRef }} + - name: mtls-ca + secret: + secretName: {{ .Values.a2a.auth.mtls.caSecretRef.name }} + items: + - key: {{ .Values.a2a.auth.mtls.caSecretRef.key }} + path: ca.crt + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/a2a-shared/templates/ingress.yaml b/deploy/helm/a2a-shared/templates/ingress.yaml new file mode 100644 index 0000000..911bdca --- /dev/null +++ b/deploy/helm/a2a-shared/templates/ingress.yaml @@ -0,0 +1,37 @@ +{{- if .Values.a2a.enabled }} +{{- /* +External surface (opt-in). Each tenant with `external: true` is published on its +own host per card-projection.md §2: + https://a2a../rpc +All hosts route to the single `a2a-shared` Service (disambiguated by the `tenant` +body param, not by host). Exec-tier tenants MUST be external:false (card-projection +§5) — enforced by the card generator/contract, not here. Traefik is ClusterIP and +these hosts flow through the Cloudflare tunnel; CF terminates TLS, so no tls block. +*/ -}} +{{- range .Values.a2a.tenants }} +{{- if .external }} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: a2a-shared-ext-{{ include "a2a.repoSlug" .repo }} + labels: + app.kubernetes.io/name: a2a-shared + app.kubernetes.io/part-of: fuzeagent + app.kubernetes.io/component: a2a-server +spec: + ingressClassName: {{ $.Values.deploy.ingressClassName }} + rules: + - host: a2a.{{ include "a2a.repoSlug" .repo }}.{{ $.Values.deploy.externalDomain }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: a2a-shared + port: + number: {{ $.Values.a2a.service.port }} +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/helm/a2a-shared/templates/service.yaml b/deploy/helm/a2a-shared/templates/service.yaml new file mode 100644 index 0000000..774da5c --- /dev/null +++ b/deploy/helm/a2a-shared/templates/service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.a2a.enabled }} +apiVersion: v1 +kind: Service +metadata: + # MUST be `a2a-shared`: binding.md/card-projection.md §2 hardcode the in-cluster + # interface URL http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc. + name: {{ include "a2a.name" . }} + labels: + {{- include "a2a.labels" . | nindent 4 }} +spec: + # ClusterIP only — ingress is Cloudflare-tunnel-only (no LoadBalancer/NodePort). + type: {{ .Values.a2a.service.type }} + selector: + {{- include "a2a.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.a2a.service.port }} + targetPort: http +{{- end }} diff --git a/deploy/helm/a2a-shared/templates/values-configmap.yaml b/deploy/helm/a2a-shared/templates/values-configmap.yaml new file mode 100644 index 0000000..da81926 --- /dev/null +++ b/deploy/helm/a2a-shared/templates/values-configmap.yaml @@ -0,0 +1,17 @@ +{{- if .Values.a2a.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "a2a.name" . }}-values + labels: + {{- include "a2a.labels" . | nindent 4 }} +data: + # The values-interface document the server reads at startup via A2A_VALUES_FILE + # (runtime.build_from_env -> config.load_config, which does values.get("a2a", …)). + # It is exactly the `a2a` block (frozen operator interface) — DATA that lets one + # shared server front every tenant. Only secret *references* (name/key) can appear + # here, never secret VALUES; load_config reads non-secret fields + keyId only. + # GitOps source of truth — never live-mutated. + values.json: | + {{ dict "a2a" .Values.a2a | toJson }} +{{- end }} diff --git a/deploy/helm/a2a-shared/values-prod.yaml b/deploy/helm/a2a-shared/values-prod.yaml new file mode 100644 index 0000000..7b3b093 --- /dev/null +++ b/deploy/helm/a2a-shared/values-prod.yaml @@ -0,0 +1,77 @@ +# ============================================================================= +# Production overlay (Contabo k3s), consumed by the Argo CD `a2a-shared` Application. +# +# SHIPS DISABLED. Enabling A2A in prod has hard PRECONDITIONS that are out of this +# chart's scope (see deploy/helm/a2a-shared/README.md go-live steps): +# 1. The shared A2A server image exists on GHCR (backend-engineer + release.yml). +# 2. `providesTo` is backfilled on every served repo's manifest — authz.md §3 +# fail-closed: absent == DENY, so enabling before backfill is safe but useless. +# 3. The card-signing SealedSecret is provisioned. +# Until then this stays enabled:false → the Argo app renders nothing (inert). +# +# The single `tag:` line below is rewritten to the built commit SHA by +# .github/workflows/release.yml — keep it the ONLY `tag:` key in this file. +# All secrets come from SealedSecrets — never inline here. +# ============================================================================= +a2a: + enabled: false + + image: + repository: ghcr.io/izzywdev/fuzeagent-a2a + tag: latest + pullPolicy: IfNotPresent + + service: + type: ClusterIP + port: 8080 + + protocolVersion: "1.0" + + # Identity config (authz.md). REQUIRED before flipping enabled:true — supply the + # family OIDC issuer (Authentik/Keycloak) at go-live. oidcIssuerUrl is REQUIRED + # whenever `auth` is present, so it stays commented while disabled. + # auth: + # oidcIssuerUrl: https:///application/o/a2a/ + # audience: a2a + # callerClaim: sub + # mtls: + # enabled: true + # caSecretRef: { name: a2a-mtls-ca, key: ca.crt } + + # JWS Agent-Card signing key (SealedSecret-provisioned at go-live). + # cardSigning: + # keySecretRef: { name: a2a-card-signing, key: jws.key } + # keyId: a2a-v1 + + # Tenants onboard here at go-live — one entry per served product/exec agent, + # only AFTER that repo's `providesTo` is backfilled. No new pod per tenant. + tenants: [] + # - tenant: FuzePlan + # repo: izzywdev/FuzePlan + # ref: main + # enabled: true + # entryRole: product-manager + # external: false + # provider: + # name: anthropic + # apiKeySecretRef: { name: a2a-provider-anthropic, key: api-key } + +deploy: + replicas: 1 + # Private GHCR pull secret (sealed; name must match the SealedSecret exactly). + imagePullSecrets: + - name: ghcr-pull + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { cpu: 500m, memory: 512Mi } + externalDomain: prod.fuzefront.com + ingressClassName: traefik + agentProvider: anthropic + gitImage: alpine/git:latest + # Go-live: seal these and reference them here (all SealedSecret-provisioned). + # reposGitTokenSecretRef: { name: a2a-repos-git, key: token } # private tenant repos + # providerApiKeySecretRef: { name: a2a-provider-anthropic, key: api-key } + # stateConfigMap: a2a-state # Managed-Agents id-state + reposGitTokenSecretRef: null + providerApiKeySecretRef: null + stateConfigMap: null diff --git a/deploy/helm/a2a-shared/values.yaml b/deploy/helm/a2a-shared/values.yaml new file mode 100644 index 0000000..3190ce9 --- /dev/null +++ b/deploy/helm/a2a-shared/values.yaml @@ -0,0 +1,101 @@ +# ============================================================================= +# a2a-shared — default values (local/dev shape; server DISABLED by default). +# +# The `a2a:` block below is the OPERATOR/TENANT surface and mirrors EXACTLY the +# frozen contract (no invented keys, no invented defaults): +# agent-templates/contracts/a2a/v1/schema/values-interface.schema.json +# It is additionalProperties:false there, so `a2a` carries ONLY the contract's +# properties. Pure deployment mechanics that the interface deliberately leaves to +# devops (it defines no templates) live under the separate `deploy:` block and are +# NOT part of the operator contract. +# +# Design invariant (schema): ONE shared server, so per-repo config is DATA +# (`a2a.tenants[]`), never a chart-per-repo. Production shape lives in values-prod.yaml. +# ============================================================================= +a2a: + # The gate. false = the shared A2A server is not deployed at all. + enabled: false + + image: + repository: ghcr.io/izzywdev/fuzeagent-a2a + # Immutable tag; prod values bump this (release.yml). Never `latest` in prod. + tag: latest + pullPolicy: IfNotPresent + + service: + # MUST be ClusterIP — ingress is Cloudflare-tunnel-only (no LB/NodePort). + type: ClusterIP + port: 8080 + + # A2A protocol version served. Frozen for contract v1. + protocolVersion: "1.0" + + # Identity config for callee-side enforcement (authz.md). `auth` is optional in + # the interface, but oidcIssuerUrl is REQUIRED whenever `auth` is set, so real + # deployments supply it in the prod overlay. Omitted here (server disabled). + # auth: + # oidcIssuerUrl: "" # issuer projected into the card's `fuze-oidc` scheme + # audience: "" # expected `aud` claim + # callerClaim: sub # token claim carrying caller repo identity (authz.md §2) + # mtls: + # enabled: false + # caSecretRef: { name: "", key: "" } + + # JWS signing key for the Agent Card (Fuze profile requires non-empty signatures[]). + # keySecretRef is REQUIRED when `cardSigning` is present — supplied in prod overlay + # from a SealedSecret. Secret VALUES never appear in git. + # cardSigning: + # keySecretRef: { name: a2a-card-signing, key: jws.key } + # keyId: a2a-v1 + + # One entry per served product/exec agent. THIS array is how a repo onboards + # onto A2A — no new chart, no new pod. Empty by default. + # Shape (see $defs/tenant in the interface schema): + # - tenant: FuzePlan # routing key; == card AgentInterface.tenant + # repo: izzywdev/FuzePlan # projection source (owner/name) + # ref: main # git ref manifest/roles are read from + # enabled: true # per-tenant gate (independent of server gate) + # entryRole: product-manager # overrides manifest.a2a.entryRole + # servingRoles: [] # overrides manifest.a2a.servingRoles + # external: false # tunnel-publish w/ https interface (MUST be false for exec) + # provider: + # name: anthropic + # environmentId: "" + # apiKeySecretRef: { name: "", key: "" } + # vaultIds: [] # NEVER projected onto the card + # memoryResources: [] + # env: + # - name: SOME_VAR + # valueFrom: { name: some-secret, key: some-key } # secretRef only, never inline + tenants: [] + +# --- Deployment mechanics (devops slice; NOT part of the contract interface) --- +# Kept OUT of `a2a` so that block stays byte-conformant to the frozen operator +# interface. These are chart-internal knobs devops sets, not values a tenant/product +# sets to be served. +deploy: + replicas: 1 + # GHCR pull secret (sealed; name must match the SealedSecret). Empty for local + # charts that use public/locally-built images. + imagePullSecrets: [] + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { cpu: 500m, memory: 512Mi } + # External (opt-in) tunnel host domain for tenants with `external: true` + # (card-projection.md §2: https://a2a../rpc). + externalDomain: prod.fuzefront.com + ingressClassName: traefik + # Managed-Agents provider id set as AGENT_PROVIDER (runtime.build_from_env default). + agentProvider: anthropic + # Init-container image that shallow-clones each enabled tenant's repo into /repos + # (runtime.LocalRepoResolver reads the projection inputs from there). + gitImage: alpine/git:latest + # Optional token (SealedSecret ref {name,key}) for cloning PRIVATE tenant repos in + # the repo-sync init container. Unset = anonymous clone (public repos only). + reposGitTokenSecretRef: null + # Optional Managed-Agents API key (SealedSecret ref {name,key}) exported as + # ANTHROPIC_API_KEY for session provisioning (sync/common.py). Unset = not injected. + providerApiKeySecretRef: null + # Optional ConfigMap name holding the Managed-Agents id-state (agent/vault/memory/ + # environment ids) mounted at FUZE_STATE_DIR=/state, same pattern as handoff-mcp. + stateConfigMap: null