Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/helm-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
23 changes: 22 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
61 changes: 61 additions & 0 deletions agent-templates/a2a/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
5 changes: 5 additions & 0 deletions agent-templates/a2a/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions deploy/argocd/applications/a2a-shared.yaml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions deploy/helm/a2a-shared/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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"
75 changes: 75 additions & 0 deletions deploy/helm/a2a-shared/README.md
Original file line number Diff line number Diff line change
@@ -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.<repo-slug>.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/<repo-name>`, 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`.
49 changes: 49 additions & 0 deletions deploy/helm/a2a-shared/ci/enabled-values.yaml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions deploy/helm/a2a-shared/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -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 }}
35 changes: 35 additions & 0 deletions deploy/helm/a2a-shared/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -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.<repo-slug>.prod.fuzefront.com/rpc
Argument: the tenant's `repo` string.
*/}}
{{- define "a2a.repoSlug" -}}
{{- $parts := splitList "/" . -}}
{{- last $parts | lower -}}
{{- end -}}
Loading
Loading