diff --git a/internal/preflight/version.go b/internal/preflight/version.go index 04d7db4..cd7fe60 100644 --- a/internal/preflight/version.go +++ b/internal/preflight/version.go @@ -63,6 +63,20 @@ func cmp(a, b int) int { // automates - see ARCHITECTURE.md §1/§4.1. 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 --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 // semver from its output. func DetectVersion(ctx context.Context, binaryPath string) (string, error) { diff --git a/internal/stage/image.go b/internal/stage/image.go new file mode 100644 index 0000000..68201bb --- /dev/null +++ b/internal/stage/image.go @@ -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 +} diff --git a/internal/stage/image_test.go b/internal/stage/image_test.go new file mode 100644 index 0000000..72ce461 --- /dev/null +++ b/internal/stage/image_test.go @@ -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) +}