diff --git a/CHANGELOG.md b/CHANGELOG.md index 75085e32..dfd19846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ says „not started yet", alarms the operator; a late start is a success; never testing: the watch starts inside its own restore, before the page's record ends, so „a restore of the app runs" alone ended every watch at once and would have kept every copy (`TestFixA_AStableAppReleasesTheCopyAfterTheWindow` in the production order, seen failing with the plain check). Each watch reads its timing once, at its start. +- **Found live on 9202:** the container sampler first read `docker inspect -f '…{{if .State.Health}}…'`; for a + container with NO healthcheck Docker stops with „map has no entry for key Health", so every such app read „not + started" (a healthy Vikunja restore was reported not started). It reads the inspect JSON now + (`parseContainerSamples`, `TestFixA_ParseContainerSamples`); the sampler's error keeps Docker's stderr, and a DEBUG + line says why a sample was not ok. - **Fix B (R-1279, `appexport/restore.go`):** the import asks the restores' own D48 helper (`PrepareRestoreImages`, passed in by main.go — the package may not import `backup`) about the bundle's definition BEFORE step 1 removes the running app's volumes; a version that cannot be downloaded refuses with the two D48 sentences and nothing changed. diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index b73219db..babc1644 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "encoding/json" "errors" "flag" "fmt" @@ -3465,22 +3466,38 @@ func (a *stackAdapter) ContainerSamples(name string) ([]backup.ContainerSample, if len(ids) == 0 { return nil, nil } - out, err = run(append([]string{"inspect", "-f", "{{.Id}} {{.State.Status}} {{.RestartCount}} {{if .State.Health}}{{.State.Health.Status}}{{end}}"}, ids...)...) + // The JSON, not a -f template: a container with no healthcheck has no State.Health, and Docker's template stops + // with „map has no entry for key Health" (measured on 9202, 2026-10-25 — every such app read „not started"). + out, err = run(append([]string{"inspect"}, ids...)...) if err != nil { return nil, err } + return parseContainerSamples(out) +} + +// parseContainerSamples reads `docker inspect` JSON into stable-start samples; State.Health is absent for a container +// with no healthcheck (Health ""). Pinned by TestFixA_ParseContainerSamples. +func parseContainerSamples(out []byte) ([]backup.ContainerSample, error) { + var raw []struct { + ID string `json:"Id"` + RestartCount int `json:"RestartCount"` + State struct { + Status string `json:"Status"` + Health *struct { + Status string `json:"Status"` + } `json:"Health"` + } `json:"State"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil, fmt.Errorf("docker inspect: %w", err) + } var res []backup.ContainerSample - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - f := strings.Fields(line) - if len(f) < 3 { - continue + for _, c := range raw { + cs := backup.ContainerSample{ID: c.ID, Running: c.State.Status == "running", RestartCount: c.RestartCount} + if c.State.Health != nil { + cs.Health = c.State.Health.Status } - n, _ := strconv.Atoi(f[2]) - c := backup.ContainerSample{ID: f[0], Running: f[1] == "running", RestartCount: n} - if len(f) > 3 { - c.Health = f[3] - } - res = append(res, c) + res = append(res, cs) } return res, nil } diff --git a/controller/cmd/controller/r1279_wiring_test.go b/controller/cmd/controller/r1279_wiring_test.go index 50e2d0f2..a2544b9e 100644 --- a/controller/cmd/controller/r1279_wiring_test.go +++ b/controller/cmd/controller/r1279_wiring_test.go @@ -20,3 +20,18 @@ func TestR1279_MainWiresTheImportsDownloadFirst(t *testing.T) { t.Fatal("main.go's stack adapter must read container samples for the stable-start rule (fix A)") } } + +// Fix A: the sampler reads a container with NO healthcheck (no State.Health) — measured on 9202: a -f template stopped +// at „map has no entry for key Health", so every such app read „not started". +func TestFixA_ParseContainerSamples(t *testing.T) { + out := []byte(`[{"Id":"a1","RestartCount":0,"State":{"Status":"running"}}, + {"Id":"b2","RestartCount":3,"State":{"Status":"running","Health":{"Status":"healthy"}}}, + {"Id":"c3","RestartCount":1,"State":{"Status":"restarting"}}]`) + got, err := parseContainerSamples(out) + if err != nil || len(got) != 3 { + t.Fatalf("parse: %v %+v", err, got) + } + if !got[0].Running || got[0].Health != "" || got[1].Health != "healthy" || got[1].RestartCount != 3 || got[2].Running { + t.Fatalf("wrong samples: %+v", got) + } +}