Merge pull request #7 from LINUXexpert-org/docker-stage-image
Stage a container image the way a binary is staged
This commit is contained in:
@@ -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 <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
|
||||
// semver from its output.
|
||||
func DetectVersion(ctx context.Context, binaryPath string) (string, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user