refactor(cli): rename operator command to tht
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
package serverops
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/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()
|
||||
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.MkdirTemp(temporaryRoot, "tht-serverops-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user