From 8fde1f81c71b5e4b75c982d7fb8d93b74b429a8f Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 4 Aug 2026 18:40:50 +0200 Subject: [PATCH] fix: harden thothctl pi lifecycle --- .dockerignore | 1 + .gitignore | 1 + tools/thothctl/cmd/thothctl/main.go | 48 ++++++++- tools/thothctl/internal/pi/commands.go | 83 +++++++++++++++ tools/thothctl/internal/pi/commands_test.go | 15 +++ tools/thothctl/internal/pi/state.go | 38 +++++++ tools/thothctl/internal/pi/update.go | 109 +++++++++++++++++--- tools/thothctl/internal/pi/update_test.go | 40 +++++-- 8 files changed, 308 insertions(+), 27 deletions(-) diff --git a/.dockerignore b/.dockerignore index 73fefc6e..fc356502 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ **/*.pyc .git .worktrees +.thothctl .gitignore **/.env **/.env.* diff --git a/.gitignore b/.gitignore index 93f6fa1e..355fc9a6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Thoth/ # === Visual companion brainstorming artifacts (local-only) === .superpowers/ .worktrees/ +.thothctl/ # === Python === __pycache__/ diff --git a/tools/thothctl/cmd/thothctl/main.go b/tools/thothctl/cmd/thothctl/main.go index cba2d972..3e5ed0fc 100644 --- a/tools/thothctl/cmd/thothctl/main.go +++ b/tools/thothctl/cmd/thothctl/main.go @@ -30,9 +30,11 @@ Commands: 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. + 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 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() { @@ -156,13 +158,22 @@ func piCommand(ctx context.Context, installation config.Installation, runner com fmt.Fprintln(stdout, "Pi/core smoke checks passed.") return 0 case "logs": - logArgs, err := logsArgs(args[1:]) - if err != nil { - return commandUsageError(stderr, "pi logs accepts only --follow") + if len(args) != 1 { + return commandUsageError(stderr, "pi logs does not support --follow; use bounded snapshots") } - logArgs = append(logArgs, "core") + logArgs := []string{"logs", "--tail", "200", "core"} result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil) 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": request, err := parsePiUpdateArgs(args[1:], filepath.Join(installation.ProjectDirectory, ".thothctl", "update-state.json")) 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) { request := pi.Request{StatePath: statePath, Source: pi.BuildSource} for len(args) > 0 { diff --git a/tools/thothctl/internal/pi/commands.go b/tools/thothctl/internal/pi/commands.go index b98a5667..9f5b0a98 100644 --- a/tools/thothctl/internal/pi/commands.go +++ b/tools/thothctl/internal/pi/commands.go @@ -7,11 +7,94 @@ import ( "errors" "fmt" "io" + "net/url" + "os" + "path/filepath" + "regexp" "strings" "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. type Runner interface { Run(context.Context, []string, io.Reader) (compose.Result, error) diff --git a/tools/thothctl/internal/pi/commands_test.go b/tools/thothctl/internal/pi/commands_test.go index 98890185..90e7ef04 100644 --- a/tools/thothctl/internal/pi/commands_test.go +++ b/tools/thothctl/internal/pi/commands_test.go @@ -2,6 +2,7 @@ package pi import ( "context" + "path/filepath" "strings" "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) { fake := newFakeRunner() if err := Test(context.Background(), fake); err != nil { diff --git a/tools/thothctl/internal/pi/state.go b/tools/thothctl/internal/pi/state.go index d09abc22..7931c702 100644 --- a/tools/thothctl/internal/pi/state.go +++ b/tools/thothctl/internal/pi/state.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "time" ) @@ -30,9 +31,19 @@ type Image struct { ID string `json:"id"` Reference string `json:"reference"` Volumes []string `json:"volumes"` + Mounts []Mount `json:"mounts"` 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 // digest-pinned image reference; it intentionally never contains credentials. type Target struct { @@ -96,11 +107,38 @@ func writeState(path string, state State) error { temporary.Close() 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 { return errors.New("could not write update recovery state") } if err := os.Rename(temporaryName, path); err != nil { 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 } + +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) } diff --git a/tools/thothctl/internal/pi/update.go b/tools/thothctl/internal/pi/update.go index 3300fca8..42b293c6 100644 --- a/tools/thothctl/internal/pi/update.go +++ b/tools/thothctl/internal/pi/update.go @@ -9,6 +9,7 @@ import ( "regexp" "sort" "strings" + "time" ) var ( @@ -45,6 +46,11 @@ type Result struct { // 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) { + lock, err := acquireLock(request.StatePath) + if err != nil { + return Result{StatePath: request.StatePath}, err + } + defer lock.Release() if request.StatePath == "" { 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 { return Result{StatePath: request.StatePath}, err } + frontendStopped := false if running { if !request.Drain { return Result{StatePath: request.StatePath}, ErrActiveSessions } - running, err = activeSessions(ctx, runner) - if err != nil { - return Result{StatePath: request.StatePath}, err + stopped, stopErr := runCompose(ctx, runner, "stop", "frontend") + if stopErr != nil { + 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 { 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) 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 { state.Phase, state.Error = PhaseFailed, "core recreation failed" _ = 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 { 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) } 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. 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 { 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) == "" { 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 { return Image{}, commandError("core volume check", mounts, err) } - volumes := nonEmptyLines(mounts.Stdout) - if len(volumes) == 0 { - return Image{}, errors.New("core has no named volumes to preserve") + var contract []Mount + if err := json.Unmarshal([]byte(mounts.Stdout), &contract); err != nil { + 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 { @@ -239,14 +287,14 @@ func prepareCandidate(ctx context.Context, runner Runner, request Request, refer } 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 { return commandError("core recreation", result, err) } 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") if err != nil { return commandError("core health check", health, err) @@ -265,15 +313,15 @@ func verifyCandidate(ctx context.Context, runner Runner, wanted string, previous if err != nil { return err } - if configured.ConfigurationSHA != configurationSHA { + if configured.ConfigurationSHA != previous.ConfigurationSHA { return errors.New("external endpoint configuration changed during Pi update") } after, err := runningImage(ctx, runner, configured.Reference) if err != nil { return err } - if !sameStrings(previousVolumes, after.Volumes) { - return errors.New("core volume set changed during Pi update") + if !sameMounts(previous.Mounts, after.Mounts) { + return errors.New("core persistence mount contract changed during Pi update") } return nil } @@ -294,8 +342,20 @@ func restore(ctx context.Context, runner Runner, previous Image) error { if err != nil { return err } - if !sameStrings(previous.Volumes, after.Volumes) { - return errors.New("core volume set changed during rollback") + if after.ID != previous.ID { + 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 } @@ -316,3 +376,20 @@ func sameStrings(left, right []string) bool { sort.Strings(right) 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) +} diff --git a/tools/thothctl/internal/pi/update_test.go b/tools/thothctl/internal/pi/update_test.go index 5ed917bd..054b1ee0 100644 --- a/tools/thothctl/internal/pi/update_test.go +++ b/tools/thothctl/internal/pi/update_test.go @@ -28,7 +28,7 @@ func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *t 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 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") 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) @@ -83,7 +83,7 @@ func TestUpdateRollsBackAfterPostRecreateFailures(t *testing.T) { t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack) } 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" { t.Fatalf("volumes changed: %v", fake.volumes) } @@ -138,7 +138,16 @@ func TestUpdateRequiresConfirmationAndDrainsActiveSessions(t *testing.T) { func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) { fake := newFakeRunner() 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) if err != nil { t.Fatalf("Rollback() error = %v", err) @@ -147,7 +156,7 @@ func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) { t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack) } 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) { @@ -161,6 +170,18 @@ func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) { 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 { calls []string fail string @@ -168,6 +189,7 @@ type fakeRunner struct { activeSessions bool built bool volumes []string + mountsJSON string } func newFakeRunner() *fakeRunner { @@ -177,6 +199,9 @@ func newFakeRunner() *fakeRunner { func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { call := strings.Join(args, " ") 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") { 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 case strings.Contains(call, "inspect --format {{.Image}}"): return compose.Result{Stdout: "sha256:old\n"}, nil - case strings.Contains(call, "inspect --format {{range .Mounts}}"): - return compose.Result{Stdout: strings.Join(f.volumes, "\n") + "\n"}, nil + case strings.Contains(call, "inspect --format {{json .Mounts}}"): + 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"): if f.activeSessions { f.activeSessions = false