Kubernetes
Come afe gira su Kubernetes oggi.
Quattro milestone sono arrivate. V6d ha aggiunto le revisioni della configurazione, i manifest
compatibili con Kubernetes e gli endpoint operativi. V6e ha aggiunto l'immagine e l'ambiente kind.
V7b ha aggiunto i Pod dei ticket, i bundle del workspace, netlock, l'RBAC del worker e i test su
kind. V7c ha aggiunto l'operator, la custom resource AgentFlowEngine, le CRD, KEDA e l'Ingress.
Una custom resource descrive l'installazione, e l'operator la riconcilia. Requisiti: FR1, FR6, FR21 e FR25–FR28.
Cosa gira oggi
- Il plugin
afe-kubernetes(sandboxes/kubernetes) implementa il portSandboxsopra la CLIkubectl. Usa una PVC ReadWriteOnce e un Pod per ticket nel namespace sandbox. Li applica, aspettaReady, ed esegueexec/run/stream/put/get. Li rimuove tramite l'etichettaafe.ticket. deploy/kind/sandbox/porta il namespace sandbox (Pod Securityprivileged). Porta anche la Role e la RoleBinding con namespace del worker (solopods,pods/exece PVC, maisecrets), e laNetworkPolicydi solo ingress come secondo livello. Il worker annota il suo Pod e la sua PVC con i marcatori di ciclo di vita, quindi la Role concede anchepatcheupdatesu entrambi.netlock(docker/netlock) è l'init container che scarta ogni pacchetto in uscita dal Pod tranne quelli dello uid del proxy, su IPv4 e IPv6.docker/egress-proxyè il proxysquidallowlist nel Pod, raggiungibile dalla sandbox sulocalhost:3128.- Il workspace vive nel Pod ed è durevole. Dopo ogni nodo che l'ha usato, il motore fa il checkpoint del workspace e salva un bundle incrementale nello store. Un Pod perso viene ricreato e ripristinato dall'ultimo checkpoint. Vedi Workspace.
docker composeporta su tutto lo stack su una macchina conmake compose-up. L'adattatore Docker tiene un volume e un container per ticket, lo stesso modellovolume + exec(vedi Far girare tutto lo stack con Compose).tests/kind(markerkind,make kind-test, fuori damake ci) lo dimostra su un cluster vero (vedi Distribuire su kind). Esegue un flowgit+deepfino adone, e cancella un Pod del ticket dopo un checkpoint per controllare che si ripristini senza rieseguire un nodo completato. Controlla anche l'isolamento di rete con e senza allowlist, e che il Pod e la PVC siano spariti a fine ticket.plugins/afe-operator/è l'operator (afe-operator, moduloafe_plugins.operator). Osserva le risorseAgentFlowEnginedel suo namespace, valida la configurazione tramiteafe_coree riconcilia gli oggetti posseduti. Il suo status porta le condition e la revisione corrente.deploy/operator/è l'install kustomize: le CRD generate, il Deployment dell'operator, il suo ServiceAccount e due Role con namespace.make operator-installlo applica una volta. Ciò che l'operator non deve possedere resta indeploy/operator/admin/(vedi Installare l'operator).
Il motore rimuove il Pod e la PVC di un ticket tramite l'etichetta afe.ticket quando il ticket
finisce (done, failed, cancelled). Aprire il Pod di un ticket è idempotente: viene riusato
finché immagine, limiti, nomi delle variabili d'ambiente ed egress non cambiano, e ricreato al
cambio di firma (V7b R3).
Un crash può comunque lasciare un orfano. Il reaper dell'operator elenca gli oggetti della sandbox
per le etichette afe.ticket e app.kubernetes.io/managed-by: afe. Cancella quelli terminal, e
i running il cui heartbeat è più vecchio di sandbox.orphanGrace. Tiene un Pod paused, e non
legge né store né API (PRD FR28).
Principi
- La scalabilità viene dal design che c'è già.
afe serveserve solo l'API, i processiafe workerprendono i ticket da una coda Redis, e tutto ciò che serve a riprendere un ticket sta in Postgres. Su Kubernetes, più throughput vuol dire più repliche dei worker. - La web UI arriva con
serve.afe serve --uiserve il bundle sullo stesso host e sulla stessa porta dell'API, quindi non c'è un Deployment, un'immagine o un Service UI separati. - Nessuno storage condiviso. I clienti possono avere volumi ReadWriteMany e rifiutarsi comunque di usarli, quindi nulla dipende da essi. Ogni ticket tiene i suoi file in un Pod tutto suo, su un volume locale ReadWriteOnce.
- I worker non tengono file. Parlano con il Pod del ticket attraverso l'API exec di Kubernetes. Il token Git resta nel worker.
- L'operator gestisce il ciclo di vita, non la scalabilità (V7c). Valida la configurazione, fa il deploy dei processi, gestisce o referenzia Postgres e Redis, e riporta lo stato.
- Un motore per cluster. I tenant segregati per ora non sono un obiettivo.
Topologia
kubectl apply ──▶ AgentFlowEngine + Flow, Agent, Schema… (afe.dev) ──watch──▶ afe-operator
│ valida,
│ pubblica la
│ revisione, deploy
┌─ namespace afe ──────────────────────────────────────────────────────────────▼─────────┐
│ │
│ Ingress (WebSocket + UI) ──▶ Service ──▶ Deployment serve ×N (la UI arriva con lui) │
│ │ accoda ▲ eventi │
│ ▼ │ │
│ Deployment worker ×1..M ◀── KEDA ScaledObject (lag coda) │
│ nessun volume; il token Git e le chiavi dei modelli │
│ restano qui, serve non tiene credenziali │
│ │
└───────────────────────────────────┬────────────────────────────────────────────────────┘
│ API Kubernetes: crea Pod e PVC, exec (stdin/stdout)
┌─ namespace afe-sandbox ───────────▼────────────────────────────────────────────────────┐
│ Pod afe-<ticket>-<uid> + PVC ReadWriteOnce (local-path, TopoLVM, un disco cloud) │
│ uno per ticket; l'RBAC del worker arriva solo a questo namespace │
└────────────────────────────────────────────────────────────────────────────────────────┘
┌─ namespace afe-data ───────────────────────────────────────────────────────────────────┐
│ Cluster CloudNativePG: ticket, ledger, checkpoint, revisioni, bundle dei workspace │
│ StatefulSet Redis (AOF): coda, lock, notifiche, heartbeat │
└────────────────────────────────────────────────────────────────────────────────────────┘
Langfuse o un qualsiasi collector OpenTelemetry: esterno, indicato da URL e Secret
I servizi dati stanno in un namespace a parte, così il permesso pods/exec del worker non li
I servizi dati stanno in un namespace a parte, così il permesso pods/exec del worker non li
raggiunge mai. L'operator crea i Deployment serve e worker, i Service, la ConfigMap di
configurazione, l'Ingress, lo ScaledObject e uno store o broker managed. Non crea nessuno dei
namespace, l'RBAC del worker, l'envSecret o i Secret dei dati. Quelli, e le classi di storage,
runtime e ingress, restano i manifest dell'admin (deploy/operator/admin/).
Il Pod del ticket
Pod afe-<ticket>-<uid> volume: PVC ReadWriteOnce, cancellato col ticket
├─ init netlock NET_ADMIN, gira una volta iptables: scarta ogni pacchetto in uscita,
│ tranne quelli dello uid 1337 (il proxy)
├─ sandbox uid 1000, nessuna capability /workspace/repo
│ file tool, git locale, /workspace/worktrees/<scope>
│ ws.exec, ws.test, agente acp
└─ proxy uid 1337, solo con egress squid con l'allowlist del progetto
nessun token del service account · seccomp RuntimeDefault · RuntimeClass runc (default) o Kata
- Un Pod per ticket, non per scope. I worktree delle lane condividono un solo clone. I limiti di CPU e memoria valgono per l'intero ticket.
- I file tool girano dentro il Pod. Su una sola macchina girano sull'host e devono confinare ogni percorso. Nel Pod non c'è nulla da proteggere, perché la sandbox non contiene segreti.
- Variabili d'ambiente per nome. La chiave del modello di un agente
acpviene da un Secret inafe-sandboxgestito dall'operator, referenziato consecretKeyRef. Il worker non tocca mai il valore.
Rete senza dipendere dal CNI
Una NetworkPolicy Kubernetes da sola non basta, per due motivi:
- È solo un'API: la applica il plugin CNI, e alcuni (flannel, per esempio) la ignorano senza errori.
- Seleziona indirizzi e Pod, non nomi di host, quindi un'allowlist come
openrouter.ainon si può scrivere.
Per questo l'init container netlock imposta regole iptables nel network namespace del Pod,
la tecnica che Istio usa per il suo sidecar. Solo l'utente del proxy può aprire connessioni.
La sandbox raggiunge solo localhost:3128, dove il proxy applica l'allowlist. Nessuna query DNS
esce direttamente dal Pod. La sandbox non ha NET_ADMIN, quindi non può annullare le regole. Un
ticket senza egress non ha proxy, e non esce nulla.
Le regole si basano su netfilter e -m owner, che gVisor non implementa. Sotto gVisor, netlock
sarebbe un no-op, e il Pod sembrerebbe isolato senza esserlo. Il manifest rifiuta quindi un
runtimeClassName che nomina gVisor, e il runtime resta runc (default) o Kata.
Solo l'init container ha bisogno di NET_ADMIN, quindi il namespace afe-sandbox deve
permetterlo (livello Pod Security privileged, o un'eccezione in Kyverno o Gatekeeper). Dove il
CNI applica le policy, l'operator aggiunge una NetworkPolicy come secondo livello. Con
spec.afe.sandbox.networkPolicy.manage a true (il default) crea afe-sandbox-ingress nel
namespace sandbox: solo ingress, default deny. Con manage a false la policy è dell'admin e
l'operator non ne crea alcuna.
Git tramite bundle
Il Pod non riceve mai il token Git, e non ha mai bisogno della rete per Git.
clone worker: git clone con il token (cartella temporanea) ─▶ git bundle ─▶ exec stdin
─▶ Pod: git clone dal bundle
salva Pod: commit WIP ─▶ git bundle incrementale di afe/* ─▶ exec stdout ─▶ worker ─▶ store
ripristina worker: bundle dallo store ─▶ exec stdin ─▶ nuovo Pod: git fetch
push Pod: git bundle afe/<ticket>-<uid>/* ─▶ exec stdout ─▶ worker: git push con il token
Dopo ogni nodo che scrive file, il workspace viene committato e il suo bundle salvato nello store. La perdita di un nodo o di un Pod quindi non rompe la promessa di ripartire senza ripetere lavoro pagato. Il worker successivo ripristina il workspace su un Pod nuovo, e riparte dal checkpoint salvato.
Il limite è la dimensione del repository: un repository molto grande viene trasferito due volte al clone. Una cache dentro il cluster arriva se diventa un problema.
Un ticket dall'inizio alla fine
client ── ticket.start ──▶ serve ── fissa la revisione corrente, accoda ──▶ Redis
worker ── prende il job, acquisisce il lock del ticket (Redis)
── carica la revisione e il checkpoint del ticket (Postgres)
── apre il Pod del ticket, o lo ripristina dall'ultimo bundle
── nodo agent: chiamate al modello dal worker; ws.* e ws.exec via exec nel Pod
── il nodo ha scritto file ──▶ commit WIP, bundle salvato nello store
── eventi ──▶ Postgres + una notifica Redis ──▶ serve ──▶ client WebSocket
── il ticket finisce ──▶ Pod e PVC cancellati tramite la label afe.ticket
un worker muore ▸ il suo lock scade ▸ un altro worker prende il job
▸ riprende dal token salvato e ritrova il Pod
Revisioni della configurazione
cartella (afe serve -c) ─┐
├─▶ valida ─▶ normalizza (prompt inclusi, Runtime escluso)
ConfigMap indicata da configMapRef ─┘ ─▶ sha256 ─▶ tabella revisioni nello store (immutabile)
ticket.start ─▶ ticket.revision = la corrente ─▶ il worker la carica per hash ─▶ esegue
resume, recovery, la UI, un nuovo avvio dello stesso ticket ─▶ sempre la revisione del ticket
- Una configurazione nuova non cambia mai un ticket in corso, e non richiede di riavviare i worker. I worker leggono dallo store la revisione di ogni ticket.
- Una revisione congela la configurazione, non il codice. Un'immagine nuova del motore deve continuare a leggere i checkpoint dei ticket più vecchi. Il contratto è una finestra di una sola generazione del formato persistito (B25), e fuori da quella finestra il motore fallisce, senza rileggere in silenzio.
- L'operator legge la ConfigMap indicata da
spec.config.configMapRefnel namespace della CR. Scrive le chiavi come file, genera ilRuntimedaspec.afee valida l'insieme conafe validate. Un insieme non valido non pubblica nulla e porta i messaggi per campo inConfigValidated=False. spec.config.revisionè un pin opzionale. Quando corrisponde alla revisione calcolata i workload ruotano. Quando non corrisponde,RevisionPinned=False, reasonRevisionMismatch, nomina entrambi gli hash e nulla ruota.
I manifest come custom resource
V7c. I file che scrivi per una cartella sono gli stessi che passi a kubectl apply.
apiVersion: afe.dev/v1alpha1
kind: Schema
metadata:
name: text-request # una label DNS-1123: minuscole, cifre e `-`
annotations:
afe.dev/description: Cosa chiede l'utente
spec:
fields:
topic: string
Un oggetto Kubernetes porta alcune regole che un file semplice non ha.
| Regola | Perché |
|---|---|
| Nomi e id dei nodi sono label DNS-1123 (al massimo 63 caratteri). | Kubernetes rifiuta qualsiasi altro nome di oggetto. |
La descrizione è l'annotation afe.dev/description. | metadata.description non è un campo Kubernetes, e l'API server lo scarta. |
| Un prompt è testo inline, oppure un riferimento a un file che solo una cartella risolve (una ConfigMap su Kubernetes). | Una custom resource non ha file accanto. |
Nei when, - diventa _: text_request.topic == 'k8s'. | text-request.topic verrebbe letto come una sottrazione. |
Le custom resource hanno nomi completi (agents.afe.dev) e nomi brevi (afeagent). | Agent, Flow e Plugin sono nomi di kind molto comuni. |
Runtime non è una custom resource: l'operator lo genera da AgentFlowEngine.
L'operator
Un AgentFlowEngine descrive l'installazione. spec.config nomina la ConfigMap sorgente e un pin
opzionale alla revisione. spec.afe porta la semantica del Runtime (store, broker, tracer, sandbox).
spec.deployment porta le manopole dei workload. Le chiavi sconosciute vengono rifiutate.
apiVersion: afe.dev/v1alpha1
kind: AgentFlowEngine
metadata:
name: afe
namespace: afe
spec:
config:
configMapRef:
name: afe-source # la ConfigMap con i manifest dei flow
revision: 3f2a91c0d4e8 # pin opzionale; un mismatch congela il rollout
afe:
store:
external:
secretRef:
name: afe-postgres
key: dsn
# oppure managed: { instances: 3, storage: 20Gi } # un Cluster CloudNativePG
broker:
external:
secretRef:
name: afe-redis
key: AFE_REDIS_URL
# oppure managed: { storage: 2Gi } # uno StatefulSet Redis, senza auth
tracer:
otlp:
endpoint: http://otel-collector:4318
headersSecretRef: { name: otlp-headers }
sandbox:
namespace: afe-sandbox
storage: 2Gi
runtimeClassName: kata # runc (default) o Kata; gVisor è rifiutato
envSecret: afe-sandbox-env
deployment:
image:
repository: ghcr.io/agentflowengine/afe
tag: "0.7.0" # oppure digest: sha256:...
api:
replicas: 2
apiTokenSecretRef:
name: afe-secrets
key: AFE_API_TOKEN
workers:
replicas: 2
concurrency: 4
# La chiave del modello e il token Git restano entrambi solo al worker; serve non detiene
# credenziali.
envSecretRefs:
- { name: afe-secrets, key: OPENROUTER_API_KEY }
- { name: afe-secrets, key: GITHUB_TOKEN }
autoscaling:
minReplicas: 1
maxReplicas: 20
triggerAuthentication: keda-redis # una TriggerAuthentication fornita dall'admin
ingress:
className: nginx
host: afe.internal
tls:
- secretName: afe-tls
managedè opt-in, mai un default. Uno storemanagedè un soloClusterCloudNativePG, e l'operator referenzia il Secret-appche CloudNativePG scrive. Un brokermanagedè uno StatefulSet Redis senza autenticazione. Un servizio autenticato o esterno èexternal, e il suosecretRefviene proiettato nei workload solo per riferimento.- KEDA è opzionale. Con
workers.autoscalinge KEDA presente l'operator possiede unoScaledObjecte non scrivereplicassul Deployment del worker. Senza KEDA i worker girano amaxReplicas, eAutoscalingReady=False, reasonKEDAAbsent. - Gli orfani vengono ripuliti, non adottati. Il reaper rimuove gli oggetti
terminaldella sandbox e irunningpiù vecchi disandbox.orphanGrace(default30m). Tiene ipaused. - Cosa resta admin. I namespace, il ServiceAccount/Role/RoleBinding del worker, l'
envSecret, iSecretdei dati e le classi di storage, runtime e ingress. L'installer li spedisce come esempi sottodeploy/operator/admin/. - Nessun valore di segreto entra mai nella CR. Ogni segreto è un
*Ref(namepiùkeyopzionale), e l'operator lo nomina senza leggerlo. servenon tiene credenziali. Non esegue alcun harness e risolve i provider senza chiedere una chiave, quindi l'operator non proietta su di lui né le chiavi dei modelli né il token forge: le voci diworkers.envSecretRefse i mountenvFromrestano solo al worker, eserve— raggiungibile attraverso il Service dell'API — non è mai una via per raggiungere una credenziale del provider o del repository (AFE-293).
L'operator non adotta nulla. Se un oggetto desiderato esiste già con il nome giusto ma senza
l'ownerReference e la label dell'operator, l'operator non lo sovrascrive. Mette Ready=False,
reason ResourceConflict, ed emette un Evento che nomina l'oggetto estraneo. Il resto del reconcile
prosegue.
L'operator ripete sei passi a ogni reconcile.
1. configurazione leggi configMapRef ─▶ afe validate ─✗─▶ ConfigValidated=False, nulla cambia
─✓─▶ pubblica la revisione
2. dati un Cluster CNPG managed o il Secret esterno · un Redis managed o esterno
3. processi Deployment serve e worker · Service · Ingress · la ConfigMap di configurazione
4. scalabilità uno ScaledObject KEDA sul lag della coda, se KEDA è installato
5. pulizia gli oggetti sandbox che il reaper rimuove, riportati in status.orphansRemoved
6. status condition, revisione corrente, componenti, endpoint e conteggio orfani
Cosa serve e cosa no
Pochi pezzi portano peso vero. Molti pezzi Kubernetes comuni non servono affatto.
| Serve | Perché |
|---|---|
| CloudNativePG | serve e i worker si collegano al servizio -rw, con la chiave uri del Secret -app. I backup su S3 contano, perché checkpoint, ledger e bundle stanno lì. |
| Una storage class locale o ReadWriteOnce | Un volume per ogni Pod di ticket. |
| KEDA (opzionale) | Il suo scaler redis-streams legge il lag del consumer group, quindi il motore non esporta nulla in più per scalare. |
| Kata (consigliato). gVisor rifiutato. | Nei Pod dei ticket gira codice scritto da un modello. netlock richiede netfilter, che gVisor non implementa. |
| Non serve | Perché |
|---|---|
| Storage ReadWriteMany | I workspace stanno nei Pod dei ticket. |
| Calico o Cilium | netlock isola il Pod. Una NetworkPolicy è solo un secondo livello. |
| CNPG Pooler (PgBouncer) | Ogni processo ha il suo piccolo pool. Le migrazioni usano un advisory lock a livello di transazione, che il transaction mode di PgBouncer permette comunque. |
| Redis Sentinel o un operator Redis | La fonte di verità è Postgres, e all'avvio i worker rimettono in coda i ticket in corso. Basta uno StatefulSet con AOF. Funziona anche Valkey. |
| Argo, Tekton, un Job per ticket | Coda, lock del ticket e token di resume fanno già da scheduler. |
| Un demone Docker | I Pod dei ticket sostituiscono i container. |
In locale con il solo Docker
Due modalità girano senza un cluster vero.
| Modo | Cosa gira | Per |
|---|---|---|
| Docker | afe run --local, oppure make compose-up (un profilo Compose con serve --ui, un worker, Postgres, Redis e la UI). Un volume e un container per ticket. | sviluppo e demo. |
| kind o k3d | Kubernetes dentro Docker: i Pod dei ticket, netlock, i namespace e l'RBAC di deploy/kind/, e l'operator da deploy/operator/. | i test su kind (make kind-test) e un cluster simile alla produzione. |
Le sandbox Docker e Kubernetes condividono lo stesso modello: un volume più exec, con il workspace dentro il container o il Pod del ticket. In Compose il worker ha bisogno del socket Docker, che equivale a root sull'host. È accettabile su un portatile, non in produzione.
Limiti
- Una CI che ha bisogno di Docker (testcontainers, per esempio) non può girare in un Pod di ticket senza privilegi.
- I plugin stdio e i server MCP stdio girano nel container del worker: la sua immagine deve contenere i loro runtime, oppure girano come plugin HTTP in Deployment propri.
- Esporre
servefuori dal cluster richiede l'autenticazione del browser decisa in V7.
Risoluzione dei problemi
Un Pod di ticket non raggiunge mai Ready.
Controlla che il livello Pod Security del namespace sandbox permetta il NET_ADMIN dell'init
container, e che il nodo abbia capacità per sandbox.cpus/memory del ticket.
afe-kubernetes aspetta Ready e poi solleva SandboxGone, che il motore tratta come una
sandbox persa: rimuove Pod e PVC e rimette in coda il ticket.
Una chiamata dalla sandbox raggiunge un host fuori da sandbox.egress
Controlla che il runtime non sia gVisor. netlock richiede netfilter e -m owner, che gVisor
non implementa, quindi un ticket su gVisor sembrerebbe solo isolato. Per questo il manifest
rifiuta un runtimeClassName che nomina gVisor.
Il Pod e la PVC di un ticket ci sono ancora dopo la fine
Il motore li rimuove tramite l'etichetta afe.ticket quando il ticket raggiunge done,
failed o cancelled. Un crash del worker tra la fine del ticket e la rimozione può lasciare un
orfano. V7b non li cerca. L'operator di V7c riconcilia l'etichetta e rimuove e segnala gli
orfani.