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:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user