zdd-4 task 3: StagedIdentity, ResolveLive and VerifyStaged
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
3 files changed,
+205
−9
internal/release/staged.go+77 −0internal/release/staged_test.go+114 −0internal/release/versions.go+14 −9
diff --git a/internal/release/staged.go b/internal/release/staged.go| new file mode 100644 |
| index 000000000..d42b12f2d |
| --- /dev/null |
| +++ b/internal/release/staged.go |
| @@ -0,0 +1,77 @@ |
| +// The identity of a staged artifact (zero-downtime deploys spec §4.2, §7.9, |
| +// §8.5): which bytes a process was exec'd from, kept so that recovery runs |
| +// exactly those bytes again and can tell when they have changed. |
| +package release |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +// StagedIdentity is the retained identity of a staged artifact (spec §4.2, |
| +// §8.5): the version name it was staged under, the immutable path it is |
| +// exec'd from, the sha256 of its bytes, and the release's own swap demand as |
| +// verified for those bytes ("hold" or ""). The agent captures it at exec, |
| +// never from the live link afterwards, because the link moves under a |
| +// running process. |
| +type StagedIdentity struct { |
| + Version string |
| + StagedPath string |
| + Digest string |
| + Demand string |
| +} |
| + |
| +// ErrStagedCopyChanged is recovery's refusal (spec §4.2): the staged file no |
| +// longer hashes to the digest retained for it, so it is not the build that |
| +// passed, and running it would be running something nobody verified. |
| +var ErrStagedCopyChanged = errors.New("staged copy no longer matches the digest it was verified with") |
| + |
| +// ErrVersionReused is root staging's refusal (spec §4.2): the store holds |
| +// different bytes under a version name this box already staged, and |
| +// something on the box still refers to the staged copy, so it is not |
| +// overwritten. The text is the spec's. |
| +var ErrVersionReused = errors.New("version name reused with different content, ship under a new name") |
| + |
| +// ResolveLive reads host's live link ONCE and reports the file it names and |
| +// that file's sha256. Version is the link's version as LiveVersion reads it, |
| +// "" for a link outside the version store; the path and digest identify the |
| +// bytes either way. Demand is left empty: only CanonicalDemand can vouch for |
| +// it. |
| +func ResolveLive(optDir, host string) (StagedIdentity, error) { |
| + link := LiveLink(optDir, host) |
| + raw, err := os.Readlink(link) |
| + if err != nil { |
| + return StagedIdentity{}, fmt.Errorf("read live link %s: %w", link, err) |
| + } |
| + path := raw |
| + if !filepath.IsAbs(path) { |
| + path = filepath.Join(filepath.Dir(link), path) |
| + } |
| + path = filepath.Clean(path) |
| + sum, err := FileSHA256(path) |
| + if err != nil { |
| + return StagedIdentity{}, fmt.Errorf("hash %s: %w", path, err) |
| + } |
| + return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: path, Digest: sum}, nil |
| +} |
| + |
| +// VerifyStaged checks the file at id.StagedPath against id.Digest, the digest |
| +// retained when the build was exec'd (spec §4.2's recovery check). It never |
| +// consults the canonical manifest: a later re-ship of the same name must not |
| +// revoke local recovery. |
| +func VerifyStaged(id StagedIdentity) error { |
| + if id.StagedPath == "" || id.Digest == "" { |
| + return fmt.Errorf("%w: no retained path and digest for %q", ErrStagedCopyChanged, id.Version) |
| + } |
| + sum, err := FileSHA256(id.StagedPath) |
| + if err != nil { |
| + return fmt.Errorf("%w: %s: %v", ErrStagedCopyChanged, id.StagedPath, err) |
| + } |
| + if !strings.EqualFold(sum, id.Digest) { |
| + return fmt.Errorf("%w: %s hashes to %s, retained %s", ErrStagedCopyChanged, id.StagedPath, sum, id.Digest) |
| + } |
| + return nil |
| +} |
diff --git a/internal/release/staged_test.go b/internal/release/staged_test.go| new file mode 100644 |
| index 000000000..5f9b2cd36 |
| --- /dev/null |
| +++ b/internal/release/staged_test.go |
| @@ -0,0 +1,114 @@ |
| +package release |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestResolveLiveReadsTheLinkOnce(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("build one")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PointLive(opt, "h.example.com", "bab", "v1", "km"); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := ResolveLive(opt, "h.example.com") |
| + if err != nil { |
| + t.Fatalf("ResolveLive: %v", err) |
| + } |
| + want := StagedIdentity{Version: "v1", StagedPath: VersionPath(opt, "bab", "v1", "km"), Digest: etagOf([]byte("build one"))} |
| + if id != want { |
| + t.Fatalf("ResolveLive = %+v, want %+v", id, want) |
| + } |
| + if id.StagedPath == LiveLink(opt, "h.example.com") { |
| + t.Fatal("the identity names the live link, which moves; it must name the staged file") |
| + } |
| +} |
| + |
| +// Review Focus 1: a link an operator made by hand, outside the version store. |
| +func TestResolveLiveOutsideTheStoreStillIdentifiesTheBytes(t *testing.T) { |
| + opt := t.TempDir() |
| + elsewhere := filepath.Join(t.TempDir(), "hand-built") |
| + if err := os.WriteFile(elsewhere, []byte("operator's build"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(elsewhere, LiveLink(opt, "h.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := ResolveLive(opt, "h.example.com") |
| + if err != nil { |
| + t.Fatalf("ResolveLive: %v", err) |
| + } |
| + want := StagedIdentity{Version: "", StagedPath: elsewhere, Digest: etagOf([]byte("operator's build"))} |
| + if id != want { |
| + t.Fatalf("ResolveLive = %+v, want %+v (no version, but the bytes still identified)", id, want) |
| + } |
| +} |
| + |
| +// Review Focus 1: a relative link resolves against live/, and its version |
| +// reads exactly as LiveVersion reads it. |
| +func TestResolveLiveFollowsARelativeLink(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("b")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(filepath.Join("..", "versions", "v1", "km"), LiveLink(opt, "h.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := ResolveLive(opt, "h.example.com") |
| + if err != nil { |
| + t.Fatalf("ResolveLive: %v", err) |
| + } |
| + lv, _ := LiveVersion(opt, "h.example.com") |
| + if id.StagedPath != VersionPath(opt, "bab", "v1", "km") || id.Digest != etagOf([]byte("b")) || id.Version != lv { |
| + t.Fatalf("ResolveLive = %+v; want path %s, the file's digest, and version %q", id, VersionPath(opt, "bab", "v1", "km"), lv) |
| + } |
| +} |
| + |
| +func TestResolveLiveWithNoLinkIsAnError(t *testing.T) { |
| + if _, err := ResolveLive(t.TempDir(), "h.example.com"); err == nil { |
| + t.Fatal("ResolveLive with no live link returned no error") |
| + } |
| +} |
| + |
| +func TestVerifyStaged(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "bab", "v1", "km", []byte("build one")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PointLive(opt, "h.example.com", "bab", "v1", "km"); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := ResolveLive(opt, "h.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := VerifyStaged(id); err != nil { |
| + t.Fatalf("VerifyStaged on untouched bytes: %v", err) |
| + } |
| + // Damaged in place, to a different length. |
| + if err := os.WriteFile(id.StagedPath, []byte("build one, damaged"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := VerifyStaged(id); !errors.Is(err, ErrStagedCopyChanged) { |
| + t.Fatalf("VerifyStaged on changed bytes = %v, want ErrStagedCopyChanged", err) |
| + } |
| + if err := os.Remove(id.StagedPath); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := VerifyStaged(id); !errors.Is(err, ErrStagedCopyChanged) { |
| + t.Fatalf("VerifyStaged on a missing file = %v, want ErrStagedCopyChanged", err) |
| + } |
| + if err := VerifyStaged(StagedIdentity{Version: "v1"}); !errors.Is(err, ErrStagedCopyChanged) { |
| + t.Fatalf("VerifyStaged with no retained path or digest = %v, want ErrStagedCopyChanged", err) |
| + } |
| +} |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index c2952e1f1..47a7cf1fe 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -295,21 +295,26 @@ func LiveVersion(optDir, host string) (string, error) { |
| } |
| return "", err |
| } |
| - // target is <opt>/versions/<ver>/<app> (ops) or |
| - // <opt>/versions/<ver>/<acct>/<app> (everyone else), so the version is the |
| - // FIRST segment under the versions dir — not simply the parent directory, |
| - // which would read an account sqid as a version. |
| + return versionOfTarget(optDir, target), nil |
| +} |
| + |
| +// versionOfTarget is the version a live link's RAW target names, or "" when |
| +// the target is not under the version store. target is |
| +// <opt>/versions/<ver>/<app> (ops) or <opt>/versions/<ver>/<acct>/<app> |
| +// (everyone else), so the version is the FIRST segment under the versions |
| +// dir, not simply the parent directory, which would read an account sqid as |
| +// a version. A target outside the store, or a relative one, is not ours to |
| +// interpret: "" is the same answer an absent link gets. |
| +func versionOfTarget(optDir, target string) string { |
| rel, err := filepath.Rel(versionsDir(optDir), target) |
| if err != nil || strings.HasPrefix(rel, "..") { |
| - // A link pointing outside the version store isn't ours to interpret; |
| - // "" means "not under version management", the same answer as absent. |
| - return "", nil |
| + return "" |
| } |
| ver, _, ok := strings.Cut(filepath.ToSlash(rel), "/") |
| if !ok { |
| - return "", nil |
| + return "" |
| } |
| - return ver, nil |
| + return ver |
| } |
| // WaitHealthyVersion waits until the host behind socket not only answers but |