fix: preserve portable rollback mount identity
This commit is contained in:
+6
-5
@@ -71,12 +71,13 @@
|
|||||||
harness pytest passed **827 passed / 4 deselected** with the existing 74 warnings; touched Python
|
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,
|
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
|
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
|
traced to Docker Desktop alternating equivalent bind sources between `/private/...` and
|
||||||
`/host_mnt/private/...`; the persistence fingerprint now normalizes only the known macOS Docker
|
`/host_mnt/private/...`. Exact state-v4 source hashes remain unchanged; only fresh bind
|
||||||
Desktop aliases while retaining distinct Linux `/host_mnt` paths. The rollback-only smoke passed
|
observations made by a Darwin `thothctl` carry non-serialized aliases for the rollback
|
||||||
twice consecutively after the fix, and the subsequent full unified smoke passed with exact
|
comparison, so pre-fix recovery state remains readable and Linux `/host_mnt` paths remain
|
||||||
cleanup.
|
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
|
## Historical snapshots and archived reference notes
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,13 @@ type Image struct {
|
|||||||
|
|
||||||
// Mount is the complete persistence identity relevant to safe core recreation.
|
// Mount is the complete persistence identity relevant to safe core recreation.
|
||||||
type Mount struct {
|
type Mount struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
SourceSHA256 string `json:"source_sha256"`
|
SourceSHA256 string `json:"source_sha256"`
|
||||||
Destination string `json:"destination"`
|
SourceAliases []string `json:"-"`
|
||||||
RW bool `json:"rw"`
|
Destination string `json:"destination"`
|
||||||
Options string `json:"options,omitempty"`
|
RW bool `json:"rw"`
|
||||||
|
Options string `json:"options,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -136,17 +137,27 @@ func writeFileDurably(path, prefix string, contents []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mountSourceHash(source string) string {
|
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))
|
sum := sha256.Sum256([]byte(source))
|
||||||
return fmt.Sprintf("%x", sum[:])
|
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 {
|
func mountFingerprint(mounts []Mount) string {
|
||||||
values := make([]string, len(mounts))
|
values := make([]string, len(mounts))
|
||||||
for i, mount := range mounts {
|
for i, mount := range mounts {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -588,7 +589,7 @@ func runningImage(ctx context.Context, runner Runner, reference string) (Image,
|
|||||||
if mount.Type == "" || mount.Source == "" || mount.Destination == "" {
|
if mount.Type == "" || mount.Source == "" || mount.Destination == "" {
|
||||||
return Image{}, errors.New("core returned incomplete persistence mount data")
|
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
|
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) {
|
if len(left) != len(right) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
key := func(m Mount) string {
|
identityWithoutSource := func(m Mount) string {
|
||||||
return m.Type + "\x00" + m.Name + "\x00" + m.SourceSHA256 + "\x00" + m.Destination + "\x00" + fmt.Sprint(m.RW) + "\x00" + m.Options
|
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))
|
sourceMatches := func(a, b Mount) bool {
|
||||||
for i := range left {
|
if a.SourceSHA256 == b.SourceSHA256 {
|
||||||
a[i] = key(left[i])
|
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 {
|
matched := make([]bool, len(right))
|
||||||
b[i] = key(right[i])
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -802,22 +802,26 @@ func TestRunningImageCapturesServerBindAndNamedMountIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerDesktopBindAliasesKeepOnePersistenceIdentity(t *testing.T) {
|
func TestDockerDesktopBindAliasesPreserveLegacyStateOnlyOnDarwin(t *testing.T) {
|
||||||
for _, paths := range [][2]string{
|
hostPath := "/private/var/folders/task/models.json"
|
||||||
{"/private/var/folders/task/models.json", "/host_mnt/private/var/folders/task/models.json"},
|
vmPath := "/host_mnt/private/var/folders/task/models.json"
|
||||||
{"/Users/operator/thoth/models.json", "/host_mnt/Users/operator/thoth/models.json"},
|
hostHash, vmHash := mountSourceHash(hostPath), mountSourceHash(vmPath)
|
||||||
} {
|
if hostHash == vmHash {
|
||||||
left := Mount{Type: "bind", SourceSHA256: mountSourceHash(paths[0]), Destination: "/config/models.json"}
|
t.Fatal("the persisted exact source hash changed instead of retaining state-v4 compatibility")
|
||||||
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])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
left := Mount{Type: "bind", SourceSHA256: mountSourceHash("/srv/thoth/models.json"), Destination: "/config/models.json"}
|
legacy := Mount{Type: "bind", SourceSHA256: vmHash, Destination: "/config/models.json"}
|
||||||
right := Mount{Type: "bind", SourceSHA256: mountSourceHash("/host_mnt/srv/thoth/models.json"), Destination: "/config/models.json"}
|
darwinCurrent := Mount{Type: "bind", SourceSHA256: hostHash, SourceAliases: mountSourceAliases("bind", hostPath, "darwin"), Destination: "/config/models.json"}
|
||||||
if sameMounts([]Mount{left}, []Mount{right}) {
|
if !sameMounts([]Mount{legacy}, []Mount{darwinCurrent}) {
|
||||||
t.Fatal("an unknown /host_mnt path was collapsed into a distinct Linux bind source")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user