mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
The repository's config.example.yaml ships the release version this code was cut from (upstream bumps it in the release commit: v1.7.21's config.example says v1.7.21), while the live config.yaml is operator data that no merge ever touches. So after an update the header badge - and the ?v= on every static asset - kept saying the version the installation was born with while the tree already ran newer code. An update, an adoption, or a rollback now keeps the two together: after the merge the update reads the version the new code carries (config.example.yaml) and writes it into the live config's version field - that one line, temp file plus atomic rename, everything else byte-for-byte preserved, and only when the value actually differs. The job log records the change (the page shows the step), and the state file records both numbers so a rollback puts the old version back with the old code; a number hand-edited after the update is the operator's and is left alone. The side effect worth naming: the version bump also changes the ?v= query on static assets, so a browser stops serving the pre-update JavaScript. Trees without a live config or without a shipped version are left untouched - a source checkout has nothing to sync, and nothing is ever guessed. internal/config gains the two focused helpers (FileVersion / WriteVersion, line level like the tarball upgrade path always did); tests pin the replace, the insert-when-absent, byte preservation, the mode, the malformed-value refusal, and the update/adopt/rollback/leave-alone behaviours end to end.
544 lines
21 KiB
Go
544 lines
21 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// These tests drive real git repositories in temporary directories. A fake would let the
|
|
// thing this package exists for - "git says no because your files would be overwritten" -
|
|
// pass silently.
|
|
|
|
func requireGit(t *testing.T) {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
}
|
|
|
|
type tree struct {
|
|
upstream string
|
|
install string
|
|
}
|
|
|
|
// newTree builds an upstream repository with one code file and one shipped role, then
|
|
// clones it the way an operator would (the clone's origin is the user's own fork, not a
|
|
// fixed third-party repository).
|
|
func newTree(t *testing.T) *tree {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
up := filepath.Join(root, "fork")
|
|
install := filepath.Join(root, "install")
|
|
mustGit(t, root, "init", "-q", "-b", "main", up)
|
|
|
|
writeFile(t, filepath.Join(up, "internal_service.go"), "package service\n\nconst Version = \"1\"\n")
|
|
writeFile(t, filepath.Join(up, "roles", "shipped.yaml"), "name: shipped\ndescription: upstream copy\n")
|
|
writeFile(t, filepath.Join(up, "run.sh"), "#!/bin/sh\necho old\n")
|
|
mustGit(t, up, "add", ".")
|
|
mustGit(t, up, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "first")
|
|
|
|
mustGit(t, root, "clone", "-q", up, install)
|
|
mustGit(t, install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "--allow-empty", "-m", "clone marker")
|
|
// The clone's own commit makes HEAD and origin/main differ only by the operator's
|
|
// history; drop it so the tree starts exactly level with its remote.
|
|
mustGit(t, install, "reset", "--hard", "--quiet", "origin/main")
|
|
|
|
// The live config file is operator data that is never in the repository.
|
|
writeFile(t, filepath.Join(install, "config.yaml"), "server:\n port: 8088\n")
|
|
return &tree{upstream: up, install: install}
|
|
}
|
|
|
|
func (t *tree) upstreamCommit(t2 *testing.T, message string, files map[string]string) {
|
|
t2.Helper()
|
|
for path, content := range files {
|
|
writeFile(t2, filepath.Join(t.upstream, path), content)
|
|
}
|
|
mustGit(t2, t.upstream, "add", ".")
|
|
mustGit(t2, t.upstream, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", message)
|
|
}
|
|
|
|
// opts points an update at the tree's own source repository: the explicit address is
|
|
// now the whole configuration, so tests name it the way an operator would.
|
|
func opts(tr *tree) Options {
|
|
return Options{Root: tr.install, Repo: tr.upstream, BinaryName: "none"}
|
|
}
|
|
|
|
func TestStatusWithoutNetworkDescribesTheTree(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
|
|
snap, err := Status(context.Background(), opts(tr))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !snap.Installed {
|
|
t.Fatal("a clone must be reported as an installable tree")
|
|
}
|
|
if snap.Branch != "main" {
|
|
t.Errorf("local branch = %q, want main", snap.Branch)
|
|
}
|
|
if snap.Repo != tr.upstream || !snap.RepoConfigured {
|
|
t.Errorf("repo = %q configured=%v, want the configured source %q", snap.Repo, snap.RepoConfigured, tr.upstream)
|
|
}
|
|
if snap.Commit == "" {
|
|
t.Error("commit is empty")
|
|
}
|
|
// config.yaml is operator data and is untracked, so it is not a blocking change.
|
|
if len(snap.BlockingChanges) != 0 {
|
|
t.Errorf("untracked config.yaml must not block an update: %+v", snap.BlockingChanges)
|
|
}
|
|
}
|
|
|
|
func TestStatusReportsTheInstallRootInAbsoluteForm(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
|
|
// A relative config path gives filepath.Dir as "."; the page must still be able to say
|
|
// which directory is being updated.
|
|
t.Chdir(tr.install)
|
|
snap, err := Status(context.Background(), Options{Root: ".", Repo: tr.upstream, BinaryName: "none"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !filepath.IsAbs(snap.Root) || filepath.Base(snap.Root) != "install" {
|
|
t.Errorf("root = %q, want the absolute install directory", snap.Root)
|
|
}
|
|
|
|
if _, err := Status(context.Background(), Options{Root: " "}); err == nil {
|
|
t.Error("an empty install root must be an error, not a scan of the process CWD")
|
|
}
|
|
if _, err := Rollback(context.Background(), Options{Root: ""}); err == nil {
|
|
t.Error("rollback must refuse an empty install root for the same reason")
|
|
}
|
|
}
|
|
|
|
func TestStatusOnNonGitTreeIsAStateNotAnError(t *testing.T) {
|
|
requireGit(t)
|
|
dir := t.TempDir()
|
|
snap, err := Status(context.Background(), Options{Root: dir})
|
|
if err != nil {
|
|
t.Fatalf("a tarball install is a readable state, not a fault: %v", err)
|
|
}
|
|
if snap.Installed || snap.CheckError == "" {
|
|
t.Errorf("installed=%v checkError=%q, want false with a reason", snap.Installed, snap.CheckError)
|
|
}
|
|
if snap.Repo != DefaultRepoURL || snap.RepoConfigured {
|
|
t.Errorf("repo = %q configured=%v, want the official repository by default", snap.Repo, snap.RepoConfigured)
|
|
}
|
|
}
|
|
|
|
func TestCheckFetchesAndListsIncoming(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
tr.upstreamCommit(t, "second: bump service", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"})
|
|
tr.upstreamCommit(t, "third: new role", map[string]string{"roles/extra.yaml": "name: extra\n"})
|
|
|
|
snap, err := Check(context.Background(), opts(tr))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if snap.CheckError != "" {
|
|
t.Fatalf("check failed: %s", snap.CheckError)
|
|
}
|
|
if !snap.UpdateAvailable || snap.Behind != 2 || snap.Ahead != 0 || snap.Diverged {
|
|
t.Errorf("behind=%d ahead=%d available=%v diverged=%v, want 2/0/true/false", snap.Behind, snap.Ahead, snap.UpdateAvailable, snap.Diverged)
|
|
}
|
|
if len(snap.Incoming) != 2 || snap.Incoming[0].Subject != "third: new role" {
|
|
t.Errorf("incoming = %+v, want the two new commits newest first", snap.Incoming)
|
|
}
|
|
if snap.IncomingTotal != 2 {
|
|
t.Errorf("incomingTotal = %d, want 2", snap.IncomingTotal)
|
|
}
|
|
}
|
|
|
|
// TestApplyKeepsOperatorContentIsThePointOfTheWholePackage: an update moves code and does
|
|
// not destroy the roles, skills or tools the operator made or edited from the console.
|
|
func TestApplyKeepsOperatorContentAndMovesCode(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
// The operator edits a shipped role and adds their own; upstream later rewrites that
|
|
// same role and takes the untracked file's name for itself.
|
|
writeFile(t, filepath.Join(tr.install, "roles", "shipped.yaml"), "name: shipped\ndescription: mine, do not touch\n")
|
|
writeFile(t, filepath.Join(tr.install, "roles", "mine.yaml"), "name: mine\n")
|
|
|
|
tr.upstreamCommit(t, "upstream rewrites roles and code", map[string]string{
|
|
"roles/shipped.yaml": "name: shipped\ndescription: upstream rewrite\n",
|
|
"roles/mine.yaml": "name: mine\ndescription: upstream stole the name\n",
|
|
"internal_service.go": "package service\n\nconst Version = \"3\"\n",
|
|
})
|
|
|
|
res, err := Apply(context.Background(), opts(tr), nil)
|
|
if err != nil {
|
|
t.Fatalf("apply failed: %v\nresult: %+v", err, res)
|
|
}
|
|
if res.Commits == 0 || res.FromCommit == res.ToCommit {
|
|
t.Errorf("nothing moved: %+v", res)
|
|
}
|
|
|
|
if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"3\"") {
|
|
t.Errorf("code was not updated, got %q", got)
|
|
}
|
|
if got := readFile(t, filepath.Join(tr.install, "roles", "shipped.yaml")); !strings.Contains(got, "mine, do not touch") {
|
|
t.Errorf("the operator's role was overwritten by upstream: %q", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tr.install, "roles", "mine.yaml")); err != nil {
|
|
t.Errorf("the operator's own role must survive: %v", err)
|
|
}
|
|
if got := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(got, "8088") {
|
|
t.Errorf("config.yaml was disturbed: %q", got)
|
|
}
|
|
if len(res.KeptContent) == 0 {
|
|
t.Error("keptContent must name the files that were protected, not silently drop them")
|
|
}
|
|
found := false
|
|
for _, p := range res.KeptContent {
|
|
if p == "roles/shipped.yaml" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("keptContent = %v, want roles/shipped.yaml in it", res.KeptContent)
|
|
}
|
|
if res.BinaryBuilt {
|
|
t.Error("BinaryName none must not claim a build")
|
|
}
|
|
if !res.NeedsRestart {
|
|
t.Error("an update that moved code reports that a restart is needed")
|
|
}
|
|
if _, ok := readState(tr.install); !ok {
|
|
t.Error("state file missing: rollback would be unavailable")
|
|
}
|
|
}
|
|
|
|
func TestApplyRefusesLocalSourceEdits(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
tr.upstreamCommit(t, "new upstream", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"})
|
|
writeFile(t, filepath.Join(tr.install, "internal_service.go"), "package service\n\nconst Version = \"local hand edit\"\n")
|
|
|
|
_, err := Apply(context.Background(), opts(tr), nil)
|
|
ue, ok := err.(*Error)
|
|
if !ok {
|
|
t.Fatalf("want *Error, got %v", err)
|
|
}
|
|
if ue.Reason != "local_source_edits" {
|
|
t.Errorf("reason = %q, want local_source_edits", ue.Reason)
|
|
}
|
|
if !strings.Contains(strings.Join(ue.Items, " "), "internal_service.go") {
|
|
t.Errorf("the refusal must name the file, got %v", ue.Items)
|
|
}
|
|
if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "local hand edit") {
|
|
t.Errorf("a refused update must not touch anything, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyRefusesDivergedBranch(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"9\"\n"})
|
|
writeFile(t, filepath.Join(tr.install, "my_patch.go"), "package service\n")
|
|
mustGit(t, tr.install, "add", "my_patch.go")
|
|
mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "my own commit")
|
|
|
|
_, err := Apply(context.Background(), opts(tr), nil)
|
|
ue, ok := err.(*Error)
|
|
if !ok || ue.Reason != "diverged" {
|
|
t.Fatalf("a branch with its own commits is a merge decision, not a download; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApplyWhenAlreadyUpToDateIsANoOp(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
res, err := Apply(context.Background(), opts(tr), nil)
|
|
if err != nil {
|
|
t.Fatalf("up to date must not be an error: %v", err)
|
|
}
|
|
if res.Commits != 0 || res.ToCommit != res.FromCommit {
|
|
t.Errorf("res = %+v, want zero movement", res)
|
|
}
|
|
if _, err := os.Stat(statePath(tr.install)); !os.IsNotExist(err) {
|
|
t.Error("a no-op update must not write a rollback state")
|
|
}
|
|
}
|
|
|
|
func TestRollbackMovesBackToTheRecordedCommit(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
before := mustGit(t, tr.install, "rev-parse", "HEAD")
|
|
tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"})
|
|
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Rollback restores the binary kept before a swap; stand one up the way installBinary
|
|
// would have left it.
|
|
bin := filepath.Join(tr.install, Binary)
|
|
writeFile(t, bin, "new binary bytes")
|
|
writeFile(t, bin+".prev", "old binary bytes")
|
|
|
|
res, err := Rollback(context.Background(), Options{Root: tr.install})
|
|
if err != nil {
|
|
t.Fatalf("rollback failed: %v", err)
|
|
}
|
|
if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != strings.TrimSpace(before) {
|
|
t.Errorf("HEAD after rollback = %s, want %s", got, before)
|
|
}
|
|
if got := readFile(t, bin); !strings.Contains(got, "old binary") {
|
|
t.Errorf("binary after rollback = %q, want the kept previous one", got)
|
|
}
|
|
if res.ToCommit == "" {
|
|
t.Error("rollback must report where it went")
|
|
}
|
|
if _, err := os.Stat(statePath(tr.install)); !os.IsNotExist(err) {
|
|
t.Error("a completed rollback must clear the state file")
|
|
}
|
|
}
|
|
|
|
func TestRollbackRefusesWhenHeadMoved(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
tr.upstreamCommit(t, "upstream new", map[string]string{"internal_service.go": "package service\n\nconst Version = \"7\"\n"})
|
|
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin := filepath.Join(tr.install, Binary)
|
|
writeFile(t, bin, "new")
|
|
writeFile(t, bin+".prev", "old")
|
|
// Somebody else moved the tree after the update.
|
|
writeFile(t, filepath.Join(tr.install, "extra.go"), "package service\n")
|
|
mustGit(t, tr.install, "add", "extra.go")
|
|
mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "later work")
|
|
|
|
_, err := Rollback(context.Background(), Options{Root: tr.install})
|
|
ue, ok := err.(*Error)
|
|
if !ok || ue.Reason != "moved_since_update" {
|
|
t.Fatalf("rollback must refuse rather than discard work done after the update, got %v", err)
|
|
}
|
|
if _, err := os.Stat(bin + ".prev"); err != nil {
|
|
t.Error("a refused rollback must leave the .prev binary where it was")
|
|
}
|
|
}
|
|
|
|
func TestProtectedCoversOperatorDataAndNotCode(t *testing.T) {
|
|
yes := []string{"roles/CTF.yaml", "skills/x/SKILL.md", "tools/semgrep.yaml", "agents/a.md", "bundles/pack/bundle.yaml",
|
|
"knowledge_base/note.md", "data/conversations.db", "log/server.log", "config.yaml", ".env", "venv/bin/python"}
|
|
no := []string{"internal/handler/plugin.go", "web/static/js/plugins.js", "cmd/server/main.go", "Makefile", "docs/zh-CN/c2.md"}
|
|
for _, p := range yes {
|
|
if !Protected(p) {
|
|
t.Errorf("%s must be protected from an update", p)
|
|
}
|
|
}
|
|
for _, p := range no {
|
|
if Protected(p) {
|
|
t.Errorf("%s is product source and must be updated", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidNameRejectsArgumentInjection(t *testing.T) {
|
|
for _, bad := range []string{"", "-C", "--upload-pack=touch /tmp/x", "a b", "a;b", "a\nb", "-x/y", "/abs/path", "../evil"} {
|
|
if ValidName(bad) {
|
|
t.Errorf("%q must be rejected before it reaches git's argv", bad)
|
|
}
|
|
}
|
|
for _, good := range []string{"origin", "mine", "main", "feature/docker", "v1.2.3"} {
|
|
if !ValidName(good) {
|
|
t.Errorf("%q is a legitimate remote or branch name", good)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInstallBinaryKeepsPrevAndRestoresOnFailedSwap(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dst := filepath.Join(dir, "cyberstrike-ai")
|
|
src := filepath.Join(dir, "new")
|
|
writeFile(t, dst, "old")
|
|
writeFile(t, src, "new")
|
|
|
|
prev, err := installBinary(src, dst)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if readFile(t, dst) != "new" || readFile(t, prev) != "old" {
|
|
t.Errorf("swap result: dst=%q prev=%q", readFile(t, dst), readFile(t, prev))
|
|
}
|
|
if err := restorePrevBinary(dst); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if readFile(t, dst) != "old" {
|
|
t.Errorf("rollback binary = %q, want old", readFile(t, dst))
|
|
}
|
|
if fileExists(dst + ".prev") {
|
|
t.Error("restoring the previous binary must consume the .prev file")
|
|
}
|
|
}
|
|
|
|
func TestTailKeepsTheEndOfABuildLog(t *testing.T) {
|
|
long := strings.Repeat("download\n", 500) + "vet: real error at the end"
|
|
got := tail(long, 200)
|
|
if !strings.Contains(got, "real error at the end") {
|
|
t.Error("tail must keep the compiler's actual error")
|
|
}
|
|
if len(got) > 260 {
|
|
t.Errorf("tail did not truncate: %d bytes", len(got))
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func readFile(t *testing.T, path string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func mustGit(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// The release version rides with the code: after an update the live config has to say the
|
|
// version of the code now on disk (the header badge reads it), and a rollback has to put
|
|
// the old number back next to the old code.
|
|
func TestApplySyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
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\"\nserver:\n port: 8088\n",
|
|
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
|
|
})
|
|
|
|
res, err := Apply(context.Background(), opts(tr), nil)
|
|
if err != nil {
|
|
t.Fatalf("apply failed: %v\nresult: %+v", err, res)
|
|
}
|
|
live := readFile(t, filepath.Join(tr.install, "config.yaml"))
|
|
if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") {
|
|
t.Fatalf("live config after update = %q, want the new version with everything else untouched", live)
|
|
}
|
|
st, ok := readState(tr.install)
|
|
if !ok || st.VersionBefore != "v1.0.0" || st.VersionAfter != "v1.1.0" {
|
|
t.Fatalf("state = %+v, want the version change recorded for the rollback", st)
|
|
}
|
|
|
|
// Rollback puts the number back with the code.
|
|
bin := filepath.Join(tr.install, Binary)
|
|
writeFile(t, bin, "new binary bytes")
|
|
writeFile(t, bin+".prev", "old binary bytes")
|
|
if _, err := Rollback(context.Background(), Options{Root: tr.install}); 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"`) {
|
|
t.Fatalf("live config after rollback = %q, want the old version back", live)
|
|
}
|
|
}
|
|
|
|
func TestRollbackDoesNotClobberAManuallyChangedVersion(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
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",
|
|
})
|
|
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Somebody pins their own version after the update; a rollback is not theirs to undo.
|
|
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v9.9.9-mine\"\nserver:\n port: 8088\n")
|
|
bin := filepath.Join(tr.install, Binary)
|
|
writeFile(t, bin, "new")
|
|
writeFile(t, bin+".prev", "old")
|
|
if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v9.9.9-mine") {
|
|
t.Fatalf("a hand-set version must survive a rollback, got %q", live)
|
|
}
|
|
}
|
|
|
|
func TestApplyLeavesTheVersionAloneWhenTheTreeCarriesNone(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
// The fixture's upstream has no config.example.yaml at all: nothing to sync, and the
|
|
// live config must not be rewritten with a guess.
|
|
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
|
|
tr.upstreamCommit(t, "second: bump service", map[string]string{
|
|
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
|
|
})
|
|
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v1.0.0") {
|
|
t.Fatalf("live config = %q, want it untouched", live)
|
|
}
|
|
if st, _ := readState(tr.install); st.VersionAfter != "" || st.VersionBefore != "" {
|
|
t.Fatalf("state = %+v, want no version fields when nothing changed", st)
|
|
}
|
|
}
|
|
|
|
func TestAdoptSyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
|
|
requireGit(t)
|
|
tr, dir := newAdoptFixture(t)
|
|
writeFile(t, filepath.Join(dir, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n")
|
|
tr.upstreamCommit(t, "release: bump version", map[string]string{
|
|
"config.example.yaml": "version: \"v1.1.0\"\n",
|
|
})
|
|
|
|
res, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("adopt failed: %v\nresult: %+v", err, res)
|
|
}
|
|
live := readFile(t, filepath.Join(dir, "config.yaml"))
|
|
if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") {
|
|
t.Fatalf("live config after adopt = %q, want the connected code's version with the rest untouched", live)
|
|
}
|
|
}
|
|
|
|
// A tree that is already current still gets its bookkeeping aligned: the version field is
|
|
// the one thing an update may correct with no commits to move (that is exactly the state a
|
|
// pre-feature installation is in).
|
|
func TestApplyAlignsTheVersionEvenWithNothingToPull(t *testing.T) {
|
|
requireGit(t)
|
|
tr := newTree(t)
|
|
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n")
|
|
writeFile(t, filepath.Join(tr.install, "config.example.yaml"), "version: \"v1.1.0\"\n")
|
|
|
|
res, err := Apply(context.Background(), opts(tr), nil)
|
|
if err != nil {
|
|
t.Fatalf("no-op apply failed: %v", err)
|
|
}
|
|
if res.Commits != 0 {
|
|
t.Fatalf("res = %+v, want zero movement", res)
|
|
}
|
|
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, `version: "v1.1.0"`) {
|
|
t.Fatalf("live config = %q, want the version aligned even though nothing moved", live)
|
|
}
|
|
// Bookkeeping only: a version-only correction writes no rollback state.
|
|
if _, ok := readState(tr.install); ok {
|
|
t.Fatal("a version-only alignment must not claim to be a rollback point")
|
|
}
|
|
}
|