package update import ( "context" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" ) // fakeGo is a scripted stand-in for the Go toolchain: `go env GOVERSION` answers, and // `go build -o ...` either writes a recognisable file or fails, decided by a marker // file the test toggles. It goes first on PATH (the real PATH stays behind it, because git // still has to resolve), so the update's real build step runs without compiling the whole // server inside a test. type fakeGo struct { marker string } func scriptFakeGo(t *testing.T) *fakeGo { t.Helper() if runtime.GOOS == "windows" { // The scripted toolchain is a POSIX shell script: Windows would not execute it, // LookPath would not even find it, and the scenario would silently assert // nothing. Driving it there needs a different harness, not a different promise. t.Skip("the scripted go toolchain needs a POSIX shell") } dir := t.TempDir() marker := filepath.Join(dir, "fail-build") f := &fakeGo{marker: marker} script := "#!/bin/sh\n" + "if [ \"$1\" = env ]; then echo go1.23.4; exit 0; fi\n" + "out=\"\"\n" + "prev=\"\"\n" + "for a in \"$@\"; do\n" + " if [ \"$prev\" = -o ]; then out=\"$a\"; fi\n" + " prev=\"$a\"\n" + "done\n" + "if [ -f \"" + marker + "\" ]; then echo 'vet: internal/foo.go:12: fake failure' >&2; exit 1; fi\n" + "commit=$(git rev-parse --short HEAD) || exit 1\n" + "echo \"fake binary for $commit\" > \"$out\"\n" if err := os.WriteFile(filepath.Join(dir, "go"), []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) return f } func (f *fakeGo) failBuild(t *testing.T) { t.Helper() if err := os.WriteFile(f.marker, []byte("x"), 0o644); err != nil { t.Fatal(err) } } func (f *fakeGo) succeed(t *testing.T) { t.Helper() if err := os.Remove(f.marker); err != nil && !os.IsNotExist(err) { t.Fatal(err) } } // hideToolchain takes `go` off the PATH child processes see while leaving everything else // (git) reachable: a directory holding a symlink to the real git and nothing else. func hideToolchain(t *testing.T) { t.Helper() if runtime.GOOS == "windows" { // Same reason as scriptFakeGo: symlinks need privileges there and the bare-name // file would not resolve through LookPath. t.Skip("PATH surgery here needs POSIX symlinks") } realGit, err := exec.LookPath("git") if err != nil { t.Skip("git not installed") } dir := t.TempDir() if err := os.Symlink(realGit, filepath.Join(dir, "git")); err != nil { t.Fatal(err) } t.Setenv("PATH", dir) } // buildOpts drives an update with a real binary to swap - the configuration of every // installed tree, where "the source moved but the binary did not" is even possible. func buildOpts(tr *tree) Options { return Options{Root: tr.install, Repo: tr.upstream, BinaryName: Binary} } // TestRetryAfterFailedBuildCompilesTheBinary: a failed build leaves the source updated // (HEAD is on the new commit), so a retry sees "nothing to pull" - and used to report // success while the old binary stayed in place. The retry has to finish the build. func TestRetryAfterFailedBuildCompilesTheBinary(t *testing.T) { requireGit(t) tr := newTree(t) fake := scriptFakeGo(t) fake.failBuild(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "old binary bytes") tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) res, err := Apply(context.Background(), buildOpts(tr), nil) ue, ok := err.(*Error) if !ok || ue.Reason != "build_failed" { t.Fatalf("first apply: err=%v result=%+v, want build_failed", err, res) } if res.BinaryBuilt { t.Error("a failed build must not claim a binary") } if got := readFile(t, bin); got != "old binary bytes" { t.Errorf("binary after the failed build = %q, want the old one untouched", got) } snap, err := Status(context.Background(), buildOpts(tr)) if err != nil { t.Fatal(err) } if !snap.BuildPending { t.Error("after a failed build the tree owes a compile; Status must say so") } // Same tree, a working toolchain: the retry compiles instead of reporting success for // work that never happened. fake.succeed(t) res, err = Apply(context.Background(), buildOpts(tr), nil) if err != nil { t.Fatalf("retry failed: %v (result %+v)", err, res) } if !res.BinaryBuilt || res.Commits != 0 || res.FromCommit != res.ToCommit { t.Errorf("retry result = %+v, want a build with no commits moved", res) } if got := readFile(t, bin); !strings.Contains(got, "fake binary") { t.Errorf("binary after retry = %q, want the freshly built one", got) } if got := readFile(t, bin+".prev"); got != "old binary bytes" { t.Errorf("prev binary = %q, want the one the swap kept", got) } if buildPending(tr.install) { t.Error("a finished build must clear the pending marker") } } // TestRetryFinishesTheBuildWhenTheStateWriteFailed: the merge landed, but the update // aborted before the state file could be written (a full disk, a directory sitting where // the file goes). The tree still owes a binary, and the retry must know that - the marker // is written the moment the merge lands, not after the bookkeeping that may fail. func TestRollbackAfterFailedBuildKeepsTheMatchingPreviousBinary(t *testing.T) { requireGit(t) tr := newTree(t) fake := scriptFakeGo(t) fake.failBuild(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") before := mustGit(t, tr.install, "rev-parse", "HEAD") tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { t.Fatal("the build must fail") } if fileExists(bin + ".prev") { t.Fatal("a failed build must not create a rollback binary") } snap, err := Status(context.Background(), buildOpts(tr)) if err != nil { t.Fatal(err) } if !snap.HasRollback { t.Fatal("source can roll back to the commit already matching the live binary") } res, err := Rollback(context.Background(), buildOpts(tr)) if err != nil { t.Fatalf("rollback failed: %v", err) } if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != before { t.Fatalf("rollback source = %s, want %s", got, before) } if got := readFile(t, bin); got != "original binary" { t.Fatalf("rollback changed an already-matching binary: %q", got) } if res.BinaryBuilt { t.Fatalf("rollback result must not claim a binary swap: %+v", res) } if buildPending(tr.install) { t.Fatal("rollback must clear the pending build marker") } } func TestRetryFinishesTheBuildWhenTheStateWriteFailed(t *testing.T) { requireGit(t) tr := newTree(t) fake := scriptFakeGo(t) fake.succeed(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "old binary bytes") tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) // A directory where writeState's temp file goes makes the state write fail. blocked := filepath.Join(tr.install, ".update-state.json.tmp") if err := os.Mkdir(blocked, 0o755); err != nil { t.Fatal(err) } res, err := Apply(context.Background(), buildOpts(tr), nil) if ue, ok := err.(*Error); !ok || ue.Reason != "state_unwritable" { t.Fatalf("want state_unwritable, got %v (result %+v)", err, res) } if got := readFile(t, bin); got != "old binary bytes" { t.Errorf("no build may happen while the state write is broken: %q", got) } // Clear the obstruction and retry: the owed build must happen, not a success report. if err := os.Remove(blocked); err != nil { t.Fatal(err) } res, err = Apply(context.Background(), buildOpts(tr), nil) if err != nil { t.Fatalf("retry failed: %v (result %+v)", err, res) } if !res.BinaryBuilt { t.Fatalf("retry result = %+v, want the owed build", res) } if got := readFile(t, bin); !strings.Contains(got, "fake binary") { t.Errorf("binary after retry = %q, want the compiled one", got) } } // TestAdoptBuildFailureIsFinishedByTheNextUpdate: an adoption that landed the source but // failed its build leaves a work tree that has "nothing to pull" on the next click. The // same marker has to make that click compile rather than report "already up to date". func TestAdoptBuildFailureIsFinishedByTheNextUpdate(t *testing.T) { requireGit(t) tr, dir := newAdoptFixture(t) fake := scriptFakeGo(t) fake.failBuild(t) o := Options{Root: dir, Repo: tr.upstream, BinaryName: Binary} res, err := Adopt(context.Background(), o, nil) ue, ok := err.(*Error) if !ok || ue.Reason != "build_failed" { t.Fatalf("adopt: err=%v result=%+v, want build_failed", err, res) } snap, err := Status(context.Background(), o) if err != nil { t.Fatal(err) } if !snap.BuildPending { t.Error("the adopted tree owes a build; Status must say so") } fake.succeed(t) res, err = Apply(context.Background(), o, nil) if err != nil { t.Fatalf("retry failed: %v (result %+v)", err, res) } if !res.BinaryBuilt { t.Fatalf("retry result = %+v, want the owed build", res) } if got := readFile(t, filepath.Join(dir, Binary)); !strings.Contains(got, "fake binary") { t.Errorf("binary after retry = %q, want the compiled one", got) } if buildPending(dir) { t.Error("a finished build must clear the pending marker") } } // TestRetryWithoutToolchainKeepsSayingSoAndThenBuilds: the no_toolchain message tells the // operator "安装 go 后再点一次更新即可补上二进制". Until the fix that retry answered // success without compiling; the promise is only real if the click after installing go // actually builds - and the clicks before it keep an honest refusal. func TestRetryWithoutToolchainKeepsSayingSoAndThenBuilds(t *testing.T) { requireGit(t) tr := newTree(t) hideToolchain(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "old binary bytes") tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { t.Fatal("a tree without a toolchain must refuse the build, not report success") } else if ue, ok := err.(*Error); !ok || ue.Reason != "no_toolchain" { t.Fatalf("want no_toolchain, got %v", err) } // Still no go: the retry keeps refusing. if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { t.Fatal("a retry without a toolchain must keep refusing, not report success") } // Toolchain installed: the same click now does what the message said it would. scriptFakeGo(t) res, err := Apply(context.Background(), buildOpts(tr), nil) if err != nil { t.Fatalf("retry with a toolchain failed: %v (result %+v)", err, res) } if !res.BinaryBuilt { t.Errorf("result = %+v, want the binary built on retry", res) } if got := readFile(t, bin); !strings.Contains(got, "fake binary") { t.Errorf("binary = %q, want the compiled one", got) } } func TestNewUpdateFinishesThePendingCommitBeforeAdvancing(t *testing.T) { requireGit(t) tr := newTree(t) fake := scriptFakeGo(t) fake.failBuild(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { t.Fatal("the first build must fail") } second := mustGit(t, tr.install, "rev-parse", "--short", "HEAD") tr.upstreamCommit(t, "third", map[string]string{"internal_service.go": "package service\n\nconst Version = \"3\"\n"}) third := mustGit(t, tr.upstream, "rev-parse", "--short", "HEAD") fake.succeed(t) res, err := Apply(context.Background(), buildOpts(tr), nil) if err != nil { t.Fatalf("second apply failed: %v (result %+v)", err, res) } if got := readFile(t, bin); !strings.Contains(got, third) { t.Fatalf("live binary = %q, want third commit %s", got, third) } if got := readFile(t, bin+".prev"); !strings.Contains(got, second) { t.Fatalf("rollback binary = %q, want second commit %s", got, second) } if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { t.Fatalf("rollback failed: %v", err) } if head := mustGit(t, tr.install, "rev-parse", "--short", "HEAD"); head != second { t.Fatalf("rollback source = %s, want %s", head, second) } if got := readFile(t, bin); !strings.Contains(got, second) { t.Fatalf("rollback binary = %q, want the same commit as source %s", got, second) } } func TestResidualPendingMarkerDoesNotReplaceTheRollbackBinary(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") before := mustGit(t, tr.install, "rev-parse", "--short", "HEAD") tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatal(err) } updated := mustGit(t, tr.install, "rev-parse", "--short", "HEAD") legacy, ok := readState(tr.install) if !ok { t.Fatal("successful update must leave rollback state") } legacy.BinaryCommit = "" legacy.BinarySHA256 = "" legacy.PreviousBinarySHA256 = "" if err := writeState(tr.install, legacy); err != nil { t.Fatal(err) } if err := os.WriteFile(buildPendingPath(tr.install), []byte("source is ahead of the binary\n"), 0o644); err != nil { t.Fatal(err) } res, err := Apply(context.Background(), buildOpts(tr), nil) if err != nil { t.Fatalf("retry with a residual marker failed: %v", err) } if !res.BinaryBuilt || !res.NeedsRestart { t.Fatalf("the already-installed binary must still be reported as awaiting restart: %+v", res) } if got := readFile(t, bin+".prev"); got != "original binary" { t.Fatalf("residual marker replaced rollback binary with %q", got) } if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { t.Fatalf("rollback failed: %v", err) } if head := mustGit(t, tr.install, "rev-parse", "--short", "HEAD"); head != before { t.Fatalf("rollback source = %s, want %s (updated was %s)", head, before, updated) } if got := readFile(t, bin); got != "original binary" { t.Fatalf("rollback binary = %q, want original binary", got) } } func TestLegacyPendingMarkerRefusesAHeadThatMovedAgain(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatal(err) } rollbackBinary := readFile(t, bin+".prev") legacy, ok := readState(tr.install) if !ok { t.Fatal("successful update must leave rollback state") } legacy.BinaryCommit = "" legacy.BinarySHA256 = "" legacy.PreviousBinarySHA256 = "" if err := writeState(tr.install, legacy); err != nil { t.Fatal(err) } if err := os.WriteFile(buildPendingPath(tr.install), []byte("source is ahead of the binary\n"), 0o644); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(tr.install, "local.go"), "package service\n") mustGit(t, tr.install, "add", "local.go") mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "local move after update") if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { t.Fatal("a legacy marker must not be reinterpreted after HEAD moved") } else if ue, ok := err.(*Error); !ok || ue.Reason != "build_state_mismatch" { t.Fatalf("want build_state_mismatch, got %v", err) } if got := readFile(t, bin+".prev"); got != rollbackBinary { t.Fatalf("refused migration changed rollback binary: %q", got) } } // A crash between the version write and the state write leaves the new number in the live // config with no rollback record beside it. The marker is written *before* the config, so it // still carries the pair - which is what lets the retry rebuild that record and the rollback // put the old number back next to the old code. func TestRetryRebuildsTheRollbackRecordTheCrashNeverWrote(t *testing.T) { requireGit(t) tr := newTree(t) fake := scriptFakeGo(t) fake.failBuild(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n") tr.upstreamCommit(t, "release: bump version", map[string]string{ "config.example.yaml": "version: \"v1.1.0\"\n", "internal_service.go": "package service\n\nconst Version = \"2\"\n", }) if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { t.Fatal("the first build must fail") } pending, present, err := readBuildPending(tr.install) if err != nil || !present { t.Fatalf("marker = %+v present=%v err=%v", pending, present, err) } if pending.VersionBefore != "v1.0.0" || pending.VersionAfter != "v1.1.0" { t.Fatalf("the marker must carry the version pair before the config is written: %+v", pending) } // The crash: the config already carries the new number, the state file never landed. if err := os.Remove(statePath(tr.install)); err != nil { t.Fatal(err) } fake.succeed(t) if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatalf("retry failed: %v", err) } st, ok := readState(tr.install) if !ok || st.VersionBefore != "v1.0.0" || st.VersionAfter != "v1.1.0" { t.Fatalf("the retry must rebuild the rollback record from the marker: ok=%v state=%+v", ok, st) } if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { t.Fatalf("rollback failed: %v", err) } live := readFile(t, filepath.Join(tr.install, "config.yaml")) if !strings.Contains(live, `version: "v1.0.0"`) || !strings.Contains(live, "port: 8088") { t.Fatalf("live config after rollback = %q, want the old version back with the settings intact", live) } } func TestRetryRestoresProtectedContentWhenInterruptedBeforeMerge(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") role := filepath.Join(tr.install, "roles", "shipped.yaml") writeFile(t, role, "name: shipped\ndescription: mine\n") tr.upstreamCommit(t, "second", map[string]string{ "internal_service.go": "package service\n\nconst Version = \"2\"\n", "roles/shipped.yaml": "name: shipped\ndescription: upstream\n", }) snap, err := Check(context.Background(), buildOpts(tr)) if err != nil || snap.CheckError != "" { t.Fatalf("check: %v, %s", err, snap.CheckError) } backupDir, kept, _, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) if err != nil { t.Fatal(err) } if err := removeProtected(tr.install, kept); err != nil { t.Fatal(err) } if fileExists(role) { t.Fatal("fixture must stop after the operator role was moved aside") } previous := mustGit(t, tr.install, "rev-parse", "HEAD") target := mustGit(t, tr.install, "rev-parse", snap.sourceRef()) oldHash, err := fileSHA256(bin) if err != nil { t.Fatal(err) } if err := writeBuildPending(tr.install, buildPendingState{ Commit: target, PreviousCommit: previous, BackupDir: backupDir, KeptContent: kept, PreviousBinarySHA256: oldHash, }); err != nil { t.Fatal(err) } if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatalf("retry failed: %v", err) } if got := readFile(t, role); !strings.Contains(got, "description: mine") { t.Fatalf("operator role was not restored: %q", got) } } func TestRetryAfterMergeRestoresOnlyUntouchedProtectedContent(t *testing.T) { for _, tc := range []struct { name string afterMerge string want string }{ {name: "merge copy untouched", want: "description: mine before crash"}, {name: "operator edited after crash", afterMerge: "name: shipped\ndescription: mine after crash\n", want: "description: mine after crash"}, } { t.Run(tc.name, func(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") role := filepath.Join(tr.install, "roles", "shipped.yaml") writeFile(t, role, "name: shipped\ndescription: mine before crash\n") tr.upstreamCommit(t, "second", map[string]string{ "internal_service.go": "package service\n\nconst Version = \"2\"\n", "roles/shipped.yaml": "name: shipped\ndescription: upstream\n", }) snap, err := Check(context.Background(), buildOpts(tr)) if err != nil || snap.CheckError != "" { t.Fatalf("check: %v, %s", err, snap.CheckError) } backupDir, kept, _, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) if err != nil { t.Fatal(err) } if err := removeProtected(tr.install, kept); err != nil { t.Fatal(err) } previous := mustGit(t, tr.install, "rev-parse", "HEAD") target := mustGit(t, tr.install, "rev-parse", snap.sourceRef()) oldHash, err := fileSHA256(bin) if err != nil { t.Fatal(err) } if err := writeBuildPending(tr.install, buildPendingState{ Commit: target, PreviousCommit: previous, BackupDir: backupDir, KeptContent: kept, PreviousBinarySHA256: oldHash, }); err != nil { t.Fatal(err) } mustGit(t, tr.install, "merge", "--ff-only", "--no-verify", snap.sourceRef()) if tc.afterMerge != "" { writeFile(t, role, tc.afterMerge) } if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatalf("retry failed: %v", err) } if got := readFile(t, role); !strings.Contains(got, tc.want) { t.Fatalf("protected content after retry = %q, want %q", got, tc.want) } }) } } // Legacy records cannot tell an installed rebuild from a binary still owed. // A different build of the same source must never be promoted into .prev. func TestLegacyPendingWithUnprovableBinaryPreservesRollback(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatal(err) } st, _ := readState(tr.install) st.BinaryCommit, st.BinarySHA256, st.PreviousBinarySHA256 = "", "", "" if err := writeState(tr.install, st); err != nil { t.Fatal(err) } writeFile(t, bin, "different build of the updated commit") writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n") head := mustGit(t, tr.install, "rev-parse", "HEAD") _, err := Apply(context.Background(), buildOpts(tr), nil) if ue, ok := err.(*Error); !ok || ue.Reason != "binary_state_unknown" { t.Fatalf("want binary_state_unknown, got %v", err) } if got := readFile(t, bin+".prev"); got != "original binary" { t.Fatalf("rollback binary overwritten: %q", got) } if got := readFile(t, bin); got != "different build of the updated commit" { t.Fatalf("live binary overwritten: %q", got) } if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head { t.Fatal("refusal moved HEAD") } if !buildPending(tr.install) { t.Fatal("refusal cleared recovery marker") } } func TestLegacyMarkerUsesRecordedHashForADifferentInstalledBuild(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatal(err) } writeFile(t, bin, "another valid build recorded by the updater") st, _ := readState(tr.install) var err error st.BinarySHA256, err = fileSHA256(bin) if err != nil { t.Fatal(err) } if err := writeState(tr.install, st); err != nil { t.Fatal(err) } writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n") if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatal(err) } if got := readFile(t, bin+".prev"); got != "original binary" { t.Fatalf("rollback binary overwritten: %q", got) } if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { t.Fatal(err) } if got := readFile(t, bin); got != "original binary" { t.Fatalf("rollback binary = %q", got) } } func TestInstallBinaryMissingStagingKeepsLiveAndPrevious(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, Binary) writeFile(t, bin, "live binary") writeFile(t, bin+".prev", "rollback binary") if _, err := installBinary(filepath.Join(dir, "missing-staging"), bin); err == nil { t.Fatal("missing staging must fail") } if got := readFile(t, bin); got != "live binary" { t.Fatalf("live binary lost: %q", got) } if got := readFile(t, bin+".prev"); got != "rollback binary" { t.Fatalf("previous binary lost: %q", got) } } func TestLegacyMarkerRefusesContradictoryState(t *testing.T) { for _, tc := range []struct { name, reason string badCommit bool }{ {name: "unrelated binary commit", reason: "bad_state", badCommit: true}, {name: "changed binary hash", reason: "binary_changed"}, } { t.Run(tc.name, func(t *testing.T) { requireGit(t) tr := newTree(t) scriptFakeGo(t) bin := filepath.Join(tr.install, Binary) writeFile(t, bin, "original binary") tr.upstreamCommit(t, "second", map[string]string{"internal_service.go": "package service\nconst Version = \"2\"\n"}) if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { t.Fatal(err) } st, _ := readState(tr.install) if tc.badCommit { st.BinaryCommit = "not-a-commit" } else { st.BinarySHA256 = strings.Repeat("0", 64) } if err := writeState(tr.install, st); err != nil { t.Fatal(err) } writeFile(t, buildPendingPath(tr.install), "source is ahead of the binary\n") live := readFile(t, bin) stateBytes := readFile(t, statePath(tr.install)) _, err := Apply(context.Background(), buildOpts(tr), nil) if ue, ok := err.(*Error); !ok || ue.Reason != tc.reason { t.Fatalf("want %s, got %v", tc.reason, err) } if got := readFile(t, bin); got != live { t.Fatal("refusal changed live binary") } if got := readFile(t, bin+".prev"); got != "original binary" { t.Fatal("refusal changed rollback binary") } if got := readFile(t, statePath(tr.install)); got != stateBytes { t.Fatal("refusal rewrote state") } }) } }