Files
ThothII/tools/thothctl/internal/serverops/operations_test.go
T

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
}