package pi import ( "context" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "testing" "github.com/aritmolab/thothii/tools/thothctl/internal/compose" ) func TestActiveSessionsParsesAuthenticatedBackendBareArrayFixture(t *testing.T) { contents, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "backend", "test", "fixtures", "sessions-scope-all.json")) if err != nil { t.Fatal(err) } fake := newFakeRunner() fake.sessionsWire = string(contents) active, err := activeSessions(context.Background(), fake) if err != nil { t.Fatalf("activeSessions() error = %v", err) } if !active { t.Fatal("activeSessions() = false, want open session from backend wire fixture") } } func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) { fake := newFakeRunner() dir := t.TempDir() result, err := Update(context.Background(), fake, Request{ StatePath: filepath.Join(dir, ".thothctl", "update-state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true, }) if err != nil { t.Fatalf("Update() error = %v", err) } if result.Phase != PhaseVerified { t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified) } assertCalled(t, fake.calls, "build --pull --build-arg PI_VERSION=0.81.0 core") assertCalled(t, fake.calls, "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) } if got := string(readStateBytes(t, result.StatePath)); strings.Contains(got, "llm.example.invalid") { t.Fatalf("state = %q, want an endpoint-free configuration digest", got) } } func TestUpdateUsesATransactionScopedComposeOverrideWithoutMutatingTheConfiguredImage(t *testing.T) { fake := newFakeRunner() statePath := filepath.Join(t.TempDir(), ".thothctl", "update-state.json") if _, err := Update(context.Background(), fake, Request{StatePath: statePath, Version: "0.81.0", Source: BuildSource, Confirm: true}); err != nil { t.Fatal(err) } if fake.buildReference == "" || fake.buildReference == fake.configuredImage || !strings.Contains(fake.buildReference, "thothctl-") { t.Fatalf("build reference = %q, configured = %q; want unique lifecycle tag", fake.buildReference, fake.configuredImage) } assertNotCalled(t, fake.calls, "image tag sha256:old "+fake.configuredImage) if matches, err := filepath.Glob(filepath.Join(filepath.Dir(statePath), "pi-lifecycle-*.yaml")); err != nil || len(matches) != 0 { t.Fatalf("terminal lifecycle overrides = %v, error = %v; want none", matches, err) } } func TestTwoInstallationsSharingAConfiguredTagUseDifferentLifecycleTags(t *testing.T) { first, second := newFakeRunner(), newFakeRunner() for _, item := range []struct { fake *fakeRunner path string }{ {first, filepath.Join(t.TempDir(), "one", "state.json")}, {second, filepath.Join(t.TempDir(), "two", "state.json")}, } { if _, err := Update(context.Background(), item.fake, Request{StatePath: item.path, Version: "0.81.0", Source: BuildSource, Confirm: true}); err != nil { t.Fatal(err) } } if first.buildReference == second.buildReference { t.Fatalf("installations reused lifecycle tag %q", first.buildReference) } } func TestDigestPinnedConfiguredImageIsNeverUsedAsARollbackTagTarget(t *testing.T) { fake := newFakeRunner() fake.configuredImage = "registry.example.invalid/core@sha256:" + strings.Repeat("b", 64) fake.tags = map[string]string{fake.configuredImage: "sha256:old"} fake.fail = "health" _, _ = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true}) assertNotCalled(t, fake.calls, "image tag sha256:old "+fake.configuredImage) } func TestMaintenanceLostResponsesAreResolvedByStatusAndEveryRecreateStartsGated(t *testing.T) { for _, lost := range []string{"activate", "deactivate"} { t.Run(lost, func(t *testing.T) { fake := newFakeRunner() fake.lostMaintenanceResponse = lost if _, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true}); err != nil { t.Fatal(err) } assertCalled(t, fake.calls, "/internal/maintenance/status") for index, active := range fake.maintenanceAtRecreate { if !active { t.Fatalf("recreate %d started without durable maintenance", index+1) } } }) } } func TestCompensationReactivatesMaintenanceAndRescansBeforeRollback(t *testing.T) { fake := newFakeRunner() fake.fail = "version" fake.dropMaintenanceAfterCandidate = true _, _ = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true}) rollback := lastCallIndexBefore(fake.calls, "image tag sha256:old", len(fake.calls)) if rollback < 0 { t.Fatalf("calls %v contain no rollback", fake.calls) } recreate := callIndex(fake.calls, "force-recreate core") activate := lastCallIndexBefore(fake.calls, "/internal/maintenance/activate", rollback) scan := lastCallIndexBefore(fake.calls, "/sessions?scope=all", rollback) if activate <= recreate || scan <= recreate { t.Fatalf("calls %v do not reactivate/confirm and rescan after candidate recreate before rollback", fake.calls) } } func TestUpdatePullsOnlyDigestPinnedSource(t *testing.T) { fake := newFakeRunner() digest := "registry.example.invalid/thothii-core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" _, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: PullSource, Image: digest, Confirm: true}) if err != nil { t.Fatalf("Update() pull error = %v", err) } assertCalled(t, fake.calls, "pull "+digest) assertCalled(t, fake.calls, "image tag "+digest+" thothii-core:thothctl-") assertNotCalled(t, fake.calls, "image tag "+digest+" thothii-core:local") fake = newFakeRunner() _, err = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: PullSource, Image: "registry.example.invalid/thothii-core:latest", Confirm: true}) if err == nil || !strings.Contains(err.Error(), "immutable sha256 digest") { t.Fatalf("Update() error = %v, want digest-pinning rejection", err) } assertNotCalled(t, fake.calls, "compose") } func TestUpdateIsNoOpWhenDesiredVersionAlreadyRuns(t *testing.T) { fake := newFakeRunner() fake.version = "0.80.3" result, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.80.3", Source: BuildSource, Confirm: true}) if err != nil { t.Fatalf("Update() error = %v", err) } if result.Phase != PhaseNoop { t.Fatalf("phase = %q, want %q", result.Phase, PhaseNoop) } assertNotCalled(t, fake.calls, "compose build") } func TestUpdateRollsBackAfterPostRecreateFailures(t *testing.T) { for _, failure := range []string{"recreate", "health", "version", "smoke", "config-drift", "mount-drift"} { t.Run(failure, func(t *testing.T) { fake := newFakeRunner() fake.fail = failure statePath := filepath.Join(t.TempDir(), "state.json") result, err := Update(context.Background(), fake, Request{StatePath: statePath, Version: "0.81.0", Source: BuildSource, Confirm: true}) if err == nil { t.Fatal("Update() error = nil, want verification failure") } if result.Phase != PhaseRolledBack { t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack) } assertCalled(t, fake.calls, "image tag sha256:old thothii-core:thothctl-") assertNotCalled(t, fake.calls, "image tag sha256:old thothii-core:local") assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core") if got := string(readStateBytes(t, statePath)); !strings.Contains(got, `"phase": "rolled_back"`) { t.Fatalf("state = %q, want rollback metadata", got) } }) } } func TestEveryRecoveryStateWriteFailureIsHandledTransactionally(t *testing.T) { for failAt := 1; failAt <= 4; failAt++ { t.Run(fmt.Sprintf("write-%d", failAt), func(t *testing.T) { fake := newFakeRunner() writes := 0 hooks := defaultLifecycleHooks hooks.writeState = func(path string, state State) error { writes++ if writes == failAt { return errors.New("injected state write failure") } return writeState(path, state) } result, err := updateWithHooks(context.Background(), fake, Request{ StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true, }, hooks) if err == nil { t.Fatal("updateWithHooks() error = nil, want injected state failure") } if fake.currentImage != "sha256:old" { t.Fatalf("current image = %q, want restored previous", fake.currentImage) } if failAt > 1 && result.Phase != PhaseRolledBack { t.Fatalf("phase = %q, want rolled_back", result.Phase) } if fake.maintenance { t.Fatal("maintenance remained active after proven stable recovery") } }) } } func TestCompensationWriteFailureKeepsMaintenanceActiveForExplicitRecovery(t *testing.T) { fake := newFakeRunner() fake.fail = "health" writes := 0 hooks := defaultLifecycleHooks hooks.writeState = func(path string, state State) error { writes++ if writes == 4 { return errors.New("injected compensation state write failure") } return writeState(path, state) } result, err := updateWithHooks(context.Background(), fake, Request{ StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true, }, hooks) if err == nil || result.Phase != PhaseFailed { t.Fatalf("result=%+v error=%v, want failed recovery", result, err) } if !fake.maintenance { t.Fatal("maintenance was cleared without durable rollback state") } } func TestMaintenanceClearAndCompensationFailuresRemainGated(t *testing.T) { for _, failure := range []string{"maintenance-clear", "compensation"} { t.Run(failure, func(t *testing.T) { fake := newFakeRunner() fake.fail = failure result, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true}) if err == nil || result.Phase != PhaseFailed { t.Fatalf("result=%+v error=%v", result, err) } if !fake.maintenance { t.Fatal("maintenance was cleared after an unverified terminal failure") } }) } } func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testing.T) { fake := newFakeRunner() fake.maintenance = true statePath := filepath.Join(t.TempDir(), "state.json") previous := stateImageForTest(t, fake) state := State{Transaction: "recover-test", Phase: PhaseVerified, Previous: previous} writeStateForTest(t, statePath, state) overridePath := lifecycleOverridePath(statePath, state.Transaction) if err := writeLifecycleOverride(overridePath, previous.Reference); err != nil { t.Fatal(err) } if err := RecoverMaintenance(context.Background(), fake, statePath, true); err != nil { t.Fatalf("RecoverMaintenance() error = %v", err) } if fake.maintenance { t.Fatal("maintenance remained active after verified terminal recovery") } if _, err := os.Stat(overridePath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("lifecycle override still exists: %v", err) } assertCalled(t, fake.calls, "/models") assertCalled(t, fake.calls, "/settings") } func TestRecoverMaintenanceRefusesPendingTransaction(t *testing.T) { fake := newFakeRunner() fake.maintenance = true statePath := filepath.Join(t.TempDir(), "state.json") writeStateForTest(t, statePath, State{Phase: PhaseRecreated, Previous: stateImageForTest(t, fake)}) err := RecoverMaintenance(context.Background(), fake, statePath, true) if !errors.Is(err, ErrInterruptedUpdate) { t.Fatalf("RecoverMaintenance() error = %v, want ErrInterruptedUpdate", err) } if !fake.maintenance { t.Fatal("pending transaction maintenance was cleared") } } func TestRollbackFinalStateWriteFailureKeepsMaintenanceAndOverrideForRecovery(t *testing.T) { fake := newFakeRunner() statePath := filepath.Join(t.TempDir(), "state.json") previous := stateImageForTest(t, fake) previous.Reference = "thothii-core:thothctl-rollback-test-previous" fake.tags[previous.Reference] = previous.ID writeStateForTest(t, statePath, State{ Transaction: "rollback-test", Phase: PhaseRecreated, Previous: previous, }) hooks := defaultLifecycleHooks hooks.writeState = func(string, State) error { return errors.New("injected rollback state write failure") } result, err := rollbackWithHooks(context.Background(), fake, statePath, true, hooks) if err == nil || result.Phase != PhaseFailed { t.Fatalf("rollbackWithHooks() = %+v, %v; want failed durable finalization", result, err) } if !fake.maintenance { t.Fatal("maintenance was cleared without durable rollback finalization") } if _, err := os.Stat(lifecycleOverridePath(statePath, "rollback-test")); err != nil { t.Fatalf("recovery override was not preserved: %v", err) } } func TestUpdateDoesNotRecreateWhenPreflightOrBuildFails(t *testing.T) { for _, failure := range []string{"preflight", "build"} { t.Run(failure, func(t *testing.T) { fake := newFakeRunner() fake.fail = failure result, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true}) if err == nil { t.Fatal("Update() error = nil, want failure") } if failure == "preflight" && result.Phase == PhaseRolledBack { t.Fatalf("preflight failure unexpectedly rolled back: %+v", result) } if failure == "build" && result.Phase != PhaseRolledBack { t.Fatalf("candidate build failure must compensate: %+v", result) } if failure == "preflight" { assertNotCalled(t, fake.calls, "force-recreate") } }) } } func TestUpdateRequiresConfirmationAndDrainsActiveSessions(t *testing.T) { fake := newFakeRunner() _, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource}) if !errors.Is(err, ErrConfirmationRequired) { t.Fatalf("Update() error = %v, want confirmation error", err) } assertNotCalled(t, fake.calls, "compose") fake = newFakeRunner() fake.activeSessions = true _, err = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true}) if !errors.Is(err, ErrActiveSessions) { t.Fatalf("Update() error = %v, want active-sessions error", err) } fake = newFakeRunner() fake.activeSessions = true _, err = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true, Drain: true}) if err != nil { t.Fatalf("Update() with drain error = %v", err) } assertCalled(t, fake.calls, "http://127.0.0.1:8787/sessions?scope=all") assertCalled(t, fake.calls, "/internal/maintenance/activate") assertCalled(t, fake.calls, "/internal/maintenance/deactivate") } func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) { fake := newFakeRunner() statePath := filepath.Join(t.TempDir(), "state.json") 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 previous.Reference = "thothii-core:thothctl-test-previous" fake.tags[previous.Reference] = previous.ID 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) } if result.Phase != PhaseRolledBack { t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack) } assertCalled(t, fake.calls, "image tag sha256:old thothii-core:thothctl-test-previous") assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core") } func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) { fake := newFakeRunner() statePath := filepath.Join(t.TempDir(), "state.json") writeStateForTest(t, statePath, State{Phase: PhaseRecreated, Previous: Image{ID: "sha256:old", Reference: "thothii-core:local", MountFingerprint: mountFingerprint(nil)}}) _, err := Update(context.Background(), fake, Request{StatePath: statePath, Version: "0.81.0", Source: BuildSource, Confirm: true}) if !errors.Is(err, ErrInterruptedUpdate) { t.Fatalf("Update() error = %v, want interrupted update error", err) } 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) } if strings.Contains(fmt.Sprint(image), "/srv/thothii") || image.Mounts[0].SourceSHA256 == "" || image.MountFingerprint == "" { t.Fatalf("mount contract leaked a server source or lacks a safe fingerprint: %#v", image) } } func TestCanonicalDigestReferenceRejectsCredentialsAndURLForms(t *testing.T) { valid := "registry.example.invalid/thothii-core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" if got, err := canonicalDigestReference(valid); err != nil || got != valid { t.Fatalf("canonicalDigestReference() = %q, %v", got, err) } for _, invalid := range []string{"https://registry.example.invalid/a@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "user:pass@registry.example/a@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "registry.example/a@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?token=x"} { if _, err := canonicalDigestReference(invalid); err == nil { t.Fatalf("accepted unsafe reference %q", invalid) } } } type fakeRunner struct { calls []string fail string version string activeSessions bool built bool currentImage string mountsJSON string sessionsWire string configuredImage string buildReference string tags map[string]string imageVersions map[string]string maintenance bool maintenanceAtRecreate []bool lostMaintenanceResponse string dropMaintenanceAfterCandidate bool modelsWire string rollbackPrepared bool } func newFakeRunner() *fakeRunner { return &fakeRunner{ version: "0.80.3", currentImage: "sha256:old", configuredImage: "thothii-core:local", tags: map[string]string{"thothii-core:local": "sha256:old"}, imageVersions: map[string]string{"sha256:old": "0.80.3"}, } } func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { call := strings.Join(args, " ") f.calls = append(f.calls, call) if f.built && f.fail != "compensation" && 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") } if f.fail == "build" && containsArg(args, "build") { return compose.Result{ExitCode: 1}, errors.New("build token=secret") } if f.fail == "health" && f.built && strings.Contains(call, "curl -fsS http://127.0.0.1:8787/health") { return compose.Result{ExitCode: 1}, errors.New("health token=secret") } if f.fail == "compensation" && f.built && !f.rollbackPrepared && strings.Contains(call, "curl -fsS http://127.0.0.1:8787/health") { return compose.Result{ExitCode: 1}, errors.New("candidate health failure") } if f.fail == "version" && f.built && strings.Contains(call, "pi --version") && strings.Contains(call, "exec") { return compose.Result{ExitCode: 1}, errors.New("version token=secret") } if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/models") { return compose.Result{ExitCode: 1}, errors.New("smoke token=secret") } switch { case strings.Contains(call, "config --format json"): endpoint := "https://llm.example.invalid" if f.fail == "config-drift" && f.currentImage == "sha256:candidate" { endpoint = "https://drift.example.invalid" } return compose.Result{Stdout: `{"services":{"core":{"image":"` + selectedCoreReference(args, f.configuredImage) + `","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil case strings.Contains(call, "ps -q core"): return compose.Result{Stdout: "core-container\n"}, nil case strings.Contains(call, "inspect --format {{.Image}}"): return compose.Result{Stdout: f.currentImage + "\n"}, nil case strings.Contains(call, "inspect --format {{json .Mounts}}"): if f.fail == "mount-drift" && f.currentImage == "sha256:candidate" { return compose.Result{Stdout: `[{"Type":"volume","Name":"wrong-settings","Source":"wrong-settings","Destination":"/data/settings","RW":true}]`}, 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, "/internal/maintenance/activate"): f.maintenance = true if f.lostMaintenanceResponse == "activate" { f.lostMaintenanceResponse = "" return compose.Result{ExitCode: 52}, errors.New("lost activation response") } return compose.Result{Stdout: `{"active":true,"admissions":0}`}, nil case strings.Contains(call, "/internal/maintenance/deactivate"): if f.fail == "maintenance-clear" { return compose.Result{ExitCode: 53}, errors.New("maintenance clear failure") } f.maintenance = false if f.lostMaintenanceResponse == "deactivate" { f.lostMaintenanceResponse = "" return compose.Result{ExitCode: 52}, errors.New("lost deactivation response") } return compose.Result{Stdout: `{"active":false,"admissions":0}`}, nil case strings.Contains(call, "/internal/maintenance/status"): return compose.Result{Stdout: fmt.Sprintf(`{"active":%t,"admissions":0}`, f.maintenance)}, nil case strings.Contains(call, "/sessions?scope=all"): if f.sessionsWire != "" { return compose.Result{Stdout: f.sessionsWire}, nil } if f.activeSessions { f.activeSessions = false return compose.Result{Stdout: `[{"status":"open","archived":false}]`}, nil } return compose.Result{Stdout: `[]`}, nil case containsArg(args, "build"): f.built = true f.buildReference = selectedCoreReference(args, f.configuredImage) f.tags[f.buildReference] = "sha256:candidate" f.imageVersions["sha256:candidate"] = "0.81.0" return compose.Result{}, nil case len(args) == 2 && args[0] == "pull": f.tags[args[1]] = "sha256:candidate" f.imageVersions["sha256:candidate"] = "0.81.0" return compose.Result{}, nil case len(args) >= 4 && args[0] == "image" && args[1] == "tag": source, target := args[2], args[3] id := source if tagged, ok := f.tags[source]; ok { id = tagged } f.tags[target] = id if f.built && id == "sha256:old" { f.rollbackPrepared = true } return compose.Result{}, nil case containsArg(args, "up"): f.maintenanceAtRecreate = append(f.maintenanceAtRecreate, f.maintenance) reference := selectedCoreReference(args, f.configuredImage) if id, ok := f.tags[reference]; ok { f.currentImage = id } if version, ok := f.imageVersions[f.currentImage]; ok { f.version = version } if f.dropMaintenanceAfterCandidate && f.currentImage == "sha256:candidate" { f.maintenance = false f.dropMaintenanceAfterCandidate = false } if f.fail == "recreate" && f.currentImage == "sha256:candidate" { return compose.Result{ExitCode: 54}, errors.New("recreate failure") } if f.fail == "compensation" && f.rollbackPrepared { return compose.Result{ExitCode: 55}, errors.New("rollback recreate failure") } return compose.Result{}, nil case strings.Contains(call, "pi --version"): return compose.Result{Stdout: f.version + "\n"}, nil case strings.Contains(call, "/models"): if f.modelsWire != "" { return compose.Result{Stdout: f.modelsWire}, nil } return compose.Result{Stdout: `{"models":[{"id":"model","provider":"provider"}]}`}, nil case strings.Contains(call, "/settings"): return compose.Result{Stdout: `{"provider":"provider","model":"model","thinking":"medium"}`}, nil case strings.Contains(call, "/health"): return compose.Result{Stdout: `{"status":"ok"}`}, nil } return compose.Result{}, nil } func containsArg(args []string, wanted string) bool { for _, arg := range args { if arg == wanted { return true } } return false } func selectedCoreReference(args []string, fallback string) string { for index := 0; index+1 < len(args); index++ { if args[index] != "-f" || !strings.Contains(filepath.Base(args[index+1]), "pi-lifecycle-") { continue } contents, err := os.ReadFile(args[index+1]) if err != nil { continue } for _, line := range strings.Split(string(contents), "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "image:") { continue } value := strings.TrimSpace(strings.TrimPrefix(line, "image:")) if decoded, err := strconv.Unquote(value); err == nil { return decoded } return value } } return fallback } func callIndex(calls []string, contains string) int { for index, call := range calls { if strings.Contains(call, contains) { return index } } return -1 } func lastCallIndexBefore(calls []string, contains string, before int) int { for index := before - 1; index >= 0; index-- { if strings.Contains(calls[index], contains) { return index } } return -1 } func assertCalled(t *testing.T, calls []string, want string) { t.Helper() for _, call := range calls { if strings.Contains(call, want) { return } } t.Fatalf("calls %v did not include %q", calls, want) } func assertNotCalled(t *testing.T, calls []string, prohibited string) { t.Helper() for _, call := range calls { if strings.Contains(call, prohibited) { t.Fatalf("calls %v unexpectedly included %q", calls, prohibited) } } } func readStateBytes(t *testing.T, path string) []byte { t.Helper() contents, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return contents } func writeStateForTest(t *testing.T, path string, state State) { t.Helper() if err := writeState(path, state); err != nil { t.Fatal(err) } } func stateImageForTest(t *testing.T, fake *fakeRunner) Image { t.Helper() configured, err := renderedCore(context.Background(), fake) if err != nil { t.Fatal(err) } image, err := runningImage(context.Background(), fake, fake.configuredImage) if err != nil { t.Fatal(err) } image.ConfigurationSHA = configured.ConfigurationSHA image.Reference = "thothii-core:thothctl-test-previous" return image }