fix: make server operations executable
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user