Skip to content

Commit 8f4767a

Browse files
committed
docs(compose): rebuild design, analysis & adversarial review notes
Root-cause analysis of the cache/rebuild behavior, the implementation plan, and the adversarial code-review issue list for the mpm compose rebuild feature.
1 parent 982f62d commit 8f4767a

3 files changed

Lines changed: 473 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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

Comments
 (0)