From 9c3bdba3f91f276514f5c9296dad850a9eb48245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Fri, 28 Aug 2026 17:50:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20deploy=20the=20agent=20=E2=80=94=20imag?= =?UTF-8?q?e,=20manifests,=20and=20the=20Flux=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-stage Dockerfile: the runtime image carries neither uv nor a build toolchain, runs as uid 10001, drops every capability and needs no writable filesystem (verified with --read-only and no tmpfs). k8s/ is what runs, dev/ is the Flux plumbing that applies it, self-managing after three manual applies — the same shape every kong-* repo settled on. Three choices worth naming: - One namespace for the estate. What isolates an agent is its Keycloak client and the audiences it may request, not a namespace boundary. - The GitRepository uses anonymous HTTPS rather than SSH with a Vault-held deploy key. The repo is already anonymously readable on gitea, so a key would guard nothing while adding two objects to create by hand before anything deploys. If the repos ever go private this becomes the kong-* SSH pattern. - The lab CA certificate is committed. It is a certificate, never a key, and checking it in means the deployment has no hidden prerequisite. replicas: 1 is a constraint, not a default — conversation history lives in the process, so a second replica would route a user's "yes" to a pod that never saw the question. Co-Authored-By: Claude Opus 5 --- .dockerignore | 8 ++++ Dockerfile | 42 +++++++++++++++++++ README.md | 41 ++++++++++++++++++ dev/bootstrap-kustomization.yaml | 33 +++++++++++++++ dev/flux-kustomization.yaml | 14 +++++++ dev/gitrepository.yaml | 19 +++++++++ k8s/ca-configmap.yaml | 36 ++++++++++++++++ k8s/configmap.yaml | 26 ++++++++++++ k8s/deployment.yaml | 71 ++++++++++++++++++++++++++++++++ k8s/externalsecret.yaml | 24 +++++++++++ k8s/kustomization.yaml | 9 ++++ k8s/namespace.yaml | 9 ++++ k8s/service.yaml | 12 ++++++ 13 files changed, 344 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 dev/bootstrap-kustomization.yaml create mode 100644 dev/flux-kustomization.yaml create mode 100644 dev/gitrepository.yaml create mode 100644 k8s/ca-configmap.yaml create mode 100644 k8s/configmap.yaml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/externalsecret.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/service.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2f6fbff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.venv/ +.git/ +.pytest_cache/ +__pycache__/ +*.pyc +tests/ +k8s/ +dev/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2ebe03 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Two stages so the runtime image carries no build toolchain and no uv. +FROM python:3.12-slim AS build + +COPY --from=ghcr.io/astral-sh/uv:0.9.7 /uv /bin/uv + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY src ./src + +# One step, no lockfile: the version floors in pyproject.toml are what pins this, +# and they are deliberately recent (August 2026) to keep the vulnerability +# surface small. Add a uv.lock when the build needs to be byte-reproducible. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv venv /app/.venv && uv pip install --python /app/.venv/bin/python . + +FROM python:3.12-slim AS runtime + +# Non-root, no shell login, no home to write to. The agent holds a Keycloak +# client secret in its environment; a container that cannot be written to is one +# less way to read it back out. +RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin agent + +WORKDIR /app +COPY --from=build /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +USER 10001 +EXPOSE 8080 + +# The Agent Card is the natural health endpoint: it is served by the same app, +# needs no auth, and a 200 means the A2A routes are actually mounted. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/.well-known/agent-card.json', timeout=4).status==200 else 1)"] + +ENTRYPOINT ["python", "-m", "catalogue_agent"] diff --git a/README.md b/README.md index 85aba73..4769177 100644 --- a/README.md +++ b/README.md @@ -132,3 +132,44 @@ running library: - Writes, and the A2A call to Approvisionnement. - Deployment manifests — they belong in `classic-models-agent-platform` once a second agent shows what is actually shared. + +## Deployment + +``` +ghcr.io/staillanibm/classic-models-agent-catalogue:v0.1.0 +``` + +Built from the `Dockerfile` here — two stages, so the runtime image carries no +build toolchain; non-root, read-only root filesystem, all capabilities dropped. +The image is built and pushed by hand for now: this repo lives on gitea and +gitea Actions is not set up, so there is no pipeline to do it. + +``` +docker build -t ghcr.io/staillanibm/classic-models-agent-catalogue:vX.Y.Z . +docker push ghcr.io/staillanibm/classic-models-agent-catalogue:vX.Y.Z +``` + +`k8s/` is what runs; `dev/` is the Flux plumbing that applies it. Three manual +applies close the loop once — nothing can watch `./dev` before the namespace and +the source that serves it exist: + +``` +kubectl apply -f k8s/namespace.yaml +kubectl apply -f dev/gitrepository.yaml +kubectl apply -f dev/bootstrap-kustomization.yaml +``` + +After that every file here reconciles from git, `dev/` included. Never +`kubectl apply` the objects under `k8s/` afterwards — Flux reverts them within +its interval and the cluster silently stops matching what you edited. + +What the deployment assumes: + +| | | +|---|---| +| Namespace | `agents` — one for the estate; what isolates an agent is its Keycloak client, not its namespace | +| Secret | `CATALOGUE_CLIENT_SECRET`, from the same Vault entry the realm import reads, through the `vault-keycloak` store | +| CA | `k8s/ca-configmap.yaml` holds the lab CA **certificate** — public by construction, checked in so the deployment has no hidden prerequisite. Regenerate it when the CA rotates (expires 2026-11-24) | +| DNS | every `*.sttlab.local` name resolves in-cluster already; no `hostAliases` needed | +| Replicas | exactly one. Conversation history lives in the process, so a second replica would route a user's "yes" to a pod that never saw the question | + diff --git a/dev/bootstrap-kustomization.yaml b/dev/bootstrap-kustomization.yaml new file mode 100644 index 0000000..205d977 --- /dev/null +++ b/dev/bootstrap-kustomization.yaml @@ -0,0 +1,33 @@ +# Watches ./dev, so the GitRepository and the Kustomization below it reconcile +# from git like any other manifest. It lives in ./dev itself, so it also manages +# itself — the arrangement `flux bootstrap` sets up, which flux-operator does +# not create, and the same one every kong-* repo now uses. +# +# It sits in the agents namespace, not flux-system: a sourceRef resolves in the +# Kustomization's own namespace, and this is the agent team's to own. +# +# Three manual applies, once, to close the loop — nothing can watch ./dev until +# the namespace and the source that serves it exist: +# +# kubectl apply -f k8s/namespace.yaml +# kubectl apply -f dev/gitrepository.yaml +# kubectl apply -f dev/bootstrap-kustomization.yaml +# +# After that every file in this repo, this one included, reconciles from git. +# +# Sharp edge: with prune enabled, deleting flux-kustomization.yaml from git +# deletes the Kustomization it defines, and that Kustomization's own prune then +# removes the whole deployment. Correct GitOps behaviour, blast radius two +# levels deep. +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: catalogue-agent-bootstrap + namespace: agents +spec: + interval: 10m + sourceRef: + kind: GitRepository + name: catalogue-agent + path: ./dev + prune: true diff --git a/dev/flux-kustomization.yaml b/dev/flux-kustomization.yaml new file mode 100644 index 0000000..6dabde4 --- /dev/null +++ b/dev/flux-kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: catalogue-agent + namespace: agents +spec: + interval: 10m + sourceRef: + kind: GitRepository + name: catalogue-agent + path: ./k8s + prune: true + wait: true + timeout: 3m diff --git a/dev/gitrepository.yaml b/dev/gitrepository.yaml new file mode 100644 index 0000000..48f5036 --- /dev/null +++ b/dev/gitrepository.yaml @@ -0,0 +1,19 @@ +# HTTPS with no credentials, unlike the kong-* repos which use SSH and a deploy +# key from Vault. Deliberate: this repo is already anonymously readable over +# HTTPS on gitea, so a deploy key would guard nothing while adding a Vault entry +# and a gitea key to create by hand before anything deploys. TLS still gives +# integrity and authenticity of the source. +# +# If the gitea repos are ever made private, this becomes an SSH sourceRef with +# an ExternalSecret holding the deploy key — the pattern in +# kong-catalog-api/dev/. +apiVersion: source.toolkit.fluxcd.io/v1 +kind: GitRepository +metadata: + name: catalogue-agent + namespace: agents +spec: + interval: 5m + url: https://gitea.sttlab.eu/stt/classic-models-agent-catalogue.git + ref: + branch: main diff --git a/k8s/ca-configmap.yaml b/k8s/ca-configmap.yaml new file mode 100644 index 0000000..b329e44 --- /dev/null +++ b/k8s/ca-configmap.yaml @@ -0,0 +1,36 @@ +# The lab CA, so the agent can verify keycloak.sttlab.local and every gateway +# rather than disabling verification. A CA certificate is public by +# construction — this is the certificate, never the key, and it is checked in +# deliberately so the deployment has no hidden prerequisite. +# +# Issued by the sttlab-local-ca ClusterIssuer, valid until 2026-11-24. When it +# is rotated this file must be regenerated: +# kubectl -n cert-manager get secret sttlab-local-ca \ +# -o jsonpath='{.data.tls\.crt}' | base64 -d +apiVersion: v1 +kind: ConfigMap +metadata: + name: sttlab-ca + namespace: agents +data: + ca.crt: | + -----BEGIN CERTIFICATE----- + MIIDPDCCAiSgAwIBAgIUd/utDf+f0yQR3v3qTjZqx+wvTZMwDQYJKoZIhvcNAQEL + BQAwGTEXMBUGA1UEAwwOKi5zdHRsYWIubG9jYWwwHhcNMjUxMTI0MTUzODQ1WhcN + MjYxMTI0MTUzODQ1WjAZMRcwFQYDVQQDDA4qLnN0dGxhYi5sb2NhbDCCASIwDQYJ + KoZIhvcNAQEBBQADggEPADCCAQoCggEBALj0sryq8qXtkShT/B+/EjVJNN/TLHIn + QJYc6xuUH/f7q3dYdLm4iH0XnFgrSQLUo4iqd/Mczef5iWCYwT5FFa7P17/TcLVf + gso0j41qNLBjGxDQGQ6Z1VCxi6oan8ow98rumjVj0E0Qgs5CX/rN+gcEmZ/TZFWU + qudVoztpeiVqJ0dxCIq+KMutzd1BU7BXx4VRO9Y+t1idUc7oY7BPmS2qPAN7TtXP + j1KhH2BgLsmF5GRhYHNS578eCltcmS2sbQ3c7GwOV96lpgF9TixEvFXeN4xk43E6 + 1COUD7CRiWAZ0XI8FMlpZGeNZeV4bbV/MqVig1ybWNOz9MDKYj3eoXUCAwEAAaN8 + MHowHQYDVR0OBBYEFDbzYWOPr4vxa+VONs7xHbcNx70YMB8GA1UdIwQYMBaAFDbz + YWOPr4vxa+VONs7xHbcNx70YMA8GA1UdEwEB/wQFMAMBAf8wJwYDVR0RBCAwHoIO + Ki5zdHRsYWIubG9jYWyCDHN0dGxhYi5sb2NhbDANBgkqhkiG9w0BAQsFAAOCAQEA + tU4p5/ONXm7x4YDzSeL8NdThVLguiFur0jcTxvvPUS4NFNsnQJk4OXxBgjy1YwpL + 3i56/4mCEd9OCQF/Hl0eFwGZBzNKlMoTA08AC/lBDvPrnVjuUu0qe/b/2KOkhESa + x8rQNp0o8pcj0f29nu0JfRqYGkzy0Lh2qgXhPu2iC+t2PfdleRAky1tt68wogdFL + TvHDsJbVo/ihsPpONlSyHfxG6bb/0Mkz64PH46TTt9GvJflEKMqDwivNFUA1P5S/ + U4Ic8Qapnxc7h2+WbRjmYrnuygC2wMBE6rL9OF3gwCr1fkgGGRr5lVRUq2X2HZV5 + 5jqViv6eurq8O61N3vWGCQ== + -----END CERTIFICATE----- diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..75a6159 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,26 @@ +# Everything here is an endpoint or a public identifier. The one secret the +# agent holds arrives through catalogue-agent-secrets; nothing in this file is +# sensitive, which is the point of keeping the split visible. +apiVersion: v1 +kind: ConfigMap +metadata: + name: catalogue-agent-config + namespace: agents +data: + CATALOGUE_KEYCLOAK_REALM_URL: https://keycloak.sttlab.local/realms/classic-models + CATALOGUE_CLIENT_ID: classic-models-agent-catalogue + + CATALOGUE_MCP_CATALOG_URL: https://catalog-dev.sttlab.local/catalog-mcp + CATALOGUE_MCP_SALES_URL: https://sales-dev.sttlab.local/sales-mcp + + CATALOGUE_LLM_BASE_URL: https://llm-dev.sttlab.local/llm/v1 + CATALOGUE_LLM_MODEL: google/gemini-3.7-flash + + # Trust the lab CA rather than disabling verification. Mounted from the + # sttlab-ca ConfigMap. + CATALOGUE_CA_BUNDLE: /etc/sttlab-ca/ca.crt + + # What other agents dial, so it is the Service DNS name and not the bind + # address. It is advertised in the Agent Card; get it wrong and A2A callers + # are pointed at nothing. + CATALOGUE_PUBLIC_URL: http://catalogue-agent.agents.svc.cluster.local:8080/ diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..47d9f1f --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: catalogue-agent + namespace: agents +spec: + # One replica, and that is a constraint rather than a default. Conversation + # history — which the confirm-before-writing flow depends on — lives in the + # process, keyed by A2A contextId. A second replica would route the user's + # "yes" to a pod that never saw the question. Scaling out needs sticky routing + # on contextId or a shared store first. + replicas: 1 + selector: + matchLabels: + app: catalogue-agent + template: + metadata: + labels: + app: catalogue-agent + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: agent + image: ghcr.io/staillanibm/classic-models-agent-catalogue:v0.1.0 + ports: + - name: http + containerPort: 8080 + envFrom: + - configMapRef: + name: catalogue-agent-config + - secretRef: + name: catalogue-agent-secrets + volumeMounts: + - name: sttlab-ca + mountPath: /etc/sttlab-ca + readOnly: true + # The Agent Card needs no auth and is served by the same app, so a 200 + # means the A2A routes are genuinely mounted — not merely that the + # process is alive. + readinessProbe: + httpGet: + path: /.well-known/agent-card.json + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /.well-known/agent-card.json + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 192Mi + limits: + cpu: "1" + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: sttlab-ca + configMap: + name: sttlab-ca diff --git a/k8s/externalsecret.yaml b/k8s/externalsecret.yaml new file mode 100644 index 0000000..075ee4b --- /dev/null +++ b/k8s/externalsecret.yaml @@ -0,0 +1,24 @@ +# The agent's Keycloak client secret, and nothing else. It comes from the same +# Vault entry the realm import reads, so there is exactly one copy of this +# secret in the estate and rotating it in Vault rotates it everywhere. +# +# vault-keycloak rather than a store of the agents' own: this value is a +# Keycloak client secret, owned by the realm, and inventing a second AppRole to +# read the same path would add a credential without adding a boundary. +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: catalogue-agent-secrets + namespace: agents +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: vault-keycloak + target: + name: catalogue-agent-secrets + data: + - secretKey: CATALOGUE_CLIENT_SECRET + remoteRef: + key: keycloak/classic-models-realm + property: KC_SECRET_CLASSIC_MODELS_AGENT_CATALOGUE diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..2d6888a --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - ca-configmap.yaml + - externalsecret.yaml + - configmap.yaml + - deployment.yaml + - service.yaml diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..8b2b443 --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,9 @@ +# One namespace for the whole agent estate rather than one per agent. The agents +# share nothing but a trust boundary — no data, no storage, no service mesh +# policy — and splitting them would multiply the CA ConfigMap and the +# ExternalSecret without isolating anything that matters. What isolates them is +# their Keycloak client and the audiences it may request, not the namespace. +apiVersion: v1 +kind: Namespace +metadata: + name: agents diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..87db003 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: catalogue-agent + namespace: agents +spec: + selector: + app: catalogue-agent + ports: + - name: http + port: 8080 + targetPort: http