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
+49
View File
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"fmt"
"os"
"syscall"
)
// matchOwnership gives paths the uid and gid of reference.
//
// It exists for the container path, where this tool writes the migrated
// config onto the host side of a volume and something inside the container
// has to read it. That something is not root: the official Stalwart image
// runs as uid 2000, so a config written root-owned and 0640 is a config
// the server cannot open, and the failure arrives as a recovery boot that
// will not come up rather than as a permissions error anyone would
// recognise.
//
// The reference is the data directory itself, whose ownership is already
// whatever the container writes as. Copying it is more reliable than
// resolving a user out of the image, and it is the same move
// cutover.installConfig makes for the systemd path with
// ConfigOwnerReference.
//
// ARCHITECTURE.md §4.8 is the reason this is not left to chance: the
// rollback that was removed from this tool restored every byte correctly
// and lost ownership, and reported success.
func matchOwnership(reference string, paths ...string) (string, error) {
info, err := os.Stat(reference)
if err != nil {
return "", fmt.Errorf("stat %s to copy its ownership: %w", reference, err)
}
sys, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return "ownership unchanged (this platform does not report it)", nil
}
uid, gid := int(sys.Uid), int(sys.Gid)
for _, p := range paths {
if err := os.Chown(p, uid, gid); err != nil {
return "", fmt.Errorf(
"set ownership on %s to %d:%d, which is what owns %s - the server in the container runs as that user and "+
"cannot read a config it does not own: %w", p, uid, gid, reference, err)
}
}
return fmt.Sprintf("uid %d, gid %d, copied from %s", uid, gid, reference), nil
}
+34 -3
View File
@@ -240,6 +240,7 @@ func runRun(args []string) (err error) {
// side.
convertedConfig := filepath.Join(runWorkDir, "config.json")
containerConfigPath := ""
containerConfigDir, containerConfigOwner := "", ""
if isContainer {
mount, ok := containerFacts.MountFor(*dataDir)
if !ok {
@@ -254,6 +255,8 @@ func runRun(args []string) (err error) {
}
convertedConfig = filepath.Join(hostDir, "config.json")
containerConfigPath = path.Join(mount.Destination, "stalwart-migrate", "config.json")
containerConfigOwner = mount.Source
containerConfigDir = hostDir
}
convertedExport := filepath.Join(runWorkDir, "export.json")
unmigratedPath := filepath.Join(runWorkDir, "unmigrated.txt")
@@ -378,6 +381,17 @@ func runRun(args []string) (err error) {
if len(tenantFix.Adoptions) > 0 {
detail += " - " + tenantFix.String()
}
// The container reads this config, and it does not run as
// root. Done here rather than at cutover because the recovery
// cycle is the first thing to open it, and a config it cannot
// read surfaces there as a boot that never comes up.
if containerConfigOwner != "" {
owner, err := matchOwnership(containerConfigOwner, containerConfigDir, convertedConfig)
if err != nil {
return checkpoint.StepOutcome{}, err
}
detail += fmt.Sprintf("; %s is owned %s", convertedConfig, owner)
}
return checkpoint.StepOutcome{Detail: detail}, nil
}); err != nil {
return fmt.Errorf("convert settings: %w", err)
@@ -446,7 +460,7 @@ func runRun(args []string) (err error) {
ServiceUnitPath: *serviceUnitPath, ConfigPath: *newConfigPath,
ConfigSource: configSource, ConfigOwnerReference: *configPath,
Deployment: service.Options{Kind: service.Kind(rs.Topology.DeploymentKind), UnitName: *unitName, ContainerName: *containerName},
Container: containerCutover(isContainer, *containerName, stagedImage, runStateDir),
Container: containerCutover(isContainer, *containerName, stagedImage, runStateDir, containerConfigOf(p, containerConfigPath)),
RecoveryPointConfirmed: *recoveryConfirmed,
AdminURL: *adminURL, AdminUser: *adminUser, AdminPassword: *adminPassword,
HTTPClient: httpClient, RecalculateQuotas: *recalcQuotas && p.CrossesMajorBoundary,
@@ -524,9 +538,26 @@ func buildSupplement(settingsPath, principalsPath, unmigratedPath, outPath strin
// containerCutover is the cutover options for a container deployment, or
// nil for a binary one. Nil is what keeps cutover refusing a container it
// was given no image to recreate from.
func containerCutover(isContainer bool, name, image, preserveDir string) *cutover.ContainerOptions {
// containerConfigOf is the container-side config path cutover should start
// the new container with, which is none at all for a patch bump: nothing
// was converted, so the container keeps the command its image gives it and
// the configuration it was already running under.
func containerConfigOf(p *plan.Plan, configPath string) string {
if !p.CrossesMajorBoundary {
return ""
}
return configPath
}
func containerCutover(isContainer bool, name, image, preserveDir, configPath string) *cutover.ContainerOptions {
if !isContainer {
return nil
}
return &cutover.ContainerOptions{ContainerName: name, StagedImage: image, PreserveDir: preserveDir}
// configPath is the container-side path to the migrated config, and is
// empty for a patch bump that converted nothing - there the container
// keeps whatever command its image gives it, which is what it was
// already running under.
return &cutover.ContainerOptions{
ContainerName: name, StagedImage: image, PreserveDir: preserveDir, ConfigPath: configPath,
}
}