Status: Accepted Datum: 2026-05-08 Oblast: Stopa B.5 / Kubernetes orchestrace
Context
Stopa B.5 navazuje na B.4
ADR-017 zavedl AppDeployer — per-app Deployment + ClusterIP Service v namespace
tenant-{tenantSlug}. Service je ale dostupná pouze interně v clusteru. Stopa B.5 přidává
Ingress s TLS, díky čemuž je user-app dostupná z veřejného internetu.
Dosud po úspěšném deployi Kai obdržel 200 OK s deploymentName a serviceName, ale
URL pro uživatele neexistovala. B.5 uzavírá tuto mezeru: pipeline se prodlouží o
IngressProvisioner krok a uživatel dostane funkční HTTPS URL.
Architektura DEV/PROD prostředí
TalkIDE provozuje dva paralelní životní cykly per projekt:
| Prostředí | URL pattern | Trigger | DB |
|---|---|---|---|
| DEV (Preview) | {projectUuid}.talkide.app | Každý deploy (Mara commit) | Per-deploy vlastní DB |
| PROD (Published) | {slug}.talkide.app | Explicitní Publish trigger (B.7) | Persistent vlastní DB |
Oba env běží paralelně v namespace tenant-{tenantSlug} se svými vlastními K8s resources.
projectUuid je persistent per projekt (generuje se při Create Project, neregeneruje při
redeploy) — zajišťuje stabilní “share preview link” URL pro sdílení WIP buildu.
B.5 se zaměřuje výhradně na DEV prostředí. PROD env (Publish flow) přidá Stopa B.7 spolu s DRAFT→PUBLISHED state machine.
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.
Po úspěšném B.4 deploy Kai rozšíří volání internal endpointu o projectUuid. BE provede
AppDeployer → IngressProvisioner jako jednu atomickou operaci.
Kai nesmí nést žádný interní auth token (riziko leaku do Anthropic logs při tool-call serializaci) — internal endpoint zůstává localhost-only jako v B.4 (ADR-017, sekce 4).
Deployment topology (zděděno z ADR-014)
| Prostředí | talkide.k8s.enabled | Wired bean |
|---|---|---|
local (výchozí) | false (matchIfMissing) | NoopIngressProvisioner |
cloud (prod pod) | true | K8sIngressProvisioner |
Decision
1. Konfigurační pattern (Variant B — konzistentní s ADR-014/015/016/017)
IngressProvisioner (Kotlin interface — v package features/deployment/)
fun provisionIngress(tenantId: Long, slug: String, env: String, projectUuid: String): IngressProvisioningResult
K8sIngressProvisioner (@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "true"))
- inject: KubernetesClient (fabric8)
- inject: TenantRepository (lookup tenantSlug → namespace name)
- implementace: get-or-create|patch Ingress s TLS
NoopIngressProvisioner (@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "false", matchIfMissing = true))
- log volání, vrátí stub IngressProvisioningResult
@ConditionalOnProperty(matchIfMissing=true) na Noop je deterministický fallback —
konzistentní s erratou z ADR-014 (@ConditionalOnMissingBean není spolehlivý v test
context loaderu při Spring Boot 3.4.4).
2. Package: features/deployment/
IngressProvisioner patří do features/deployment/ — stejný package jako AppDeployer
(B.4). Ingress je přímé pokračování deployment lifecycle: bez Ingress není user-app
veřejně dostupná. Oddělení do hypotetického features/k8s/ingress/ by rozbilo
koherenci deployment pipeline a komplikovalo orientaci pro nového developera.
3. Jeden Ingress per app (ne shared Ingress)
Každá user-app dostane vlastní Ingress resource:
- Jméno:
app-{slug}-{env}(example:app-demo-dev) - Namespace:
tenant-{tenantSlug}
Odmítnutý přístup — shared Ingress v platformním namespace talkide s dynamickými
rules — by vyžadoval centrální write přístup a riskoval race condition při souběžných
deployích. Výhody per-app Ingress:
- Izolace: smazání projektu =
kubectl delete ingress app-{slug}-devv namespace, žádná modifikace sdíleného resource - Paralelní DEV + PROD: každé prostředí má vlastní Ingress bez konfliktu rules
- Čitelnost:
kubectl get ingress -n tenant-acmezobrazí přesně apps v tomto tenantu - RBAC: namespace-scoped oprávnění; BE pod nepotřebuje cluster-wide write na Ingress
4. DEV hostname: {projectUuid}.talkide.app
UUID je persistent per projekt — generuje se při Create Project jako UUID v6
(time-ordered, lepší pro DB indexing než v4), ukládá se do sloupce projects.uuid.
Neregeneruje se při redeploy.
- Forma v URL: lowercase, dashes —
01959e8c-6c2e-7fd5-a0e0-abc123456789.talkide.app - Wildcard cert pokrytí:
*.talkide.appmatchuje jeden level subdomény → UUID hostname je validně pokryt - Reserved slug validator (talkide-be#38) UUID nekontroluje — UUID je v oddělené
subdomain namespace a nemůže kolidovat s rezervovanými slovy jako
api,wwwapod. - Stabilní “share preview link”: Kai může vygenerovat URL ihned po první deploy; URL zůstane platná i po dalších redeploy stejného projektu
5. TLS strategie: secret-copy do tenant namespace
Wildcard certifikát *.talkide.app je spravován cert-managerem jako K8s Secret
talkide-tls-cert v platformním namespace talkide. Kubernetes neumí cross-namespace
secret reference — Ingress v tenant-{slug} ns nemůže přímo odkazovat na secret z talkide ns.
Zvolená strategie: zkopírovat secret do tenant namespace při namespace provisioning.
Pattern je konzistentní s registry-talkide pull secret, který K8sNamespaceProvisioner
(ADR-015 / B.2) již kopíruje do každého nového tenant namespace. Rozšíření:
K8sNamespaceProvisioner zkopíruje také talkide-tls-cert při vytvoření namespace.
Výhody:
- Každý Ingress má explicitní
tlsblok sesecretName: talkide-tls-cert— čitelné a debuggable bez znalosti cert-manager internals - Vyhneme se per-app cert (Let’s Encrypt rate limit: 50 certů / doménu / týden)
- Nulová závislost na cert-manager annotation na Ingress — cert je již hotov
Cert renewal follow-up: cert-manager renewuje secret v platformním namespace talkide.
Po renewal je nutné re-syncovat nový secret do všech tenant ns. Mechanismus — rozšíření
K8sNamespaceProvisioner o scheduled job/watcher — je odložen jako post-MVP follow-up.
Bez tohoto stepu wildcard cert expiruje za 90 dní od posledního kopírování (Let’s Encrypt
TTL). Issue evidovat před public-alpha launch.
6. Scope B.5: pouze DEV env
K8sIngressProvisioner přijímá parametr env: String a validuje, že hodnota je "dev".
Jakákoliv jiná hodnota způsobí IllegalArgumentException před voláním K8s API.
prod env přidá Stopa B.7. Architektura je záměrně připravena — env je String, ne
enum — přidání prod nevyžaduje změnu interface.
7. Kubernetes Ingress manifest
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-{slug}-dev
namespace: tenant-{tenantSlug}
labels:
app: user-app
tenant: {tenantSlug}
slug: {slug}
env: dev
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
# cert-manager.io/cluster-issuer záměrně vynechán — TLS cert je kopírovaný secret,
# ne dynamicky vydávaný; cert-manager annotation by způsobil konflikt
spec:
ingressClassName: nginx
tls:
- hosts:
- "{projectUuid}.talkide.app"
secretName: talkide-tls-cert # zkopírovaný do tenant ns z platformního ns
rules:
- host: "{projectUuid}.talkide.app"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app-{slug}-dev # ClusterIP Service z B.4 (ADR-017)
port:
number: 80
Poznámky k manifestu:
ssl-redirect: "true"— HTTP → HTTPS redirect je povinný; žádný plaintext přístupcert-manager.io/cluster-issuerannotation je záměrně vynechán — přítomnost annotation by spustil cert-manager, který by se pokusil vydat nový cert a kolidoval by s existujícím zkopírovaným secret- Service backend
port: 80odpovídá ClusterIP Service z ADR-017 (mapuje na containerPort 8080)
8. Idempotence (redeploy)
Get-or-create|patch pattern — konzistentní s ADR-017 (AppDeployer):
| Ingress existuje? | Akce |
|---|---|
| Ne | create() |
| Ano | patch() s aktuálními rules a tls |
patch() na Ingress triggeruje ingress-nginx reload (~sekunda), žádný downtime na BE side.
Idempotence umožňuje Kaie volat deploy opakovaně bez vedlejších efektů.
9. Integrace do DeployAppUseCase — atomic operation
IngressProvisioner je volán z DeployAppUseCase po AppDeployer.deployApp().
Pipeline:
BuildService → AppDeployer.deployApp() → IngressProvisioner.provisionIngress()
Volání zůstávají synchronní. Selže-li IngressProvisioner, AppDeployer již uspěl —
DeployAppUseCase zaznamená warning do logu a vrátí partial result (deployment OK,
ingress fail). Kai obdrží HTTP 200 s ingressStatus: "failed" a může zkusit retry
(idempotence zaručuje čistý výsledek).
Rozšíření API kontraktu internal endpointu (rozšíření z B.4):
POST /api/internal/deploy
Content-Type: application/json
{
"tenantId": 1,
"slug": "demo",
"imageTag": "registry.digitalocean.com/talkide/demo:abc123",
"projectUuid": "01959e8c-6c2e-7fd5-a0e0-abc123456789"
}
{
"deploymentName": "app-demo-dev",
"serviceName": "app-demo-dev",
"ingressName": "app-demo-dev",
"previewUrl": "https://01959e8c-6c2e-7fd5-a0e0-abc123456789.talkide.app",
"deploymentStatus": "applied",
"ingressStatus": "applied"
}
ingressStatus nabývá hodnot applied (úspěch) nebo failed (chyba při provisioning).
previewUrl je vždy vrácena (BE ji sestaví ze vstupního projectUuid), i pokud
ingressStatus == "failed" — Kai může URL zobrazit uživateli, ale s varováním.
10. Error handling
K8sIngressProvisioner nezachytává KubernetesClientException — propaguje ji ven.
DeployAppUseCase ji zachytí, zapíše warning log a vrátí partial result (viz sekce 9).
Mapování výjimek na HTTP response v @RestControllerAdvice:
KubernetesClientException (ingress) → partial result, HTTP 200 s ingressStatus: "failed"
IllegalArgumentException (env != "dev") → 400 Bad Request
{ "code": "VALIDATION", "message": "Only 'dev' env is supported in B.5" }
11. Ingress nevystavuje /api/internal/
ingress-nginx rules pro TalkIDE platformní URL (api.talkide.app) neexposují
/api/internal/** cestu. User-app Ingress rules (uuid.talkide.app) míří výhradně na
user-app ClusterIP Service — internal endpoint BE platformy je fyzicky v jiném namespace
a jiné Service. Žádná explicitní deny rule není nutná; routování zajišťuje K8s namespace
izolace.
12. Přidání projectUuid sloupce do projects tabulky
B.5 vyžaduje, aby projects tabulka obsahovala sloupec uuid VARCHAR(36) NOT NULL UNIQUE.
Liquibase migrace přidá sloupec a seed data ho naplní. CreateProjectUseCase generuje
UUID v6 při vytvoření projektu.
UUID v6 se volí namísto v4 z důvodu time-ordered generování — lepší pro B-tree index v PostgreSQL (nové záznamy jsou sekvenčně za starými, méně page splits). Vizuálně jsou v URL lépe čitelné — prefix kóduje čas vzniku.
13. Test plán
| Vrstva | Tool | Rozsah |
|---|---|---|
Unit — K8sIngressProvisioner | mockito-kotlin, explicit mock variables | ~5 testů |
Unit — NoopIngressProvisioner | mockito-kotlin | ~3 testy |
Integration smoke — K3sIngressSmokeIntegrationTest | Testcontainers k3s | 1 test |
| Controller test (rozšíření B.4 testu) | MockMvc | aktualizovat existující test |
Unit testy K8sIngressProvisioner (mockito-kotlin):
Explicitní mock variables per resource type (konzistentní s ADR-017):
val ingressOps = mock<NonNamespaceOperation<Ingress, ...>>()
val ingressInNs = mock<MixedOperation<Ingress, ...>>()
val ingressWithName = mock<Resource<Ingress>>()
Pokryté scénáře:
- Happy path — nový Ingress (neexistuje →
create()) - Redeploy — Ingress existuje →
patch()s aktuálními rules - Delete Ingress (cleanup při smazání projektu)
- K8s API error při create →
KubernetesClientExceptionpropaguje env != "dev"→IllegalArgumentExceptionpřed K8s voláním
Integration smoke test (Testcontainers k3s):
Vytvoř Ingress, ověř:
Ingress.spec.rules[0].host == "{projectUuid}.talkide.app"(spec je správná)Ingress.spec.tls[0].secretName == "talkide-tls-cert"(TLS blok je přítomen)Ingress.metadata.labels["env"] == "dev"(labels jsou správné)
V k3s bez load balanceru Ingress.status.loadBalancer.ingress bude prázdné — postačí
ověřit spec (stejný přístup jako smoke test AppDeployer z ADR-017 errata).
Consequences
Pozitiva
- Konzistence —
IngressProvisionerwrapper zapadá do patternu ADR-014/015/016/017 bez výjimek; nový developer vidí identický Variant B vzor napříč všemi provisionery. - Atomic deploy pipeline — Kai volá jeden endpoint, BE orchestruje AppDeployer + IngressProvisioner; žádný 2-step choreography na straně Kaie.
- Stabilní preview URL —
projectUuidse negeneruje při redeploy; uživatel může sdílet odkaz na WIP build; URL zůstane platná po celý lifecycle projektu. - Izolace per-app Ingress — smazání projektu = čisté smazání namespace (vč. Ingress); žádná modifikace sdíleného resource.
- Idempotence — Kai může volat deploy opakovaně; výsledek je vždy konzistentní K8s stav.
- Partial result handling — selže-li Ingress provisioning, deployment zůstane živý; retry opraví situaci bez potřeby full-redeploy.
Rizika a omezení
-
Cert renewal bez automatické re-sync — secret
talkide-tls-certzkopírovaný do tenant ns není automaticky aktualizován při cert-manager renewal. Bez follow-up implementace watcher/scheduleru expiruje TLS cert za 90 dní od posledního kopírování. Nutno evidovat jakoblocking::public-alphaissue před launch. -
UUID v URL — UUID v6 je méně přívětivé než slug (
01959e8c-....talkide.app), ale zajišťuje stabilitu a neduplicitnost. Uživatelsky přívětivý stable URL = PROD slug v B.7. -
Single wildcard cert = flat namespace —
*.talkide.apppokrývá jen jeden subdomain level. Multi-level subdomény (např.dev.{uuid}.talkide.app) by vyžadovaly druhý wildcard certifikát. Pro MVP flat namespace postačuje. -
Custom domény odloženy — uživatelé nemohou v B.5 přidat vlastní doménu (cname na user-app). Vyžaduje per-app TLS cert (cert-manager + DNS challenge) — post-alpha feature.
-
Localhost-only internal endpoint předpokládá Kai v stejném podu — konzistentní omezení z ADR-017; Kai musí zůstat Node.js sidecar v BE podu.
Alternatives Considered
TLS strategie A: per-app cert (cert-manager cluster-issuer annotation)
Cert-manager vydá Let’s Encrypt cert pro každý {uuid}.talkide.app dynamicky při
vytvoření Ingress. Výhoda: zero-copy, cert je přímo v tenant ns. Nevýhody:
- Let’s Encrypt rate limit: 50 certů / registrovaná doména / týden — při aktivní platformě (desítky projektů) dosažitelný limit
- Latence: první request na novou app čeká na cert issuance (~30 s)
- Komplexita: cert-manager musí mít přístup k DNS challenge pro
*.talkide.app
Odmítnuto ve prospěch secret-copy strategie (wildcard cert je jeden cert pro všechny apps).
TLS strategie B: default-ssl-certificate flag ingress-nginx
ingress-nginx podporuje globální --default-ssl-certificate flag — Ingress bez tls bloku
dostane wildcard cert automaticky. Výhoda: žádné kopírování secret. Nevýhody:
- Ingress manifesty nemají explicitní
tlsblok — obtížně debuggable (není vidět, jaký cert je použit bez inspekce nginx config) - Vyžaduje změnu ingress-nginx Helm values (globální flag) — infrastrukturní zásah mimo scope B.5
- Ztráta per-Ingress flexibility pro budoucí custom domény
Odmítnuto ve prospěch explicitního tls bloku s zkopírovaným secret.
Shared Ingress v platformním namespace talkide
Jeden Ingress resource v ns talkide s dynamicky spravovanými rules pro každou
user-app. Výhoda: jeden resource místo N. Nevýhody:
- Race condition při souběžných deployích (dva BE instance modifikují stejný resource)
- Smazání projektu vyžaduje patch shared resource (ne čisté delete)
- BE pod potřebuje cluster-wide write oprávnění na Ingress (narušuje least-privilege)
- Debugging:
kubectl get ingress -n talkidezobrazí stovky rules dohromady
Odmítnuto ve prospěch per-app Ingress.
UUID v4 místo UUID v6 pro projectUuid
UUID v4 je plně náhodný — žádná časová struktura. V URL jsou vizuálně identické. Nevýhoda: náhodné UUID způsobují random page splits v B-tree indexu PostgreSQL při insertech — výkonnostní degradace při škálování. UUID v6 (time-ordered) generuje sekvenčně nové hodnoty → nové rows se přidávají na konec indexu → výrazně méně page splits.
Odmítnuto v4 ve prospěch v6.
Separátní endpoint POST /api/internal/ingress (Možnost B)
Kai by volal dvě operace: nejprve POST /api/internal/deploy, poté POST /api/internal/ingress.
Výhoda: separace concerns, každý endpoint má vlastní retry logiku. Nevýhody:
- Kai musí orchestrovat pořadí volání — přidává komplexitu na straně AI agenta
- Riziko, že Kai zapomene zavolat ingress endpoint (prompt engineering fail)
- Více kódu, více testů, více surface area
Odmítnuto ve prospěch atomic operace v DeployAppUseCase.
Implementation Notes
- Issue: talkide-be#22 — https://gitlab.com/talkide/talkide-be/-/work_items/22
- ADR-014 (B.1): K8s client foundation —
KubernetesClientbean, fabric8 DSL patterns - ADR-015 (B.2):
K8sNamespaceProvisioner— vzor pro kopírování secrets do tenant ns - ADR-017 (B.4):
AppDeployer— naming conventionapp-{slug}-{env}, packagefeatures/deployment/ - Navazující: #23 / Stopa B.6 (Pod status watch loop / SSE → UI streaming)
- Navazující: #24 / Stopa B.7 (Publish flow, PROD env Ingress
{slug}.talkide.app) - Cert renewal follow-up: evidovat jako
blocking::public-alphaissue — re-synctalkide-tls-certdo tenant ns po cert-manager renewal (scheduled job nebo K8s watcher) - talkide-be#38 (reserved slug validator): slug vstupuje do Ingress jména; nevalidní slug = K8s API error — validator musí být hotov před B.5 deployem
- talkide-be#44 (tenant slug RFC-1123 validator): stejná závislost jako v ADR-017
Errata
(Bude doplněno při opravách nebo upřesněních.)
Thanks for the feedback.