Tell what a container inherits from what it overrides

Checked against a real stalwartlabs/stalwart image, `docker inspect` on an
ordinary container reports User "stalwart", Entrypoint
["/usr/local/bin/stalwart"] and Cmd ["--config",
"/etc/stalwart/config.json"] — all three inherited, none of them given.

Two things followed from reading those as the operator's.

A container user was listed as configuration a recreate would drop, so
every container off the official image was refused as unrecreatable. That
refusal lived in cutover, downstream of the stop, the settings conversion
and the store migration: it arrived with mail down and data already
moved, which is the failure issue #1 was filed for. Each of the three is
now compared against `docker image inspect` of the image the container is
on. Inherited values are left to the new image, whose own defaults are
the ones that go with it. Overrides are carried: --user, --entrypoint,
and the rest of an entrypoint as leading argv. Cmd and Entrypoint were
not being read at all, so an overridden one was silently dropped — the
exact loss the unsupported list exists to prevent.

The recreatability question also moved into preflight, while the server
is still running. Cutover asks it again, since the two are separated by
the whole migration, but only one of them can refuse without cost.

The other half: the recreated container is now started with `--config`
pointing at the migrated config in the data volume. Left to the image's
default command it came up on /etc/stalwart/config.json — a different
volume, holding whatever the old version left there — so cutover would
have produced a running server with nothing to do with the migration that
preceded it. An overridden command and that --config are the same argv
and cannot be merged honestly, so a container with one is refused and
told why.

The config is also chowned to whatever owns the data directory, before
the recovery cycle opens it. The image runs as uid 2000 and this tool
writes as root; §4.8 is the standing reminder that byte-perfect and
unreadable is a way to report success.

Found while checking @kaya-eu's field report in #1 against a real image.
Their three manual migrations are where the config step comes from.
This commit is contained in:
2026-08-29 17:45:23 -07:00
parent 964b61bb47
commit e96e72bf79
8 changed files with 654 additions and 25 deletions
+55 -1
View File
@@ -474,6 +474,14 @@ func withFakeDocker(t *testing.T) {
// dockerPreflight runs a minimal but real preflight against a fake 0.15.5
// install with a fake docker on PATH.
func dockerPreflight(t *testing.T, advisory bool) Report {
t.Helper()
return dockerPreflightOn(t, advisory, nil)
}
// dockerPreflightOn runs preflight against a container whose inspect
// document has been rewritten by edit, for the cases that need it to be
// something other than an ordinary one.
func dockerPreflightOn(t *testing.T, advisory bool, edit func(string) string) Report {
t.Helper()
// disk-space stats DataDir on this host, and container-data-volume
// wants it covered by a mount, so it has to be both: a real directory,
@@ -484,7 +492,11 @@ func dockerPreflight(t *testing.T, advisory bool) Report {
t.Skipf("host has %s, which detection prefers over docker", p)
}
}
fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume(dataDir)}))
doc := inspectDoc(t, nil, []Mount{dataVolume(dataDir)})
if edit != nil {
doc = edit(doc)
}
fakeInspect(t, doc)
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
@@ -553,3 +565,45 @@ func TestRehearseStillRunsAgainstADockerDeployment(t *testing.T) {
t.Fatalf("advisory mode should not block on docker, got:\n%s", report.String())
}
}
// Cutover already refused a container it could not recreate, but cutover
// is downstream of the stop, the settings conversion and the store
// migration - so that refusal arrived with the mail down and the data
// already moved, which is the shape of failure issue #1 was filed for.
// The answer never changes between the two points, so it is asked here,
// while the server is still running.
func TestPreflightRefusesAContainerItCouldNotRecreate(t *testing.T) {
report := dockerPreflightOn(t, false, func(doc string) string {
return strings.Replace(doc, `"State":`, `"HostConfig":{"Privileged":true},"State":`, 1)
})
if !report.Blocking() {
t.Fatalf("a container preflight cannot recreate should block before anything stops:\n%s", report.String())
}
var found bool
for _, res := range report.Results {
if res.Name == "container-recreatable" {
found = true
if res.Status != StatusFail {
t.Errorf("container-recreatable status = %q, want %q", res.Status, StatusFail)
}
if !strings.Contains(res.Detail, "privileged") {
t.Errorf("detail should name what would be dropped, got %q", res.Detail)
}
}
}
if !found {
t.Fatalf("no container-recreatable result in report:\n%s", report.String())
}
}
// rehearse never stops or recreates anything, so the same finding is a
// warning there: an operator migrating by hand needs to know it more than
// an automated run does.
func TestRehearseWarnsRatherThanBlocksOnAnUnrecreatableContainer(t *testing.T) {
report := dockerPreflightOn(t, true, func(doc string) string {
return strings.Replace(doc, `"State":`, `"HostConfig":{"Privileged":true},"State":`, 1)
})
if report.Blocking() {
t.Fatalf("advisory mode should not block, got:\n%s", report.String())
}
}