| Sekce | Hodnota |
|---|---|
| Status | Accepted |
| Datum | 2026-05-07 |
| Stopa | B.0.2 |
| GitLab issue | talkide-be#19 |
| Navazuje na | ADR-018 (SnapshotService B.0.1), ADR-014 (fabric8), ADR-015 (per-tenant ns), ADR-017 (AppDeployer B.4) |
Context
Po implementaci SnapshotService (B.0.1) je k dispozici imutabilní kopie working tree v /projects/{slug}/.snapshots/{buildId}/. Stopa B.0.2 zavádí BuildService — orchestraci Docker image buildu pomocí Kaniko v K8s, která snapshot konzumuje a produkuje image v DO Container Registry.
Hodnotový kontrakt
- Mara a Kai (AI agenti) i koncový uživatel mohou buildy spouštět, ale uživatel o K8s, Kaniko, registry ani namespace neví — vidí jen
Kai → "stavím image"v UI. - Live dev cyklus: každá Marina úprava může vyvolat nový build (preview / share link). Latency status updatu UI musí být max 3 s; “stuck” stav delší než ~15 s je UX disaster.
- Multi-pod BE deployment: BE pody jsou stateless, build state musí být sdílený (DB), ne in-memory.
- Cost atribuce: build pod konzumuje compute → musí běžet v tenant namespace, kde se metriky agregují per-tenant a fakturace je čistá. Platforma neabsorbuje build cost do své režie.
Existující kontext (z research B.0.1 + B.4)
| Co existuje | Detail |
|---|---|
NFS PVC mara-workspace | nfs-persistent storageClass, RWX, BE pod mountuje na /projects |
RBAC talkide-be-orchestrator | Cluster-scope, verby pro batch/jobs: create,get,list,watch,delete ✅ |
Per-tenant namespace tenant-{slug} | Provisionovaný K8sNamespaceProvisioner (B.2) |
K8sAppDeployer (B.4) pattern | Interface + K8s impl + Noop, fabric8 Builder DSL, idempotence |
Pull secret registry-talkide | Existuje jen v platform talkide ns — pro per-tenant build je nutné kopírovat |
Co chybí
- Žádný Kaniko-related kód v BE ani Helm
TalkidePropertiesnemábuildblok- Žádný distributed lock / scheduling pattern (concurrence řeší zatím jen conversation in-memory)
- Žádná entita
buildv doméně
Decision
1. Komponenta a její API
interface BuildService {
fun submitBuild(slug: String, tenantSlug: String): BuildResult
fun getBuild(buildId: String): Build
fun listBuilds(slug: String, limit: Int = 20): List<Build>
}
data class BuildResult(val buildId: String, val status: BuildStatus)
- Interface:
BuildServicevfeatures/build/ - Prod impl:
KanikoBuildService(@ConditionalOnProperty("talkide.k8s.enabled", havingValue = "true")) - Lokál impl:
NoopBuildService(@ConditionalOnMissingBean) — pouze loguje a vrací fake success pro CI/E2E v lokále bez K8s
2. Build orchestration: Kaniko v K8s Job
- Kaniko executor image:
gcr.io/kaniko-project/executor:v1.23.2(latest stable, non-debug) - Spouštěno jako
batch/v1/Jobv per-tenant namespacetenant-{tenantSlug}(ne v platformtalkidens) - Job mountuje stejný NFS PVC
mara-workspacejako BE pod (RWX podporuje shared mount), build context je<snapshot-dir>(z B.0.1) - Image push do
registry.digitalocean.com/talkide/userapp/{slug}:{buildId} - Po success Kaniko taggne i
:latest(mutable, last successful build) — usnadňuje k8s deployment manifesty (B.4 nemusí znát buildId) - Job arguments:
(mount--dockerfile=Dockerfile --context=dir:///projects/{`{`}slug{`}`}/.snapshots/{`{`}buildId{`}`} --destination=registry.digitalocean.com/talkide/userapp/{`{`}slug{`}`}:{`{`}buildId{`}`} --destination=registry.digitalocean.com/talkide/userapp/{`{`}slug{`}`}:latest --cache=true --cache-dir=/cache/kanikobuild-cache-pvcna/cache/kanikopřidán v B.0.5; původně--cache=false)
3. Build namespace: per-tenant
Rozhodnutí: Kaniko Job běží v tenant-{tenantSlug} namespace.
Rationale (business): Compute pro buildy konzumuje user, ne platforma. Per-tenant namespace umožňuje čisté metric agregace pro fakturaci. Kdyby buildy běžely v platform ns, museli bychom je rozpočítávat per-user uměle a nebylo by to fér (každý uživatel platformu využívá jinak).
Důsledek: registry-talkide pull secret musí být zkopírovaný do každého tenant ns.
4. Pull secret propagation
NamespaceProvisioner(B.2) rozšířen — připrovisionTenantNamespace()zkopírujeregistry-talkideSecret ztalkidedotenant-{slug}.- Implementačně:
client.secrets().inNamespace("talkide").withName("registry-talkide").get()→ stripmetadata.uid/resourceVersion/namespace→client.secrets().inNamespace(tenantNs).create(copy). - Trade-off: Při rotaci secretu (rare event) je nutné re-sync do všech tenant ns. Akceptovatelné — alternativy (kubernetes-reflector, external-secrets) jsou pro MVP overkill.
5. ResourceQuota
Aktuální tenant ResourceQuota: pods=8 zůstává. Tenant ns obsahuje:
- 1× user app BE pod (případně replicas=2 → 2 pody)
- 1× user app FE pod (případně replicas=2 → 2 pody)
- 1× Kaniko build pod (transientní, ttl 5 min po success)
→ Worst case 5 podů, kvóta 8 má bezpečný buffer. Tenant Postgres pod neexistuje — DB jsou v jednom platform-shared cluster a cost se rozpočítává per-user přes metriky.
6. Persistence — entita build
CREATE TABLE build (
id UUID PRIMARY KEY,
build_id VARCHAR(32) NOT NULL UNIQUE, -- yyyyMMdd-HHmmss-{6-char-hex}
slug VARCHAR(64) NOT NULL,
tenant_slug VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING/RUNNING/SUCCESS/FAILED/TIMEOUT
image_tag VARCHAR(255) NULL,
kaniko_job_name VARCHAR(64) NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_build_slug_status ON build (slug, status);
CREATE INDEX idx_build_status_started ON build (status, started_at);
Liquibase changeset db/changelog/changes/0XX-build.xml (číslo dle aktuálního stavu changelogu).
7. Concurrency control (per-slug lock + 409)
@Transactional(isolation = Isolation.SERIALIZABLE)
fun submitBuild(slug: String, tenantSlug: String): BuildResult {
// distributed lock přes DB row check
val active = buildRepo.findActiveBySlugForUpdate(slug) // SELECT ... FOR UPDATE
if (active.isNotEmpty()) {
throw BuildConflictException("Build already in progress for $slug: ${active.first().buildId}")
}
val buildId = generateBuildId()
buildRepo.save(Build(slug, tenantSlug, buildId, status = PENDING, ...))
submitKanikoJob(...)
return BuildResult(buildId, RUNNING)
}
findActiveBySlugForUpdate=SELECT * FROM build WHERE slug=:slug AND status IN ('PENDING','RUNNING') FOR UPDATE- Multi-pod safe (Postgres row-level lock)
- HTTP mapping:
BuildConflictException→ 409 Conflict +BUILD_CONFLICTerror code
8. buildId format
Format: {yyyyMMdd}-{HHmmss}-{6-char-hex}
Příklad: 20260507-143022-a3f2c1
Vlastnosti:
- Sortable (stringová ASCII = chronologická)
- Readable (human-friendly v logs / UI)
- Unique (timestamp s 1 s rozlišením + 6 hex znaků = 16M možností v rámci sekundy)
- Image-tag safe (jen
[a-z0-9-], žádné podtržítko, OCI tag spec)
9. Build timeout a Job lifecycle
- App-level timeout: 10 min (konfigurovatelné
talkide.build.timeout-minutes, default 10) - K8s-level timeout:
activeDeadlineSeconds: 600na Kaniko Job — K8s sám pod ukončí, BE nepotřebuje samostatný kill mechanismus - TTL po success:
ttlSecondsAfterFinished: 300(5 min, K8s GC smaže Job + pod) - Failed Job: ponechat (žádné TTL) → manuální debug přes
kubectl logs - Snapshot cleanup:
- po SUCCESS → volat
SnapshotService.releaseSnapshot(slug, buildId) - po FAILED/TIMEOUT → ponechat snapshot pro debug; cleanup řeší pozdější housekeeping job (mimo scope B.0.2)
- po SUCCESS → volat
10. Status update — distributed scheduled poller (ShedLock)
Mechanismus: ne in-memory watcher per pod, ne K8s informer — scheduled task s distribuovaným lockem.
@Scheduled(fixedDelay = 3000)
@SchedulerLock(name = "buildStatusPoller", lockAtMostFor = "PT30S", lockAtLeastFor = "PT2S")
fun pollActiveBuilds() {
val active = buildRepo.findByStatusIn(listOf(PENDING, RUNNING))
active.forEach { build ->
val job = client.batch().v1().jobs()
.inNamespace("tenant-${build.tenantSlug}")
.withName(build.kanikoJobName)
.get()
when {
job == null -> markFailed(build, "Kaniko job lost")
job.status?.succeeded == 1 -> markSuccess(build)
job.status?.failed == 1 -> markFailed(build, job.status.conditions?.firstOrNull()?.message ?: "Build failed")
else -> { /* still running */ }
}
}
}
ShedLock: net.javacrumbs.shedlock:shedlock-spring + shedlock-provider-jdbc-template (DB-backed lock, žádný Redis dependency).
Latency: max 3 s mezi reálným Job done a UI update — splňuje hodnotový požadavek (Kai musí reagovat live).
Crash safety: pokud aktivní pod umře mid-build, jiný pod převezme polling do 3 s. Žádný stale RUNNING window.
Cost: 1× SELECT + N× K8s Job.get per 3 s. Při 10 současných buildech ≈ 3 RPS na K8s API — well within limits.
11. SSE channel pro live UI updates
- Endpoint:
GET /api/builds/{buildId}/events(Server-Sent Events) - Event stream: status změny + finished log tail po success/fail
- Konzument: Kai persona v Mara/UI activities feed
- Connection close po terminal status (SUCCESS/FAILED/TIMEOUT)
- Backend implementace:
Sinks.Manyper buildId (in-memory map), poller emit-uje na sink při status change → SSE klienti dostanou push - Multi-pod: každý BE pod má vlastní sinks; klient SSE musí být sticky-routed přes ingress (
session-cookieaffinity), nebo jen pollovat RESTGET /api/builds/{id}s 3 s intervalem (UI fallback) - Pro MVP: implementovat REST polling (
GET /api/builds/{id}) + SSE jako enhancement (může jít do následující stopy, pokud SSE komplikace)
12. Dockerfile templates per-stack
Přidat do existujících scaffolding template:
talkide-be/src/main/resources/templates/
├── kotlin-spring/
│ ├── ...existing...
│ ├── Dockerfile ← NEW: multistage eclipse-temurin:21-jdk → :21-jre
│ └── __dockerignore ← NEW: ignore build/, .gradle/, *.log
└── ts-vue/
├── ...existing...
├── Dockerfile ← NEW: node:22 build → nginx:alpine runtime
└── __dockerignore ← NEW: ignore node_modules/, dist/
- Renderování stejným mechanizmem jako ostatní scaffolding (
__PACKAGE_PATH__-style placeholders se nepoužívají; Dockerfile je generic per-stack, žádné per-app substituce nutné v MVP) - Skopírováno do
/projects/{slug}/při create-project (existující ProjectScaffolder) - User i Mara mohou Dockerfile upravit — je to jejich soubor v jejich repo
Konvence __ prefix: Stejně jako __gitignore (Gradle resources processing problém s . prefixem) — __dockerignore se přejmenuje na .dockerignore při create-project rendering.
13. Konfigurace přes TalkideProperties
Přidat nový blok:
data class BuildProperties(
val kanikoImage: String = "gcr.io/kaniko-project/executor:v1.23.2",
val timeoutMinutes: Int = 10,
val jobTtlSeconds: Int = 300,
val pollerIntervalMs: Long = 3000,
val housekeepingIntervalMs: Long = 60_000,
val registryPrefix: String = "registry.digitalocean.com/talkide/userapp",
val cacheEnabled: Boolean = true, // B.0.5: zapnout/vypnout layer cache; PVC size viz K8sProperties.buildCacheSize
val cacheMountPath: String = "/cache/kaniko",
)
14. Error codes
Přidat do ErrorCode enum:
BUILD_CONFLICT→ HTTP 409 (build už běží pro slug)BUILD_NOT_FOUND→ HTTP 404BUILD_TIMEOUT→ HTTP 500 (Kaniko Job překročil timeout)BUILD_FAILED→ HTTP 500 (Kaniko vrátil non-zero exit)BUILD_INFRASTRUCTURE_ERROR→ HTTP 500 (K8s API error, secret missing, atd.)
15. REST endpointy
POST /api/builds { slug } → 202 { buildId, status }
→ 409 BUILD_CONFLICT pokud aktivní build pro slug
GET /api/builds/{buildId} → 200 { build } | 404
GET /api/builds?slug=... → 200 [...latest 20...]
GET /api/builds/{buildId}/events → SSE (post-MVP)
Auth: bearer token, scope app:build (per-user/Mara).
16. Test strategy
- Pure unit:
BuildServiceTestmockujeKubernetesClient,BuildRepository,SnapshotService,Clock(pro deterministic buildId) - Smoke (
@Tag("smoke")): Testcontainers k3s + dummy Dockerfile → Kaniko Job submit → polling → success/fail. Velmi heavy — lokálně ne defaultní run, jen při explicitgradle smokeTest - Cloud verify: manuální test po deploy přes
POST /api/buildss reálným tenant projektem
Consequences
Pozitivní
- ✅ Multi-pod safe: ShedLock + DB persistence eliminuje single-pod assumptions
- ✅ Crash recovery by design: poller každé 3 s detekuje stale stav, žádný “stuck” UI
- ✅ Live dev UX: 3 s latency na UI update splňuje Kai hodnotový kontrakt
- ✅ Cost transparency: per-tenant build = čistá fakturace
- ✅ User-editable Dockerfile: customization bez platform involvement (Mara může upravit per-app)
- ✅ K8s-native cleanup: TTL na Job, žádná custom housekeeping logic pro success buildy
- ✅ Stateless BE: žádný in-memory
Map<buildId, Future>, restart-safe
Negativní / trade-offs
- ⚠️ Pull secret duplikace:
registry-talkidemusí být kopírovaný do každého tenant ns. Rotace = re-sync. Akceptovatelné (rare event), monitoring TBD ve Stopě F. - ⚠️ Polling load: 1× SELECT + N× K8s
Job.getper 3 s. Při ≫ 100 souběžných buildech bude třeba batch optimize. Pro alpha (jednotky až desítky tenants) bezpečně OK. - ⚠️ ShedLock dependency: nová closely-coupled lib (
net.javacrumbs.shedlock) — battle-tested, ale +1 transitive dep tree - ⚠️ Failed snapshot retention:
releaseSnapshotse po failed buildu nevolá → housekeeping job nutný ve Stopě C (snapshot lifecycle management) - ✅ Per-namespace layer cache (B.0.5): každý tenant ns má vlastní
build-cache-pvc(5 GB, NFS RWX). Druhý a další build téhož projektu trefí ~70% layer hit (~30-60 s místo 3-5 min cold start). Cross-tenant cache sharing zamítnut — cache poisoning a info leak risk. Detaily viz ADR-015 § Kompletní seznam K8s resources per tenant.
Otevřené body (mimo scope B.0.2)
- Snapshot cleanup pro failed buildy — housekeeping cron job, scope Stopa C nebo dedikovaná B.0.X
- SSE proper multi-pod broadcast — buď sticky session, nebo Redis pub/sub. Pro MVP REST polling
- Build prioritization — preview build vs publish build, scope Stopa B.7 (state machine)
- Resource limits na Kaniko Job —
requests/limits.cpu/memoryna pod, ne na app — TBD calibration na real workload
Alternatives considered
| Alternativa | Proč zamítnuto |
|---|---|
| In-memory watcher (CompletableFuture per pod) | Multi-pod nesafe; pod crash = stuck RUNNING 15 min; UX disaster |
| Kubernetes Informer pattern | Větší komplexita (event handlers, reconciliation), informer life-cycle management při pod restart, race conditions u writes |
| Kaniko sidecar webhook | Kaniko sám webhook neposílá; sidecar v Job-u řešitelné, ale +1 container, +1 race (webhook vs poller) |
| PG advisory lock místo row-lock | Advisory locks zmizí při disconnect → recovery je obtížnější. Row-lock je explicitnější a dohledatelnější přes SELECT * FROM build WHERE status='RUNNING' |
| BuildKit místo Kaniko | Vyžaduje root nebo privileged seccomp; Kaniko je standard pro untrusted builds v K8s |
| Tekton Pipelines | Overkill pro MVP; future možnost když přibudou multi-step build workflows |
| Cancel-and-replace concurrency | Komplexní state machine, race conditions při Kaniko Job delete; lock+409 je deterministic |
| FIFO build queue per slug | Stav queue v DB, scheduler logic. Pro alpha overkill — uživatel/Mara může retry pollnout |
Platform talkide namespace pro buildy | Cost atribuce nefér (musela by se rozpočítávat per-user), ostrá hranice tenant izolace zmizí |
Dedikovaný talkide-builds namespace | Stejný cost atribuce problém jako platform ns; navíc +1 namespace + RBAC bez business benefitu |
Implementation Notes
File layout (BE)
features/build/
├── BuildService.kt # interface
├── KanikoBuildService.kt # @ConditionalOnProperty K8s impl
├── NoopBuildService.kt # @ConditionalOnMissingBean lokál impl
├── BuildStatusPoller.kt # @Scheduled + @SchedulerLock
├── BuildController.kt # REST endpointy
├── BuildRepository.kt # Spring Data JPA
├── Build.kt # entita
├── BuildStatus.kt # enum
├── BuildException.kt # base + BuildConflictException, BuildNotFoundException, ...
└── KanikoJobBuilder.kt # fabric8 Job manifest constructor (extracted helper)
common/config/
├── TalkideProperties.kt # rozšíření o BuildProperties
└── ShedLockConfiguration.kt # @EnableSchedulerLock + LockProvider bean
common/exception/model/ErrorCode.kt # BUILD_* enum hodnoty
common/exception/ExceptionHandler.kt # BuildException handlers
src/main/resources/
├── db/changelog/changes/0XX-build.xml # Liquibase migration
└── templates/
├── kotlin-spring/
│ ├── Dockerfile # NEW
│ └── __dockerignore # NEW
└── ts-vue/
├── Dockerfile # NEW
└── __dockerignore # NEW
Kotlin-Spring Dockerfile template
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /workspace
COPY gradlew gradlew
COPY gradle/ gradle/
COPY build.gradle.kts settings.gradle.kts ./
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies -q || true
COPY src/ src/
RUN ./gradlew --no-daemon bootJar -x test
FROM eclipse-temurin:21-jre-alpine AS runtime
RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app
WORKDIR /app
COPY --from=builder /workspace/build/libs/*.jar app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s \
CMD wget -q -O- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
TS-Vue Dockerfile template
FROM node:22-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q -O- http://localhost/ || exit 1
NamespaceProvisioner rozšíření (B.2)
Při provisionTenantNamespace(tenantSlug):
- Create namespace
tenant-{slug}(existing logic) - Apply ResourceQuota (existing logic)
- NOVÉ: Copy
registry-talkideSecret ztalkidens:val source = client.secrets().inNamespace("talkide").withName("registry-talkide").get() ?: throw NamespaceProvisioningException("registry-talkide secret missing in talkide ns") val copy = SecretBuilder(source) .editMetadata() .withName("registry-talkide") .withNamespace(tenantNs) .withResourceVersion(null) .withUid(null) .endMetadata() .build() client.secrets().inNamespace(tenantNs).resource(copy).createOrReplace()
Kaniko Job — per-namespace build cache (B.0.5)
Kaniko Job spawnovaný v tenant ns mountuje build-cache-pvc (PVC provisionovaný K8sNamespaceProvisioner v rámci B.0.5) na cestu /cache/kaniko. Job arguments se rozšíří o:
--cache=true
--cache-dir=/cache/kaniko
Kompletní seznam K8s resources provisionovaných per tenant ns (Namespace, ResourceQuota, LimitRange, Secrets, PVC) viz ADR-015 § Kompletní seznam K8s resources per tenant.
Liquibase changeset hint
Convention: dohledat aktuální nejvyšší změnu v db/changelog/changes/, číslovat dál. Ne použít hardcoded 0XX v PR.
References
- Kaniko docs
- ShedLock
- Kubernetes Job — activeDeadlineSeconds & ttlSecondsAfterFinished
- ADR-014 — fabric8 K8s client
- ADR-015 — per-tenant namespace
- ADR-018 — SnapshotService (consumed by BuildService)
Thanks for the feedback.