Internal Documentation internal
TalkIDE internal documentation

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_id FK na tenants.id je již v DB schématu
  • CreateProjectUseCase tahá tenantId z JWT přes AuthorizationService.getCurrentTenantId()
  • TenantEntity leží v features/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.enabledWired bean
local (výchozí)falseNoopNamespaceProvisioner
cloud (prod pod)trueK8sNamespaceProvisioner

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átDů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)

ResourceLimitZdůvodnění
pods82 user-apps × (BE + FE) = 4 + 1 Kaniko job aktivní + buffer 3
requests.cpu4Spring Boot BE: request 0.5 CPU; 8 podů × 0.5 = 4 (konzervativní horní mez)
requests.memory8GiBE = 1 GB request, FE = 64 MB; fit do 8 GB s bufferem
requests.storage10GiNFS 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).

TypeDefault requestDefault limit
Container0.1 CPU / 256Mi1 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 @ConditionalOnMissingBean je sjednocený s ADR-014 errata. Důvod: Spring Boot 3.4.4 @ConditionalOnMissingBean není 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-test je dostatečný workaround.
  • Tenant lifecycle UC (create/delete tenant) je post-MVP scope.

10. Test strategie

VrstvaToolÚčel
Unitmockito-kotlin (mock K8sClient)Get-or-create logika, slug lookup z TenantRepository, error handling při nenalezeném tenantovi
Integration smokeTestcontainers k3s1× 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:

  1. Atomicita — DB a K8s state jsou vždy konzistentní. Nelze mít Project entity bez odpovídajícího namespace.
  2. Jednoduchost — Spring @Transactional rollback je out-of-the-box. Žádný manuální compensating action kód.
  3. 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.
  4. Žá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 @Retryable nebo 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ž @Transactional to řeší automatically.

Implikace pro testy:

Unit test CreateProjectUseCaseTest musí pokrýt:

  • provisionTenantNamespace throws → CreateProjectUseCase re-throws → Project entity NENÍ uložena (verify projectRepository.save NOT called)

Toto je scope #15 (mimo separate testovací ticket).

12. Scope issue #15 — úzce vymezený

Zahrnuto v #15:

  • NamespaceProvisioner Kotlin interface
  • K8sNamespaceProvisioner implementace s @ConditionalOnProperty
  • NoopNamespaceProvisioner implementace s @ConditionalOnProperty(matchIfMissing=true)
  • Volání provisionTenantNamespace(tenantId) z CreateProjectUseCase
  • ResourceQuota + LimitRange při provisioning (per rozhodnutí 2 a 3)
  • Idempotence (get-or-create per resource)
  • deprovisionTenantNamespace stub (log + no-op)
  • Unit testy + integration smoke test

Mimo scope (separátní issues):

FeatureIssue / Stopa
Tenant slug RFC-1123 validatortalkide-be#44 (blocking::public-alpha)
NetworkPolicyStopa B.4
Per-namespace ServiceAccount + RBACodlož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.

#ResourceNamePoznámka / OriginSekce / ADR
1Namespacetenant-{slug}Základní izolační unitADR-015 § 1
2ResourceQuotadefault-quotaLimity podů, CPU, paměti, storageADR-015 § 2
3LimitRangedefault-limitsDefault requests/limits pro kontejneryADR-015 § 3
4Secret (dockerconfigjson)registry-talkideReplikováno z talkide nsADR-019 § 4 (Kaniko push/pull)
5Secret (TLS)talkide-tls-certReplikováno z talkide nsADR-021 § 5 (per-tenant ingress)
6PersistentVolumeClaimbuild-cache-pvcSelf-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

  • KonzistenceNamespaceProvisioner wrapper 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í

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

  2. Loose coupling Project → TenantProjectEntity.tenantId je plain Long, ne @ManyToOne FK. K8sNamespaceProvisioner musí provádět extra TenantRepository.findById() lookup pro každé volání. Minimální performance impact, ale přidává runtime failure point (tenant nenalezen).

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

  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


Was this page helpful?

Thanks for the feedback.