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
+110 -5
View File
@@ -10,33 +10,72 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
// The defaults the official Stalwart image gives every container made from
// it. A container reporting exactly these has overridden nothing, which is
// the case the image comparison exists to recognise - `docker inspect`
// reports all three either way.
var (
imageUser = "stalwart"
imageEntrypoint = []string{"/usr/local/bin/stalwart"}
imageCmd = []string{"--config", "/etc/stalwart/config.json"}
)
// fakeInspect writes a `docker` that answers `inspect` with the given JSON
// document, so the container checks can be exercised without a container.
// `image inspect` answers with the official image's own defaults.
func fakeInspect(t *testing.T, doc string) {
t.Helper()
fakeInspectOn(t, doc, imageDoc(t, imageUser, imageEntrypoint, imageCmd))
}
// fakeInspectOn is fakeInspect with the image's defaults named, for the
// tests that need the container and its image to disagree.
func fakeInspectOn(t *testing.T, containerDoc, imgDoc string) {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, "inspect.json")
if err := os.WriteFile(out, []byte(doc), 0o644); err != nil {
if err := os.WriteFile(out, []byte(containerDoc), 0o644); err != nil {
t.Fatal(err)
}
script := fmt.Sprintf("#!/bin/sh\ncase \"$1\" in inspect) cat %q ;; *) exit 1 ;; esac\n", out)
img := filepath.Join(dir, "image.json")
if err := os.WriteFile(img, []byte(imgDoc), 0o644); err != nil {
t.Fatal(err)
}
script := fmt.Sprintf("#!/bin/sh\n"+
"case \"$1 $2\" in \"image inspect\") cat %q ; exit 0 ;; esac\n"+
"case \"$1\" in inspect) cat %q ;; *) exit 1 ;; esac\n", img, out)
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func imageDoc(t *testing.T, user string, entrypoint, cmd []string) string {
t.Helper()
b, err := json.Marshal([]map[string]any{{
"Config": map[string]any{"User": user, "Entrypoint": entrypoint, "Cmd": cmd},
}})
if err != nil {
t.Fatal(err)
}
return string(b)
}
func inspectDoc(t *testing.T, labels map[string]string, mounts []Mount) string {
t.Helper()
doc := []map[string]any{{
"Name": "/stalwart",
"Image": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"Config": map[string]any{"Image": "stalwartlabs/stalwart:v0.15.5", "Labels": labels},
"Name": "/stalwart",
"Image": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"Config": map[string]any{
"Image": "stalwartlabs/stalwart:v0.15.5", "Labels": labels,
"User": imageUser, "Entrypoint": imageEntrypoint, "Cmd": imageCmd,
},
"State": map[string]any{"Running": true},
"Mounts": mounts,
}}
@@ -218,3 +257,69 @@ func TestRehearseReportsContainerProblemsWithoutBlocking(t *testing.T) {
}
}
}
// docker reports Config.User, Cmd and Entrypoint whether the operator set
// them or the image did. A container off the official image reports user
// "stalwart" having been given no --user, and reading that as an operator
// override made this tool refuse to recreate every ordinary Stalwart
// container - at cutover, with the mail already down. Found while checking
// @kaya-eu's field report against a real image.
func TestInspectContainerIgnoresWhatItInheritedFromItsImage(t *testing.T) {
fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")}))
facts, err := InspectContainer(context.Background(), "stalwart")
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
if facts.User != "" {
t.Errorf("User = %q, want empty: it is the image's own USER, not an override", facts.User)
}
if len(facts.Cmd) != 0 {
t.Errorf("Cmd = %v, want none: it is the image's own CMD", facts.Cmd)
}
if len(facts.Entrypoint) != 0 {
t.Errorf("Entrypoint = %v, want none: it is the image's own ENTRYPOINT", facts.Entrypoint)
}
if len(facts.Unsupported) != 0 {
t.Errorf("Unsupported = %v, want none for a plain container off the official image", facts.Unsupported)
}
}
// The other half of the same distinction: what the operator really did
// override has to be visible, because a recreate that drops it starts
// cleanly as a different server.
func TestInspectContainerReportsWhatTheOperatorOverrode(t *testing.T) {
doc := inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")})
doc = strings.Replace(doc, `"User":"stalwart"`, `"User":"1500:1500"`, 1)
doc = strings.Replace(doc, `"Cmd":["--config","/etc/stalwart/config.json"]`, `"Cmd":["--config","/srv/mine.toml"]`, 1)
if strings.Contains(doc, `"User":"stalwart"`) || strings.Contains(doc, "/etc/stalwart/config.json") {
t.Fatal("the fixture did not take the overrides; the inspect document shape changed")
}
fakeInspect(t, doc)
facts, err := InspectContainer(context.Background(), "stalwart")
if err != nil {
t.Fatalf("InspectContainer: %v", err)
}
if facts.User != "1500:1500" {
t.Errorf("User = %q, want the overridden 1500:1500", facts.User)
}
if strings.Join(facts.Cmd, " ") != "--config /srv/mine.toml" {
t.Errorf("Cmd = %v, want the overridden command", facts.Cmd)
}
// An entrypoint it did not override still reads as inherited.
if len(facts.Entrypoint) != 0 {
t.Errorf("Entrypoint = %v, want none", facts.Entrypoint)
}
}
// Without the image's defaults there is no way to tell an override from an
// inheritance, and guessing decides what a recreate carries. Same rule as
// a failed container inspect: an error, not an assumption.
func TestInspectContainerRefusesWhenTheImageCannotBeRead(t *testing.T) {
fakeInspectOn(t, inspectDoc(t, nil, []Mount{dataVolume("/var/lib/stalwart")}), "")
if _, err := InspectContainer(context.Background(), "stalwart"); err == nil {
t.Fatal("want an error when the image's defaults cannot be read")
}
}