|
| 1 | +# Root-Cause-Analyse: `mpm compose up` baut Container trotz geänderter Dateien oft nicht neu |
| 2 | + |
| 3 | +> Status: ✅ Analyse abgeschlossen & Code-Aussagen unabhängig verifiziert (Direkt-Reads von `up.rs`, `docker.rs`, `watch.rs`, `cli.rs`, plus crate-weiter Grep). Datum: 2026-06-22. |
| 4 | +> Erstellt via Multi-Agent-Workflow (8 Investigatoren → Hypothesen → adversariale Verifikation 2 Linsen/Hypothese → Synthese) + manueller Nachprüfung. |
| 5 | +
|
| 6 | +## Wichtigste Korrektur am Ausgangsbild |
| 7 | + |
| 8 | +Es gibt **kein mpm-internes Caching-/State-System**, das Builds überspringt. Grep über das gesamte |
| 9 | +`src/package_manager/compose/`-Modul nach `sha|hash|digest|checksum|build-state|fingerprint` |
| 10 | +(ohne `HashMap`/`HashSet`/Kommentare/Tests) liefert **null Treffer**. `docker compose build` läuft |
| 11 | +**immer**. Der einzige Cache im Spiel ist **Dockers eigener Layer-Cache** — und genau den umgeht |
| 12 | +`mpm compose up` im Normalpfad nie. Die Beschwerde ist also nicht „mpm überspringt den Build", sondern |
| 13 | +„mpm baut **immer mit Cache** und erzwingt **nie** ein Container-Recreate". |
| 14 | + |
| 15 | +## Executive Summary |
| 16 | + |
| 17 | +Der normale (Nicht-Watch-)Aufruf `mpm compose up` ruft `run_deploy_cycle(&base_dir, client.as_ref(), false)` |
| 18 | +mit dem Flag `build_context_changed` **hart auf `false`** (`up.rs:69`). Dieses eine Boolean ist die |
| 19 | +**einzige** Stelle, die steuert, ob `docker compose build` mit `--no-cache` läuft |
| 20 | +(`up.rs:316` → `docker.rs:303-307`). Die komplette Change-Detection (`is_build_context_change`, |
| 21 | +mtime-Snapshots) lebt ausschließlich im Watch-Loop (`watch.rs`) und ist aus einem normalen |
| 22 | +`mpm compose up` **strukturell unerreichbar** (nur `watch.rs:428` übergibt je einen berechneten Wert). |
| 23 | +Zusätzlich läuft `docker compose up -d` **ohne `--force-recreate`** und **ohne `--pull`** (`docker.rs:258-268`). |
| 24 | + |
| 25 | +Zwei bestätigte Hauptursachen: **RC-1** (Nicht-Watch-Pfad kann nie `--no-cache` setzen) und der |
| 26 | +robustheitskritische Teil von **RC-2** (kein `--force-recreate`, kein Image-ID-Vergleich im Post-Deploy-Check). |
| 27 | +RC-3/RC-4 sind reale, aber nur-im-Watch-Modus relevante Nebenbefunde. H5 (falscher Build-Context) ist |
| 28 | +widerlegt; H6 (`MPM_MOCK_DOCKER`) ist nur ein Umgebungs-Footgun, kein Logikbug. |
| 29 | + |
| 30 | +## Tatsächlicher Kontrollfluss (verifiziert) |
| 31 | + |
| 32 | +1. `main.rs:139` — `ComposeCommands::Up { watch, debounce_ms } => compose_up(watch, debounce_ms)`. |
| 33 | + `watch` Default `false` (`cli.rs:159-167`). Nacktes `mpm compose up` ⇒ `watch == false`. |
| 34 | +2. `compose_up` (`up.rs:49`): Manifest-Dir ermitteln, `default_client()` (`up.rs:55`). |
| 35 | +3. Verzweigung (`up.rs:57-70`): |
| 36 | + - **Nicht-Watch:** `up.rs:69` → `run_deploy_cycle(&base_dir, client.as_ref(), false)` — Literal `false`. |
| 37 | + - **Watch:** initialer Deploy `up.rs:60` ebenfalls `false`, danach `run_watch_loop` (`watch.rs`). |
| 38 | +4. `run_deploy_cycle(.., build_context_changed)` (`up.rs:83`): render → secrets → pre-checks → mounts → |
| 39 | + `run_docker_compose_up(client, &context, build_context_changed)` (`up.rs:106`) → post-checks. |
| 40 | +5. `run_docker_compose_up` (`up.rs:268`): |
| 41 | + - **immer** `ComposeBuildOptions { .., no_cache: build_context_changed }` (`up.rs:310-316`) → `compose_build`. |
| 42 | + - **immer** `ComposeUpOptions { build: false, detach: true, remove_orphans: true }` (`up.rs:320-329`) → |
| 43 | + `compose_up`. **Kein** `force_recreate`-Feld. |
| 44 | +6. CLI-Kommandos: |
| 45 | + - `compose_build` (`docker.rs:287-324`): `docker compose -p <proj> --project-directory .results -f <compose> build`; |
| 46 | + `--no-cache` **nur** bei `options.no_cache` (`docker.rs:303-307`). |
| 47 | + - `compose_up` (`docker.rs:242-285`): `docker compose … up`; `--build` nur bei `options.build` (=`false`), |
| 48 | + `-d`, `--remove-orphans`. **Niemals** `--force-recreate`, **niemals** `--pull`. |
| 49 | +7. Nur `watch.rs:428` liefert je `build_context_changed=true`. |
| 50 | + |
| 51 | +Selbst-Dokumentation des Codes (`up.rs:259-267`, `304-309`) räumt ein, dass `up --build` „may not always |
| 52 | +invalidate the layer cache correctly" — der eigentliche Fix (`--no-cache`) ist aber nur an den Watch-Loop verdrahtet. |
| 53 | + |
| 54 | +## Bestätigte Root Causes |
| 55 | + |
| 56 | +### RC-1 — Nicht-Watch `mpm compose up` kann nie `--no-cache` setzen (Likelihood: hoch) |
| 57 | +`build_context_changed` ist im Normalpfad Literal `false`; Change-Detection ist Watch-exklusiv. Folge: |
| 58 | +Normaler `mpm compose up` baut **immer mit Layer-Cache**; ob eine geänderte Quelldatei ein neues Image erzeugt, |
| 59 | +entscheidet allein Docker/BuildKit. |
| 60 | + |
| 61 | +Lücken-Klassen, bei denen Dockers Cache eine echte Änderung NICHT sieht (Docker-Doku, high confidence): |
| 62 | +- `RUN`-Schritte sind nur auf den **Befehlsstring** gekeyt: `RUN git clone`/`curl`/`apt-get update`/`cargo fetch` |
| 63 | + holen geänderten Upstream NICHT neu (https://docs.docker.com/build/cache/invalidation/). |
| 64 | +- COPY/ADD ignorieren **mtime** im Checksum. |
| 65 | +- Per `.dockerignore` ausgeschlossene oder gar nicht via COPY ins Image gezogene Dateien busten den Cache nicht. |
| 66 | + |
| 67 | +Präzisierung: Für eine Datei, die tatsächlich via COPY ins Image wandert und sich inhaltlich ändert, |
| 68 | +**bustet BuildKit den Layer auch ohne `--no-cache`** — der häufige Fall „edit `src/foo.rs`, wird kopiert" wird |
| 69 | +also auch heute oft korrekt gebaut. Die Lücke betrifft die obigen Klassen. Passt zu „**oft** nicht neu gebaut". |
| 70 | + |
| 71 | +Evidenz: `up.rs:60/69` (`false`), `up.rs:106`, `up.rs:310-318`, `docker.rs:303-307`, |
| 72 | +`watch.rs:315-319` (leere Kontexte ⇒ `false`), `watch.rs:416-428` (einziger `true`-Producer), |
| 73 | +`cli.rs:159-167` (kein `--no-cache`/`--rebuild`-Flag). |
| 74 | + |
| 75 | +### RC-2 — Kein `--force-recreate`, kein Image-ID-Vergleich (Code-Tatsachen bestätigt; Schaden bedingt) |
| 76 | +- `ComposeUpOptions` hat kein `force_recreate`-Feld (`docker.rs:53-71`); `compose_up` emittiert nie |
| 77 | + `--force-recreate`/`--pull` (`docker.rs:258-268`); crate-weiter Grep: null Nicht-Test-Treffer. |
| 78 | +- Render berechnet **keinen** content-basierten/eindeutigen Image-Tag (`render.rs:375-434`) ⇒ Tag konstant über Läufe. |
| 79 | +- Post-Deploy-Check (`up.rs:186-246`, `health.rs`) pollt nur Status/Health, vergleicht **nie** die laufende |
| 80 | + Image-ID mit dem frisch gebauten Image ⇒ mpm meldet Erfolg auch bei stale Container. |
| 81 | + |
| 82 | +Bedingung: Ist ein Build voll aus dem Cache (RC-1), bleibt die Image-ID gleich ⇒ Compose-Default-Konvergenz |
| 83 | +(`com.docker.compose.config-hash`, in den der Image-Digest einfließt) erkennt korrekt „keine Änderung" und |
| 84 | +rekreiert by-design nicht (Container ist byte-identisch). Schaden entsteht (a) gekoppelt mit RC-1, wenn ein Rebuild |
| 85 | +hätte passieren sollen, der Cache ihn aber verhindert, und (b) bei dokumentierten Compose-Recreate-Regressionen |
| 86 | +(docker/compose#9259, #9450 — versionsabhängig, extern nicht aus dem Repo verifizierbar), gegen die mpm mangels |
| 87 | +`--force-recreate` keinen Fallback hat. Eigenständig stärkste Lücke: fehlender Image-ID-Vergleich im Post-Deploy-Check. |
| 88 | + |
| 89 | +### RC-3 — Change-Detection (nur Watch) ist mtime-Gleichheit, nicht Content-Hash (eng) |
| 90 | +`mtimes_changed` (`watch.rs:245-259`) vergleicht Dateianzahl + exakte `SystemTime`-Gleichheit; kein Content-Hash |
| 91 | +existiert. Bei Gleichheit wird das Event verworfen (`watch.rs:396-402`). Korrektur: die „git checkout"-Story |
| 92 | +widerlegt sich selbst (git setzt mtime auf Wall-Clock-Checkout-Zeit ⇒ Änderung wird erkannt). Realer Blind Spot eng: |
| 93 | +mtime-erhaltende Tools (`git-restore-mtime`, `cp -p`, `rsync -t`, `tar`) oder zwei Writes in derselben groben Sekunde. |
| 94 | +**Nur Watch-Modus** betroffen. |
| 95 | + |
| 96 | +### RC-4 — `extract_build_contexts` lässt Kontexte still fallen (eng, nur Watch) |
| 97 | +Leere/partielle Liste bei: nicht lesbarer/parsebarer `.results`-Compose (`watch.rs:48-62`, nur `debug!`), |
| 98 | +`build:`-Block mit nur `dockerfile:` ohne `context:` (`watch.rs:80-89`), Kontext außerhalb Git-Root |
| 99 | +(`watch.rs:96-104`), Nicht-Verzeichnis (`watch.rs:106-113`). Leere Liste ⇒ `is_build_context_change == false`. |
| 100 | +`--no-cache` ist zudem **global** über alle Services (alles-oder-nichts). **Nur Watch-Modus** relevant. |
| 101 | + |
| 102 | +## Verworfene / unwahrscheinliche Hypothesen |
| 103 | +- **H5 (falscher Build-Context via `../../<service>`):** Widerlegt. `../../` ist für das init-Layout korrekt |
| 104 | + (`mod.rs:37` `.results`, `init.rs:103-112`, Test `init.rs:477`). Docker und Change-Detector lösen `build.context` |
| 105 | + gegen dasselbe `.results` auf. Falscher Kontext würde laut fehlschlagen, nicht still alten Cache liefern. |
| 106 | +- **H6 (`MPM_MOCK_DOCKER=1`):** Mechanismus existiert (`docker.rs:429-436,481-493`), aber kein Logikbug — nur relevant, |
| 107 | + falls die Variable in eine echte Shell leakt. Druckt sichtbar `mock: compose_build …` nach stdout. |
| 108 | + Diagnose: `env | grep MPM_MOCK_DOCKER`. |
| 109 | + |
| 110 | +## Cache behalten UND Rebuild garantieren |
| 111 | +Zwei orthogonale Hebel: (1) **Image-Rebuild** via `docker compose build` (Cache) bzw. `--no-cache`; |
| 112 | +(2) **Container-Recreate** via Compose-Konvergenz (`config-hash`, Image-Digest) bzw. `--force-recreate`. |
| 113 | +mtime ist die falsche Basis (BuildKit ignoriert mtime für COPY/ADD; mtime-erhaltende Tools verschlucken Änderungen). |
| 114 | +Robuste Größe: **Content-Hash** (SHA-256 über alle Dateien des aufgelösten Build-Contexts **unter `.dockerignore`** + |
| 115 | +Dockerfile + relevante gerenderte Compose-Felder), persistent gespeichert und pro Deploy verglichen. |
| 116 | +- Hash gleich ⇒ normaler `docker compose build` mit Cache, `up -d` ohne erzwungenes Recreate (schnell). |
| 117 | +- Hash unterschiedlich ⇒ `--no-cache` (garantierter Rebuild) **und** `--force-recreate` (garantierter Roll). |
| 118 | +Anti-Footgun: Den Build-Schritt **nie** anhand eines unvollständigen internen Hashes überspringen — `compose build` |
| 119 | +läuft weiter (BuildKit fängt Fälle ab, die ein grober Hash verpasst); der interne Hash steuert nur `--no-cache`/`--force-recreate`. |
| 120 | + |
| 121 | +## Konkrete Fixes (gerankt) |
| 122 | + |
| 123 | +> ⚠️ **Korrektur (2026-06-22):** Der ursprüngliche Fix 1 („Content-Hash gated `--no-cache`") wurde verworfen — |
| 124 | +> `--no-cache` bei jeder Änderung verwirft den gesamten Layer-Cache und widerspricht „inklusive Caching", |
| 125 | +> und ein selbst berechneter Content-Hash riskiert, von BuildKits Cache-Keying zu divergieren. Das |
| 126 | +> autoritative, korrigierte Design (Cached Build + Image-ID-Vergleich als Ground Truth) steht in |
| 127 | +> **[PLAN.md](./PLAN.md)**. Die folgenden Stellen-Referenzen bleiben gültig; nur der Mechanismus von Fix 1/2 |
| 128 | +> ist dort neu gefasst. |
| 129 | +
|
| 130 | +### Fix 1 (kritisch) — Content-Hash-gesteuertes `--no-cache` auch im Nicht-Watch-Pfad |
| 131 | +Ort: `up.rs:69` (+`up.rs:60`), Logik geteilt aus `watch.rs:41-118`. |
| 132 | +- `extract_build_contexts` in gemeinsames Modul (`compose/build_context.rs`) verschieben. |
| 133 | +- `compute_build_context_hash(base_dir) -> Hash`: SHA-256 über jeden aufgelösten Kontext (wie Docker ihn gegen |
| 134 | + `.results` auflöst) unter `.dockerignore` + Dockerfile + gerenderte `.results/docker-compose.yaml`. |
| 135 | +- Persistierten Hash (`.results/.build-state.json` oder `config.rs`) laden; `build_context_changed = stored != current` |
| 136 | + und diesen Wert statt `false` übergeben. |
| 137 | +- Interim/minimal: explizites `--no-cache`/`--rebuild`-Flag an `Up` (`cli.rs:159-167`, `main.rs:139`, `compose_up`-Signatur). |
| 138 | + |
| 139 | +### Fix 2 (kritisch) — `--force-recreate` bei echter Änderung + Image-ID-Verifikation |
| 140 | +Ort: `docker.rs:53-71` (Struct), `docker.rs:258-268` (Args), `up.rs:320-331` (Aufruf), `up.rs:186-246`/`health.rs` (Check). |
| 141 | +- `force_recreate: bool` zu `ComposeUpOptions`; in `compose_up` `if options.force_recreate { cmd.arg("--force-recreate"); }`. |
| 142 | +- `force_recreate: build_context_changed` setzen (Recreate nur bei echter Änderung). |
| 143 | +- Post-Deploy-Check härten: laufende Image-ID via `inspect` (vgl. `preflight.rs:585`) mit frisch gebautem Image |
| 144 | + vergleichen, bei Divergenz laut fehlschlagen. → Aufhänger für Regressionstest. |
| 145 | + |
| 146 | +### Fix 3 (mittel, nur Watch) — `extract_build_contexts` robuster + lauter |
| 147 | +Fehlendes `context:` auf Dockerfile-Dir defaulten; Parse-Fehler ⇒ Deploy fehlschlagen statt `Vec::new()`; |
| 148 | +fallengelassene Kontexte auf `warn!` statt `debug!`. |
| 149 | + |
| 150 | +### Fix 4 (mittel, nur Watch) — mtime-Gate durch Content-Hash ersetzen |
| 151 | +`mtimes_changed`-Gate (`watch.rs:245-259`, `396-402`) durch denselben Content-Hash aus Fix 1 ersetzen. |
| 152 | + |
| 153 | +### Fix 5 (niedrig) — Mock-Footgun entschärfen |
| 154 | +Sichtbares `warn!`/Banner bei aktivem Mock auf jedem Aufruf; optional hinter Build-Profil-Gate. |
| 155 | + |
| 156 | +## Minimale Reproduktion |
| 157 | +```bash |
| 158 | +# 0) Mock-Check |
| 159 | +env | grep MPM_MOCK_DOCKER # darf nichts ausgeben |
| 160 | + |
| 161 | +# 1) Service mit RUN-fetch (klassischer Cache-Treffer-Fall) |
| 162 | +# Dockerfile: RUN echo "marker $(date +%s)" > /marker.txt ; COPY data.txt /data.txt |
| 163 | +echo v1 > app/data.txt |
| 164 | +mpm compose up # data: v1 |
| 165 | + |
| 166 | +# 2) Quelle aendern, erneut normal deployen |
| 167 | +echo v2 > app/data.txt |
| 168 | +mpm compose up |
| 169 | +# COPY-Datei wird gebustet -> v2. Aendert man stattdessen NUR eine RUN-gefetchte / |
| 170 | +# .dockerignore-ausgeschlossene / nicht-kopierte Quelle, bleibt das Image gleich |
| 171 | +# -> v1 bleibt, Container wird NICHT rekreiert (Symptom). |
| 172 | + |
| 173 | +# 3) Gegenprobe |
| 174 | +docker compose -p <proj> -f deployment/.results/docker-compose.yaml \ |
| 175 | + --project-directory deployment/.results build --no-cache |
| 176 | +docker compose -p <proj> ... up -d --force-recreate # jetzt v2 |
| 177 | +``` |
| 178 | + |
| 179 | +## Verifikationsstatus der zentralen Aussagen (manuell nachgeprüft) |
| 180 | +- ✅ `up.rs:60` und `up.rs:69` übergeben hart `false`; nur `watch.rs:428` übergibt berechneten Wert. |
| 181 | +- ✅ `up.rs:316` `no_cache: build_context_changed`; `ComposeUpOptions { build:false, … }`, kein `force_recreate`. |
| 182 | +- ✅ `docker.rs` `compose_up` emittiert nur `up`/`-d`/`--remove-orphans` (`--build` nur bei `build:true`); kein `--force-recreate`/`--pull`. |
| 183 | +- ✅ `docker.rs` `compose_build` hängt `--no-cache` nur bei `options.no_cache` an. |
| 184 | +- ✅ `cli.rs:159-167` `Up` exponiert nur `watch` + `debounce_ms`. |
| 185 | +- ✅ `mtimes_changed` nutzt exakte `SystemTime`-Gleichheit; `is_build_context_change` ⇒ `false` bei leeren Kontexten. |
| 186 | +- ✅ Kein Content-Hash / persistenter Build-State im gesamten `compose/`-Modul (Grep: 0 Treffer). |
| 187 | +- ⚠️ Extern/unsicher: Compose-Recreate-Regressionen #9259/#9450 (versionsabhängig); `MPM_MOCK_DOCKER`-Leak (Umgebung). |
0 commit comments