Status: Accepted Datum: 2026-05-07 Oblast: Stopa B.0.1 / Build context — immutable working-tree snapshot
Context
Stopa B.0.x — build pipeline pro Kaniko
Stopy B.1–B.4 (ADR-014 až ADR-017) řeší K8s infrastrukturu: namespace, databázi, deployment. Stopa B.0.x adresuje přípravnou fázi buildu: jak bezpečně předat working tree Kaniku jako immutable build context.
Stopa B.0.1 je první krokem: vytvoření immutable snapshotu working tree před tím, než Kaniko začne číst soubory.
Problém: Mara edituje working tree paralelně s buildem
Mara (Anthropic Claude Agent SDK, Node.js sidecar v BE podu) edituje soubory v project working tree průběžně — může dostat od uživatele instrukci i ve chvíli, kdy build teprve probíhá. Kaniko čte build context jako filesystem directory. Bez snapshotu hrozí:
- Torn read: Kaniko přečte část souboru v průběhu zápisu → nekonzistentní image.
- Non-reproducible build: dva po sobě jdoucí buildy ze stejného
gitShamohou produkovat různé image (různé generace Mara editů). - Build context races: NFS neposkytuje per-directory read/write locking.
NFS layout a omezení
/projects/ ← TALKIDE_OUTPUT_DIR (PVC mara-workspace, ReadWriteMany)
{slug}/
backend/
frontend/
documentation/
.talkide/
Property talkide.output-dir → env TALKIDE_OUTPUT_DIR=/projects (prod) /
/Users/mirek/.../workspace/output-projects (lokál).
Žádné userId v path — issue #18 zmiňoval <userId>/<projectId>, ale aktuální
implementace používá pouze {slug} jako diskriminátor.
Dostupnost nativních snapshot mechanismů
| Mechanismus | Dostupnost | Důvod nedostupnosti |
|---|---|---|
DO managed NFS .snapshot/ API | ❌ Nedostupné | Používáme self-host nfs-server-provisioner, ne managed NFS |
| ZFS/BTRFS copy-on-write snapshot | ❌ Nedostupné | NFS server provisioner běží na standardním ext4 block volume |
| Git clone working tree | ❌ Out of scope | Working tree není izolovaný Git repo pro snapshot účely |
cp -a + atomic rename | ✅ Dostupné | Funguje na libovolném POSIX FS, žádné závislosti na infrastruktuře |
Decision
1. Snapshot location: subdir v existujícím PVC
Path pattern:
{outputDir}/.snapshots/{slug}/{buildId}/
Příklad: /projects/.snapshots/demo/build-abc123/
Zdůvodnění:
- Žádný nový PVC, žádná změna Helm chartu.
.snapshots/začíná tečkou — slug validátor (RFC-1123) nemůže vytvořit slug začínající tečkou; žádná kolize s working tree projektu.- Single-tenant alpha = storage quota není kritický concern.
- Future: pokud build storage poroste, refactor na separátní
/builds/mount je triviální (změna property + Helm volume bez úpravy kódu).
2. Atomicity: cp -a do .tmp + atomic mv
Sekvence operací:
1. cp -a {outputDir}/{slug} → {outputDir}/.snapshots/{slug}/{buildId}.tmp
2. Files.move({buildId}.tmp, {buildId}, ATOMIC_MOVE)
3. return Path({outputDir}/.snapshots/{slug}/{buildId})
Zdůvodnění:
- Crash recovery: buď existuje
.tmp(incomplete, lze bezpečně smazat) nebo finální dir (hotový snapshot). Žádný mezistav, žádný corrupted snapshot. - Defensive pro konzumenta: Kaniko nikdy nevidí half-copied strom — adresář buď neexistuje, nebo je kompletní.
cp -azachová symlinky, permissions, timestamps. Java NIOFiles.walkFileTreemá edge cases na NFS (permissive handles, race na symlinky) — procesovýcpje robustnější.ATOMIC_MOVEv rámci stejného FS = jedinýrename(2)syscall; NFS v rámci stejného mount pointu garantuje atomicitu (NFSv4 compound operation).
3. Service interface
interface SnapshotService {
/**
* Vytvoří immutable snapshot working tree pro daný build.
*
* @param slug Identifikátor projektu (RFC-1123, not blank).
* @param buildId Identifikátor buildu (alphanumeric/dash, max 64 znaků).
* @return Absolutní Path k hotovému snapshot directory.
* @throws SnapshotException pokud cp/mv selže (cause = IOException nebo
* InterruptedException) nebo vstupní podmínky nejsou splněny.
*/
fun snapshot(slug: String, buildId: String): Path
/**
* Smaže snapshot directory.
* Idempotentní — neexistující path vrátí bez chyby (no-op + debug log).
*
* @param slug Identifikátor projektu.
* @param buildId Identifikátor buildu.
*/
fun releaseSnapshot(slug: String, buildId: String)
}
4. Validace vstupů
| Parametr | Pravidlo | Chování při porušení |
|---|---|---|
slug | not blank, RFC-1123 (existující validátor z projects) | IllegalArgumentException |
buildId | not blank, pattern [a-zA-Z0-9-]{1,64} | IllegalArgumentException |
{outputDir}/{slug} | musí existovat jako adresář | SnapshotException("project working tree not found") |
{outputDir}/.snapshots/{slug}/{buildId} | nesmí existovat | SnapshotException("snapshot already exists for buildId={...}") — idempotence řeší caller voláním releaseSnapshot předem |
Regex [a-zA-Z0-9-]{1,64} na buildId preventuje path traversal (žádné .., /, \).
5. Jedna implementace — bez Variant B
Stopy B.1–B.4 používají Variant B (conditional bean wiring) kvůli KubernetesClient, který
nelze v testech instantiovat bez reálného clusteru. Pro filesystem operace tato potřeba
odpadá — @TempDir v JUnit 5 poskytuje izolovaný reálný filesystem.
Výsledná struktura:
SnapshotService (Kotlin interface — features/snapshot/)
LocalSnapshotServiceImpl (@Service — výchozí implementace pro lokál i cloud)
SnapshotException (RuntimeException — features/snapshot/)
Žádný NoopSnapshotService, žádný @ConditionalOnProperty. Pokud by budoucí potřeba
vyvstala (např. mock pro jiný modul), lze LocalSnapshotServiceImpl nahradit bez změny
interface.
6. ProcessBuilder pro cp -a
val process = ProcessBuilder("cp", "-a", src.toString(), dst.toString())
.redirectErrorStream(false)
.start()
// stderr streamujeme do logu pro diagnostiku
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { log.debug("[cp] {}", it) }
}
val exited = process.waitFor(5, TimeUnit.MINUTES)
if (!exited) {
process.destroyForcibly()
throw SnapshotException("cp -a timed out after 5 minutes for slug=$slug buildId=$buildId")
}
if (process.exitValue() != 0) {
throw SnapshotException("cp -a failed with exit code ${process.exitValue()} for slug=$slug buildId=$buildId")
}
- Timeout: 5 minut. Pro typický working tree (< 100 MB) je
cpřádově sekundy. destroyForcibly()po timeoutu — zanechá.tmpdir, který cleanup job může smazat.- Stderr do debug logu — neblokuje proces, umožní diagnostiku selhání.
7. Cleanup — releaseSnapshot
Implementace přes Java NIO Files.walkFileTree (žádný external proces):
fun releaseSnapshot(slug: String, buildId: String) {
val path = snapshotPath(slug, buildId)
if (!Files.exists(path)) {
log.debug("releaseSnapshot: path does not exist, no-op: {}", path)
return
}
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.delete(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
if (exc != null) throw exc
Files.delete(dir)
return FileVisitResult.CONTINUE
}
})
log.info("releaseSnapshot: deleted snapshot {}", path)
}
IOExceptionpři smazání → log warn + propagace ke calleru (caller = build cleanup; neúspěšné smazání je přijatelné — zanechá orphan snapshot dir, storage overhead).- Idempotentní: neexistující path = no-op.
8. Error handling
SnapshotException(message: String, cause: Throwable? = null)
extends RuntimeException
Error code v common exceptions: SNAPSHOT_ERROR.
ExceptionHandler:
SnapshotException → 500 Internal Server Error
{ "code": "SNAPSHOT_ERROR", "message": "<detail>" }
SnapshotService je vnitřní bean — HTTP endpoint nevzniká v B.0.1. ExceptionHandler
mapování je připraveno pro volání z BuildService (B.0.2+), které bude mít HTTP vrstvu.
9. Test plán
Unit testy s @TempDir (žádný Testcontainers, žádný k3s):
| Test | GIVEN | WHEN | THEN |
|---|---|---|---|
snapshot_happyPath | Existující outputDir/{slug} se soubory; buildId neexistuje | snapshot(slug, buildId) | Vrátí Path; soubory zkopírovány; .tmp neexistuje |
snapshot_sourceNotFound | outputDir/{slug} neexistuje | snapshot(slug, buildId) | SnapshotException("project working tree not found") |
snapshot_targetAlreadyExists | Snapshot pro buildId již existuje | snapshot(slug, buildId) | SnapshotException("snapshot already exists for buildId=...") |
snapshot_invalidSlug | Blank slug | snapshot("", buildId) | IllegalArgumentException |
snapshot_invalidBuildId | BuildId s path traversal ../evil | snapshot(slug, "../evil") | IllegalArgumentException |
releaseSnapshot_happyPath | Existující snapshot dir | releaseSnapshot(slug, buildId) | Dir smazán; Files.exists = false |
releaseSnapshot_noOp | Snapshot dir neexistuje | releaseSnapshot(slug, buildId) | No-op; žádná výjimka |
Pozn.: cp se v unit testech volá jako reálný systémový příkaz nad @TempDir — žádný
mock ProcessBuilder. Testy jsou proto deterministické a ověřují skutečné filesystem chování.
10. Scope B.0.1 — co je mimo
| Feature | Důvod vyloučení |
|---|---|
DO native .snapshot/ API | Nedostupné na self-host nfs-server-provisioner |
| HTTP endpoint pro snapshot | SnapshotService je vnitřní bean; volán z BuildService (B.0.2+) |
| Multi-pod file locking | Single-pod alpha; řeší se pokud kdy nastane |
| Git-based snapshot (clone) | Out of scope; využíváme cp pro simplicitu |
| Storage quota tracking | Pre-alpha; řeší ops |
| Async snapshot (event/queue) | Overkill pro synchronní build pipeline v B.0.x |
.tmp orphan cleanup job | Tech debt; post-alpha ops concern |
Consequences
Pozitiva
- Žádná infrastrukturní závislost —
cp -afunguje na libovolném POSIX FS, žádný vendor lock-in; přechod na managed NFS nebo jiný storage nevyžaduje změnu kódu. - Atomický handoff — Kaniko nikdy nevidí incomplete snapshot; crash recovery je čistý
(smazat
.tmp, zkusit znovu). - Jednoduchá testovatelnost —
@TempDir+ reálnýcpbez mockování; testy jsou rychlé (< 1 s) a spolehlivé. - Nulový operační overhead — žádný nový PVC, žádná Helm změna, žádný sidecar.
- Interface připraven pro refactor — změna storage backendu (separátní
/builds/mount) vyžaduje pouze novou implementaciSnapshotService, ne změnu callera.
Rizika a omezení
-
Storage growth — každý build zanechá snapshot dir dokud není zavoláno
releaseSnapshot. Caller (B.0.2+BuildService) musí garantovat cleanup i při chybách buildu (try/finally blok). Bez cleanup = pomalé plnění NFS PVC. -
cpjako systémová závislost —ProcessBuilder("cp", ...)předpokládá Unixcpv PATH. Funguje v Linux kontejneru (prod) i macOS (lokál). Neběží na Windows — přijatelné (BE je Spring Boot kontejner, dev na macOS/Linux). -
5minutový timeout je konzervativní — pro working tree > 500 MB by mohl být nedostatečný. Pre-alpha projekty jsou malé; pokud by nastaly timeout failures, property
talkide.snapshot.timeout-minuteslze přidat bez breaking change. -
Žádný
.tmporphan cleanup — crash BE podu pocpa předATOMIC_MOVEzanechá.tmpdir na NFS. Není automaticky odklizen. Post-alpha: přidat startup cleanup job (scan.snapshots/**/*.tmp, delete older than X hours). -
Snapshot kopíruje celý working tree — včetně
node_modules(pokud existuje),.git/, cache adresářů. Caller (B.0.2+) by měl zvážit.talkideignorenebo-a --excludepattern pro přeskočení velkých ephemeral adresářů. Pro B.0.1 není řešeno.
Alternatives Considered
Java NIO Files.walkFileTree pro kopírování místo cp -a
Zvažováno. Výhoda: čistý Java kód bez ProcessBuilder, lépe testovatelný přes mock.
Nevýhoda: na NFS má Files.walkFileTree dokumentované edge cases (symbolic links, file
handle leaks při IOException uprostřed stromu, permissions handling). cp -a je
battle-tested POSIX nástroj s konzistentním chováním na NFS. Odmítnuto ve prospěch cp.
Separátní PVC /builds/ pro snapshots
Zvažováno jako čistší separace concerns (build context izolován od working tree). Nevýhoda:
vyžaduje Helm změnu, nový PersistentVolumeClaim, úpravu BE Deployment manifestu. Pro
alpha jednoznačně overengineering — cp do .snapshots/ subdir v existujícím PVC dosáhne
stejného výsledku. Odkládáme jako post-alpha option pokud storage oddělení vyvstane jako
potřeba (property change + Helm, bez kódové změny).
rsync místo cp -a
Zvažováno. rsync umí --exclude patterns a inkrementální kopírování. Nevýhoda: není
garantovaně v PATH ve všech base images; přidává deployment dependency. cp -a je součástí
GNU coreutils — přítomnost garantována. Odmítnuto; rsync lze zvážit pokud -a --exclude
potřeba vyvstane v B.0.2+.
NoopSnapshotService pro testy (Variant B pattern)
Zvažováno z důvodu konzistence s B.1–B.4. Odmítnuto — Variant B byl v těch ADR nutný kvůli
KubernetesClient (nelze instantiovat bez cluster). Filesystem operace jsou plně testovatelné
s @TempDir. Přidání noop implementace by jen komplikovalo konfiguraci bez přínosu.
Implementation Notes
- Issue: talkide-be#18 — https://gitlab.com/talkide/talkide-be/-/work_items/18
- Package:
features/snapshot/— nový samostatný package, nepatří dofeatures/k8s/ - Navazující: B.0.2
BuildService(volásnapshot()+releaseSnapshot()v try/finally) - ADR-013: Git versioning strategy — working tree obsahuje
.git/; snapshot kopíruje i.git/(výchozí); pokud by Kaniko.git/nepotřeboval, lze přidat--exclude .gitv B.0.2 - talkide-be#44 (slug RFC-1123 validator): slug validátor sdílen s
SnapshotService
FEEDBACK
Podklady byly výjimečně kompletní — všechna rozhodnutí předschválena, interface i pseudokódy
přímo použitelné, scope jasně ohraničen. Jediné, co by drobně ulehčilo práci: explicitní
zmínka o cílovém package (features/snapshot/ jsem odvodil z konvence B.1–B.4, ale nebylo
řečeno) a potvrzení, zda SnapshotException patří do common exceptions nebo přímo do
package (zvolil jsem features/snapshot/ jako výchozí, ale přesunout do common je trivální).
Thanks for the feedback.