fix: make server operations executable
This commit is contained in:
@@ -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