Status: Accepted Datum: 2026-05-07 Oblast: Stopa B.2 / Kubernetes orchestrace
Context
Stopa B.2 staví na foundation z ADR-014
ADR-014 (Stopa B.1) zavedl K8sClient foundation — fabric8 klientskou knihovnu, wrapper
architekturu (interface + FabricK8sClient + NoopK8sClient) a RBAC ClusterRole pro BE pod.
Stopa B.2 přidává první reálnou K8s operaci: provisioning per-tenant namespace včetně
ResourceQuota a LimitRange.
Multi-tenancy model TalkIDE
TalkIDE má tabulku tenants (id BIGINT, slug VARCHAR(50) UNIQUE, owner_id FK→users).
Namespace v K8s patří tenantovi, ne individuálnímu uživateli. Relace je 1:N (jeden user
může vlastnit víc tenants), v alpha fázi je ale prakticky 1 tenant per user.
Existující vazby:
Project.tenant_idFK natenants.idje již v DB schématuCreateProjectUseCasetahátenantIdz JWT přesAuthorizationService.getCurrentTenantId()TenantEntityleží vfeatures/user/data/entity/TenantEntity.kt
Slug jako zdroj namespace jména
tenants.slug je VARCHAR(50), UNIQUE, a seed data dodržují RFC 1123 konvenci de facto
(lowercase, alfanumerický, pomlčky). Formální BE validátor pro RFC 1123 ale chybí —
tato mezera je evidovaná v talkide-be#44 (blocking::public-alpha).
Deployment topology (zděděno z ADR-014)
| Prostředí | talkide.k8s.enabled | Wired bean |
|---|---|---|
local (výchozí) | false | NoopNamespaceProvisioner |
cloud (prod pod) | true | K8sNamespaceProvisioner |
Decision
1. Naming convention: tenant-{slug}
Namespace jméno = "tenant-" + tenants.slug.
- Prefix
tenant-— jednoznačně identifikuje účel namespace; nemůže kolidovat s K8s systémovými namespaces (kube-system,kube-public,default) ani s TalkIDE vlastním namespace (talkide). - Slug — přichází z
tenants.slug(max 50 znaků, UNIQUE). Celková délka namespace jména: 7 (tenant-) + 50 = 57 znaků — pod K8s limitem 63.
Příklad: tenant slug acme → namespace tenant-acme.
Odmítnuté alternativy:
| Kandidát | Důvod odmítnutí |
|---|---|
t-{slug} | Úspora znaků, ale výrazně méně čitelné (t-acme nevypovídá o účelu) |
user-{userId} | Špatný entitní model — namespace patří tenantovi, ne uživateli; Long ID navíc není human-readable |
{slug} (bez prefixu) | Riziko kolize s K8s systémovými namespaces nebo budoucími platformními namespaces |
2. ResourceQuota limity (per namespace)
| Resource | Limit | Zdůvodnění |
|---|---|---|
pods | 8 | 2 user-apps × (BE + FE) = 4 + 1 Kaniko job aktivní + buffer 3 |
requests.cpu | 4 | Spring Boot BE: request 0.5 CPU; 8 podů × 0.5 = 4 (konzervativní horní mez) |
requests.memory | 8Gi | BE = 1 GB request, FE = 64 MB; fit do 8 GB s bufferem |
requests.storage | 10Gi | NFS PVC pro per-project working tree + Git history |
Limity jsou záměrně konzervativní pro alpha fázi (1 tenant, 2 user-apps max). Budou revidovány při přechodu na public alpha na základě reálného usage profilu.
3. LimitRange — výchozí requests/limits pro pody
Chrání namespace před pody bez explicitních resource requirements (cargo-cult deploymenty, chybně nakonfigurované Helm charty).
| Type | Default request | Default limit |
|---|---|---|
| Container | 0.1 CPU / 256Mi | 1 CPU / 1Gi |
Per-deployment override v Stopě B.4 (deploymenty user-app podů mají vlastní explicitní values). LimitRange je záchranná síť, ne primární konfigurační mechanismus.
4. Per-namespace ServiceAccount + RBAC: vynecháno
User-app pody (Spring Boot BE + nginx FE) nepotřebují K8s API access. Výchozí default
ServiceAccount postačí — pody pouze naslouchají na HTTP portu, čtou ConfigMapy přes env vars
(Stopa B.4) a nemají žádný K8s API use case.
Přidáme explicitní SA + RBAC tehdy, pokud budoucí use case to vyžaduje (např. ConfigMap live-reload). Vyhýbáme se cargo-cult RBAC, který přidává útočnou plochu bez benefitu.
5. NetworkPolicy: odloženo do Stopy B.4
Default-deny NetworkPolicy vyžaduje přesné zmapování egress flows každého user-app podu:
DO Managed Postgres endpoint, DO Spaces (S3 API), internet egress, a případná zpětná
komunikace na talkide-be. Tyto flows jsou nejlépe viditelné až s reálnými user-app
deploymenty — provést analýzu před B.4 by bylo předčasné.
Implementujeme NetworkPolicy v B.4 jako součást deployment provisioning, nikoli jako standalone krok v B.2.
6. Trigger: lazy v CreateProjectUseCase
namespaceProvisioner.provisionTenantNamespace(tenantId) se volá v CreateProjectUseCase,
po validaci vstupu, před uložením ProjectEntity do DB.
Volání je idempotentní — opakované volání pro tenant s již existujícím namespace je no-op (get-or-create sémantika, viz rozhodnutí 8).
Registrace uživatele samotná K8s namespace neprovisionuje. Lazy přístup je efektivní: userům, kteří se zaregistrují ale nikdy nevytvoří projekt, nevzniká K8s overhead.
7. Wrapper architektura — konzistentní s ADR-014
Stejný pattern jako K8sClient, ProjectDatabaseProvisioner, ProjectStorageProvisioner:
NamespaceProvisioner (Kotlin interface)
fun provisionTenantNamespace(tenantId: Long): Unit
fun deprovisionTenantNamespace(tenantId: Long): Unit
K8sNamespaceProvisioner (@ConditionalOnProperty(talkide.k8s.enabled=true))
- inject: K8sClient, TenantRepository
- lookup: TenantRepository.findById(tenantId) → slug
- create: Namespace + ResourceQuota + LimitRange (get-or-create)
NoopNamespaceProvisioner (@ConditionalOnProperty(name = ["talkide.k8s.enabled"], havingValue = "false", matchIfMissing = true))
- log volání, vrátí bez akce
Path: talkide-be/src/main/kotlin/com/mddsummer/talkide/features/k8s/
Nový developer vidí jeden konzistentní vzor; žádné výjimky ze zavedeného patternu.
Pozn. (errata 2026-05-07): Pattern
@ConditionalOnProperty(matchIfMissing=true)namísto@ConditionalOnMissingBeanje sjednocený s ADR-014 errata. Důvod: Spring Boot 3.4.4@ConditionalOnMissingBeannení spolehlivý v test context loaderu.
8. Idempotence: get-or-create per resource
Každý K8s resource se provisionuje get-or-create sémantikkou. Pokud resource existuje, volání je tiché no-op — žádná výjimka, žádný conflict error.
// Pseudokód — ilustrace get-or-create vzoru
val nsName = "tenant-${tenant.slug}"
client.namespaces().withName(nsName).get()
?: client.namespaces().resource(buildNamespace(nsName)).create()
client.resourceQuotas().inNamespace(nsName).withName("default-quota").get()
?: client.resourceQuotas().inNamespace(nsName).resource(buildQuota(nsName)).create()
client.limitRanges().inNamespace(nsName).withName("default-limits").get()
?: client.limitRanges().inNamespace(nsName).resource(buildLimitRange(nsName)).create()
Idempotence je klíčová pro: restart podu při probíhající transakci, retry logiku v
CreateProjectUseCase, manuální re-run při debugování.
9. Deprovision: stub (log-only) v MVP
deprovisionTenantNamespace(tenantId) v MVP loguje warning a je no-op. Reálná implementace
přijde s “Delete Tenant” UC, který v alpha fázi neexistuje. Důvody odložení:
kubectl delete namespace tenant-X= kaskádový delete všech resources (destruktivní, nevratné). Vyžaduje explicitní confirmation flow na úrovni UI/UX.- Alpha = 1 tenant. Manuální
kubectl delete ns tenant-testje dostatečný workaround. - Tenant lifecycle UC (create/delete tenant) je post-MVP scope.
10. Test strategie
| Vrstva | Tool | Účel |
|---|---|---|
| Unit | mockito-kotlin (mock K8sClient) | Get-or-create logika, slug lookup z TenantRepository, error handling při nenalezeném tenantovi |
| Integration smoke | Testcontainers k3s | 1× test: real namespace + ResourceQuota + LimitRange creation — ověří, že K8s manifesty jsou validní a RBAC permissions z ADR-014 pokrývají i resourcequotas a limitranges |
Smoke test je záměrně minimalistický — analogicky k ADR-014. Ověřuje, že K8s skutečně přijme generované manifesty (odhalí YAML chyby a RBAC mezery dříve než prod deploy).
11. Error handling — throw + transaction rollback
Pokud K8sNamespaceProvisioner.provisionTenantNamespace(tenantId) selže (K8s API vrátí 5xx,
network timeout, RBAC reject, jakákoliv KubernetesClientException), provisioner propaguje
výjimku ven. CreateProjectUseCase je @Transactional, takže Spring automaticky
rollbackuje DB transakci — Project entity se neuloží.
User dostane error response (HTTP 500 nebo specifické 503 podle ErrorCode mappingu) a může operaci retry.
Důvody:
- Atomicita — DB a K8s state jsou vždy konzistentní. Nelze mít Project entity bez odpovídajícího namespace.
- Jednoduchost — Spring
@Transactionalrollback je out-of-the-box. Žádný manuální compensating action kód. - User-driven retry — alpha = 1 user, retry je triviální (znovu klik na “Create Project”). V public alpha může FE přidat auto-retry.
- Žádný orphan state — orphan Project entity (bez namespace) by byla bad UX (user “vidí” projekt, ale nelze deploy v B.4).
Implementace:
@Transactional
class CreateProjectUseCase(...) {
operator fun invoke(...): Project {
// 1. validace + auth check
// 2. namespace provisioning (throws on failure → DB rollback)
namespaceProvisioner.provisionTenantNamespace(tenantId)
// 3. persist Project entity
return projectRepository.save(...)
}
}
Pokud volání #2 throwne, transakce se rollbackuje a #3 se neprovede.
K8sNamespaceProvisioner.provisionTenantNamespace() nezachytává KubernetesClientException
— propaguje ji nahoru. Idempotence (get-or-create) řeší jen AlreadyExistsException interně,
jakýkoliv jiný error → throw.
Alternativy zamítnuté:
- Retry s exponential backoff (Spring
@Retryablenebo Resilience4j) — overkill pro alpha. Transient K8s API errors budou v praxi vzácné, manuální user retry je dostatečný. Možný upgrade post-MVP. - Best effort (log warning, projekt se přesto vytvoří) — anti-pattern, vede k orphan Project entities. Odmítnuto.
- Compensating action (delete Project entity manuálně po failed namespace) — komplexita bez
benefitu, když
@Transactionalto řeší automatically.
Implikace pro testy:
Unit test CreateProjectUseCaseTest musí pokrýt:
provisionTenantNamespacethrows →CreateProjectUseCasere-throws → Project entity NENÍ uložena (verifyprojectRepository.saveNOT called)
Toto je scope #15 (mimo separate testovací ticket).
12. Scope issue #15 — úzce vymezený
Zahrnuto v #15:
NamespaceProvisionerKotlin interfaceK8sNamespaceProvisionerimplementace s@ConditionalOnPropertyNoopNamespaceProvisionerimplementace s@ConditionalOnProperty(matchIfMissing=true)- Volání
provisionTenantNamespace(tenantId)zCreateProjectUseCase ResourceQuota+LimitRangepři provisioning (per rozhodnutí 2 a 3)- Idempotence (get-or-create per resource)
deprovisionTenantNamespacestub (log + no-op)- Unit testy + integration smoke test
Mimo scope (separátní issues):
| Feature | Issue / Stopa |
|---|---|
| Tenant slug RFC-1123 validator | talkide-be#44 (blocking::public-alpha) |
| NetworkPolicy | Stopa B.4 |
| Per-namespace ServiceAccount + RBAC | odloženo — žádný use case |
| Kaniko / image build pipeline | #16 / Stopa B.3 |
| Pod / Deployment provisioning | #17 / Stopa B.4 |
| Ingress provisioning | #22 / Stopa B.5 |
| Pod status watch loop (SSE) | #23 / Stopa B.6 |
| Tenant lifecycle UC (create/delete tenant) | post-MVP |
Kompletní seznam K8s resources per tenant
Tato sekce je autoritativním přehledem všech K8s resources, které K8sNamespaceProvisioner provisionuje při vzniku nového tenant namespace. Slouží jako reference pro ostatní ADRy (ADR-019, ADR-021) i pro operační dohled.
| # | Resource | Name | Poznámka / Origin | Sekce / ADR |
|---|---|---|---|---|
| 1 | Namespace | tenant-{slug} | Základní izolační unit | ADR-015 § 1 |
| 2 | ResourceQuota | default-quota | Limity podů, CPU, paměti, storage | ADR-015 § 2 |
| 3 | LimitRange | default-limits | Default requests/limits pro kontejnery | ADR-015 § 3 |
| 4 | Secret (dockerconfigjson) | registry-talkide | Replikováno z talkide ns | ADR-019 § 4 (Kaniko push/pull) |
| 5 | Secret (TLS) | talkide-tls-cert | Replikováno z talkide ns | ADR-021 § 5 (per-tenant ingress) |
| 6 | PersistentVolumeClaim | build-cache-pvc | Self-provisioned (5Gi, RWX, nfs-persistent) | ADR-019 § B.0.5 (Kaniko cache) |
Replikace secretů
K8sNamespaceProvisioner čte zdrojový secret z namespace talkide a kopíruje ho do tenant ns idempotentním get→patch|create patternem (strip uid, resourceVersion, namespace z metadata). Totéž platí pro oba secrets (registry-talkide i talkide-tls-cert). Při rotaci zdrojového secretu (vzácná událost) je nutné provést re-sync do všech existujících tenant namespaces — automatizace tohoto kroku je mimo scope MVP a řeší ji budoucí operační runbook (Stopa F).
PVC build-cache-pvc
build-cache-pvc je per-tenant PVC (5Gi, nfs-persistent storageClass, accessMode RWX). Kaniko Job spawnovaný KanikoBuildService ho mountuje na /cache/kaniko a běží s --cache=true --cache-dir=/cache/kaniko. Per-tenant izolace cache je záměrná bezpečnostní volba — sdílený cache PVC napříč tenanty byl zamítnut z důvodu cross-tenant cache poisoning (untrusted user package.json s malicious postinstall) a potenciálního úniku informací (cached layers obsahují FS stav buildu). Velikost PVC je konfigurovatelná přes property talkide.k8s.buildCacheSize (default 5 Gi).
Consequences
Pozitiva
- Konzistence —
NamespaceProvisionerwrapper zapadá do patternu ADR-014 bez výjimek; jeden konzistentní vzor napříč všemi provisionery. - Lazy provisioning — namespace vzniká pouze pokud tenant skutečně vytvoří projekt; registrovaní ale neaktivní usei nezatěžují cluster.
- Idempotence — restart podu nebo retry operace nevytváří duplicate error; safe pro use case s at-least-once sémantkou.
- ResourceQuota + LimitRange — tenant namespace je od prvního okamžiku chráněn před neomezenou konzumací cluster resources.
Rizika a omezení
-
Slug RFC-1123 nevalidováno v BE — talkide-be#44 (
blocking::public-alpha). Pokud tenant slug obsahuje neplatné znaky (uppercase, mezery, podtržítka), K8s namespace creation selže s API errorem. Aktuální seed slug"test"je validní. Bez opravy #44 je feature nezpůsobilá pro public alpha. -
Loose coupling Project → Tenant —
ProjectEntity.tenantIdje plainLong, ne@ManyToOneFK.K8sNamespaceProvisionermusí provádět extraTenantRepository.findById()lookup pro každé volání. Minimální performance impact, ale přidává runtime failure point (tenant nenalezen). -
No NetworkPolicy — cross-tenant network isolation chybí. V alpha fázi (1 tenant) je akceptovatelné; nutný fix před public alpha v Stopě B.4.
-
No deprovision — po případném manuálním delete tenanta z DB zůstane K8s namespace orphaned. V alpha fázi: manuální
kubectl delete ns tenant-{slug}. Post-MVP: Delete Tenant UC implementuje reálný deprovision.
Alternatives Considered
Namespace per user (ne per tenant)
Odmítnuto. Entitní model TalkIDE je tenant-centric — Project.tenant_id FK existuje,
tenants tabulka existuje. Mapovat namespace na user (user-{userId}) by bylo v rozporu
s datovým modelem a znemožnilo budoucí multi-user tenant (team features post-alpha).
Provisioning při user registraci (eager, ne lazy)
Odmítnuto. User se může zaregistrovat, prozkoumat landing page a odejít bez vytvoření
projektu. Eager provisioning by vytvářel K8s namespace (a ResourceQuota overhead) pro
každého registrovaného usera, i ty neaktivní. Lazy trigger v CreateProjectUseCase je
přesnější a efektivnější.
Provisioning jako samostatný job/event (async)
Zvažováno, odmítnuto pro B.2 scope. Async provisioning (Kafka event, background job) by
zkomplikoval error handling: CreateProjectUseCase by nedostal okamžitou informaci o selhání
namespace creation. Synchronní volání v use case je jednoduché a přímé. Async přístup
zvážit post-alpha pokud provisioning čas začne být problémem.
LimitRange s agresivnějšími limity
Odmítnuto. Defaultní limit 1 CPU / 1Gi per container je záměrně velkorysý — dává prostor pro user-app pody bez nutnosti okamžitě ladit values. Stopa B.4 přepíše defaults explicitními per-deployment values. LimitRange v B.2 je záchranná síť, ne production tuning.
Implementation Notes
- Issue: talkide-be#15 — https://gitlab.com/talkide/talkide-be/-/work_items/15
- Slug RFC-1123 validator follow-up: talkide-be#44 — https://gitlab.com/talkide/talkide-be/-/work_items/44
- ADR-014 (B.1): K8s client foundation — pattern reference pro wrapper architekturu
- talkide-be#39 (Delete UC cleanup):
NoopXxxProvisionerpattern referenční implementace - Navazující B.x issues: #16 (Kaniko build), #17 (deploy), #22 (ingress), #23 (watch loop)
Thanks for the feedback.