Make how the target version is started a seam

Both phases that bring the target version up against a not-yet-migrated
store -- the recovery cycle, and the ordinary boot validate does after it --
constructed a child process from a path on this host directly. That is the
one thing about them packaging changes: a container runs an image against
the data volume instead. Everything either of them is started *for* is
identical afterwards.

So starting it is now a Launcher, returning a Supervised the callers stop
and read output from. BinaryLauncher is today's behaviour and the default
when Options.Launcher is nil, so every existing caller is unchanged -- no
test needed editing, which is the evidence for that rather than a claim
about it.

Deliberately narrower than the interface sketched in issue #3. Stage and
cutover also differ by packaging, but designing their interfaces now would
be designing against a guess: there is no second implementation yet to
shape them, and the shape a container needs is what PR 3 and PR 4 find out.
This seam is different because it already had two callers doing the same
thing for the same reason, so extracting it describes the code rather than
predicting it.

outputSuffix now takes Supervised. It only ever needed Output(), and the
diagnosis it exists to preserve -- the server's own words about a bind
conflict or a rejected config value, which a bare timeout loses -- matters
whatever started the process.
This commit is contained in:
2026-08-28 17:02:48 -07:00
parent bb257892e7
commit 5a7a126960
4 changed files with 238 additions and 9 deletions
+123
View File
@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package recovery
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/LINUXexpert-org/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")
}
}