fix: harden thothctl pi lifecycle

This commit is contained in:
2026-08-04 18:40:50 +02:00
parent 20e59b8d32
commit 8fde1f81c7
8 changed files with 308 additions and 27 deletions
+1
View File
@@ -7,6 +7,7 @@
**/*.pyc **/*.pyc
.git .git
.worktrees .worktrees
.thothctl
.gitignore .gitignore
**/.env **/.env
**/.env.* **/.env.*
+1
View File
@@ -8,6 +8,7 @@ Thoth/
# === Visual companion brainstorming artifacts (local-only) === # === Visual companion brainstorming artifacts (local-only) ===
.superpowers/ .superpowers/
.worktrees/ .worktrees/
.thothctl/
# === Python === # === Python ===
__pycache__/ __pycache__/
+43 -5
View File
@@ -30,9 +30,11 @@ Commands:
pi status Show the Pi version embedded in core. pi status Show the Pi version embedded in core.
pi doctor Check Pi preconditions without changing the installation. pi doctor Check Pi preconditions without changing the installation.
pi test Run the temporary Pi/core smoke checks. pi test Run the temporary Pi/core smoke checks.
pi check Alias for pi test.
pi configure Store non-secret Pi defaults (credentials stay in PI_AUTH_FILE).
pi update Rebuild or pull a pinned Pi image (requires --yes). pi update Rebuild or pull a pinned Pi image (requires --yes).
pi rollback --yes Restore the image recorded by the latest Pi update. pi rollback --yes Restore the image recorded by the latest Pi update.
pi logs [--follow] Show sanitized core logs. pi logs Show the latest sanitized core logs.
` `
func main() { func main() {
@@ -156,13 +158,22 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
fmt.Fprintln(stdout, "Pi/core smoke checks passed.") fmt.Fprintln(stdout, "Pi/core smoke checks passed.")
return 0 return 0
case "logs": case "logs":
logArgs, err := logsArgs(args[1:]) if len(args) != 1 {
if err != nil { return commandUsageError(stderr, "pi logs does not support --follow; use bounded snapshots")
return commandUsageError(stderr, "pi logs accepts only --follow")
} }
logArgs = append(logArgs, "core") logArgs := []string{"logs", "--tail", "200", "core"}
result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil) result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil)
return writeResult(result, err, secretValues, stdout, stderr) return writeResult(result, err, secretValues, stdout, stderr)
case "configure":
defaults, err := parsePiConfigureArgs(args[1:])
if err != nil {
return commandUsageError(stderr, err.Error())
}
if err := pi.Configure(ctx, controlled, filepath.Join(installation.ProjectDirectory, ".thothctl", "pi-defaults.json"), defaults); err != nil {
return piFailure(stderr, err, secretValues)
}
fmt.Fprintln(stdout, "Pi defaults saved. Put credentials only in the configured PI_AUTH_FILE (mode 0600).")
return 0
case "update": case "update":
request, err := parsePiUpdateArgs(args[1:], filepath.Join(installation.ProjectDirectory, ".thothctl", "update-state.json")) request, err := parsePiUpdateArgs(args[1:], filepath.Join(installation.ProjectDirectory, ".thothctl", "update-state.json"))
if err != nil { if err != nil {
@@ -193,6 +204,33 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
} }
} }
func parsePiConfigureArgs(args []string) (pi.Defaults, error) {
var value pi.Defaults
for len(args) > 0 {
if len(args) < 2 {
return pi.Defaults{}, errors.New("configure options require values")
}
key, v := args[0], args[1]
args = args[2:]
switch key {
case "--provider":
value.Provider = v
case "--model":
value.Model = v
case "--thinking":
value.Thinking = v
case "--llm-url":
value.LLMURL = v
default:
return pi.Defaults{}, fmt.Errorf("unknown pi configure option %q", key)
}
}
if value.Provider == "" || value.Model == "" || value.Thinking == "" || value.LLMURL == "" {
return pi.Defaults{}, errors.New("pi configure requires --provider --model --thinking --llm-url")
}
return value, nil
}
func parsePiUpdateArgs(args []string, statePath string) (pi.Request, error) { func parsePiUpdateArgs(args []string, statePath string) (pi.Request, error) {
request := pi.Request{StatePath: statePath, Source: pi.BuildSource} request := pi.Request{StatePath: statePath, Source: pi.BuildSource}
for len(args) > 0 { for len(args) > 0 {
+83
View File
@@ -7,11 +7,94 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings" "strings"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose" "github.com/aritmolab/thothii/tools/thothctl/internal/compose"
) )
var choicePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
type Defaults struct {
Provider string `json:"provider"`
Model string `json:"model"`
Thinking string `json:"thinking"`
LLMURL string `json:"llm_url"`
}
// Configure validates and atomically stores only local non-secret Pi defaults.
func Configure(ctx context.Context, runner Runner, path string, value Defaults) error {
if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) {
return errors.New("provider and model must be supported identifiers")
}
if value.Thinking != "low" && value.Thinking != "medium" && value.Thinking != "high" {
return errors.New("thinking must be low, medium, or high")
}
u, err := url.Parse(value.LLMURL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return errors.New("LLM endpoint must be an http(s) URL without credentials, query, or fragment")
}
models, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/models")
if err != nil {
return commandError("Pi options check", models, err)
}
var payload struct {
Models []struct {
Provider string `json:"provider"`
ID string `json:"id"`
} `json:"models"`
}
if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 {
return errors.New("Pi options response is invalid or empty")
}
found := false
for _, model := range payload.Models {
if model.Provider == value.Provider && model.ID == value.Model {
found = true
}
}
if !found {
return errors.New("provider/model is not in Pi options")
}
return writeJSON(path, value)
}
func writeJSON(path string, value any) error {
contents, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
contents = append(contents, '\n')
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return errors.New("could not create Pi configuration directory")
}
temporary, err := os.CreateTemp(filepath.Dir(path), ".pi-defaults-*.tmp")
if err != nil {
return errors.New("could not write Pi configuration")
}
name := temporary.Name()
defer os.Remove(name)
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(contents)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Rename(name, path)
}
if err != nil {
return errors.New("could not atomically write Pi configuration")
}
return nil
}
// Runner is the narrow, shell-free command boundary shared with thothctl. // Runner is the narrow, shell-free command boundary shared with thothctl.
type Runner interface { type Runner interface {
Run(context.Context, []string, io.Reader) (compose.Result, error) Run(context.Context, []string, io.Reader) (compose.Result, error)
@@ -2,6 +2,7 @@ package pi
import ( import (
"context" "context"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -16,6 +17,20 @@ func TestDoctorRequiresExternalEndpointAuthPiStateAndHealth(t *testing.T) {
} }
} }
func TestConfigureValidatesBackendModelOptionsAndWritesNoSecrets(t *testing.T) {
fake := newFakeRunner()
path := filepath.Join(t.TempDir(), "pi-defaults.json")
if err := Configure(context.Background(), fake, path, Defaults{Provider: "provider", Model: "model", Thinking: "medium", LLMURL: "https://llm.example.invalid"}); err != nil {
t.Fatal(err)
}
if got := string(readStateBytes(t, path)); strings.Contains(got, "secret") || !strings.Contains(got, "llm.example.invalid") {
t.Fatalf("defaults=%q", got)
}
if err := Configure(context.Background(), fake, path, Defaults{Provider: "provider", Model: "unknown", Thinking: "medium", LLMURL: "https://llm.example.invalid"}); err == nil {
t.Fatal("expected unknown model rejection")
}
}
func TestTestUsesOnlySanitizedPiAndCoreProbes(t *testing.T) { func TestTestUsesOnlySanitizedPiAndCoreProbes(t *testing.T) {
fake := newFakeRunner() fake := newFakeRunner()
if err := Test(context.Background(), fake); err != nil { if err := Test(context.Background(), fake); err != nil {
+38
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"time" "time"
) )
@@ -30,9 +31,19 @@ type Image struct {
ID string `json:"id"` ID string `json:"id"`
Reference string `json:"reference"` Reference string `json:"reference"`
Volumes []string `json:"volumes"` Volumes []string `json:"volumes"`
Mounts []Mount `json:"mounts"`
ConfigurationSHA string `json:"configuration_sha256,omitempty"` ConfigurationSHA string `json:"configuration_sha256,omitempty"`
} }
// Mount is the complete persistence identity relevant to safe core recreation.
type Mount struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Source string `json:"source"`
Destination string `json:"destination"`
RW bool `json:"rw"`
}
// Target records the immutable input selected by the operator. Source is either build or a // Target records the immutable input selected by the operator. Source is either build or a
// digest-pinned image reference; it intentionally never contains credentials. // digest-pinned image reference; it intentionally never contains credentials.
type Target struct { type Target struct {
@@ -96,11 +107,38 @@ func writeState(path string, state State) error {
temporary.Close() temporary.Close()
return errors.New("could not write update recovery state") return errors.New("could not write update recovery state")
} }
if err := temporary.Sync(); err != nil {
temporary.Close()
return errors.New("could not durably write update recovery state")
}
if err := temporary.Close(); err != nil { if err := temporary.Close(); err != nil {
return errors.New("could not write update recovery state") return errors.New("could not write update recovery state")
} }
if err := os.Rename(temporaryName, path); err != nil { if err := os.Rename(temporaryName, path); err != nil {
return errors.New("could not finalize update recovery state") return errors.New("could not finalize update recovery state")
} }
if runtime.GOOS != "windows" {
if directoryHandle, err := os.Open(directory); err == nil {
_ = directoryHandle.Sync()
_ = directoryHandle.Close()
}
}
return nil return nil
} }
type updateLock struct{ path string }
func acquireLock(statePath string) (*updateLock, error) {
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
return nil, errors.New("could not create Pi update recovery directory")
}
path := statePath + ".lock"
if err := os.Mkdir(path, 0o700); err != nil {
if errors.Is(err, os.ErrExist) {
return nil, errors.New("another Pi update or rollback is already in progress; recovery lock retained")
}
return nil, errors.New("could not acquire Pi update lock")
}
return &updateLock{path: path}, nil
}
func (l *updateLock) Release() { _ = os.Remove(l.path) }
+93 -16
View File
@@ -9,6 +9,7 @@ import (
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"time"
) )
var ( var (
@@ -45,6 +46,11 @@ type Result struct {
// Update performs a recoverable core-only Pi update using the default Compose command layout. // Update performs a recoverable core-only Pi update using the default Compose command layout.
func Update(ctx context.Context, runner Runner, request Request) (Result, error) { func Update(ctx context.Context, runner Runner, request Request) (Result, error) {
lock, err := acquireLock(request.StatePath)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
defer lock.Release()
if request.StatePath == "" { if request.StatePath == "" {
return Result{}, errors.New("update state path is required") return Result{}, errors.New("update state path is required")
} }
@@ -73,13 +79,30 @@ func Update(ctx context.Context, runner Runner, request Request) (Result, error)
if err != nil { if err != nil {
return Result{StatePath: request.StatePath}, err return Result{StatePath: request.StatePath}, err
} }
frontendStopped := false
if running { if running {
if !request.Drain { if !request.Drain {
return Result{StatePath: request.StatePath}, ErrActiveSessions return Result{StatePath: request.StatePath}, ErrActiveSessions
} }
running, err = activeSessions(ctx, runner) stopped, stopErr := runCompose(ctx, runner, "stop", "frontend")
if err != nil { if stopErr != nil {
return Result{StatePath: request.StatePath}, err return Result{StatePath: request.StatePath}, commandError("frontend admission gate", stopped, stopErr)
}
frontendStopped = true
defer func() {
if frontendStopped {
_, _ = runCompose(context.Background(), runner, "up", "--detach", "frontend")
}
}()
for attempts := 0; attempts < 30; attempts++ {
running, err = activeSessions(ctx, runner)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
if !running {
break
}
time.Sleep(time.Second)
} }
if running { if running {
return Result{StatePath: request.StatePath}, ErrActiveSessions return Result{StatePath: request.StatePath}, ErrActiveSessions
@@ -119,6 +142,17 @@ func Update(ctx context.Context, runner Runner, request Request) (Result, error)
_ = writeState(request.StatePath, state) _ = writeState(request.StatePath, state)
return Result{Phase: PhaseFailed, StatePath: request.StatePath}, err return Result{Phase: PhaseFailed, StatePath: request.StatePath}, err
} }
if frontendStopped {
running, err = activeSessions(ctx, runner)
if err != nil {
return Result{Phase: PhaseFailed, StatePath: request.StatePath}, err
}
if running {
state.Phase, state.Error = PhaseFailed, "new session admitted while draining"
_ = writeState(request.StatePath, state)
return Result{Phase: PhaseFailed, StatePath: request.StatePath}, ErrActiveSessions
}
}
if err := recreateCore(ctx, runner); err != nil { if err := recreateCore(ctx, runner); err != nil {
state.Phase, state.Error = PhaseFailed, "core recreation failed" state.Phase, state.Error = PhaseFailed, "core recreation failed"
_ = writeState(request.StatePath, state) _ = writeState(request.StatePath, state)
@@ -129,7 +163,7 @@ func Update(ctx context.Context, runner Runner, request Request) (Result, error)
if err := writeState(request.StatePath, state); err != nil { if err := writeState(request.StatePath, state); err != nil {
return Result{Phase: PhaseRecreated, StatePath: request.StatePath}, err return Result{Phase: PhaseRecreated, StatePath: request.StatePath}, err
} }
if err := verifyCandidate(ctx, runner, request.Version, previous.Volumes, previous.ConfigurationSHA); err != nil { if err := verifyCandidate(ctx, runner, request.Version, previous); err != nil {
return rollbackAfterFailure(ctx, runner, request.StatePath, state, err) return rollbackAfterFailure(ctx, runner, request.StatePath, state, err)
} }
state.Phase, state.Error = PhaseVerified, "" state.Phase, state.Error = PhaseVerified, ""
@@ -141,6 +175,11 @@ func Update(ctx context.Context, runner Runner, request Request) (Result, error)
// Rollback restores the image recorded in durable update state. It is safe for interrupted runs. // Rollback restores the image recorded in durable update state. It is safe for interrupted runs.
func Rollback(ctx context.Context, runner Runner, statePath string, confirm bool) (Result, error) { func Rollback(ctx context.Context, runner Runner, statePath string, confirm bool) (Result, error) {
lock, err := acquireLock(statePath)
if err != nil {
return Result{StatePath: statePath}, err
}
defer lock.Release()
if !confirm { if !confirm {
return Result{StatePath: statePath}, ErrConfirmationRequired return Result{StatePath: statePath}, ErrConfirmationRequired
} }
@@ -208,15 +247,24 @@ func runningImage(ctx context.Context, runner Runner, reference string) (Image,
if err != nil || strings.TrimSpace(image.Stdout) == "" { if err != nil || strings.TrimSpace(image.Stdout) == "" {
return Image{}, commandError("running core image check", image, err) return Image{}, commandError("running core image check", image, err)
} }
mounts, err := runner.Run(ctx, []string{"inspect", "--format", "{{range .Mounts}}{{if eq .Type \"volume\"}}{{.Name}}{{\"\\n\"}}{{end}}{{end}}", id}, nil) mounts, err := runner.Run(ctx, []string{"inspect", "--format", "{{json .Mounts}}", id}, nil)
if err != nil { if err != nil {
return Image{}, commandError("core volume check", mounts, err) return Image{}, commandError("core volume check", mounts, err)
} }
volumes := nonEmptyLines(mounts.Stdout) var contract []Mount
if len(volumes) == 0 { if err := json.Unmarshal([]byte(mounts.Stdout), &contract); err != nil {
return Image{}, errors.New("core has no named volumes to preserve") return Image{}, errors.New("core returned invalid persistence mount data")
} }
return Image{ID: strings.TrimSpace(image.Stdout), Reference: reference, Volumes: volumes}, nil if len(contract) == 0 {
return Image{}, errors.New("core has no persistence mounts to preserve")
}
volumes := make([]string, 0, len(contract))
for _, mount := range contract {
if mount.Type == "volume" && mount.Name != "" {
volumes = append(volumes, mount.Name)
}
}
return Image{ID: strings.TrimSpace(image.Stdout), Reference: reference, Volumes: volumes, Mounts: contract}, nil
} }
func prepareCandidate(ctx context.Context, runner Runner, request Request, reference string) error { func prepareCandidate(ctx context.Context, runner Runner, request Request, reference string) error {
@@ -239,14 +287,14 @@ func prepareCandidate(ctx context.Context, runner Runner, request Request, refer
} }
func recreateCore(ctx context.Context, runner Runner) error { func recreateCore(ctx context.Context, runner Runner) error {
result, err := runCompose(ctx, runner, "up", "--detach", "--no-deps", "--force-recreate", "core") result, err := runCompose(ctx, runner, "up", "--detach", "--wait", "--wait-timeout", "45", "--no-deps", "--force-recreate", "core")
if err != nil { if err != nil {
return commandError("core recreation", result, err) return commandError("core recreation", result, err)
} }
return nil return nil
} }
func verifyCandidate(ctx context.Context, runner Runner, wanted string, previousVolumes []string, configurationSHA string) error { func verifyCandidate(ctx context.Context, runner Runner, wanted string, previous Image) error {
health, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health") health, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health")
if err != nil { if err != nil {
return commandError("core health check", health, err) return commandError("core health check", health, err)
@@ -265,15 +313,15 @@ func verifyCandidate(ctx context.Context, runner Runner, wanted string, previous
if err != nil { if err != nil {
return err return err
} }
if configured.ConfigurationSHA != configurationSHA { if configured.ConfigurationSHA != previous.ConfigurationSHA {
return errors.New("external endpoint configuration changed during Pi update") return errors.New("external endpoint configuration changed during Pi update")
} }
after, err := runningImage(ctx, runner, configured.Reference) after, err := runningImage(ctx, runner, configured.Reference)
if err != nil { if err != nil {
return err return err
} }
if !sameStrings(previousVolumes, after.Volumes) { if !sameMounts(previous.Mounts, after.Mounts) {
return errors.New("core volume set changed during Pi update") return errors.New("core persistence mount contract changed during Pi update")
} }
return nil return nil
} }
@@ -294,8 +342,20 @@ func restore(ctx context.Context, runner Runner, previous Image) error {
if err != nil { if err != nil {
return err return err
} }
if !sameStrings(previous.Volumes, after.Volumes) { if after.ID != previous.ID {
return errors.New("core volume set changed during rollback") return errors.New("rollback core image does not match recorded previous image")
}
if configured.ConfigurationSHA != previous.ConfigurationSHA {
return errors.New("external endpoint configuration drift prevents rollback proof")
}
if !sameMounts(previous.Mounts, after.Mounts) {
return errors.New("core persistence mount contract changed during rollback")
}
if err := Doctor(ctx, runner); err != nil {
return err
}
if err := Test(ctx, runner); err != nil {
return err
} }
return nil return nil
} }
@@ -316,3 +376,20 @@ func sameStrings(left, right []string) bool {
sort.Strings(right) sort.Strings(right)
return strings.Join(left, "\x00") == strings.Join(right, "\x00") return strings.Join(left, "\x00") == strings.Join(right, "\x00")
} }
func sameMounts(left, right []Mount) bool {
if len(left) != len(right) {
return false
}
key := func(m Mount) string {
return m.Type + "\x00" + m.Name + "\x00" + m.Source + "\x00" + m.Destination + "\x00" + fmt.Sprint(m.RW)
}
a, b := make([]string, len(left)), make([]string, len(right))
for i := range left {
a[i] = key(left[i])
}
for i := range right {
b[i] = key(right[i])
}
return sameStrings(a, b)
}
+34 -6
View File
@@ -28,7 +28,7 @@ func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *t
t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified) t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified)
} }
assertCalled(t, fake.calls, "compose build --pull --build-arg PI_VERSION=0.81.0 core") assertCalled(t, fake.calls, "compose build --pull --build-arg PI_VERSION=0.81.0 core")
assertCalled(t, fake.calls, "compose up --detach --no-deps --force-recreate core") assertCalled(t, fake.calls, "compose up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
assertNotCalled(t, fake.calls, "frontend") assertNotCalled(t, fake.calls, "frontend")
if got := string(readStateBytes(t, result.StatePath)); strings.Contains(got, "secret") || !strings.Contains(got, `"phase": "verified"`) { if got := string(readStateBytes(t, result.StatePath)); strings.Contains(got, "secret") || !strings.Contains(got, `"phase": "verified"`) {
t.Fatalf("state = %q, want credential-free verified metadata", got) t.Fatalf("state = %q, want credential-free verified metadata", got)
@@ -83,7 +83,7 @@ func TestUpdateRollsBackAfterPostRecreateFailures(t *testing.T) {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack) t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack)
} }
assertCalled(t, fake.calls, "image tag sha256:old thothii-core:local") assertCalled(t, fake.calls, "image tag sha256:old thothii-core:local")
assertCalled(t, fake.calls, "compose up --detach --no-deps --force-recreate core") assertCalled(t, fake.calls, "compose up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
if strings.Join(fake.volumes, ",") != "settings,pi-state,sessions,workspace-registry" { if strings.Join(fake.volumes, ",") != "settings,pi-state,sessions,workspace-registry" {
t.Fatalf("volumes changed: %v", fake.volumes) t.Fatalf("volumes changed: %v", fake.volumes)
} }
@@ -138,7 +138,16 @@ func TestUpdateRequiresConfirmationAndDrainsActiveSessions(t *testing.T) {
func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) { func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) {
fake := newFakeRunner() fake := newFakeRunner()
statePath := filepath.Join(t.TempDir(), "state.json") statePath := filepath.Join(t.TempDir(), "state.json")
writeStateForTest(t, statePath, State{Version: 1, Phase: PhaseRecreated, Previous: Image{ID: "sha256:old", Reference: "thothii-core:local", Volumes: []string{"settings", "pi-state", "sessions", "workspace-registry"}}}) configured, err := renderedCore(context.Background(), fake)
if err != nil {
t.Fatal(err)
}
previous, err := runningImage(context.Background(), fake, "thothii-core:local")
if err != nil {
t.Fatal(err)
}
previous.ConfigurationSHA = configured.ConfigurationSHA
writeStateForTest(t, statePath, State{Version: 1, Phase: PhaseRecreated, Previous: previous})
result, err := Rollback(context.Background(), fake, statePath, true) result, err := Rollback(context.Background(), fake, statePath, true)
if err != nil { if err != nil {
t.Fatalf("Rollback() error = %v", err) t.Fatalf("Rollback() error = %v", err)
@@ -147,7 +156,7 @@ func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack) t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack)
} }
assertCalled(t, fake.calls, "image tag sha256:old thothii-core:local") assertCalled(t, fake.calls, "image tag sha256:old thothii-core:local")
assertCalled(t, fake.calls, "compose up --detach --no-deps --force-recreate core") assertCalled(t, fake.calls, "compose up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
} }
func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) { func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) {
@@ -161,6 +170,18 @@ func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) {
assertNotCalled(t, fake.calls, "compose") assertNotCalled(t, fake.calls, "compose")
} }
func TestRunningImageCapturesServerBindAndNamedMountIdentity(t *testing.T) {
fake := newFakeRunner()
fake.mountsJSON = `[{"Type":"bind","Source":"/srv/thothii/data","Destination":"/data","RW":true},{"Type":"bind","Source":"/srv/thothii/pi","Destination":"/home/thoth/.pi","RW":true},{"Type":"volume","Name":"sessions","Source":"/var/lib/docker/volumes/sessions/_data","Destination":"/data/sessions","RW":true}]`
image, err := runningImage(context.Background(), fake, "thothii-core:local")
if err != nil {
t.Fatalf("runningImage() error = %v", err)
}
if len(image.Mounts) != 3 || image.Mounts[0].Type != "bind" || image.Mounts[0].Destination != "/data" {
t.Fatalf("mounts = %#v", image.Mounts)
}
}
type fakeRunner struct { type fakeRunner struct {
calls []string calls []string
fail string fail string
@@ -168,6 +189,7 @@ type fakeRunner struct {
activeSessions bool activeSessions bool
built bool built bool
volumes []string volumes []string
mountsJSON string
} }
func newFakeRunner() *fakeRunner { func newFakeRunner() *fakeRunner {
@@ -177,6 +199,9 @@ func newFakeRunner() *fakeRunner {
func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
call := strings.Join(args, " ") call := strings.Join(args, " ")
f.calls = append(f.calls, call) f.calls = append(f.calls, call)
if strings.Contains(call, "image tag sha256:old") {
f.fail = ""
}
if f.fail == "preflight" && strings.Contains(call, "config --format json") { if f.fail == "preflight" && strings.Contains(call, "config --format json") {
return compose.Result{ExitCode: 1}, errors.New("provider token=secret") return compose.Result{ExitCode: 1}, errors.New("provider token=secret")
} }
@@ -199,8 +224,11 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose
return compose.Result{Stdout: "core-container\n"}, nil return compose.Result{Stdout: "core-container\n"}, nil
case strings.Contains(call, "inspect --format {{.Image}}"): case strings.Contains(call, "inspect --format {{.Image}}"):
return compose.Result{Stdout: "sha256:old\n"}, nil return compose.Result{Stdout: "sha256:old\n"}, nil
case strings.Contains(call, "inspect --format {{range .Mounts}}"): case strings.Contains(call, "inspect --format {{json .Mounts}}"):
return compose.Result{Stdout: strings.Join(f.volumes, "\n") + "\n"}, nil if f.mountsJSON != "" {
return compose.Result{Stdout: f.mountsJSON}, nil
}
return compose.Result{Stdout: `[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/data/settings","RW":true},{"Type":"volume","Name":"pi-state","Source":"pi-state","Destination":"/home/thoth/.pi","RW":true},{"Type":"volume","Name":"sessions","Source":"sessions","Destination":"/data/sessions","RW":true},{"Type":"volume","Name":"workspace-registry","Source":"workspace-registry","Destination":"/data/workspace-registry","RW":true}]`}, nil
case strings.Contains(call, "tht session list --json"): case strings.Contains(call, "tht session list --json"):
if f.activeSessions { if f.activeSessions {
f.activeSessions = false f.activeSessions = false