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.
|
// 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) {
|
||||||
|
|||||||
@@ -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