Stage a container image the way a binary is staged

The container deployment's answer to downloading a release: pull the image
the operator named, then ask it what it is. That last part is the point of
the phase, exactly as it is for a binary - the tag, the registry and the
repository name are all assumptions about someone else's publishing
process, and the image's own answer is the only thing that settles what
arrived.

The image is never derived from the running container by swapping its tag.
That derivation is wrong for a digest-pinned image, wrong for a mirror and
wrong for a fork, and being wrong here means pulling the wrong software
into a mail server. It is named in full or the phase refuses.

What comes back is the image's ID rather than the tag it arrived under. A
tag can move between staging and cutover -- that is the whole reason latest
is a hazard -- and running the tag later would run something other than
what was verified here.

VersionFromOutput is exported from preflight so both paths parse a version
identically. Two copies of that regex could disagree about what they
staged, which is a difference nobody would look for.

One caveat recorded rather than hidden: asking an image its version means
running it with --version, which assumes its entrypoint is the server and
passes flags through. That has not been confirmed against a published
Stalwart image, there being none to hand. If the assumption is wrong this
fails loudly with the image's own output rather than staging something
unverified, and the fallback tries the binary by name before giving up.

SkipPull is for a host that loaded the image from a tarball, where a pull
cannot work and its failure would say nothing useful.
This commit is contained in:
2026-08-28 17:20:53 -07:00
parent f34cd95454
commit 0a6b3ad173
3 changed files with 380 additions and 0 deletions
+14
View File
@@ -63,6 +63,20 @@ func cmp(a, b int) int {
// automates - see ARCHITECTURE.md §1/§4.1. // automates - see ARCHITECTURE.md §1/§4.1.
var minSupportedSource = semver{0, 15, 0} var minSupportedSource = semver{0, 15, 0}
// VersionFromOutput extracts a semver from whatever a Stalwart build
// printed when asked for its version. Exported because the same question
// gets asked of a container image (internal/stage), where the command is
// `docker run <image> --version` rather than the binary directly - and the
// answer has to be parsed identically or the two paths could disagree
// about what they staged.
func VersionFromOutput(out string) (string, error) {
v, err := parseSemver(out)
if err != nil {
return "", err
}
return v.String(), nil
}
// DetectVersion runs the installed binary's --version flag and extracts a // DetectVersion runs the installed binary's --version flag and extracts a
// semver from its output. // semver from its output.
func DetectVersion(ctx context.Context, binaryPath string) (string, error) { func DetectVersion(ctx context.Context, binaryPath string) (string, error) {
+166
View File
@@ -0,0 +1,166 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package stage
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/preflight"
)
// DefaultDockerBinary is what shells out unless a caller names another.
const DefaultDockerBinary = "docker"
// ImageOptions configures staging a container image - the container
// deployment's answer to downloading a binary. See issue #3.
type ImageOptions struct {
// Image is the target image, named in full by the operator:
// "stalwartlabs/stalwart:v0.16.14". It is deliberately never derived
// from the running container's image by swapping the tag. That
// derivation is wrong for a digest-pinned image, wrong for a mirror,
// and wrong for a fork - and being wrong here means pulling the wrong
// software into a mail server, which is not a mistake worth a
// convenience.
Image string
// TargetVersion is what the image must report, e.g. "0.16.14".
TargetVersion string
// SkipPull uses an image already present locally. For an air-gapped
// host that loaded it from a tarball, where a pull cannot work and its
// failure would say nothing useful.
SkipPull bool
DockerBinary string
}
func (o ImageOptions) docker() string {
if o.DockerBinary == "" {
return DefaultDockerBinary
}
return o.DockerBinary
}
// StagedImage is a target image that is present locally and has been asked
// what it is.
type StagedImage struct {
// Ref is what to actually run: the image's own ID, not the tag it
// arrived under. A tag can move between staging and cutover - that is
// the whole reason `latest` is a hazard - and running the tag later
// would run something other than what was verified here.
Ref string
// Tag is what the operator named, kept for reports.
Tag string
// Version is what the image reported, which is the only thing that
// settles what was pulled.
Version string
}
// RunImage pulls the target image and confirms it is the version that was
// asked for, checkpointed as the stage phase's "stage-image" step.
//
// The version check is the point of the phase, exactly as it is for a
// binary: the tag, the registry and the repository name are all assumptions
// about someone else's publishing process, and the image's own answer is
// the only thing that settles what arrived.
//
// One caveat, recorded rather than hidden. Asking an image its version
// means running it with --version, which assumes its entrypoint is the
// server and passes flags through. That holds for an image built the
// obvious way and has not been confirmed against a published Stalwart
// image - there was none to hand when this was written. If it turns out
// not to hold, this fails loudly with the image's own output rather than
// staging something unverified, and the fix is a way to name the command
// rather than a reason to skip the check.
func RunImage(ctx context.Context, store *checkpoint.Store, rs *checkpoint.RunState, opts ImageOptions) (StagedImage, error) {
if opts.Image == "" {
return StagedImage{}, fmt.Errorf("stage: no target image named - pass the image to migrate to; it is never guessed from the running container")
}
if opts.TargetVersion == "" {
return StagedImage{}, fmt.Errorf("stage: no target version to check %s against", opts.Image)
}
var staged StagedImage
_, err := store.RunStep(rs, checkpoint.PhaseStage, "stage-image", func() (checkpoint.StepOutcome, error) {
if !opts.SkipPull {
if out, err := run(ctx, opts.docker(), "pull", opts.Image); err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("stage: pull %s: %w (%s)", opts.Image, err, out)
}
}
id, err := run(ctx, opts.docker(), "image", "inspect", "-f", "{{.Id}}", opts.Image)
if err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("stage: %s is not present locally after pull: %w (%s)", opts.Image, err, id)
}
id = strings.TrimSpace(id)
if id == "" {
return checkpoint.StepOutcome{}, fmt.Errorf("stage: docker reported no image ID for %s", opts.Image)
}
// The image's own entrypoint first, which is what an image built
// the obvious way answers to. Only if that fails is the entrypoint
// overridden to call the binary by name - a second guess, tried
// because failing the whole phase over an image that merely wraps
// its server in a shim would be worse than one extra attempt.
out, err := run(ctx, opts.docker(), "run", "--rm", id, "--version")
if err != nil {
out, err = run(ctx, opts.docker(), "run", "--rm", "--entrypoint", "stalwart", id, "--version")
}
if err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf(
"stage: %s would not report its version: %w (output: %s). This tool will not stage an image it cannot identify",
opts.Image, err, strings.TrimSpace(out))
}
got, err := preflight.VersionFromOutput(out)
if err != nil {
return checkpoint.StepOutcome{}, fmt.Errorf("stage: parse version from %s (%q): %w", opts.Image, strings.TrimSpace(out), err)
}
wanted := strings.TrimPrefix(opts.TargetVersion, "v")
if got != wanted {
return checkpoint.StepOutcome{}, fmt.Errorf(
"stage: image %s reports %s but %s was asked for - the tag does not contain what it claims",
opts.Image, got, wanted)
}
staged = StagedImage{Ref: id, Tag: opts.Image, Version: got}
return checkpoint.StepOutcome{
Detail: fmt.Sprintf("staged %s as %s, which reports %s", opts.Image, shortRef(id), got),
Extra: id,
}, nil
})
if err != nil {
return StagedImage{}, err
}
if staged.Ref == "" {
// A resumed run skipped the step, so the values above were never
// assigned. The recorded ID is what that run verified.
staged = StagedImage{Ref: rs.Outcome(checkpoint.PhaseStage, "stage-image").Extra, Tag: opts.Image, Version: opts.TargetVersion}
}
return staged, nil
}
func run(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.String(), err
}
func shortRef(id string) string {
id = strings.TrimPrefix(id, "sha256:")
if len(id) > 12 {
return id[:12]
}
return id
}
+200
View File
@@ -0,0 +1,200 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package stage
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
// fakeDockerImage installs a `docker` that records its arguments and
// answers each subcommand as scripted. versionBody is what `docker run`
// prints for the version probe.
func fakeDockerImage(t *testing.T, pullExit, imageID, versionBody string) (log string) {
t.Helper()
dir := t.TempDir()
log = filepath.Join(dir, "docker-args.log")
script := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
case "$1" in
pull) exit %s ;;
image) echo %q ;;
run) %s ;;
esac
`, log, pullExit, imageID, versionBody)
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"))
return log
}
const testImageID = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
func TestRunImageStagesAndVerifies(t *testing.T) {
log := fakeDockerImage(t, "0", testImageID, "echo 'stalwart 0.16.14'")
store, rs := newRun(t)
staged, err := RunImage(context.Background(), store, rs, ImageOptions{
Image: "stalwartlabs/stalwart:v0.16.14", TargetVersion: "0.16.14",
})
if err != nil {
t.Fatalf("RunImage: %v", err)
}
if staged.Version != "0.16.14" {
t.Errorf("Version = %q", staged.Version)
}
// The ID, not the tag: a tag can move between staging and cutover, and
// running the tag later would run something else.
if staged.Ref != testImageID {
t.Errorf("Ref = %q, want the image ID %q", staged.Ref, testImageID)
}
if got := readFile(t, log); !strings.Contains(got, "pull stalwartlabs/stalwart:v0.16.14") {
t.Errorf("image was never pulled:\n%s", got)
}
}
// The version check is the point of the phase. An image whose tag claims
// one version and whose binary reports another must not be staged.
func TestRunImageRefusesAVersionMismatch(t *testing.T) {
fakeDockerImage(t, "0", testImageID, "echo 'stalwart 0.16.9'")
store, rs := newRun(t)
_, err := RunImage(context.Background(), store, rs, ImageOptions{
Image: "stalwartlabs/stalwart:v0.16.14", TargetVersion: "0.16.14",
})
if err == nil {
t.Fatal("expected a refusal when the image reports a different version")
}
if !strings.Contains(err.Error(), "does not contain what it claims") {
t.Errorf("error should name the mismatch, got: %v", err)
}
}
// An image that will not say what it is cannot be staged - the alternative
// is migrating a mail server with software nothing identified.
func TestRunImageRefusesAnImageThatWontReportItsVersion(t *testing.T) {
fakeDockerImage(t, "0", testImageID, "echo 'no such flag' >&2; exit 1")
store, rs := newRun(t)
_, err := RunImage(context.Background(), store, rs, ImageOptions{
Image: "img:1", TargetVersion: "0.16.14",
})
if err == nil {
t.Fatal("expected a refusal when the image will not report a version")
}
if !strings.Contains(err.Error(), "will not stage an image it cannot identify") {
t.Errorf("error should explain the refusal, got: %v", err)
}
}
// If the plain entrypoint refuses the flag, the binary is tried by name
// before giving up.
func TestRunImageFallsBackToNamingTheBinary(t *testing.T) {
dir := t.TempDir()
log := filepath.Join(dir, "docker-args.log")
// Fails without --entrypoint, succeeds with it.
script := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
case "$1" in
pull) exit 0 ;;
image) echo %q ;;
run) for a in "$@"; do if [ "$a" = "--entrypoint" ]; then echo 'stalwart 0.16.14'; exit 0; fi; done; exit 1 ;;
esac
`, log, testImageID)
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"))
store, rs := newRun(t)
staged, err := RunImage(context.Background(), store, rs, ImageOptions{
Image: "img:1", TargetVersion: "0.16.14",
})
if err != nil {
t.Fatalf("RunImage should have fallen back to --entrypoint: %v", err)
}
if staged.Version != "0.16.14" {
t.Errorf("Version = %q", staged.Version)
}
}
func TestRunImageRefusesAFailedPull(t *testing.T) {
fakeDockerImage(t, "1", testImageID, "echo 'stalwart 0.16.14'")
store, rs := newRun(t)
if _, err := RunImage(context.Background(), store, rs, ImageOptions{
Image: "img:1", TargetVersion: "0.16.14",
}); err == nil {
t.Fatal("expected a refusal when the pull failed")
}
}
// SkipPull is for a host that loaded the image from a tarball, where a
// pull cannot work and its failure would say nothing useful.
func TestSkipPullUsesALocalImage(t *testing.T) {
log := fakeDockerImage(t, "1", testImageID, "echo 'stalwart 0.16.14'")
store, rs := newRun(t)
if _, err := RunImage(context.Background(), store, rs, ImageOptions{
Image: "img:1", TargetVersion: "0.16.14", SkipPull: true,
}); err != nil {
t.Fatalf("SkipPull should not have pulled: %v", err)
}
if got := readFile(t, log); strings.Contains(got, "pull ") {
t.Errorf("SkipPull still pulled:\n%s", got)
}
}
// The image is never guessed from what is running: a derived tag is wrong
// for a digest-pinned image, a mirror or a fork.
func TestRunImageRefusesWithoutAnImage(t *testing.T) {
store, rs := newRun(t)
_, err := RunImage(context.Background(), store, rs, ImageOptions{TargetVersion: "0.16.14"})
if err == nil {
t.Fatal("expected a refusal when no image was named")
}
if !strings.Contains(err.Error(), "never guessed") {
t.Errorf("error should say the image is not inferred, got: %v", err)
}
}
// A resumed run skips the step, and must still report the image the
// earlier run verified rather than an empty reference.
func TestRunImageOnResumeReturnsTheVerifiedImage(t *testing.T) {
fakeDockerImage(t, "0", testImageID, "echo 'stalwart 0.16.14'")
store, rs := newRun(t)
opts := ImageOptions{Image: "img:1", TargetVersion: "0.16.14"}
if _, err := RunImage(context.Background(), store, rs, opts); err != nil {
t.Fatal(err)
}
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatal(err)
}
staged, err := RunImage(context.Background(), store, resumed, opts)
if err != nil {
t.Fatalf("resume: %v", err)
}
if staged.Ref != testImageID {
t.Errorf("resumed Ref = %q, want %q", staged.Ref, testImageID)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatal(err)
}
return string(b)
}