Internal Documentation internal
TalkIDE internal documentation
SekceHodnota
StatusAccepted
Datum2026-05-07
StopaB.0.2
GitLab issuetalkide-be#19
Navazuje naADR-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 existujeDetail
NFS PVC mara-workspacenfs-persistent storageClass, RWX, BE pod mountuje na /projects
RBAC talkide-be-orchestratorCluster-scope, verby pro batch/jobs: create,get,list,watch,delete
Per-tenant namespace tenant-{slug}Provisionovaný K8sNamespaceProvisioner (B.2)
K8sAppDeployer (B.4) patternInterface + K8s impl + Noop, fabric8 Builder DSL, idempotence
Pull secret registry-talkideExistuje jen v platform talkide ns — pro per-tenant build je nutné kopírovat

Co chybí

  • Žádný Kaniko-related kód v BE ani Helm
  • TalkideProperties nemá build blok
  • Žádný distributed lock / scheduling pattern (concurrence řeší zatím jen conversation in-memory)
  • Žádná entita build v 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: BuildService v features/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/Job v per-tenant namespace tenant-{tenantSlug} (ne v platform talkide ns)
  • Job mountuje stejný NFS PVC mara-workspace jako 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:
    --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/kaniko
    
    (mount build-cache-pvc na /cache/kaniko př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ři provisionTenantNamespace() zkopíruje registry-talkide Secret z talkide do tenant-{slug}.
  • Implementačně: client.secrets().inNamespace("talkide").withName("registry-talkide").get() → strip metadata.uid/resourceVersion/namespaceclient.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: BuildConflictException409 Conflict + BUILD_CONFLICT error 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: 600 na 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)

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.Many per 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-cookie affinity), nebo jen pollovat REST GET /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 404
  • BUILD_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: BuildServiceTest mockuje KubernetesClient, 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 explicit gradle smokeTest
  • Cloud verify: manuální test po deploy přes POST /api/builds s 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-talkide musí 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.get per 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: releaseSnapshot se 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 Jobrequests/limits.cpu/memory na pod, ne na app — TBD calibration na real workload

Alternatives considered

AlternativaProč zamítnuto
In-memory watcher (CompletableFuture per pod)Multi-pod nesafe; pod crash = stuck RUNNING 15 min; UX disaster
Kubernetes Informer patternVětší komplexita (event handlers, reconciliation), informer life-cycle management při pod restart, race conditions u writes
Kaniko sidecar webhookKaniko 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-lockAdvisory 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 KanikoVyžaduje root nebo privileged seccomp; Kaniko je standard pro untrusted builds v K8s
Tekton PipelinesOverkill pro MVP; future možnost když přibudou multi-step build workflows
Cancel-and-replace concurrencyKomplexní state machine, race conditions při Kaniko Job delete; lock+409 je deterministic
FIFO build queue per slugStav queue v DB, scheduler logic. Pro alpha overkill — uživatel/Mara může retry pollnout
Platform talkide namespace pro buildyCost atribuce nefér (musela by se rozpočítávat per-user), ostrá hranice tenant izolace zmizí
Dedikovaný talkide-builds namespaceStejný 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):

  1. Create namespace tenant-{slug} (existing logic)
  2. Apply ResourceQuota (existing logic)
  3. NOVÉ: Copy registry-talkide Secret z talkide ns:
    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

Was this page helpful?

Thanks for the feedback.