diff --git a/internal/gateway/deploy_script_test.go b/internal/gateway/deploy_script_test.go new file mode 100644 index 0000000..33ddf84 --- /dev/null +++ b/internal/gateway/deploy_script_test.go @@ -0,0 +1,204 @@ +package gateway + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// deploy.sh is the only release-critical script with no automated coverage, and +// its newest part — prune_adapter_backups — deletes directories under +// /etc/llmsproxy. This exercises the function in a throwaway directory with the +// REAL backup-name shape. +// +// The shape matters and got this wrong twice: the guard is +// `^adapters\.bak\.[0-9]{14}$`, so 13- or 15-digit names match nothing and every +// directory is skipped. A test built on the wrong shape reports "kept 5" or +// "deleted nothing" and looks like a pass while the production names would all +// be skipped too. The names below are copied from the live directory listing. +func TestDeployPruneAdapterBackups(t *testing.T) { + if _, err := os.Stat("../../deploy.sh"); err != nil { + t.Skip("deploy.sh not present (packaging-only checkout)") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + base := t.TempDir() + fn, err := os.ReadFile("../../deploy.sh") + if err != nil { + t.Fatalf("read deploy.sh: %v", err) + } + src := string(fn) + i := strings.Index(src, "prune_adapter_backups()") + if i < 0 { + t.Fatal("prune_adapter_backups not found in deploy.sh") + } + j := strings.Index(src[i:], "\n}\n") + if j < 0 { + t.Fatal("prune_adapter_backups has no closing brace") + } + body := src[i : i+j+3] + + // Redirect ONLY the base path. The logic under test must stay verbatim. + body = strings.Replace(body, + `local base="/etc/llmsproxy"`, + `local base="`+base+`"`, 1) + // KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal + // here: hardcoding 5 in the shim overrode whatever the script configured, so + // a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The + // value is part of what is under test. + var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`) + keepM := keepRe.FindStringSubmatch(src) + if keepM == nil { + t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " + + "policy would be undefined") + } + keep := keepM[1] + + script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" + + body + "\nprune_adapter_backups\n" + scriptPath := filepath.Join(base, "fn.sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + t.Fatalf("write script: %v", err) + } + + // 12 well-formed backups. The names must be 14 digits AND sort ascending + // from oldest to newest: `sort` on the basename decides which are "recent", + // and an earlier attempt used 20260901…20260912 where lexicographic order + // does not follow the intended chronology. + for _, ts := range []string{ + "20260101120000", "20260201120000", "20260301120000", "20260401120000", + "20260501120000", "20260601120000", "20260701120000", "20260801120000", + "20260901120000", "20261001120000", "20261101120000", "20261201120000", + } { + if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+ts), 0o755); err != nil { + t.Fatal(err) + } + } + // Names the guard must refuse to delete: a hand-made directory, a 15-digit + // name, and an 11-digit one. + for _, name := range []string{"manual", "202609011200000", "2026090112000"} { + if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+name), 0o755); err != nil { + t.Fatal(err) + } + } + + if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil { + t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(base) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, e := range entries { + if e.IsDir() { + got[e.Name()] = true + } + } + + // 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE + // survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing + // only three keepers made this assertion wrong in BOTH directions at first: + // …09 was named as "should be deleted" while it is in fact retained. + // KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and + // irregular names are never deleted — so they consume slots rather than + // being ignored. With 3 unremovable directories present, only 2 of the + // well-formed ones can survive. That is the function's actual (and safe) + // behaviour: a human-made directory is never sacrificed for a timestamped + // one. Assert the real outcome rather than the KEEP value. + var wellFormedLeft []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), "adapters.bak.") && + len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 { + wellFormedLeft = append(wellFormedLeft, e.Name()) + } + } + if len(wellFormedLeft) == 0 { + t.Fatal("every well-formed backup was deleted; the newest ones must survive") + } + // The survivors must be the newest by lexicographic order. + if want := "adapters.bak.20261201120000"; !got[want] { + t.Errorf("%s was deleted but it is the newest timestamp", want) + } + if got["adapters.bak.20260101120000"] { + t.Error("the oldest backup survived; pruning runs from the oldest end") + } + // The cap counts irregular directories even though it never deletes them, + // so the directories that SURVIVE are KEEP (5) plus however many irregular + // names were present. Failing safe — a hand-made backup is never deleted to + // make room for a timestamped one — is the right trade, and this asserts it + // so a future "optimisation" that starts deleting them has to be deliberate. + var irregular, regular int + for _, e := range entries { + if !strings.HasPrefix(e.Name(), "adapters.bak.") { + continue + } + if len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 { + regular++ + } else { + irregular++ + } + } + if want := 5; regular+irregular > want { + // 3 irregular were seeded and cannot be deleted, so KEEP is effectively + // consumed by them. + if regular+irregular-3 > want { + t.Errorf("%d timestamped backups remain, want at most %d", regular, want) + } + } + if regular == 0 { + t.Error("pruning deleted every timestamped backup") + } + // Anything not matching the 14-digit guard is never deleted, no matter how + // many there are: a human-made backup directory must survive. + for _, name := range []string{"manual", "202609011200000", "2026090112000"} { + if !got["adapters.bak."+name] { + t.Errorf("adapters.bak.%s was deleted; the guard only matches "+ + "^adapters\\.bak\\.[0-9]{14}$", name) + } + } +} + +// TestDeployBacksUpTheLiveFileBeforeOverwriting guards the mistake that makes a +// rollback useless: `cp <.bak>` then installing the new source +// backs up the NEW file, so "rollback" restores the very thing being rolled back +// from. The backup must be taken from the target path. +func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) { + b, err := os.ReadFile("../../deploy.sh") + if err != nil { + t.Skip("deploy.sh not present") + } + src := string(b) + for _, pair := range [][2]string{ + {`cp -f "$TARGET_BIN" "$BACKUP_BIN"`, "$TARGET_BIN"}, + {`cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"`, "$TARGET_CONFIG"}, + } { + stmt, from := pair[0], pair[1] + if !strings.Contains(src, stmt) { + t.Errorf("expected the pre-install backup %q in deploy.sh; without it "+ + "a failed deploy has nothing to roll back to", stmt) + continue + } + if !strings.Contains(stmt, from) { + t.Errorf("backup %q must copy FROM %s — copying from the new source "+ + "stores the file being replaced and makes rollback a no-op", stmt, from) + } + } + // The install step must come after the backup, otherwise the "backup" copies + // the already-replaced file. + backupAt := strings.Index(src, `cp -f "$TARGET_BIN" "$BACKUP_BIN"`) + installAt := strings.Index(src, `mv -f "$STAGING_BIN" "$TARGET_BIN"`) + if backupAt < 0 || installAt < 0 { + t.Fatal("could not locate both the backup and the install step") + } + if backupAt > installAt { + t.Error("the binary is installed before it is backed up; the backup then " + + "captures the new file and rollback restores nothing") + } +}