diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7b7d4fce..33e661ea 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -71,12 +71,13 @@ harness pytest passed **827 passed / 4 deselected** with the existing 74 warnings; touched Python files are Ruff-clean. The complete `thothctl` Go suite, deterministic backup/restore safety test, internal semantic Compose contract, no-deployment-coupling gate, CPU/offline semantic smoke, and - unified deployment smoke all pass on `fb95be3`. The intermittent `thothctl` rollback failure was + unified deployment smoke all pass after the final fix. The intermittent `thothctl` rollback failure was traced to Docker Desktop alternating equivalent bind sources between `/private/...` and - `/host_mnt/private/...`; the persistence fingerprint now normalizes only the known macOS Docker - Desktop aliases while retaining distinct Linux `/host_mnt` paths. The rollback-only smoke passed - twice consecutively after the fix, and the subsequent full unified smoke passed with exact - cleanup. + `/host_mnt/private/...`. Exact state-v4 source hashes remain unchanged; only fresh bind + observations made by a Darwin `thothctl` carry non-serialized aliases for the rollback + comparison, so pre-fix recovery state remains readable and Linux `/host_mnt` paths remain + distinct. The rollback-only smoke passed twice consecutively after each fix revision, and the + subsequent full unified smoke passed with exact cleanup. ## Historical snapshots and archived reference notes diff --git a/tools/thothctl/internal/pi/state.go b/tools/thothctl/internal/pi/state.go index 52549700..20759ab4 100644 --- a/tools/thothctl/internal/pi/state.go +++ b/tools/thothctl/internal/pi/state.go @@ -42,12 +42,13 @@ type Image struct { // Mount is the complete persistence identity relevant to safe core recreation. type Mount struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - SourceSHA256 string `json:"source_sha256"` - Destination string `json:"destination"` - RW bool `json:"rw"` - Options string `json:"options,omitempty"` + Type string `json:"type"` + Name string `json:"name,omitempty"` + SourceSHA256 string `json:"source_sha256"` + SourceAliases []string `json:"-"` + Destination string `json:"destination"` + RW bool `json:"rw"` + Options string `json:"options,omitempty"` } // Target records the immutable input selected by the operator. Source is either build or a @@ -136,17 +137,27 @@ func writeFileDurably(path, prefix string, contents []byte) error { } func mountSourceHash(source string) string { - source = filepath.Clean(source) - for _, dockerDesktopPrefix := range []string{"/host_mnt/private/var/", "/host_mnt/Users/"} { - if strings.HasPrefix(source, dockerDesktopPrefix) { - source = strings.TrimPrefix(source, "/host_mnt") - break - } - } sum := sha256.Sum256([]byte(source)) return fmt.Sprintf("%x", sum[:]) } +func mountSourceAliases(mountType, source, goos string) []string { + if mountType != "bind" || goos != "darwin" { + return nil + } + source = filepath.Clean(source) + var alias string + switch { + case strings.HasPrefix(source, "/host_mnt/private/var/"), strings.HasPrefix(source, "/host_mnt/Users/"): + alias = strings.TrimPrefix(source, "/host_mnt") + case strings.HasPrefix(source, "/private/var/"), strings.HasPrefix(source, "/Users/"): + alias = "/host_mnt" + source + default: + return nil + } + return []string{mountSourceHash(alias)} +} + func mountFingerprint(mounts []Mount) string { values := make([]string, len(mounts)) for i, mount := range mounts { diff --git a/tools/thothctl/internal/pi/update.go b/tools/thothctl/internal/pi/update.go index fd8f004d..01515532 100644 --- a/tools/thothctl/internal/pi/update.go +++ b/tools/thothctl/internal/pi/update.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "sort" "strings" "time" @@ -588,7 +589,7 @@ func runningImage(ctx context.Context, runner Runner, reference string) (Image, if mount.Type == "" || mount.Source == "" || mount.Destination == "" { return Image{}, errors.New("core returned incomplete persistence mount data") } - contract = append(contract, Mount{Type: mount.Type, Name: mount.Name, SourceSHA256: mountSourceHash(mount.Source), Destination: mount.Destination, RW: mount.RW, Options: strings.Join([]string{mount.Mode, mount.Propagation, mount.Driver}, "\x00")}) + contract = append(contract, Mount{Type: mount.Type, Name: mount.Name, SourceSHA256: mountSourceHash(mount.Source), SourceAliases: mountSourceAliases(mount.Type, mount.Source, runtime.GOOS), Destination: mount.Destination, RW: mount.RW, Options: strings.Join([]string{mount.Mode, mount.Propagation, mount.Driver}, "\x00")}) } return Image{ID: strings.TrimSpace(image.Stdout), Reference: reference, Mounts: contract, MountFingerprint: mountFingerprint(contract)}, nil } @@ -896,15 +897,38 @@ 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.SourceSHA256 + "\x00" + m.Destination + "\x00" + fmt.Sprint(m.RW) + "\x00" + m.Options + identityWithoutSource := func(m Mount) string { + return m.Type + "\x00" + m.Name + "\x00" + m.Destination + "\x00" + fmt.Sprint(m.RW) + "\x00" + m.Options } - a, b := make([]string, len(left)), make([]string, len(right)) - for i := range left { - a[i] = key(left[i]) + sourceMatches := func(a, b Mount) bool { + if a.SourceSHA256 == b.SourceSHA256 { + return true + } + for _, alias := range a.SourceAliases { + if alias == b.SourceSHA256 { + return true + } + } + for _, alias := range b.SourceAliases { + if alias == a.SourceSHA256 { + return true + } + } + return false } - for i := range right { - b[i] = key(right[i]) + matched := make([]bool, len(right)) + for _, candidate := range left { + found := false + for index, observed := range right { + if matched[index] || identityWithoutSource(candidate) != identityWithoutSource(observed) || !sourceMatches(candidate, observed) { + continue + } + matched[index], found = true, true + break + } + if !found { + return false + } } - return sameStrings(a, b) + return true } diff --git a/tools/thothctl/internal/pi/update_test.go b/tools/thothctl/internal/pi/update_test.go index dcd04e64..2e36d434 100644 --- a/tools/thothctl/internal/pi/update_test.go +++ b/tools/thothctl/internal/pi/update_test.go @@ -802,22 +802,26 @@ func TestRunningImageCapturesServerBindAndNamedMountIdentity(t *testing.T) { } } -func TestDockerDesktopBindAliasesKeepOnePersistenceIdentity(t *testing.T) { - for _, paths := range [][2]string{ - {"/private/var/folders/task/models.json", "/host_mnt/private/var/folders/task/models.json"}, - {"/Users/operator/thoth/models.json", "/host_mnt/Users/operator/thoth/models.json"}, - } { - left := Mount{Type: "bind", SourceSHA256: mountSourceHash(paths[0]), Destination: "/config/models.json"} - right := Mount{Type: "bind", SourceSHA256: mountSourceHash(paths[1]), Destination: "/config/models.json"} - if !sameMounts([]Mount{left}, []Mount{right}) { - t.Fatalf("Docker Desktop aliases were treated as different mounts: %q and %q", paths[0], paths[1]) - } +func TestDockerDesktopBindAliasesPreserveLegacyStateOnlyOnDarwin(t *testing.T) { + hostPath := "/private/var/folders/task/models.json" + vmPath := "/host_mnt/private/var/folders/task/models.json" + hostHash, vmHash := mountSourceHash(hostPath), mountSourceHash(vmPath) + if hostHash == vmHash { + t.Fatal("the persisted exact source hash changed instead of retaining state-v4 compatibility") } - left := Mount{Type: "bind", SourceSHA256: mountSourceHash("/srv/thoth/models.json"), Destination: "/config/models.json"} - right := Mount{Type: "bind", SourceSHA256: mountSourceHash("/host_mnt/srv/thoth/models.json"), Destination: "/config/models.json"} - if sameMounts([]Mount{left}, []Mount{right}) { - t.Fatal("an unknown /host_mnt path was collapsed into a distinct Linux bind source") + legacy := Mount{Type: "bind", SourceSHA256: vmHash, Destination: "/config/models.json"} + darwinCurrent := Mount{Type: "bind", SourceSHA256: hostHash, SourceAliases: mountSourceAliases("bind", hostPath, "darwin"), Destination: "/config/models.json"} + if !sameMounts([]Mount{legacy}, []Mount{darwinCurrent}) { + t.Fatal("a legacy Docker Desktop source hash did not match its current Darwin alias") + } + + linuxCurrent := Mount{Type: "bind", SourceSHA256: hostHash, SourceAliases: mountSourceAliases("bind", hostPath, "linux"), Destination: "/config/models.json"} + if sameMounts([]Mount{legacy}, []Mount{linuxCurrent}) { + t.Fatal("distinct Linux /host_mnt and host bind paths were collapsed") + } + if aliases := mountSourceAliases("volume", hostPath, "darwin"); len(aliases) != 0 { + t.Fatalf("named-volume source received Docker Desktop bind aliases: %v", aliases) } }