Fix A (found live on 9202): the container sampler reads docker inspect JSON — a -f template stopped at 'no entry for key Health' for every app without a healthcheck, so a healthy restore read 'not started'. Commit only.
gates / gates (push) Successful in 1m27s

This commit is contained in:
2026-10-25 17:02:32 +01:00
parent 1ee0353dbd
commit 3a1f09d29f
3 changed files with 48 additions and 11 deletions
+5
View File
@@ -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.
+28 -11
View File
@@ -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
}
@@ -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)
}
}