mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 22:17:42 +02:00
725 lines
26 KiB
Go
725 lines
26 KiB
Go
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 <out> ...` 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")
|
|
}
|
|
})
|
|
}
|
|
}
|