Internal Documentation internal
TalkIDE internal documentation

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.enabledWired bean
local (výchozí)false (matchIfMissing)NoopAppDeployer
cloud (prod pod)trueK8sAppDeployer

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().

ResourceExistuje?Akce
DeploymentNecreate()
DeploymentAnopatch() s novým imageTag → K8s rolling update (single replica = ~30s downtime, OK pro alpha)
ServiceNecreate()
ServiceAnono-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

VrstvaToolRozsah
Unit — K8sAppDeployermockito-kotlin, explicit mock variables~6 testů
Unit — NoopAppDeployermockito-kotlin~3 testy
Integration smokeTestcontainers k3s1 test (~3 min)
ControllerMockMvc1 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:

  1. Happy path — nový deploy (Deployment neexistuje → create())
  2. Redeploy — Deployment existuje → edit() s novým imageTag
  3. Service již existuje → no-op (žádný druhý create())
  4. K8s API error při create → KubernetesClientException propaguje
  5. Tenant not found → IllegalArgumentException propaguje
  6. env != "dev"IllegalArgumentException př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:

  • AppDeployer Kotlin interface + AppDeploymentResult data class
  • K8sAppDeployer implementace s @ConditionalOnProperty
  • NoopAppDeployer implementace 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):

FeatureStopa / Issue
Ingress / veřejná URLB.5
Pod status watcher / SSE → UI streamingB.6
Build pipeline / vytvoření imageB.0.x (Kai předává cokoliv)
prod env / Publish triggerB.7
Rolling zero-downtime / multi-replicaprod env, B.7
Liquibase init JobUser-app Spring Boot Liquibase autostart při bootu
NetworkPolicy default-denyOdložená položka z B.2, pro B.4 znovu nepřidávána
Image registry prefix validaceTech debt post-alpha

Consequences

Pozitiva

  • KonzistenceAppDeployer wrapper 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.3K8s Secret z ADR-016 je přímo consumován přes envFrom: 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í

  1. Single pod = 30s downtime při redeploy — akceptujeme pro alpha dev prostředí. prod env v Stopě B.7 přinese rolling update s více replikami.

  2. 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.

  3. 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 v application.yaml).

  4. 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).

  5. Spring Actuator jako prerekvizita user-app — liveness/readiness probes předpokládají /actuator/health/* endpointy. Generovaný scaffold musí zahrnout spring-boot-starter-actuator jako 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#17https://gitlab.com/talkide/talkide-be/-/work_items/17
  • ADR-014 (B.1): K8s client foundation — KubernetesClient bean, fabric8 DSL patterns
  • ADR-015 (B.2): NamespaceProvisioner — tenant-{tenantSlug} namespace name convention
  • ADR-016 (B.3): DatabaseProvisioner — app-{slug}-dev-db Secret name, envFrom pattern
  • 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-alpha platí 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).


Was this page helpful?

Thanks for the feedback.