fix: make server operations executable

This commit is contained in:
2026-08-05 11:06:34 +02:00
parent a707fb442c
commit 96fe5bfa79
11 changed files with 1299 additions and 43 deletions
+60
View File
@@ -18,6 +18,7 @@ import (
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
"github.com/aritmolab/thothii/tools/thothctl/internal/serverops"
)
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
@@ -29,6 +30,10 @@ Commands:
start Start the installation in the background.
stop Stop the installation.
update --check-only Validate the current installation without changing containers.
sessions migrate --yes
Run only the server session migrator and verify pending=[] and drifted=[].
remove Display exact stopped app container IDs without mutation.
remove --yes ID... Remove only the stopped IDs copied from the preceding display.
pi status Show the Pi version embedded in core.
pi doctor Check Pi preconditions without changing the installation.
pi test Run the temporary Pi/core smoke checks.
@@ -113,12 +118,67 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return doctor(ctx, installation, runner, secretValues, stdout, stderr)
case "pi":
return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
case "sessions":
if len(commandArgs) != 2 || commandArgs[0] != "migrate" || commandArgs[1] != "--yes" {
return commandUsageError(stderr, "sessions migrate requires --yes")
}
status, operationErr := serverops.MigrateSessions(ctx, installation, runner, true)
if operationErr != nil {
return serverOperationFailure(stderr, operationErr, secretValues)
}
if encodeErr := json.NewEncoder(stdout).Encode(status); encodeErr != nil {
fmt.Fprintln(stderr, "thothctl: migration status could not be written")
return 1
}
return 0
case "remove":
var confirmedIDs []string
if len(commandArgs) > 0 {
if commandArgs[0] != "--yes" || len(commandArgs) < 2 {
return commandUsageError(stderr, "remove requires either no arguments or --yes followed by every displayed container ID")
}
confirmedIDs = commandArgs[1:]
}
removal, operationErr := serverops.Remove(ctx, installation, runner, confirmedIDs)
writeRemovalTargets(stdout, installation.ProjectName(), removal.Targets)
if errors.Is(operationErr, serverops.ErrConfirmationRequired) {
fmt.Fprint(stderr, "thothctl: inspect the exact targets above, then re-run with remove --yes")
for _, target := range removal.Targets {
fmt.Fprintf(stderr, " %s", target.ID)
}
fmt.Fprintln(stderr)
return 2
}
if operationErr != nil {
return serverOperationFailure(stderr, operationErr, secretValues)
}
fmt.Fprintf(stdout, "Removed %d stopped app containers; verified %d preserved paths.\n", len(removal.Targets), removal.Preserved)
return 0
default:
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
return writeResult(result, err, secretValues, stdout, stderr)
}
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
if len(targets) == 0 {
fmt.Fprintln(outputWriter, " (none)")
return
}
for _, target := range targets {
fmt.Fprintf(outputWriter, " service=%s name=%s id=%s state=%s\n", target.Service, target.Name, target.ID, target.State)
}
}
func serverOperationFailure(stderr io.Writer, err error, secretValues []string) int {
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), secretValues))
if errors.Is(err, serverops.ErrConfirmationRequired) || errors.Is(err, serverops.ErrUnsafeState) {
return 2
}
return 1
}
// installationRunner transforms only Compose invocations into the installation's validated,
// profile-specific argument list. Direct Docker image commands remain host-side and use arguments.
type installationRunner struct {
+58
View File
@@ -79,6 +79,8 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
"--source pull --image IMAGE@sha256:DIGEST",
"pi maintenance status",
"pi maintenance recover --yes",
"sessions migrate --yes",
"remove --yes ID...",
} {
if !strings.Contains(usage, required) {
t.Errorf("usage missing %q", required)
@@ -86,6 +88,48 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
}
}
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
fixture := newCLIFixture(t, "")
fixture.setProfile(t, "server")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{
"--installation", fixture.installationPath, "sessions", "migrate",
}, &stdout, &stderr)
if code != 2 || !strings.Contains(stderr.String(), "sessions migrate requires --yes") {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
assertDockerNotInvoked(t, fixture)
}
func TestRunRemoveDisplaysExactInstallationTargetsBeforeConfirmation(t *testing.T) {
fixture := newCLIFixture(t, "")
fixture.setProfile(t, "server")
fixture.setEnvironment(t)
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", `[{"ID":"core-id","Name":"exact-core","Service":"core","State":"exited"},{"ID":"front-id","Name":"exact-frontend","Service":"frontend","State":"exited"}]`)
var stdout, stderr bytes.Buffer
code := run(context.Background(), []string{
"--installation", fixture.installationPath, "remove",
}, &stdout, &stderr)
if code != 2 || !strings.Contains(stderr.String(), "re-run with remove --yes core-id front-id") {
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
}
for _, value := range []string{"exact-core", "core-id", "exact-frontend", "front-id", "exited"} {
if !strings.Contains(stdout.String(), value) {
t.Errorf("target display %q missing %q", stdout.String(), value)
}
}
calls := fixture.invocations(t)
if len(calls) != 1 {
t.Fatalf("Docker calls = %#v", calls)
}
assertInvocationContains(t, calls, "ps", "--all", "--format", "json", "core", "frontend")
}
type wizardRunner struct{ calls []string }
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
@@ -615,6 +659,7 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
printf '%s\n' "$@" >> "$THOTHCTL_FAKE_ARGS"
printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS"
case " $* " in
*" ps --all --format json core frontend "*) printf '%s\n' "${THOTHCTL_FAKE_STOPPED_PS:-[]}" ;;
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' ;;
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
@@ -661,6 +706,19 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
t.Setenv("THOTHCTL_FAKE_LOG", "")
t.Setenv("THOTHCTL_FAKE_FAILURE", "")
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "")
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", "[]")
}
func (f cliFixture) setProfile(t *testing.T, profile string) {
t.Helper()
composePath := filepath.Join(f.projectDirectory, "deploy", "compose."+profile+".yaml")
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n"
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
func (f cliFixture) invocations(t *testing.T) [][]string {
+81 -4
View File
@@ -148,8 +148,25 @@ func (i Installation) ProjectName() string {
// ComposeArgs builds Docker Compose arguments without shell quoting or interpolation.
func (i Installation) ComposeArgs(command ...string) []string {
return i.composeArgs(i.ComposeFiles(), command...)
}
// ComposeArgsWithFinalOverride appends one validated, generated override after every durable
// installation selector and before the Compose command.
func (i Installation) ComposeArgsWithFinalOverride(override string, command ...string) ([]string, error) {
if filepath.Clean(override) != override || !filepath.IsAbs(override) {
return nil, errors.New("final Compose override must be an absolute canonical path")
}
if err := requireRegularFile(override, "final Compose override"); err != nil {
return nil, err
}
files := append(i.ComposeFiles(), override)
return i.composeArgs(files, command...), nil
}
func (i Installation) composeArgs(files []string, command ...string) []string {
args := []string{"compose", "--project-name", i.ProjectName(), "--project-directory", i.ProjectDirectory, "--env-file", i.EnvFile}
for _, composeFile := range i.ComposeFiles() {
for _, composeFile := range files {
args = append(args, "-f", composeFile)
}
return append(args, command...)
@@ -193,15 +210,75 @@ func (i Installation) SecretFiles() ([]string, error) {
// EnvironmentValue returns one declared installation value without exposing dotenv parsing to
// callers. It is used only for operator-visible file locations, never for secret content.
func (i Installation) EnvironmentValue(name string) (string, error) {
values, err := i.environmentValues()
if err != nil {
return "", err
}
return values[name], nil
}
func (i Installation) environmentValues() (map[string]string, error) {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil {
return "", errors.New("installation environment could not be read")
return nil, errors.New("installation environment could not be read")
}
values, err := parseComposeDotenv(contents)
if err != nil {
return "", errors.New("installation environment could not be read")
return nil, errors.New("installation environment could not be read")
}
return values[name], nil
return values, nil
}
// PreservationPaths returns the server bind roots, backup root, and declared secret files whose
// filesystem identities must survive a data-preserving removal.
func (i Installation) PreservationPaths() ([]string, error) {
if i.Profile != "server" {
return nil, errors.New("data-preserving removal requires a server installation")
}
values, err := i.environmentValues()
if err != nil {
return nil, err
}
paths := make([]string, 0)
seen := make(map[string]struct{})
for _, name := range []string{
"THT_DATA_ROOT", "THT_PI_STATE_ROOT", "THT_WORKSPACE_REGISTRY_ROOT", "THT_BACKUP_ROOT",
} {
path := values[name]
if err := requireCanonicalDirectory(path); err != nil {
return nil, fmt.Errorf("%s must identify an existing canonical directory", name)
}
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
secretFiles, err := i.SecretFiles()
if err != nil {
return nil, err
}
for _, path := range secretFiles {
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
return paths, nil
}
func requireCanonicalDirectory(path string) error {
if err := safeio.ValidateCanonicalPath(path); err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || resolved != path {
return errors.New("directory path is unavailable or contains a symlink")
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return errors.New("directory path is unavailable")
}
return nil
}
func parseComposeDotenv(contents []byte) (map[string]string, error) {
@@ -78,6 +78,76 @@ func TestComposeArgsAutomaticallyIncludeTheInstallationCurrentImageOverride(t *t
}
}
func TestComposeArgsWithFinalOverridePreservesCurrentImagePrecedence(t *testing.T) {
installationPath, _, _, _ := writeInstallation(t, "server")
seed, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(seed.ControlDirectory(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(seed.CurrentImageOverridePath(), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
final := filepath.Join(seed.ControlDirectory(), "migration.yaml")
if err := os.WriteFile(final, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
args, err := installation.ComposeArgsWithFinalOverride(final, "--profile", "session-migrate", "config")
if err != nil {
t.Fatal(err)
}
want := []string{"-f", installation.CurrentImageOverridePath(), "-f", final, "--profile", "session-migrate", "config"}
if !containsSequence(args, want) {
t.Fatalf("ComposeArgsWithFinalOverride() = %#v, want %#v", args, want)
}
}
func TestPreservationPathsReturnsCanonicalBindRootsBackupsAndSecretFiles(t *testing.T) {
installationPath, _, envFile, _ := writeInstallation(t, "server")
root := filepath.Dir(envFile)
var wanted []string
var lines []string
for _, item := range []struct{ key, name string }{
{"THT_DATA_ROOT", "data"},
{"THT_PI_STATE_ROOT", "pi-state"},
{"THT_WORKSPACE_REGISTRY_ROOT", "workspace-registry"},
{"THT_BACKUP_ROOT", "backups"},
} {
path := filepath.Join(root, item.name)
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
wanted = append(wanted, path)
lines = append(lines, item.key+"="+path)
}
secret := filepath.Join(root, "secret")
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
wanted = append(wanted, secret)
lines = append(lines, "APP_TOKEN_FILE="+secret)
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
got, err := installation.PreservationPaths()
if err != nil {
t.Fatal(err)
}
assertStringsEqual(t, got, wanted)
}
func TestInstallationControlPathsAreIsolatedForDescriptorsSharingOneCheckout(t *testing.T) {
projectDirectory := t.TempDir()
first := Installation{Path: filepath.Join(t.TempDir(), installationFileName), ProjectDirectory: projectDirectory}
@@ -0,0 +1,333 @@
// Package serverops implements bounded, installation-aware server maintenance operations.
package serverops
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
)
var (
ErrConfirmationRequired = errors.New("explicit confirmation is required")
ErrUnsafeState = errors.New("server operation refused in the current state")
)
type Runner interface {
Run(context.Context, []string, io.Reader) (compose.Result, error)
}
type MigrationStatus struct {
Applied []string `json:"applied"`
Drifted []string `json:"drifted"`
Pending []string `json:"pending"`
}
type Container struct {
ID string `json:"ID"`
Name string `json:"Name"`
Service string `json:"Service"`
State string `json:"State"`
}
type RemovalResult struct {
Targets []Container
Preserved int
}
// MigrateSessions runs only the one-shot migration service and proves the resulting schema state.
func MigrateSessions(ctx context.Context, installation config.Installation, runner Runner, confirmed bool) (MigrationStatus, error) {
if !confirmed {
return MigrationStatus{}, ErrConfirmationRequired
}
if installation.Profile != "server" {
return MigrationStatus{}, fmt.Errorf("%w: session migration requires a server installation", ErrUnsafeState)
}
containers, err := inspectContainers(ctx, installation, runner)
if err != nil {
return MigrationStatus{}, err
}
if err := requireStopped(containers); err != nil {
return MigrationStatus{}, err
}
rendered, err := runCompose(ctx, runner, installation.ComposeArgs("--profile", "session-migrate", "config", "--format", "json"))
if err != nil {
return MigrationStatus{}, err
}
coreImage, err := selectedCoreImage(rendered.Stdout)
if err != nil {
return MigrationStatus{}, err
}
override, cleanup, err := migrationOverride(installation, coreImage)
if err != nil {
return MigrationStatus{}, err
}
defer cleanup()
configArgs, err := installation.ComposeArgsWithFinalOverride(override, "--profile", "session-migrate", "config", "--format", "json")
if err != nil {
return MigrationStatus{}, err
}
finalConfig, err := runCompose(ctx, runner, configArgs)
if err != nil {
return MigrationStatus{}, err
}
if err := requireMigrationImage(finalConfig.Stdout, coreImage); err != nil {
return MigrationStatus{}, err
}
runArgs, err := installation.ComposeArgsWithFinalOverride(
override, "--profile", "session-migrate", "run", "--rm", "--no-deps", "--no-TTY", "session-migrate",
)
if err != nil {
return MigrationStatus{}, err
}
result, err := runCompose(ctx, runner, runArgs)
if err != nil {
return MigrationStatus{}, err
}
status, err := parseMigrationStatus(result.Stdout)
if err != nil {
return MigrationStatus{}, err
}
if len(status.Pending) != 0 || len(status.Drifted) != 0 {
return status, fmt.Errorf("%w: session migration did not finish cleanly", ErrUnsafeState)
}
return status, nil
}
// Remove deletes only the exact stopped core/frontend container IDs displayed by the command.
// A nil confirmation performs inspection only; a non-nil confirmation must equal every target ID.
func Remove(ctx context.Context, installation config.Installation, runner Runner, confirmedIDs []string) (RemovalResult, error) {
if installation.Profile != "server" {
return RemovalResult{}, fmt.Errorf("%w: removal requires a server installation", ErrUnsafeState)
}
targets, err := inspectContainers(ctx, installation, runner)
result := RemovalResult{Targets: targets}
if err != nil {
return result, err
}
if err := requireStopped(targets); err != nil {
return result, err
}
if confirmedIDs == nil {
return result, ErrConfirmationRequired
}
if !sameTargetIDs(targets, confirmedIDs) {
return result, fmt.Errorf("%w: confirmed container IDs differ from current targets", ErrUnsafeState)
}
paths, err := installation.PreservationPaths()
if err != nil {
return result, fmt.Errorf("%w: preservation paths could not be verified", ErrUnsafeState)
}
snapshots, err := snapshotPaths(paths)
if err != nil {
return result, err
}
if len(targets) > 0 {
args := []string{"rm"}
for _, target := range targets {
args = append(args, target.ID)
}
if _, err := runDocker(ctx, runner, args); err != nil {
return result, err
}
}
remaining, err := inspectContainers(ctx, installation, runner)
if err != nil {
return result, err
}
if len(remaining) != 0 {
return result, fmt.Errorf("%w: installation containers changed during removal", ErrUnsafeState)
}
if err := verifySnapshots(snapshots); err != nil {
return result, err
}
result.Preserved = len(snapshots)
return result, nil
}
func sameTargetIDs(targets []Container, confirmed []string) bool {
if len(targets) != len(confirmed) {
return false
}
wanted := make(map[string]struct{}, len(confirmed))
for _, id := range confirmed {
if strings.TrimSpace(id) == "" {
return false
}
if _, duplicate := wanted[id]; duplicate {
return false
}
wanted[id] = struct{}{}
}
for _, target := range targets {
if _, exists := wanted[target.ID]; !exists {
return false
}
}
return true
}
func inspectContainers(ctx context.Context, installation config.Installation, runner Runner) ([]Container, error) {
result, err := runCompose(ctx, runner, installation.ComposeArgs("ps", "--all", "--format", "json", "core", "frontend"))
if err != nil {
return nil, err
}
var containers []Container
if err := json.Unmarshal([]byte(result.Stdout), &containers); err != nil {
return nil, fmt.Errorf("%w: Compose returned invalid container status", ErrUnsafeState)
}
seen := make(map[string]struct{})
for _, container := range containers {
if (container.Service != "core" && container.Service != "frontend") || container.ID == "" || container.Name == "" {
return nil, fmt.Errorf("%w: Compose returned an unexpected removal target", ErrUnsafeState)
}
if _, exists := seen[container.ID]; exists {
return nil, fmt.Errorf("%w: Compose returned duplicate container IDs", ErrUnsafeState)
}
seen[container.ID] = struct{}{}
}
return containers, nil
}
func requireStopped(containers []Container) error {
for _, container := range containers {
if strings.ToLower(container.State) != "exited" {
return fmt.Errorf("%w: %s is not stopped", ErrUnsafeState, container.Service)
}
}
return nil
}
func selectedCoreImage(document string) (string, error) {
services, err := renderedServices(document)
if err != nil {
return "", err
}
core, exists := services["core"]
if !exists || strings.TrimSpace(core.Image) == "" {
return "", fmt.Errorf("%w: rendered core image is missing", ErrUnsafeState)
}
if _, exists := services["session-migrate"]; !exists {
return "", fmt.Errorf("%w: rendered migration service is missing", ErrUnsafeState)
}
return core.Image, nil
}
type renderedService struct {
Image string `json:"image"`
Build json.RawMessage `json:"build"`
}
func renderedServices(document string) (map[string]renderedService, error) {
var configDocument struct {
Services map[string]renderedService `json:"services"`
}
if err := json.Unmarshal([]byte(document), &configDocument); err != nil {
return nil, fmt.Errorf("%w: Compose returned invalid rendered configuration", ErrUnsafeState)
}
return configDocument.Services, nil
}
func requireMigrationImage(document, coreImage string) error {
services, err := renderedServices(document)
if err != nil {
return err
}
migrator, exists := services["session-migrate"]
if !exists || migrator.Image != coreImage {
return fmt.Errorf("%w: migration image differs from selected core image", ErrUnsafeState)
}
if len(migrator.Build) != 0 && strings.TrimSpace(string(migrator.Build)) != "null" {
return fmt.Errorf("%w: migration service unexpectedly declares a build", ErrUnsafeState)
}
return nil
}
func migrationOverride(installation config.Installation, image string) (string, func(), error) {
control := installation.ControlDirectory()
if err := os.MkdirAll(control, 0o700); err != nil {
return "", func() {}, errors.New("migration control directory could not be created")
}
info, err := os.Lstat(control)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", func() {}, errors.New("migration control directory is unsafe")
}
directory, err := os.MkdirTemp(control, "session-migrate-")
if err != nil {
return "", func() {}, errors.New("migration override directory could not be created")
}
cleanup := func() {
_ = os.Remove(filepath.Join(directory, "override.yaml"))
_ = os.Remove(directory)
}
path := filepath.Join(directory, "override.yaml")
contents := "services:\n session-migrate:\n build: !reset null\n image: " + strconv.Quote(image) + "\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
cleanup()
return "", func() {}, errors.New("migration override could not be written")
}
return path, cleanup, nil
}
func parseMigrationStatus(document string) (MigrationStatus, error) {
var status MigrationStatus
decoder := json.NewDecoder(strings.NewReader(document))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&status); err != nil || status.Applied == nil || status.Drifted == nil || status.Pending == nil {
return MigrationStatus{}, fmt.Errorf("%w: migration did not return verified JSON status", ErrUnsafeState)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return MigrationStatus{}, fmt.Errorf("%w: migration returned trailing output", ErrUnsafeState)
}
return status, nil
}
type pathSnapshot struct {
path string
info os.FileInfo
}
func snapshotPaths(paths []string) ([]pathSnapshot, error) {
snapshots := make([]pathSnapshot, 0, len(paths))
for _, path := range paths {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("%w: preservation target is unavailable", ErrUnsafeState)
}
snapshots = append(snapshots, pathSnapshot{path: path, info: info})
}
return snapshots, nil
}
func verifySnapshots(snapshots []pathSnapshot) error {
for _, snapshot := range snapshots {
info, err := os.Stat(snapshot.path)
if err != nil || !os.SameFile(snapshot.info, info) {
return fmt.Errorf("%w: a preserved path changed during removal", ErrUnsafeState)
}
}
return nil
}
func runCompose(ctx context.Context, runner Runner, args []string) (compose.Result, error) {
return runDocker(ctx, runner, args)
}
func runDocker(ctx context.Context, runner Runner, args []string) (compose.Result, error) {
result, err := runner.Run(ctx, args, nil)
if err != nil {
return result, errors.New("Docker operation failed")
}
return result, nil
}
@@ -0,0 +1,314 @@
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 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 := t.TempDir()
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
}