Internal Documentation internal
TalkIDE internal documentation

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 gitSha mohou 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ů

MechanismusDostupnostDů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 scopeWorking 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 -a zachová symlinky, permissions, timestamps. Java NIO Files.walkFileTree má edge cases na NFS (permissive handles, race na symlinky) — procesový cp je robustnější.
  • ATOMIC_MOVE v 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ů

ParametrPravidloChování při porušení
slugnot blank, RFC-1123 (existující validátor z projects)IllegalArgumentException
buildIdnot 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í existovatSnapshotException("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á .tmp dir, 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)
}
  • IOException př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):

TestGIVENWHENTHEN
snapshot_happyPathExistující outputDir/{slug} se soubory; buildId neexistujesnapshot(slug, buildId)Vrátí Path; soubory zkopírovány; .tmp neexistuje
snapshot_sourceNotFoundoutputDir/{slug} neexistujesnapshot(slug, buildId)SnapshotException("project working tree not found")
snapshot_targetAlreadyExistsSnapshot pro buildId již existujesnapshot(slug, buildId)SnapshotException("snapshot already exists for buildId=...")
snapshot_invalidSlugBlank slugsnapshot("", buildId)IllegalArgumentException
snapshot_invalidBuildIdBuildId s path traversal ../evilsnapshot(slug, "../evil")IllegalArgumentException
releaseSnapshot_happyPathExistující snapshot dirreleaseSnapshot(slug, buildId)Dir smazán; Files.exists = false
releaseSnapshot_noOpSnapshot dir neexistujereleaseSnapshot(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

FeatureDůvod vyloučení
DO native .snapshot/ APINedostupné na self-host nfs-server-provisioner
HTTP endpoint pro snapshotSnapshotService je vnitřní bean; volán z BuildService (B.0.2+)
Multi-pod file lockingSingle-pod alpha; řeší se pokud kdy nastane
Git-based snapshot (clone)Out of scope; využíváme cp pro simplicitu
Storage quota trackingPre-alpha; řeší ops
Async snapshot (event/queue)Overkill pro synchronní build pipeline v B.0.x
.tmp orphan cleanup jobTech debt; post-alpha ops concern

Consequences

Pozitiva

  • Žádná infrastrukturní závislostcp -a funguje 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ý cp bez 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 implementaci SnapshotService, ne změnu callera.

Rizika a omezení

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

  2. cp jako systémová závislostProcessBuilder("cp", ...) předpokládá Unix cp v PATH. Funguje v Linux kontejneru (prod) i macOS (lokál). Neběží na Windows — přijatelné (BE je Spring Boot kontejner, dev na macOS/Linux).

  3. 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-minutes lze přidat bez breaking change.

  4. Žádný .tmp orphan cleanup — crash BE podu po cp a před ATOMIC_MOVE zanechá .tmp dir na NFS. Není automaticky odklizen. Post-alpha: přidat startup cleanup job (scan .snapshots/**/*.tmp, delete older than X hours).

  5. Snapshot kopíruje celý working tree — včetně node_modules (pokud existuje), .git/, cache adresářů. Caller (B.0.2+) by měl zvážit .talkideignore nebo -a --exclude pattern 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#18https://gitlab.com/talkide/talkide-be/-/work_items/18
  • Package: features/snapshot/ — nový samostatný package, nepatří do features/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 .git v 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í).


Was this page helpful?

Thanks for the feedback.