Status: Accepted Datum: 2026-05-07 Oblast: Stopa B.4 / Kubernetes orchestrace
Context
Stopa B.4 navazuje na B.2 a B.3
ADR-015 zavedl NamespaceProvisioner — per-tenant K8s namespace s ResourceQuota a
LimitRange. ADR-016 zavedl DatabaseProvisioner — per-app PostgreSQL databázi a K8s
Secret app-{slug}-{env}-db se Spring datasource credentials.
Stopa B.4 přidává třetí klíčovou infrastrukturní operaci: deploymenty user-app workloadů
do Kubernetes. Po úspěšném buildu user-app image (Stopa B.0.x) orchestrátor Kai předá
image tag BE, které vytvoří v tenant namespace Deployment + Service.
User-app architektura — single fat-JAR model
TalkIDE generuje user-app projekty jako Spring Boot fat-JAR, který servisuje i FE static
resources. Vite build je při image sestavení zkopírován do src/main/resources/static/ —
výsledkem je jediný container obsluhující jak REST API, tak React/Vue frontend.
Důsledek: jeden image, jeden container, jeden port (8080). V dev prostředí není potřebný
separátní FE pod ani nginx sidecar.
Caller: Kai (Mara’s DevOps agent)
Kai je AI agent (Anthropic Claude Agent SDK) běžící jako Node.js sidecar v stejném BE podu. Kai orchestruje celý DevOps lifecycle user-app: trigger build, čekání na image, volání deploy endpointu. Kai má přístup k projektu a tenantovi z kontextu konverzace, ale nesmí nést žádný interní auth token — vše, co Kai pošle Anthropicu (volání nástrojů, logy), nesmí obsahovat credentials nebo JWT tokeny.
Deployment topology (zděděno z ADR-014)
| Prostředí | talkide.k8s.enabled | Wired bean |
|---|---|---|
local (výchozí) | false (matchIfMissing) | NoopAppDeployer |
cloud (prod pod) | true | K8sAppDeployer |
Decision
1. Single Pod model pro dev env
Deployment = 1 replica, 1 container. Žádný separátní FE pod, žádný nginx sidecar.
User-app Spring Boot fat-JAR servisuje FE statické soubory z embedded Tomcatu
(src/main/resources/static/ → /). Jeden image, jeden port 8080.
Důvod: alpha jednoduchost. Multi-replica + separate FE pod přijdou s prod prostředím
v Stopě B.7. B.4 záměrně volí nejmenší možný deployment unit.
2. Scope: pouze dev env
DeployAppUseCase (resp. K8sAppDeployer) přijímá parametr env: String a validuje,
že hodnota je "dev". Jakákoliv jiná hodnota způsobí IllegalArgumentException ještě
před voláním K8s API.
prod env přidá Stopa B.7 (Publish flow). Architektura je připravena — env je String,
ne enum — přidání prod nevyžaduje změnu interface.
3. Kubernetes resources (per app)
Naming convention: resource jméno = app-{slug}-{env} (example: app-demo-dev).
Deployment app-{slug}-dev v namespace tenant-{tenantSlug}
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-{slug}-dev
namespace: tenant-{tenantSlug}
labels:
app: user-app
tenant: {tenantSlug}
slug: {slug}
env: dev
spec:
replicas: 1
selector:
matchLabels:
app: user-app
slug: {slug}
env: dev
template:
metadata:
labels:
app: user-app
tenant: {tenantSlug}
slug: {slug}
env: dev
spec:
containers:
- name: app
image: {imageTag}
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: app-{slug}-dev-db # K8s Secret z Stopy B.3 (ADR-016)
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
Resource limits vychází z CLAUDE.md “Resource limits per pod”:
- User app BE pod: ~800 MB → request 512Mi / limit 1Gi (buffer pro JVM warm-up)
- CPU request 200m reflektuje idle Spring Boot; limit 1000m dává prostor při zpracování requestů
Labels na Deployment i Pod template jsou totožné — selector + labels musejí být konzistentní.
Label tenant je navíc užitečný pro kubectl get pods -l tenant=acme debugging.
envFrom: secretRef: app-{slug}-dev-db — Secret byl vytvořen v Stopě B.3 (ADR-016).
Obsahuje klíče SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD.
Spring Boot je automaticky binduje na spring.datasource.* — nulová konfigurace na straně
user-app.
Probes předpokládají, že user-app BE má Spring Boot Actuator (spring-boot-starter-actuator)
a standardní health endpoints. Generovaný scaffold to zahrne jako default dependency.
initialDelaySeconds=60 dává prostor pro JVM startup + Liquibase migrace + HikariCP warm-up.
Service app-{slug}-dev v namespace tenant-{tenantSlug}
apiVersion: v1
kind: Service
metadata:
name: app-{slug}-dev
namespace: tenant-{tenantSlug}
labels:
app: user-app
tenant: {tenantSlug}
slug: {slug}
env: dev
spec:
type: ClusterIP
selector:
app: user-app
slug: {slug}
env: dev
ports:
- port: 80
targetPort: 8080
ClusterIP — internal only. Ingress (Stopa B.5) vytvoří veřejně přístupnou cestu
na {slug}.talkide.app. B.4 záměrně nevytváří ingress.
4. Caller Kai — localhost-only internal endpoint
Kai nesmí nést auth token (riziko leaku do Anthropic logs při tool-call serializaci).
Řešení: localhost-only listener — interní endpoint poslouchá pouze na 127.0.0.1, ne
na 0.0.0.0. Důvěra je postavena na co-location v podu: jiný pod nemůže poslat request
na cizí localhost interface (K8s síťová topologie).
Zvolen přístup: Spring Security IP filter (RemoteAddrFilter)
Alternativa A (separate server connector) by vyžadovala konfiguraci druhého embedded Tomcat connectoru na jiném portu, úpravu K8s Service/liveness probe portů a větší konfigurační overhead. Spring Security IP filter je jednodušší, bez změny port topologie:
@Configuration
class InternalEndpointSecurityConfig {
@Bean
fun internalFilterChain(http: HttpSecurity): SecurityFilterChain {
http.securityMatcher("/api/internal/**")
.authorizeHttpRequests { auth ->
auth.requestMatchers(RemoteAddrRequestMatcher("127.0.0.1")).permitAll()
.anyRequest().denyAll()
}
.csrf { it.disable() }
return http.build()
}
}
Requst z non-localhost IP na /api/internal/** → HTTP 403 (SecurityFilterChain denyAll).
Endpoint je de facto neviditelný pro vnější síť (ingress-nginx neexposuje /api/internal/
— Stopa B.5 to zadrží na ingress pravidlech).
API kontrakt interního endpointu:
POST /api/internal/deploy
Content-Type: application/json
{
"tenantId": 1,
"slug": "demo",
"imageTag": "registry.digitalocean.com/talkide/demo:abc123"
}
{
"deploymentName": "app-demo-dev",
"serviceName": "app-demo-dev",
"status": "applied"
}
imageTag je v alpha předáván as-is bez validace registry prefixu (viz rozhodnutí 8).
5. Idempotence (redeploy stejného slug)
Před každou operací: client.apps().deployments().inNamespace(ns).withName(name).get().
| Resource | Existuje? | Akce |
|---|---|---|
| Deployment | Ne | create() |
| Deployment | Ano | patch() s novým imageTag → K8s rolling update (single replica = ~30s downtime, OK pro alpha) |
| Service | Ne | create() |
| Service | Ano | no-op (Service spec se nemění mezi deploymenty) |
patch() na Deployment s novým image triggeruje K8s rolling restart podu. Pro 1 repliku
= downtime ~30 s (čas na shutdown starého + start nového podu). Akceptovatelné pro dev env.
Implementace patch přes fabric8:
// Pseudokód — ilustrace patch vzoru
val existing = client.apps().deployments().inNamespace(ns).withName(name).get()
if (existing != null) {
client.apps().deployments().inNamespace(ns).withName(name)
.edit { d ->
d.spec.template.spec.containers[0].image = imageTag
d
}
log.info("Patched Deployment '{}/{}' with new imageTag", ns, name)
} else {
client.apps().deployments().inNamespace(ns).resource(buildDeployment(...)).create()
log.info("Created Deployment '{}/{}'", ns, name)
}
6. Error handling
K8sAppDeployer nezachytává KubernetesClientException — propaguje ji ven.
Caller (Kai) obdrží HTTP 500 z internal endpoint. Kai loguje chybu a Mara může reportovat do UI “Deploy failed”.
B.4 nedělá rollback — nemá vlastní transactional state. K8s Secret (B.3) a namespace (B.2) zůstávají nedotčeny. Přízte retry volání Kai → B.4 → idempotentní create nebo patch.
Mapování výjimky na HTTP response v @RestControllerAdvice:
KubernetesClientException → 500 Internal Server Error
{ "code": "K8S_DEPLOY_ERROR", "message": "<root cause message>" }
IllegalArgumentException (env != "dev") → 400 Bad Request
{ "code": "VALIDATION", "message": "Only 'dev' env is supported in B.4" }
7. Konfigurační pattern (Variant B — konzistentní s ADR-014/015/016)
AppDeployer (Kotlin interface — v package features/deployment/)
fun deployApp(tenantId: Long, slug: String, env: String, imageTag: String): AppDeploymentResult
K8sAppDeployer (@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "true"))
- inject: KubernetesClient (fabric8)
- inject: TenantRepository (lookup tenantSlug → namespace name)
- implementace: create/patch Deployment + ensure Service
NoopAppDeployer (@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "false", matchIfMissing = true))
- log volání, vrátí stub AppDeploymentResult
@ConditionalOnProperty(matchIfMissing=true) na Noop je deterministický fallback —
konzistentní s erratou z ADR-014 (Spring Boot 3.4.4 @ConditionalOnMissingBean není
spolehlivý v test context loaderu).
Package: features/deployment/ — deployment je oddělený concern od namespace provisioning
(features/k8s/) a DB provisioning (features/database/). Nový developer vidí jeden vzor
napříč všemi třemi features/ packages.
8. Image registry validace: odložena
V alpha Kai předává imageTag as-is (např. registry.digitalocean.com/talkide/demo:abc123).
K8sAppDeployer ho předá K8s API bez validace registry prefixu.
Bezpečnostní riziko: Kai by mohl (omylem nebo při prompt injection) předat image z cizího
registry. Odloženo jako tech debt post-alpha: přidat validaci, že imageTag začíná
registry.digitalocean.com/talkide/ (konfigurovaný prefix v application.yaml).
9. Test plán
| Vrstva | Tool | Rozsah |
|---|---|---|
Unit — K8sAppDeployer | mockito-kotlin, explicit mock variables | ~6 testů |
Unit — NoopAppDeployer | mockito-kotlin | ~3 testy |
| Integration smoke | Testcontainers k3s | 1 test (~3 min) |
| Controller | MockMvc | 1 test internal endpoint IP filter |
Unit testy K8sAppDeployer (mockito-kotlin):
Explicitní mock variables per resource type (ne RETURNS_DEEP_STUBS — deep stubs jsou
nestabilní při verify() a obtížně čitelné v stack trace):
val deploymentsOps = mock<NonNamespaceOperation<Deployment, ...>>()
val deploymentInNs = mock<MixedOperation<Deployment, ...>>()
val deploymentWithName = mock<RollableScalableResource<Deployment>>()
// ... atd. per resource type
Pokryté scénáře:
- Happy path — nový deploy (Deployment neexistuje →
create()) - Redeploy — Deployment existuje →
edit()s novým imageTag - Service již existuje → no-op (žádný druhý
create()) - K8s API error při create →
KubernetesClientExceptionpropaguje - Tenant not found →
IllegalArgumentExceptionpropaguje env != "dev"→IllegalArgumentExceptionpřed K8s volání
Integration smoke test (Testcontainers k3s):
Deploy nginxinc/nginx-unprivileged:alpine jako stub user app (má port 80, ale K8s Deployment
port 8080 přijme — liveness probe bude failing, ale smoke test ověřuje jen K8s resource creation).
Podmínka průchodu: Deployment.status.readyReplicas == 1 s timeout 3 minuty.
Controller test (MockMvc):
Verify, že request z non-localhost IP → denyAll() (HTTP 403). Request z 127.0.0.1
→ 200 (s mock AppDeployer).
10. Scope B.4 — úzce vymezený
Zahrnuto v B.4:
AppDeployerKotlin interface +AppDeploymentResultdata classK8sAppDeployerimplementace s@ConditionalOnPropertyNoopAppDeployerimplementace s@ConditionalOnProperty(matchIfMissing=true)- Internal endpoint
POST /api/internal/deploy(localhost-only přes Spring Security IP filter) - Spring Security konfigurace pro
/api/internal/** - Idempotence (get → create nebo patch)
- Unit testy + k3s smoke test + controller test
Mimo scope (delegováno):
| Feature | Stopa / Issue |
|---|---|
| Ingress / veřejná URL | B.5 |
| Pod status watcher / SSE → UI streaming | B.6 |
| Build pipeline / vytvoření image | B.0.x (Kai předává cokoliv) |
prod env / Publish trigger | B.7 |
| Rolling zero-downtime / multi-replica | prod env, B.7 |
| Liquibase init Job | User-app Spring Boot Liquibase autostart při bootu |
| NetworkPolicy default-deny | Odložená položka z B.2, pro B.4 znovu nepřidávána |
| Image registry prefix validace | Tech debt post-alpha |
Consequences
Pozitiva
- Konzistence —
AppDeployerwrapper zapadá do patternu ADR-014/015/016 bez výjimek; nový developer vidí identický vzor napříč všemi provisionery. - Alpha jednoduchost — single pod model minimalizuje operační overhead; žádný ingress, žádný autoscaler, žádný PDB v B.4.
- Sdílí tenant ns + DB pattern z B.2/B.3 —
K8s Secretz ADR-016 je přímo consumován přesenvFrom: secretRef; nulová konfigurace na straně user-app. - Idempotence — Kai může volat deploy opakovaně (restart, retry); výsledek je vždy konzistentní K8s stav.
- Snadný unit test — explicit mock variables per resource type (mockito-kotlin) jsou přehledné a deterministické; žádné deep stubs.
- Localhost-only listener — jednoduchý IP filter bez nutnosti separátního portu; žádná změna K8s Service nebo probe konfigurace.
Rizika a omezení
-
Single pod = 30s downtime při redeploy — akceptujeme pro alpha
devprostředí.prodenv v Stopě B.7 přinese rolling update s více replikami. -
Localhost-only listener předpokládá Kai v stejném podu — pokud by Kai běžel v separátním podu (budoucí architekturní změna), internal endpoint by byl nedostupný. Toto omezení musí být zachyceno v talkide-be Deployment manifestu: BE + Node sidecar = jeden pod, ne dva separátní pody.
-
Image registry prefix validace odložena — alpha trust model; Kai předává image tag bez BE-side ověření. Post-alpha: přidat validátor na
registry.digitalocean.com/talkide/prefix (konfigurovatelný prefix vapplication.yaml). -
NetworkPolicy mezi tenant namespaces odložena — evidováno již v ADR-015 (B.2) a znovu v B.4 nepřidávána. Cross-tenant síťová izolace chybí do implementace NetworkPolicy (budoucí standalone issue).
-
Spring Actuator jako prerekvizita user-app — liveness/readiness probes předpokládají
/actuator/health/*endpointy. Generovaný scaffold musí zahrnoutspring-boot-starter-actuatorjako default dependency — toto je mimo scope B.4 (patří do scaffold template ticketu).
Alternatives Considered
Separate Tomcat connector na 127.0.0.1:9091 pro internal endpoints
Zvažováno. Výhoda: fyzická separace portů, jasně viditelné v K8s manifest (dva porty v
container spec). Nevýhoda: větší konfigurační overhead (custom EmbeddedServletContainerCustomizer,
úprava liveness probe portu, Spring Boot dokumentace je pro multi-connector sparse).
Spring Security IP filter dosáhne stejného výsledku jednoduššeji — odmítnuto ve prospěch
filtru.
@Profile("cloud") místo @ConditionalOnProperty
Odmítnuto — stejné zdůvodnění jako ADR-014. @ConditionalOnProperty je explicitní
a property lze nastavit nezávisle na profilu (lokální testování s kubeconfig). Konzistentní
s existujícím patternem napříč k8s package.
RETURNS_DEEP_STUBS v unit testech
Odmítnuto — deep stubs jsou obtížně debugovatelné při selhání (verify() na deep stub
vrátí matoucí stack trace). Explicit mock variables per resource type jsou více kódu ale
výrazně čitelnější; pattern je zavedený z B.2 unit testů.
Async deploy (Kafka event / Spring batch job)
Zvažováno, odmítnuto pro B.4 scope. Synchronní HTTP request/response model v internal
endpoint je jednoduché — Kai čeká na 200 OK před reportováním uživateli. Async by
vyžadoval polling nebo callback mechanismus pro Kaie. Synchronní K8s create()/edit()
trvá typicky < 500 ms → latence je akceptovatelná.
Implementation Notes
- Issue: talkide-be#17 — https://gitlab.com/talkide/talkide-be/-/work_items/17
- ADR-014 (B.1): K8s client foundation —
KubernetesClientbean, fabric8 DSL patterns - ADR-015 (B.2): NamespaceProvisioner —
tenant-{tenantSlug}namespace name convention - ADR-016 (B.3): DatabaseProvisioner —
app-{slug}-dev-dbSecret name,envFrompattern - Navazující: #22 / Stopa B.5 (Ingress), #23 / Stopa B.6 (Pod status watch loop)
- talkide-be#44 (slug RFC-1123 validator): slug vstupuje i do deployment resource jmen;
nevalidní slug = K8s API error —
blocking::public-alphaplatí i pro B.4
Errata
2026-05-07 — Smoke test assertion correction
Sekce 9 (Test plán) “Integration smoke test” původně specifikovala podmínku průchodu
Deployment.status.readyReplicas == 1. To je nedosažitelné, protože stub image
(nginxinc/nginx-unprivileged:alpine) neimplementuje /actuator/health/* endpointy
na portu 8080 — readiness probe by trvale failovala a pod by nikdy nepřešel do Ready.
Změna: smoke test ověřuje:
Deployment.status.observedGeneration > 0(K8s API přijala manifest)Deployment.spec.replicas == 1(deployment specifikace je správná)- Service existuje s odpovídajícím selectorem
To ověří fabric8 wiring + správnost K8s manifest struktury, což je skutečný účel smoke testu. User-app health je mimo scope smoke testu (prověřuje to manuální / E2E testing v cloudu po B.4).
Thanks for the feedback.