Files
jcoffey-dev 77e80cb9f2 Change the copyright holder to Coffey Labs
119 SPDX-FileCopyrightText headers and the README's licence line.

The distinction that matters here: LINUXexpert-org appears in this repository
in two completely different roles. As a copyright holder in the SPDX headers,
which is what changes, and as the GitHub organisation in the module path and 64
import statements, which does not -- the repository still lives at
github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a
licence change, it would break the build.

Both replacements are anchored to their copyright forms, so an import path
cannot match either. Import count is 64 before and after, and go.mod is
untouched.

LICENSE untouched: the FSF's copyright on the GPL text and the "<name of
author>" placeholders are not ours to edit.

go vet, go build and go test all clean.
2026-08-30 01:28:23 -07:00

210 lines
6.9 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package recovery
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fakeDocker installs a `docker` that logs every invocation's arguments and
// behaves as scripted per subcommand. runBody is what `docker run` does;
// stateBody is what `docker inspect -f {{.State.Running}}` prints.
func fakeDocker(t *testing.T, runBody, stopExit, stateBody 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
run) %s ;;
stop) exit %s ;;
inspect) %s ;;
esac
`, log, runBody, stopExit, stateBody)
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
}
// waitForArgs polls until the fake has recorded an invocation. Launch
// returns once the OS has started the client, which is before the shell it
// started has run anything.
func waitForArgs(t *testing.T, log string) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if got := readArgsFile(t, log); got != "" {
return got
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("fake docker was never invoked (log %s stayed empty)", log)
return ""
}
// waitForOutput polls until the supervised process has produced some, for
// the same reason waitForArgs exists: Launch returns before the thing it
// started has said anything, and stopping it first would race the output
// away.
func waitForOutput(t *testing.T, sup Supervised) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if got := sup.Output(); strings.TrimSpace(got) != "" {
return got
}
time.Sleep(10 * time.Millisecond)
}
return sup.Output()
}
func mounts() []ContainerMount {
return []ContainerMount{{Source: "stalwart-data", Destination: "/opt/stalwart"}}
}
func TestContainerLauncherBuildsTheRunCommand(t *testing.T) {
log := fakeDocker(t, "exec sleep 300", "0", "echo false")
sup, err := ContainerLauncher{
Image: "stalwartlabs/stalwart@sha256:abc", Mounts: mounts(),
Name: "recov", Publish: []string{"127.0.0.1:8080:8080"},
}.Launch(context.Background(), LaunchOptions{
ConfigPath: "/opt/stalwart/etc/config.json", RecoveryMode: true,
AdminUser: "admin", AdminPassword: "s3cret", ExtraEnv: []string{"FOO=bar"},
})
if err != nil {
t.Fatalf("Launch: %v", err)
}
defer sup.Stop(time.Second)
args := waitForArgs(t, log)
for _, want := range []string{
"run --rm --name recov",
"-e STALWART_RECOVERY_MODE=1",
"-e STALWART_RECOVERY_ADMIN=admin:s3cret",
"-e FOO=bar",
"-v stalwart-data:/opt/stalwart",
"-p 127.0.0.1:8080:8080",
"stalwartlabs/stalwart@sha256:abc --config /opt/stalwart/etc/config.json",
} {
if !strings.Contains(args, want) {
t.Errorf("docker run args missing %q\ngot: %s", want, args)
}
}
}
// An ordinary boot has no recovery variables. Leaving them set is the
// documented footgun cutover strips from a unit; the container path must
// not reintroduce it.
func TestContainerLauncherOmitsRecoveryEnvOnAnOrdinaryBoot(t *testing.T) {
log := fakeDocker(t, "exec sleep 300", "0", "echo false")
sup, err := ContainerLauncher{Image: "img", Mounts: mounts(), Name: "recov"}.
Launch(context.Background(), LaunchOptions{RecoveryMode: false})
if err != nil {
t.Fatal(err)
}
defer sup.Stop(time.Second)
if args := waitForArgs(t, log); strings.Contains(args, "STALWART_RECOVERY") {
t.Errorf("ordinary boot set recovery env:\n%s", args)
}
}
// Migrating an empty store and reporting success is the worst outcome
// available here, so no mounts is refused rather than defaulted.
func TestContainerLauncherRefusesWithoutMounts(t *testing.T) {
fakeDocker(t, "exec sleep 300", "0", "echo false")
_, err := ContainerLauncher{Image: "img", Name: "recov"}.
Launch(context.Background(), LaunchOptions{})
if err == nil {
t.Fatal("expected a refusal when no mounts were given")
}
if !strings.Contains(err.Error(), "empty store") {
t.Errorf("refusal should say why it matters, got: %v", err)
}
}
func TestContainerLauncherRefusesWithoutAnImage(t *testing.T) {
_, err := ContainerLauncher{Mounts: mounts()}.Launch(context.Background(), LaunchOptions{})
if err == nil {
t.Fatal("expected a refusal when no image was given")
}
}
// Stop must stop the container by name, not just the client. A killed
// client leaves the container running and holding the store, and the run
// would move on believing it had stopped.
func TestStopStopsTheContainerAndNotOnlyTheClient(t *testing.T) {
log := fakeDocker(t, "exec sleep 300", "0", "echo false")
sup, err := ContainerLauncher{Image: "img", Mounts: mounts(), Name: "recov"}.
Launch(context.Background(), LaunchOptions{})
if err != nil {
t.Fatal(err)
}
if err := sup.Stop(2 * time.Second); err != nil {
t.Fatalf("Stop: %v", err)
}
args := waitForArgs(t, log)
if !strings.Contains(args, "stop --time 2 recov") {
t.Errorf("Stop did not stop the container by name:\n%s", args)
}
}
// The hazard this exists for: docker reported the stop fine, but the
// container is still running. Nothing may touch that store, so Stop has to
// fail loudly rather than let the run continue.
func TestStopFailsWhenTheContainerSurvives(t *testing.T) {
fakeDocker(t, "exec sleep 300", "0", "echo true")
sup, err := ContainerLauncher{Image: "img", Mounts: mounts(), Name: "recov"}.
Launch(context.Background(), LaunchOptions{})
if err != nil {
t.Fatal(err)
}
err = sup.Stop(time.Second)
if err == nil {
t.Fatal("Stop reported success while the container was still running")
}
if !strings.Contains(err.Error(), "still holds the store open") {
t.Errorf("error should say why it matters, got: %v", err)
}
}
// With --rm a clean shutdown removes the container before Stop runs, so
// `docker stop` failing on a container that is already gone is the normal
// path, not a failure.
func TestStopToleratesAnAlreadyGoneContainer(t *testing.T) {
fakeDocker(t, "exec sleep 300", "1", "exit 1")
sup, err := ContainerLauncher{Image: "img", Mounts: mounts(), Name: "recov"}.
Launch(context.Background(), LaunchOptions{})
if err != nil {
t.Fatal(err)
}
if err := sup.Stop(time.Second); err != nil {
t.Fatalf("Stop should tolerate an already-removed container, got: %v", err)
}
}
// The server's own words are the diagnosis; a container must not lose them.
func TestContainerOutputIsCaptured(t *testing.T) {
fakeDocker(t, "echo 'Failed to bind to [::]:8080: Address already in use'; exit 1", "0", "exit 1")
sup, err := ContainerLauncher{Image: "img", Mounts: mounts(), Name: "recov"}.
Launch(context.Background(), LaunchOptions{})
if err != nil {
t.Fatal(err)
}
out := waitForOutput(t, sup)
_ = sup.Stop(time.Second)
if !strings.Contains(out, "Address already in use") {
t.Errorf("container output was not captured, got: %q", out)
}
}