Merge pull request #4 from LINUXexpert-org/docker-preflight-facts
Tell a container operator what stands in the way
This commit is contained in:
@@ -218,6 +218,15 @@ func (c *Checker) Run(ctx context.Context, store *checkpoint.Store, rs *checkpoi
|
|||||||
return report, err
|
return report, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The container checks below only mean anything for a container, and
|
||||||
|
// asking docker about one that isn't there would fail for the wrong
|
||||||
|
// reason.
|
||||||
|
if DeploymentKind(deploymentOutcome.Extra) == DeploymentDocker {
|
||||||
|
if err := c.runContainerChecks(ctx, runCheck); err != nil {
|
||||||
|
return report, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) {
|
storeOutcome, err := runCheck("store-backend", func() (CheckResult, string) {
|
||||||
matches, err := DetectStoreBackends(c.opts.ConfigPath)
|
matches, err := DetectStoreBackends(c.opts.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -465,12 +465,10 @@ func withFakeDocker(t *testing.T) {
|
|||||||
t.Skipf("host has %s, which detection prefers over docker", p)
|
t.Skipf("host has %s, which detection prefers over docker", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
// A healthy plain container: not compose-managed, data on a volume.
|
||||||
script := "#!/bin/sh\ncase \"$1\" in inspect) exit 0 ;; esac\nexit 1\n"
|
// The container checks inspect it for real now, so an `inspect` that
|
||||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
// merely exits 0 would fail them for the wrong reason.
|
||||||
t.Fatal(err)
|
fakeInspect(t, inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")}))
|
||||||
}
|
|
||||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dockerPreflight runs a minimal but real preflight against a fake 0.15.5
|
// dockerPreflight runs a minimal but real preflight against a fake 0.15.5
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package preflight
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkFunc is the closure Checker.Run uses to run and checkpoint one
|
||||||
|
// check. Named here so the container checks can be a method rather than
|
||||||
|
// another hundred lines inside Run.
|
||||||
|
type checkFunc func(name string, fn func() (CheckResult, string)) (checkpoint.StepOutcome, error)
|
||||||
|
|
||||||
|
// ComposeProjectLabel is set by Docker Compose on every container it
|
||||||
|
// manages. Its presence is the difference between a container this tool
|
||||||
|
// could one day recreate and one it must not: recreating a compose-managed
|
||||||
|
// container out from under compose leaves the running container and the
|
||||||
|
// compose file disagreeing about what is deployed, and the next
|
||||||
|
// `compose up` silently reverts the migration.
|
||||||
|
const ComposeProjectLabel = "com.docker.compose.project"
|
||||||
|
|
||||||
|
// Mount is one bind or volume mount as the container sees it. Only the
|
||||||
|
// fields this tool reasons about are kept; docker inspect returns more.
|
||||||
|
type Mount struct {
|
||||||
|
Type string `json:"Type"` // "volume" or "bind"
|
||||||
|
Name string `json:"Name"` // volume name, empty for binds
|
||||||
|
Source string `json:"Source"` // host path
|
||||||
|
Destination string `json:"Destination"` // path inside the container
|
||||||
|
RW bool `json:"RW"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerFacts is what `docker inspect` says about a running Stalwart
|
||||||
|
// container, reduced to the things that decide whether it can be migrated.
|
||||||
|
type ContainerFacts struct {
|
||||||
|
Name string
|
||||||
|
Image string // the tag it was started from, e.g. "stalwartlabs/stalwart:v0.15.5"
|
||||||
|
ImageID string // the digest actually running, which a tag can drift from
|
||||||
|
Labels map[string]string
|
||||||
|
Mounts []Mount
|
||||||
|
Running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeProject returns the compose project managing this container, or
|
||||||
|
// "" if it is a plain `docker run`.
|
||||||
|
func (f ContainerFacts) ComposeProject() string { return f.Labels[ComposeProjectLabel] }
|
||||||
|
|
||||||
|
// WritableMounts are the mounts data could persist in. A container with
|
||||||
|
// none keeps everything in its own writable layer, which is discarded when
|
||||||
|
// the container is replaced - and replacing the container is exactly what
|
||||||
|
// migrating it means.
|
||||||
|
func (f ContainerFacts) WritableMounts() []Mount {
|
||||||
|
var out []Mount
|
||||||
|
for _, m := range f.Mounts {
|
||||||
|
if m.RW {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountFor returns the mount whose Destination contains path, if any. A
|
||||||
|
// data directory not covered by one lives in the writable layer.
|
||||||
|
func (f ContainerFacts) MountFor(path string) (Mount, bool) {
|
||||||
|
if path == "" {
|
||||||
|
return Mount{}, false
|
||||||
|
}
|
||||||
|
var best Mount
|
||||||
|
var found bool
|
||||||
|
for _, m := range f.Mounts {
|
||||||
|
if m.Destination == path || strings.HasPrefix(path, strings.TrimSuffix(m.Destination, "/")+"/") {
|
||||||
|
// Longest destination wins: /var/lib/stalwart/data is more
|
||||||
|
// specific than /var/lib, and it is the specific one that
|
||||||
|
// actually holds the bytes.
|
||||||
|
if !found || len(m.Destination) > len(best.Destination) {
|
||||||
|
best, found = m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspectOutput is the subset of `docker inspect` this parses. Named
|
||||||
|
// separately from ContainerFacts because docker's shape is docker's to
|
||||||
|
// change, and the rest of this package should not have to know it.
|
||||||
|
type inspectOutput struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Image string `json:"Image"`
|
||||||
|
Config struct {
|
||||||
|
Image string `json:"Image"`
|
||||||
|
Labels map[string]string `json:"Labels"`
|
||||||
|
} `json:"Config"`
|
||||||
|
State struct {
|
||||||
|
Running bool `json:"Running"`
|
||||||
|
} `json:"State"`
|
||||||
|
Mounts []Mount `json:"Mounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectContainer reads the facts about containerName. An error here is
|
||||||
|
// an error, not an absent container: callers reach this only after
|
||||||
|
// DetectDeploymentKind has already established that a container answers to
|
||||||
|
// this name, so a failure now means docker stopped answering, and guessing
|
||||||
|
// past that is how a tool ends up migrating something it cannot see.
|
||||||
|
func InspectContainer(ctx context.Context, containerName string) (ContainerFacts, error) {
|
||||||
|
if containerName == "" {
|
||||||
|
containerName = "stalwart"
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "inspect", containerName).Output()
|
||||||
|
if err != nil {
|
||||||
|
return ContainerFacts{}, fmt.Errorf("preflight: docker inspect %s: %w", containerName, err)
|
||||||
|
}
|
||||||
|
var got []inspectOutput
|
||||||
|
if err := json.Unmarshal(out, &got); err != nil {
|
||||||
|
return ContainerFacts{}, fmt.Errorf("preflight: parsing docker inspect %s: %w", containerName, err)
|
||||||
|
}
|
||||||
|
if len(got) == 0 {
|
||||||
|
return ContainerFacts{}, fmt.Errorf("preflight: docker inspect %s returned no container", containerName)
|
||||||
|
}
|
||||||
|
c := got[0]
|
||||||
|
return ContainerFacts{
|
||||||
|
Name: strings.TrimPrefix(c.Name, "/"),
|
||||||
|
Image: c.Config.Image,
|
||||||
|
ImageID: c.Image,
|
||||||
|
Labels: c.Config.Labels,
|
||||||
|
Mounts: c.Mounts,
|
||||||
|
Running: c.State.Running,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runContainerChecks adds the checks that only apply to a container. They
|
||||||
|
// run after deployment-kind has already established there is one.
|
||||||
|
//
|
||||||
|
// Both are blocking for `run` and advisory for `rehearse`, on the same
|
||||||
|
// reasoning as the deployment-kind check itself: rehearse never stops or
|
||||||
|
// recreates anything, and an operator doing the migration by hand needs
|
||||||
|
// these facts more than an automated run does.
|
||||||
|
func (c *Checker) runContainerChecks(ctx context.Context, runCheck checkFunc) error {
|
||||||
|
facts, factsErr := InspectContainer(ctx, c.opts.ContainerName)
|
||||||
|
|
||||||
|
if _, err := runCheck("container-inspect", func() (CheckResult, string) {
|
||||||
|
if factsErr != nil {
|
||||||
|
return CheckResult{Status: StatusFail, Detail: factsErr.Error()}, ""
|
||||||
|
}
|
||||||
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf(
|
||||||
|
"container %s runs image %s (%s)", facts.Name, facts.Image, shortID(facts.ImageID))}, facts.Image
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if factsErr != nil {
|
||||||
|
// The two checks below read facts we do not have.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := runCheck("container-runtime", func() (CheckResult, string) {
|
||||||
|
project := facts.ComposeProject()
|
||||||
|
if project == "" {
|
||||||
|
return CheckResult{Status: StatusOK, Detail: "plain docker container, not compose-managed"}, ""
|
||||||
|
}
|
||||||
|
status := StatusFail
|
||||||
|
if c.opts.DeploymentCheckAdvisory {
|
||||||
|
status = StatusWarn
|
||||||
|
}
|
||||||
|
return CheckResult{Status: status, Detail: fmt.Sprintf(
|
||||||
|
"container is managed by docker compose (project %q). Recreating it out from under compose would leave the "+
|
||||||
|
"running container and the compose file disagreeing about what is deployed, and the next `compose up` would "+
|
||||||
|
"revert the migration. Migrate it by editing the image tag in the compose file and running `compose up -d`",
|
||||||
|
project)}, project
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := runCheck("container-data-volume", func() (CheckResult, string) {
|
||||||
|
writable := facts.WritableMounts()
|
||||||
|
if len(writable) == 0 {
|
||||||
|
status := StatusFail
|
||||||
|
if c.opts.DeploymentCheckAdvisory {
|
||||||
|
status = StatusWarn
|
||||||
|
}
|
||||||
|
return CheckResult{Status: status, Detail: "container has no writable volume or bind mount, so its data lives in " +
|
||||||
|
"the container's own writable layer - which is discarded when the container is replaced, and replacing it is " +
|
||||||
|
"what migrating it means. Move the data onto a volume before migrating"}, ""
|
||||||
|
}
|
||||||
|
// A data directory named but not covered by a mount is the same
|
||||||
|
// problem wearing a disguise, and worth saying separately: the
|
||||||
|
// mounts exist, they just are not where the data is.
|
||||||
|
if c.opts.DataDir != "" {
|
||||||
|
if m, ok := facts.MountFor(c.opts.DataDir); ok {
|
||||||
|
return CheckResult{Status: StatusOK, Detail: fmt.Sprintf(
|
||||||
|
"data dir %s is on a %s mount (%s)", c.opts.DataDir, m.Type, mountSource(m))}, m.Destination
|
||||||
|
}
|
||||||
|
status := StatusFail
|
||||||
|
if c.opts.DeploymentCheckAdvisory {
|
||||||
|
status = StatusWarn
|
||||||
|
}
|
||||||
|
return CheckResult{Status: status, Detail: fmt.Sprintf(
|
||||||
|
"data dir %s is not covered by any of the container's mounts (%s), so it lives in the writable layer and would "+
|
||||||
|
"not survive the container being replaced. Check whether --data-dir names the path inside the container",
|
||||||
|
c.opts.DataDir, describeMounts(facts.Mounts))}, ""
|
||||||
|
}
|
||||||
|
return CheckResult{Status: StatusOK, Detail: "container has writable mounts: " + describeMounts(writable)}, ""
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortID(id string) string {
|
||||||
|
id = strings.TrimPrefix(id, "sha256:")
|
||||||
|
if len(id) > 12 {
|
||||||
|
return id[:12]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func mountSource(m Mount) string {
|
||||||
|
if m.Name != "" {
|
||||||
|
return m.Name
|
||||||
|
}
|
||||||
|
return m.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeMounts(mounts []Mount) string {
|
||||||
|
if len(mounts) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(mounts))
|
||||||
|
for _, m := range mounts {
|
||||||
|
parts = append(parts, m.Destination+" <- "+mountSource(m))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package preflight
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeInspect writes a `docker` that answers `inspect` with the given JSON
|
||||||
|
// document, so the container checks can be exercised without a container.
|
||||||
|
func fakeInspect(t *testing.T, doc string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
out := filepath.Join(dir, "inspect.json")
|
||||||
|
if err := os.WriteFile(out, []byte(doc), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
script := fmt.Sprintf("#!/bin/sh\ncase \"$1\" in inspect) cat %q ;; *) exit 1 ;; esac\n", out)
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectDoc(t *testing.T, labels map[string]string, mounts []Mount) string {
|
||||||
|
t.Helper()
|
||||||
|
doc := []map[string]any{{
|
||||||
|
"Name": "/stalwart",
|
||||||
|
"Image": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
|
"Config": map[string]any{"Image": "stalwartlabs/stalwart:v0.15.5", "Labels": labels},
|
||||||
|
"State": map[string]any{"Running": true},
|
||||||
|
"Mounts": mounts,
|
||||||
|
}}
|
||||||
|
b, err := json.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataVolume(dest string) Mount {
|
||||||
|
return Mount{Type: "volume", Name: "stalwart-data", Destination: dest, RW: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectContainerReadsTheFactsThatMatter(t *testing.T) {
|
||||||
|
fakeInspect(t, inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, []Mount{dataVolume("/opt/stalwart")}))
|
||||||
|
|
||||||
|
facts, err := InspectContainer(context.Background(), "stalwart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InspectContainer: %v", err)
|
||||||
|
}
|
||||||
|
if facts.Name != "stalwart" {
|
||||||
|
t.Errorf("Name = %q, want stalwart (leading slash stripped)", facts.Name)
|
||||||
|
}
|
||||||
|
if facts.Image != "stalwartlabs/stalwart:v0.15.5" {
|
||||||
|
t.Errorf("Image = %q", facts.Image)
|
||||||
|
}
|
||||||
|
if facts.ComposeProject() != "mail" {
|
||||||
|
t.Errorf("ComposeProject() = %q, want mail", facts.ComposeProject())
|
||||||
|
}
|
||||||
|
if !facts.Running {
|
||||||
|
t.Error("Running = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tag and the digest actually running can disagree - :latest is the
|
||||||
|
// obvious way, but any moved tag does it. Both are reported because only
|
||||||
|
// one of them says what is really running.
|
||||||
|
func TestInspectContainerKeepsTagAndDigestApart(t *testing.T) {
|
||||||
|
fakeInspect(t, inspectDoc(t, nil, nil))
|
||||||
|
facts, err := InspectContainer(context.Background(), "stalwart")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if facts.ImageID == facts.Image {
|
||||||
|
t.Error("ImageID and Image should be distinct - one is a tag, the other a digest")
|
||||||
|
}
|
||||||
|
if got := shortID(facts.ImageID); got != "0123456789ab" {
|
||||||
|
t.Errorf("shortID = %q, want 0123456789ab", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountForPrefersTheMostSpecificMount(t *testing.T) {
|
||||||
|
facts := ContainerFacts{Mounts: []Mount{
|
||||||
|
{Type: "bind", Source: "/srv", Destination: "/var/lib", RW: true},
|
||||||
|
{Type: "volume", Name: "data", Destination: "/var/lib/stalwart/data", RW: true},
|
||||||
|
}}
|
||||||
|
m, ok := facts.MountFor("/var/lib/stalwart/data/db")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("MountFor found nothing for a path under a mount")
|
||||||
|
}
|
||||||
|
if m.Name != "data" {
|
||||||
|
t.Errorf("MountFor returned %q, want the more specific 'data' mount", m.Name)
|
||||||
|
}
|
||||||
|
if _, ok := facts.MountFor("/etc/stalwart"); ok {
|
||||||
|
t.Error("MountFor matched a path no mount covers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containerReport runs preflight against a fake container.
|
||||||
|
func containerReport(t *testing.T, doc string, dataDir string, advisory bool) Report {
|
||||||
|
t.Helper()
|
||||||
|
for _, p := range systemdUnitPaths {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
t.Skipf("host has %s, which detection prefers over docker", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fakeInspect(t, doc)
|
||||||
|
|
||||||
|
counterPath := filepath.Join(t.TempDir(), "invocations")
|
||||||
|
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.toml")
|
||||||
|
if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
|
||||||
|
})
|
||||||
|
store := checkpoint.NewStore(t.TempDir())
|
||||||
|
rs, err := store.Create("", "latest")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = t.TempDir()
|
||||||
|
}
|
||||||
|
report, err := New(Options{
|
||||||
|
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
|
||||||
|
TargetVersion: "latest", ToolCheckAdvisory: true, DeploymentCheckAdvisory: advisory,
|
||||||
|
}).Run(context.Background(), store, rs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultFor(t *testing.T, r Report, name string) CheckResult {
|
||||||
|
t.Helper()
|
||||||
|
for _, res := range r.Results {
|
||||||
|
if res.Name == name {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no %q result in report:\n%s", name, r.String())
|
||||||
|
return CheckResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compose-managed container must be refused even once container cutover
|
||||||
|
// exists: recreating it desyncs the running container from the compose
|
||||||
|
// file, and the next `compose up` reverts the migration.
|
||||||
|
func TestComposeManagedContainerIsRefused(t *testing.T) {
|
||||||
|
doc := inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, []Mount{dataVolume("/opt/stalwart")})
|
||||||
|
res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-runtime")
|
||||||
|
if res.Status != StatusFail {
|
||||||
|
t.Errorf("container-runtime = %q, want %q\n%s", res.Status, StatusFail, res.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlainContainerPassesTheRuntimeCheck(t *testing.T) {
|
||||||
|
doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})
|
||||||
|
res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-runtime")
|
||||||
|
if res.Status != StatusOK {
|
||||||
|
t.Errorf("container-runtime = %q, want %q\n%s", res.Status, StatusOK, res.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data in the container's own writable layer does not survive the container
|
||||||
|
// being replaced, and replacing it is what migrating it means.
|
||||||
|
func TestContainerWithNoWritableMountIsRefused(t *testing.T) {
|
||||||
|
doc := inspectDoc(t, nil, nil)
|
||||||
|
res := resultFor(t, containerReport(t, doc, "/opt/stalwart", false), "container-data-volume")
|
||||||
|
if res.Status != StatusFail {
|
||||||
|
t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusFail, res.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mounts existing is not the same as the data being on one.
|
||||||
|
func TestDataDirOutsideEveryMountIsRefused(t *testing.T) {
|
||||||
|
doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})
|
||||||
|
res := resultFor(t, containerReport(t, doc, "/var/lib/stalwart", false), "container-data-volume")
|
||||||
|
if res.Status != StatusFail {
|
||||||
|
t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusFail, res.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataDirOnAVolumePasses(t *testing.T) {
|
||||||
|
doc := inspectDoc(t, nil, []Mount{dataVolume("/opt/stalwart")})
|
||||||
|
res := resultFor(t, containerReport(t, doc, "/opt/stalwart/data", false), "container-data-volume")
|
||||||
|
if res.Status != StatusOK {
|
||||||
|
t.Errorf("container-data-volume = %q, want %q\n%s", res.Status, StatusOK, res.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rehearse has to keep working against a container it cannot migrate -
|
||||||
|
// that is when its report is most useful - so the same findings are
|
||||||
|
// advisory there.
|
||||||
|
func TestRehearseReportsContainerProblemsWithoutBlocking(t *testing.T) {
|
||||||
|
doc := inspectDoc(t, map[string]string{"com.docker.compose.project": "mail"}, nil)
|
||||||
|
// A real directory, because disk-space stats DataDir on the host. That
|
||||||
|
// a container-internal path breaks host-side checks is true and is
|
||||||
|
// PR 3's problem (path translation); it is not what this is testing.
|
||||||
|
report := containerReport(t, doc, t.TempDir(), true)
|
||||||
|
if report.Blocking() {
|
||||||
|
t.Fatalf("advisory mode should not block:\n%s", report.String())
|
||||||
|
}
|
||||||
|
for _, name := range []string{"container-runtime", "container-data-volume"} {
|
||||||
|
if got := resultFor(t, report, name).Status; got != StatusWarn {
|
||||||
|
t.Errorf("%s = %q, want %q in advisory mode", name, got, StatusWarn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user