The repositories moved off LINUXexpert-org. Here that is not a documentation change: the old organisation was the module path, so it is declared in go.mod and repeated in every internal import. Leaving it would have been worse than a stale link. GitHub redirects the repository, but a go.mod whose module line disagrees with the path it was fetched from is an error rather than a redirect, so `go get` on the new address would have failed against the old declaration. go.mod, 34 files of imports, and the repository links in README and ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved, only our own path.
124 lines
4.0 KiB
Go
124 lines
4.0 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package recovery
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
|
|
)
|
|
|
|
// recordingLauncher stands in for a deployment that is not a local binary -
|
|
// a container, when that lands. It records what it was asked for and
|
|
// delegates the actual process to BinaryLauncher, so the recovery cycle
|
|
// still has something real to talk to.
|
|
type recordingLauncher struct {
|
|
inner Launcher
|
|
calls int
|
|
lastOp LaunchOptions
|
|
}
|
|
|
|
func (r *recordingLauncher) Launch(ctx context.Context, o LaunchOptions) (Supervised, error) {
|
|
r.calls++
|
|
r.lastOp = o
|
|
return r.inner.Launch(ctx, o)
|
|
}
|
|
|
|
// The whole point of the seam: a deployment that is not a host binary can
|
|
// supply its own way of starting the target version, and everything the
|
|
// recovery cycle does afterwards is unchanged.
|
|
func TestRunUsesTheSuppliedLauncher(t *testing.T) {
|
|
port := freePort(t)
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(configPath, []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
applyDir := t.TempDir()
|
|
withFakeExecutable(t, "stalwart-cli", fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", argsFile(t, applyDir)))
|
|
exportFile := filepath.Join(t.TempDir(), "export.json")
|
|
if err := os.WriteFile(exportFile, []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, err := store.Create("0.15.5", "0.16.14")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
launcher := &recordingLauncher{inner: BinaryLauncher{BinaryPath: testBinaryPath(t)}}
|
|
_, err = Run(context.Background(), store, rs, Options{
|
|
// Deliberately empty: a supplied Launcher owns how the target
|
|
// version is started, so BinaryPath must not be consulted at all.
|
|
BinaryPath: "",
|
|
ConfigPath: configPath,
|
|
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
|
|
AdminUser: "admin",
|
|
ApplyFiles: []string{exportFile},
|
|
ExtraEnv: helperProcessEnv(port),
|
|
StartupTimeout: 5 * time.Second,
|
|
StopGrace: 5 * time.Second,
|
|
Launcher: launcher,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run with a supplied launcher: %v", err)
|
|
}
|
|
if launcher.calls != 1 {
|
|
t.Errorf("launcher called %d times, want 1", launcher.calls)
|
|
}
|
|
if !launcher.lastOp.RecoveryMode {
|
|
t.Error("recovery cycle launched without RecoveryMode set")
|
|
}
|
|
if launcher.lastOp.AdminPassword == "" {
|
|
t.Error("recovery cycle launched without a generated admin password")
|
|
}
|
|
if launcher.lastOp.ConfigPath != configPath {
|
|
t.Errorf("ConfigPath = %q, want %q", launcher.lastOp.ConfigPath, configPath)
|
|
}
|
|
}
|
|
|
|
// A nil Launcher has to keep meaning exactly what it meant before this
|
|
// seam existed, or every existing caller changes behaviour silently.
|
|
func TestNilLauncherStillRunsTheBinaryPath(t *testing.T) {
|
|
port := freePort(t)
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(configPath, []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
applyDir := t.TempDir()
|
|
applyLog := argsFile(t, applyDir)
|
|
withFakeExecutable(t, "stalwart-cli", fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", applyLog))
|
|
exportFile := filepath.Join(t.TempDir(), "export.json")
|
|
if err := os.WriteFile(exportFile, []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store := checkpoint.NewStore(t.TempDir())
|
|
rs, err := store.Create("0.15.5", "0.16.14")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Run(context.Background(), store, rs, Options{
|
|
BinaryPath: testBinaryPath(t),
|
|
ConfigPath: configPath,
|
|
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
|
|
AdminUser: "admin",
|
|
ApplyFiles: []string{exportFile},
|
|
ExtraEnv: helperProcessEnv(port),
|
|
StartupTimeout: 5 * time.Second,
|
|
StopGrace: 5 * time.Second,
|
|
// Launcher deliberately unset.
|
|
}); err != nil {
|
|
t.Fatalf("Run with a nil launcher: %v", err)
|
|
}
|
|
if got := readArgsFile(t, applyLog); got == "" {
|
|
t.Fatal("stalwart-cli apply was never invoked, so the cycle did not run")
|
|
}
|
|
}
|