367 lines
13 KiB
Go
367 lines
13 KiB
Go
package serverops
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
|
)
|
|
|
|
type fakeRunner struct {
|
|
run func(args []string) (compose.Result, error)
|
|
all [][]string
|
|
}
|
|
|
|
func (r *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
|
r.all = append(r.all, append([]string(nil), args...))
|
|
return r.run(args)
|
|
}
|
|
|
|
func TestMigrateSessionsUsesOnlyTheMigrationProfileAndSelectedCoreImage(t *testing.T) {
|
|
for _, image := range []string{
|
|
"thothii-core:local",
|
|
"registry.example.invalid/thothii/core@sha256:" + strings.Repeat("a", 64),
|
|
} {
|
|
t.Run(image, func(t *testing.T) {
|
|
installation := testInstallation(t)
|
|
if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(installation.CurrentImageOverridePath(), []byte("services:\n core:\n image: "+image+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var temporaryOverride string
|
|
configCalls := 0
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
switch {
|
|
case contains(args, "ps", "--all", "--format", "json", "core", "frontend"):
|
|
return compose.Result{Stdout: `[{"ID":"core-id","Name":"core-name","Service":"core","State":"exited"},{"ID":"front-id","Name":"front-name","Service":"frontend","State":"exited"}]`}, nil
|
|
case contains(args, "--profile", "session-migrate", "config", "--format", "json"):
|
|
configCalls++
|
|
if configCalls == 1 {
|
|
return compose.Result{Stdout: `{"services":{"core":{"image":"` + image + `"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil
|
|
}
|
|
temporaryOverride = lastComposeFile(args)
|
|
contents, err := os.ReadFile(temporaryOverride)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(contents), "image: "+strconv.Quote(image)) || !strings.Contains(string(contents), "build: !reset null") {
|
|
t.Fatalf("migration override = %q", contents)
|
|
}
|
|
if indexOf(args, installation.CurrentImageOverridePath()) >= indexOf(args, temporaryOverride) {
|
|
t.Fatalf("temporary override does not follow durable selector: %#v", args)
|
|
}
|
|
return compose.Result{Stdout: `{"services":{"core":{"image":"` + image + `"},"session-migrate":{"image":"` + image + `"}}}`}, nil
|
|
case contains(args, "--profile", "session-migrate", "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"):
|
|
return compose.Result{Stdout: `{"applied":["0001"],"drifted":[],"pending":[]}` + "\n"}, nil
|
|
default:
|
|
t.Fatalf("unexpected Docker invocation: %#v", args)
|
|
return compose.Result{}, nil
|
|
}
|
|
}}
|
|
|
|
status, err := MigrateSessions(context.Background(), installation, runner, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(status.Pending, []string{}) || !reflect.DeepEqual(status.Drifted, []string{}) {
|
|
t.Fatalf("status = %#v", status)
|
|
}
|
|
if temporaryOverride == "" {
|
|
t.Fatal("migration override was not inspected")
|
|
}
|
|
if _, err := os.Stat(temporaryOverride); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("temporary override remains after migration: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMigrateSessionsFailsClosedBeforeMutation(t *testing.T) {
|
|
installation := testInstallation(t)
|
|
for name, spec := range map[string]struct {
|
|
confirmed bool
|
|
ps string
|
|
migrationJSON string
|
|
}{
|
|
"confirmation missing": {false, `[]`, `{"applied":[],"drifted":[],"pending":[]}`},
|
|
"service running": {true, `[{"ID":"core-id","Name":"core","Service":"core","State":"running"}]`, `{"applied":[],"drifted":[],"pending":[]}`},
|
|
"pending migration": {true, `[]`, `{"applied":[],"drifted":[],"pending":["0002"]}`},
|
|
"drifted migration": {true, `[]`, `{"applied":[],"drifted":["0001"],"pending":[]}`},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
runCalled := false
|
|
configCalls := 0
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
switch {
|
|
case contains(args, "ps", "--all"):
|
|
return compose.Result{Stdout: spec.ps}, nil
|
|
case contains(args, "config", "--format", "json"):
|
|
configCalls++
|
|
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil
|
|
case contains(args, "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"):
|
|
runCalled = true
|
|
return compose.Result{Stdout: spec.migrationJSON}, nil
|
|
default:
|
|
t.Fatalf("unexpected Docker invocation: %#v", args)
|
|
return compose.Result{}, nil
|
|
}
|
|
}}
|
|
_, err := MigrateSessions(context.Background(), installation, runner, spec.confirmed)
|
|
if err == nil {
|
|
t.Fatal("MigrateSessions() error = nil")
|
|
}
|
|
if !spec.confirmed && len(runner.all) != 0 {
|
|
t.Fatalf("Docker invoked without confirmation: %#v", runner.all)
|
|
}
|
|
if strings.Contains(name, "service running") && (runCalled || configCalls != 0) {
|
|
t.Fatalf("migration advanced while app was running: %#v", runner.all)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMigrateSessionsPreservesCompleteFailureDetailBehindTypedMetadata(t *testing.T) {
|
|
longSecret := "long-secret-" + strings.Repeat("s", 700)
|
|
for _, spec := range []struct {
|
|
name string
|
|
secret string
|
|
stderr string
|
|
}{
|
|
{name: "secret longer than display limit", secret: longSecret, stderr: longSecret + " rejected"},
|
|
{name: "secret crossing display boundary", secret: "boundary-secret-value", stderr: strings.Repeat("p", 500) + "boundary-secret-value rejected"},
|
|
} {
|
|
t.Run(spec.name, func(t *testing.T) {
|
|
installation := testInstallation(t)
|
|
configCalls := 0
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
switch {
|
|
case contains(args, "ps", "--all"):
|
|
return compose.Result{Stdout: `[]`}, nil
|
|
case contains(args, "config", "--format", "json"):
|
|
configCalls++
|
|
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local"},"session-migrate":{"image":"thothii-core:local"}}}`}, nil
|
|
case contains(args, "run", "--rm", "--no-deps", "--no-TTY", "session-migrate"):
|
|
return compose.Result{Stderr: spec.stderr, ExitCode: 23}, errors.New("exit status 23")
|
|
default:
|
|
t.Fatalf("unexpected Docker invocation: %#v", args)
|
|
return compose.Result{}, nil
|
|
}
|
|
}}
|
|
|
|
_, err := MigrateSessions(context.Background(), installation, runner, true)
|
|
var operationErr *OperationError
|
|
if !errors.As(err, &operationErr) {
|
|
t.Fatalf("MigrateSessions() error = %T %v, want OperationError", err, err)
|
|
}
|
|
if operationErr.Stage() != StageSessionMigration || operationErr.Class() != ExitClassNonzero {
|
|
t.Fatalf("operation error = %#v", operationErr)
|
|
}
|
|
if strings.Contains(operationErr.Error(), spec.secret[:12]) {
|
|
t.Fatalf("typed metadata exposed secret prefix: %q", operationErr.Error())
|
|
}
|
|
if detail := operationErr.Detail(); detail != spec.stderr || !strings.Contains(detail, spec.secret) {
|
|
t.Fatalf("detail was truncated before redaction: length=%d", len(detail))
|
|
}
|
|
if configCalls != 2 {
|
|
t.Fatalf("config calls = %d", configCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemovePreservesEveryDeclaredBindSecretAndBackup(t *testing.T) {
|
|
installation, preserved := removalInstallation(t)
|
|
psCalls := 0
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
switch {
|
|
case contains(args, "ps", "--all", "--format", "json", "core", "frontend"):
|
|
psCalls++
|
|
if psCalls == 1 {
|
|
return compose.Result{Stdout: `[{"ID":"core-id","Name":"project-core-1","Service":"core","State":"exited"},{"ID":"frontend-id","Name":"project-frontend-1","Service":"frontend","State":"exited"}]`}, nil
|
|
}
|
|
return compose.Result{Stdout: `[]`}, nil
|
|
case reflect.DeepEqual(args, []string{"rm", "core-id", "frontend-id"}):
|
|
return compose.Result{Stdout: "core-id\nfrontend-id\n"}, nil
|
|
default:
|
|
t.Fatalf("unexpected Docker invocation: %#v", args)
|
|
return compose.Result{}, nil
|
|
}
|
|
}}
|
|
|
|
result, err := Remove(context.Background(), installation, runner, []string{"core-id", "frontend-id"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Preserved != len(preserved) {
|
|
t.Fatalf("preserved = %d, want %d", result.Preserved, len(preserved))
|
|
}
|
|
if got := result.Targets; len(got) != 2 || got[0].ID != "core-id" || got[1].ID != "frontend-id" {
|
|
t.Fatalf("targets = %#v", got)
|
|
}
|
|
for _, args := range runner.all {
|
|
joined := strings.Join(args, " ")
|
|
if strings.Contains(joined, " -v") || strings.Contains(joined, "volume") || strings.Contains(joined, "down") || strings.Contains(joined, "prune") {
|
|
t.Fatalf("destructive removal invocation: %q", joined)
|
|
}
|
|
}
|
|
for _, path := range preserved {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Errorf("preserved path %q: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoveDisplaysTargetsButDoesNotMutateWithoutConfirmation(t *testing.T) {
|
|
installation, _ := removalInstallation(t)
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
if !contains(args, "ps", "--all") {
|
|
t.Fatalf("mutation without confirmation: %#v", args)
|
|
}
|
|
return compose.Result{Stdout: `[{"ID":"core-id","Name":"project-core-1","Service":"core","State":"exited"}]`}, nil
|
|
}}
|
|
result, err := Remove(context.Background(), installation, runner, nil)
|
|
if !errors.Is(err, ErrConfirmationRequired) {
|
|
t.Fatalf("Remove() error = %v, want confirmation", err)
|
|
}
|
|
if len(result.Targets) != 1 || result.Targets[0].ID != "core-id" {
|
|
t.Fatalf("targets = %#v", result.Targets)
|
|
}
|
|
if len(runner.all) != 1 {
|
|
t.Fatalf("Docker calls = %#v", runner.all)
|
|
}
|
|
}
|
|
|
|
func TestRemoveRejectsRunningOrReplacedContainers(t *testing.T) {
|
|
for name, spec := range map[string]struct{ first, second string }{
|
|
"running": {`[{"ID":"core-id","Name":"core","Service":"core","State":"running"}]`, `[]`},
|
|
"replaced": {`[{"ID":"core-id","Name":"core","Service":"core","State":"exited"}]`, `[{"ID":"new-id","Name":"core","Service":"core","State":"exited"}]`},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
installation, _ := removalInstallation(t)
|
|
psCalls := 0
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
if contains(args, "ps", "--all") {
|
|
psCalls++
|
|
if psCalls == 1 {
|
|
return compose.Result{Stdout: spec.first}, nil
|
|
}
|
|
return compose.Result{Stdout: spec.second}, nil
|
|
}
|
|
if reflect.DeepEqual(args, []string{"rm", "core-id"}) {
|
|
return compose.Result{}, nil
|
|
}
|
|
t.Fatalf("unexpected Docker invocation: %#v", args)
|
|
return compose.Result{}, nil
|
|
}}
|
|
_, err := Remove(context.Background(), installation, runner, []string{"core-id"})
|
|
if err == nil {
|
|
t.Fatal("Remove() error = nil")
|
|
}
|
|
if name == "running" && len(runner.all) != 1 {
|
|
t.Fatalf("running container was mutated: %#v", runner.all)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoveRejectsConfirmationForDifferentContainerIDs(t *testing.T) {
|
|
installation, _ := removalInstallation(t)
|
|
runner := &fakeRunner{run: func(args []string) (compose.Result, error) {
|
|
if !contains(args, "ps", "--all") {
|
|
t.Fatalf("mismatched confirmation caused mutation: %#v", args)
|
|
}
|
|
return compose.Result{Stdout: `[{"ID":"replacement-id","Name":"core","Service":"core","State":"exited"}]`}, nil
|
|
}}
|
|
result, err := Remove(context.Background(), installation, runner, []string{"previously-displayed-id"})
|
|
if !errors.Is(err, ErrUnsafeState) || len(result.Targets) != 1 {
|
|
t.Fatalf("Remove() = %#v, %v", result, err)
|
|
}
|
|
if len(runner.all) != 1 {
|
|
t.Fatalf("Docker calls = %#v", runner.all)
|
|
}
|
|
}
|
|
|
|
func testInstallation(t *testing.T) config.Installation {
|
|
t.Helper()
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project := filepath.Join(root, "project")
|
|
if err := os.Mkdir(project, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return config.Installation{
|
|
Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "server",
|
|
ProjectDirectory: project, EnvFile: filepath.Join(root, "server.env"),
|
|
}
|
|
}
|
|
|
|
func removalInstallation(t *testing.T) (config.Installation, []string) {
|
|
t.Helper()
|
|
installation := testInstallation(t)
|
|
paths := make([]string, 0, 5)
|
|
values := map[string]string{}
|
|
for _, name := range []string{"data", "pi-state", "workspace-registry", "backups"} {
|
|
path := filepath.Join(filepath.Dir(installation.Path), name)
|
|
if err := os.Mkdir(path, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
paths = append(paths, path)
|
|
values[name] = path
|
|
}
|
|
secret := filepath.Join(filepath.Dir(installation.Path), "secret")
|
|
if err := os.WriteFile(secret, []byte("never-log-this"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
paths = append(paths, secret)
|
|
env := "THT_DATA_ROOT=" + values["data"] + "\n" +
|
|
"THT_PI_STATE_ROOT=" + values["pi-state"] + "\n" +
|
|
"THT_WORKSPACE_REGISTRY_ROOT=" + values["workspace-registry"] + "\n" +
|
|
"THT_BACKUP_ROOT=" + values["backups"] + "\n" +
|
|
"APP_TOKEN_FILE=" + secret + "\n"
|
|
if err := os.WriteFile(installation.EnvFile, []byte(env), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return installation, paths
|
|
}
|
|
|
|
func contains(values []string, sequence ...string) bool {
|
|
for start := range values {
|
|
if start+len(sequence) <= len(values) && reflect.DeepEqual(values[start:start+len(sequence)], sequence) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func indexOf(values []string, value string) int {
|
|
for index, candidate := range values {
|
|
if candidate == value {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func lastComposeFile(args []string) string {
|
|
last := ""
|
|
for index := 0; index+1 < len(args); index++ {
|
|
if args[index] == "-f" {
|
|
last = args[index+1]
|
|
}
|
|
}
|
|
return last
|
|
}
|