531b36924c
- job stop: real systemctl stop via SSH (was DB-only soft stop) resolves node from alloc_history or --peer flag - doctor db-retention: row count check for jobs/tasks/audit_log warns at 100k rows, suggests backup + cleanup - logs --lines: cap at 50000 (default 1000); --since upper bound 7d prevents OOM from unbounded journalctl - cache DB mode 0600 (was 0644; matches store.Open) - upgrade cutover: backup file + atomic rename (was sed -i) rollback restores from backup on failure Tests: job stop SSH, DB retention warning, logs lines cap, cache mode, cutover backup-restore + atomic rename. ---ci--- project: orca phase: 9 milestone: v0.13 status: complete requirements: covered: [158] ---/ci---
647 lines
19 KiB
Go
647 lines
19 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/migration"
|
|
"git.cloudinit.dev/coreci/orca/internal/paths"
|
|
)
|
|
|
|
type mockUpgradeRunner struct {
|
|
calls []mockCall
|
|
outputs map[string][]byte
|
|
errs map[string]error
|
|
fallback []byte
|
|
}
|
|
|
|
type mockCall struct {
|
|
name string
|
|
args []string
|
|
}
|
|
|
|
func (m *mockUpgradeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
m.calls = append(m.calls, mockCall{name: name, args: append([]string(nil), args...)})
|
|
key := name + " " + strings.Join(args, " ")
|
|
if m.errs != nil {
|
|
if err, ok := m.errs[key]; ok {
|
|
return nil, err
|
|
}
|
|
}
|
|
if m.outputs != nil {
|
|
if out, ok := m.outputs[key]; ok {
|
|
return out, nil
|
|
}
|
|
}
|
|
return m.fallback, nil
|
|
}
|
|
|
|
type mockUpgradeTransport struct {
|
|
calls []mockSSHDial
|
|
errs map[string]error
|
|
}
|
|
|
|
type mockSSHDial struct {
|
|
peer string
|
|
cmd string
|
|
}
|
|
|
|
func (m *mockUpgradeTransport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
|
m.calls = append(m.calls, mockSSHDial{peer: peer, cmd: cmd})
|
|
if m.errs != nil {
|
|
if err, ok := m.errs[peer]; ok {
|
|
return nil, err
|
|
}
|
|
}
|
|
return []byte(""), nil
|
|
}
|
|
|
|
// mockCutoverFS is an in-memory cutoverFS for testing performCutover /
|
|
// rollbackCutover without touching /etc/traefik (REQ-158, P09 T5).
|
|
type mockCutoverFS struct {
|
|
files map[string][]byte
|
|
errs map[string]error // keyed by operation: "read:<path>", "write:<path>", "rename:<old>", "stat:<path>"
|
|
}
|
|
|
|
func newMockCutoverFS() *mockCutoverFS {
|
|
return &mockCutoverFS{
|
|
files: make(map[string][]byte),
|
|
errs: make(map[string]error),
|
|
}
|
|
}
|
|
|
|
func (m *mockCutoverFS) ReadFile(path string) ([]byte, error) {
|
|
if err, ok := m.errs["read:"+path]; ok {
|
|
return nil, err
|
|
}
|
|
if data, ok := m.files[path]; ok {
|
|
return data, nil
|
|
}
|
|
return nil, fmt.Errorf("mock: %s not found", path)
|
|
}
|
|
|
|
func (m *mockCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error {
|
|
if err, ok := m.errs["write:"+path]; ok {
|
|
return err
|
|
}
|
|
cp := make([]byte, len(content))
|
|
copy(cp, content)
|
|
m.files[path] = cp
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCutoverFS) Rename(old, new string) error {
|
|
if err, ok := m.errs["rename:"+old]; ok {
|
|
return err
|
|
}
|
|
data, ok := m.files[old]
|
|
if !ok {
|
|
return fmt.Errorf("mock: rename source %s not found", old)
|
|
}
|
|
m.files[new] = data
|
|
delete(m.files, old)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCutoverFS) Remove(path string) error {
|
|
delete(m.files, path)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockCutoverFS) Stat(path string) (os.FileInfo, error) {
|
|
if err, ok := m.errs["stat:"+path]; ok {
|
|
return nil, err
|
|
}
|
|
if _, ok := m.files[path]; ok {
|
|
return mockFileInfo{name: path}, nil
|
|
}
|
|
return nil, fmt.Errorf("mock: %s not found", path)
|
|
}
|
|
|
|
type mockFileInfo struct{ name string }
|
|
|
|
func (m mockFileInfo) Name() string { return m.name }
|
|
func (m mockFileInfo) Size() int64 { return 0 }
|
|
func (m mockFileInfo) Mode() os.FileMode { return 0o644 }
|
|
func (m mockFileInfo) ModTime() time.Time { return time.Now() }
|
|
func (m mockFileInfo) IsDir() bool { return false }
|
|
func (m mockFileInfo) Sys() any { return nil }
|
|
|
|
func setupUpgradeTest(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("ORCA_HOME", t.TempDir())
|
|
}
|
|
|
|
func resetUpgradeFlags() {
|
|
upgradeTo = ""
|
|
upgradeImportCA = false
|
|
upgradeForce = false
|
|
upgradeDryRun = false
|
|
upgradeRunnerOverride = nil
|
|
httpClientOverride = nil
|
|
upgradeTransportOverride = nil
|
|
peersListerOverride = nil
|
|
migration.SetCAImporter(nil)
|
|
}
|
|
|
|
// setupUpgradeTestWithMocks calls resetRootFlags first (which resets
|
|
// all package globals including upgrade overrides), then lets the
|
|
// caller set mocks. Returns a buffer wired to rootCmd's output.
|
|
func setupUpgradeTestWithMocks(t *testing.T) *bytes.Buffer {
|
|
t.Helper()
|
|
t.Cleanup(resetUpgradeFlags)
|
|
resetRootFlags(t)
|
|
var buf bytes.Buffer
|
|
rootCmd.SetOut(&buf)
|
|
rootCmd.SetErr(&buf)
|
|
return &buf
|
|
}
|
|
|
|
func TestUpgradeCmdRegistered(t *testing.T) {
|
|
found := false
|
|
for _, cmd := range rootCmd.Commands() {
|
|
if cmd.Name() == "upgrade" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("upgrade command not registered on root")
|
|
}
|
|
}
|
|
|
|
func TestUpgradeRequiresToFlag(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
setupUpgradeTestWithMocks(t)
|
|
rootCmd.SetArgs([]string{"upgrade"})
|
|
err := rootCmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("upgrade without --to should fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "--to is required") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeDryRun(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
buf := setupUpgradeTestWithMocks(t)
|
|
upgradeTo = ""
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--dry-run"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade dry-run: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "v0.11.0") {
|
|
t.Errorf("output missing version: %s", out)
|
|
}
|
|
if !strings.Contains(out, "dry-run") {
|
|
t.Errorf("output missing dry-run mention: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeIdempotentSameVersion(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
buf := setupUpgradeTestWithMocks(t)
|
|
saved := version
|
|
version = "0.11.0"
|
|
t.Cleanup(func() { version = saved })
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "0.11.0"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade same version: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "no-op") {
|
|
t.Errorf("expected no-op message: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeCutoverVerificationSuccess(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
setupUpgradeTestWithMocks(t)
|
|
|
|
runner := &mockUpgradeRunner{
|
|
outputs: map[string][]byte{
|
|
"ss -tlnp": []byte(":443"),
|
|
},
|
|
}
|
|
upgradeRunnerOverride = runner
|
|
httpClientOverride = func(url string) (int, error) { return 200, nil }
|
|
|
|
// Provide a mock Traefik config so performCutover can read it.
|
|
cfs := newMockCutoverFS()
|
|
cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint: :443\n")
|
|
cutoverFSOverride = cfs
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade with cutover: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeCutoverRollback(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
buf := setupUpgradeTestWithMocks(t)
|
|
|
|
runner := &mockUpgradeRunner{
|
|
outputs: map[string][]byte{
|
|
"ss -tlnp": []byte(":443"),
|
|
},
|
|
}
|
|
upgradeRunnerOverride = runner
|
|
httpClientOverride = func(url string) (int, error) { return 502, nil }
|
|
|
|
// Provide a mock Traefik config so performCutover can read it.
|
|
cfs := newMockCutoverFS()
|
|
cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint: :443\n")
|
|
cutoverFSOverride = cfs
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0"})
|
|
err := rootCmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("upgrade with failed cutover should return error")
|
|
}
|
|
if !strings.Contains(err.Error(), "cutover verification failed") && !strings.Contains(err.Error(), "rolled back") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "Rolling back") {
|
|
t.Errorf("output should mention rollback: %s", out)
|
|
}
|
|
|
|
// Verify rollback: the traefik.yml content should be restored to
|
|
// :443 (the backup was renamed back over the modified file).
|
|
restored, ok := cfs.files["/etc/traefik/traefik.yml"]
|
|
if !ok {
|
|
t.Fatal("rollback: traefik.yml missing after rollback")
|
|
}
|
|
if !strings.Contains(string(restored), ":443") {
|
|
t.Errorf("rollback: traefik.yml not restored to :443, got: %s", string(restored))
|
|
}
|
|
if strings.Contains(string(restored), "127.0.0.1:8443") {
|
|
t.Errorf("rollback: traefik.yml still has 127.0.0.1:8443 after rollback: %s", string(restored))
|
|
}
|
|
|
|
foundRollback := false
|
|
for _, call := range runner.calls {
|
|
if call.name == "nft" && len(call.args) >= 2 && call.args[0] == "delete" {
|
|
foundRollback = true
|
|
}
|
|
}
|
|
if !foundRollback {
|
|
t.Errorf("rollback nft delete command not detected (calls: %v)", runner.calls)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeCutoverForceSkipsVerification(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
setupUpgradeTestWithMocks(t)
|
|
|
|
runner := &mockUpgradeRunner{
|
|
outputs: map[string][]byte{
|
|
"ss -tlnp": []byte(":443"),
|
|
},
|
|
}
|
|
upgradeRunnerOverride = runner
|
|
verificationCalled := false
|
|
httpClientOverride = func(url string) (int, error) {
|
|
verificationCalled = true
|
|
return 200, nil
|
|
}
|
|
|
|
// Provide a mock Traefik config so performCutover can read it.
|
|
cfs := newMockCutoverFS()
|
|
cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint: :443\n")
|
|
cutoverFSOverride = cfs
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade with --force: %v", err)
|
|
}
|
|
if verificationCalled {
|
|
t.Errorf("verification should be skipped with --force")
|
|
}
|
|
}
|
|
|
|
func TestUpgradeC27OrcaUserCreation(t *testing.T) {
|
|
setupUpgradeTest(t)
|
|
buf := setupUpgradeTestWithMocks(t)
|
|
|
|
runner := &mockUpgradeRunner{}
|
|
upgradeRunnerOverride = runner
|
|
peersListerOverride = func() ([]string, error) {
|
|
return []string{"peer1.example.com", "peer2.example.com"}, nil
|
|
}
|
|
transport := &mockUpgradeTransport{}
|
|
upgradeTransportOverride = transport
|
|
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade with peer user creation: %v", err)
|
|
}
|
|
|
|
var useraddCalls int
|
|
for _, call := range transport.calls {
|
|
if strings.Contains(call.cmd, "useradd -r orca") {
|
|
useraddCalls++
|
|
}
|
|
}
|
|
if useraddCalls != 2 {
|
|
t.Errorf("useradd called %d times, want 2 (one per peer)", useraddCalls)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "orca system user") {
|
|
t.Errorf("output should mention orca user creation: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeTriggersV08Migration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("ORCA_HOME", dir)
|
|
buf := setupUpgradeTestWithMocks(t)
|
|
|
|
createTestV08DB(t, filepath.Join(dir, "orca.db"))
|
|
if err := os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("cert"), 0o644); err != nil {
|
|
t.Fatalf("write ca.crt: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "ca.key"), []byte("key"), 0o644); err != nil {
|
|
t.Fatalf("write ca.key: %v", err)
|
|
}
|
|
|
|
runner := &mockUpgradeRunner{}
|
|
upgradeRunnerOverride = runner
|
|
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--dry-run"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade with v0.8 layout: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "v0.8 layout detected") {
|
|
t.Errorf("output should mention v0.8 detection: %s", out)
|
|
}
|
|
|
|
migratedDB := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db")
|
|
if _, err := os.Stat(migratedDB); err == nil {
|
|
t.Errorf("dry-run should not migrate the DB, but %s exists", migratedDB)
|
|
}
|
|
}
|
|
|
|
func createTestV08DB(t *testing.T, path string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
data := fmt.Sprintf("SQLite format 3\x00")
|
|
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
|
t.Fatalf("write db: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUpgradeFullMigration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("ORCA_HOME", dir)
|
|
setupUpgradeTestWithMocks(t)
|
|
|
|
if err := os.MkdirAll(filepath.Join(dir, "cluster"), 0o755); err != nil {
|
|
t.Fatalf("mkdir cluster: %v", err)
|
|
}
|
|
|
|
runner := &mockUpgradeRunner{}
|
|
upgradeRunnerOverride = runner
|
|
|
|
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
|
if err := rootCmd.Execute(); err != nil {
|
|
t.Fatalf("upgrade: %v", err)
|
|
}
|
|
|
|
var installCalled bool
|
|
for _, call := range runner.calls {
|
|
if call.name == "bash" && len(call.args) > 0 && strings.Contains(call.args[0], "install.sh") {
|
|
installCalled = true
|
|
}
|
|
}
|
|
if !installCalled {
|
|
t.Errorf("install.sh was not invoked (calls: %v)", runner.calls)
|
|
}
|
|
}
|
|
|
|
|
|
// TestCutoverBackupRestoreOnFailure verifies that when the cutover
|
|
// verification fails, the Traefik config is restored from the backup
|
|
// file (REQ-158, P09 T10). This is a unit-level test that calls
|
|
// performCutover directly with a mock FS.
|
|
func TestCutoverBackupRestoreOnFailure(t *testing.T) {
|
|
// Set up a mock FS with a Traefik config containing :443.
|
|
cfs := newMockCutoverFS()
|
|
original := []byte("entrypoint:\n - :443\n")
|
|
cfs.files["/etc/traefik/traefik.yml"] = original
|
|
cutoverFSOverride = cfs
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
// Mock runner that succeeds for systemctl restart.
|
|
runner := &mockUpgradeRunner{
|
|
outputs: make(map[string][]byte),
|
|
}
|
|
// Mock HTTP check returns 502 (failure).
|
|
prevHTTP := httpClientOverride
|
|
httpClientOverride = func(url string) (int, error) { return 502, nil }
|
|
t.Cleanup(func() { httpClientOverride = prevHTTP })
|
|
|
|
var buf bytes.Buffer
|
|
ok, err := performCutover(context.Background(), runner, &buf, false)
|
|
if err != nil {
|
|
t.Fatalf("performCutover: %v", err)
|
|
}
|
|
if ok {
|
|
t.Fatal("expected cutover to fail (ok=false)")
|
|
}
|
|
|
|
// Verify the Traefik config was restored from backup.
|
|
restored, exists := cfs.files["/etc/traefik/traefik.yml"]
|
|
if !exists {
|
|
t.Fatal("traefik.yml missing after rollback")
|
|
}
|
|
if string(restored) != string(original) {
|
|
t.Errorf("traefik.yml not restored to original, got: %s", string(restored))
|
|
}
|
|
// Verify 127.0.0.1:8443 is NOT in the restored file.
|
|
if strings.Contains(string(restored), "127.0.0.1:8443") {
|
|
t.Errorf("traefik.yml still has 127.0.0.1:8443 after rollback: %s", string(restored))
|
|
}
|
|
// The backup file should have been consumed by rollbackCutover's rename.
|
|
if _, bakExists := cfs.files["/etc/traefik/traefik.yml.bak"]; bakExists {
|
|
t.Error("backup file still exists after rollback (should have been renamed)")
|
|
}
|
|
}
|
|
|
|
// TestCutoverAtomicRenameSuccess verifies that the cutover writes the
|
|
// new config via atomic rename (temp file → original) and cleans up
|
|
// the backup on success (REQ-158, P09 T10).
|
|
func TestCutoverAtomicRenameSuccess(t *testing.T) {
|
|
cfs := newMockCutoverFS()
|
|
original := []byte("entrypoint:\n - :443\n")
|
|
cfs.files["/etc/traefik/traefik.yml"] = original
|
|
cutoverFSOverride = cfs
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
runner := &mockUpgradeRunner{
|
|
outputs: make(map[string][]byte),
|
|
}
|
|
prevHTTP := httpClientOverride
|
|
httpClientOverride = func(url string) (int, error) { return 200, nil }
|
|
t.Cleanup(func() { httpClientOverride = prevHTTP })
|
|
|
|
var buf bytes.Buffer
|
|
ok, err := performCutover(context.Background(), runner, &buf, false)
|
|
if err != nil {
|
|
t.Fatalf("performCutover: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected cutover to succeed (ok=true)")
|
|
}
|
|
|
|
// Verify the config was updated to 127.0.0.1:8443.
|
|
updated, exists := cfs.files["/etc/traefik/traefik.yml"]
|
|
if !exists {
|
|
t.Fatal("traefik.yml missing after cutover")
|
|
}
|
|
if !strings.Contains(string(updated), "127.0.0.1:8443") {
|
|
t.Errorf("traefik.yml should have 127.0.0.1:8443, got: %s", string(updated))
|
|
}
|
|
if strings.Contains(string(updated), ":443\n") && !strings.Contains(string(updated), "127.0.0.1:8443") {
|
|
t.Errorf("traefik.yml should not have bare :443 anymore, got: %s", string(updated))
|
|
}
|
|
// The temp file should not exist.
|
|
if _, tmpExists := cfs.files["/etc/traefik/traefik.yml.tmp"]; tmpExists {
|
|
t.Error("temp file still exists after atomic rename")
|
|
}
|
|
// The backup should have been cleaned up on success.
|
|
if _, bakExists := cfs.files["/etc/traefik/traefik.yml.bak"]; bakExists {
|
|
t.Error("backup file still exists after successful cutover (should be cleaned up)")
|
|
}
|
|
}
|
|
|
|
// TestCutoverBackupCreated verifies that a backup file is created
|
|
// before the cutover edits the config (REQ-158, P09 T10). Uses a
|
|
// custom mock FS that records the sequence of operations so we can
|
|
// assert the backup was written before the temp file.
|
|
func TestCutoverBackupCreated(t *testing.T) {
|
|
// Use a recording mock FS that fails on the rename step so the
|
|
// backup write is observable before the rollback consumes it.
|
|
cfs := newMockCutoverFS()
|
|
original := []byte("entrypoint:\n - :443\n")
|
|
cfs.files["/etc/traefik/traefik.yml"] = original
|
|
// Track write order via a custom FS that records operations.
|
|
var writeOrder []string
|
|
recordingCFS := &recordingCutoverFS{
|
|
inner: cfs,
|
|
writeOrder: &writeOrder,
|
|
}
|
|
// Make the rename of the temp file fail so the cutover aborts.
|
|
cfs.errs["rename:/etc/traefik/traefik.yml.tmp"] = fmt.Errorf("rename failed")
|
|
cutoverFSOverride = recordingCFS
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
runner := &mockUpgradeRunner{
|
|
outputs: make(map[string][]byte),
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
_, err := performCutover(context.Background(), runner, &buf, false)
|
|
if err == nil {
|
|
t.Fatal("expected error from failed rename")
|
|
}
|
|
|
|
// Verify the backup was written BEFORE the temp file.
|
|
// writeOrder records WriteFile calls in order.
|
|
bakIdx := -1
|
|
tmpIdx := -1
|
|
for i, p := range writeOrder {
|
|
if p == "/etc/traefik/traefik.yml.bak" {
|
|
bakIdx = i
|
|
}
|
|
if p == "/etc/traefik/traefik.yml.tmp" {
|
|
tmpIdx = i
|
|
}
|
|
}
|
|
if bakIdx == -1 {
|
|
t.Fatal("backup file was not written before cutover")
|
|
}
|
|
if tmpIdx == -1 {
|
|
t.Fatal("temp file was not written")
|
|
}
|
|
if bakIdx > tmpIdx {
|
|
t.Errorf("backup written after temp file (bakIdx=%d, tmpIdx=%d) — backup should come first", bakIdx, tmpIdx)
|
|
}
|
|
// The original should have been restored from backup on failure.
|
|
restored, exists := cfs.files["/etc/traefik/traefik.yml"]
|
|
if !exists {
|
|
t.Fatal("traefik.yml missing after failed rename + restore")
|
|
}
|
|
if string(restored) != string(original) {
|
|
t.Errorf("traefik.yml not restored to original after failed rename, got: %s", string(restored))
|
|
}
|
|
}
|
|
|
|
// recordingCutoverFS wraps a cutoverFS and records WriteFile call
|
|
// paths so tests can assert the order of operations (REQ-158, P09 T10).
|
|
type recordingCutoverFS struct {
|
|
inner cutoverFS
|
|
writeOrder *[]string
|
|
}
|
|
|
|
func (r *recordingCutoverFS) ReadFile(path string) ([]byte, error) {
|
|
return r.inner.ReadFile(path)
|
|
}
|
|
func (r *recordingCutoverFS) WriteFile(path string, content []byte, mode os.FileMode) error {
|
|
*r.writeOrder = append(*r.writeOrder, path)
|
|
return r.inner.WriteFile(path, content, mode)
|
|
}
|
|
func (r *recordingCutoverFS) Rename(old, new string) error {
|
|
return r.inner.Rename(old, new)
|
|
}
|
|
func (r *recordingCutoverFS) Remove(path string) error {
|
|
return r.inner.Remove(path)
|
|
}
|
|
func (r *recordingCutoverFS) Stat(path string) (os.FileInfo, error) {
|
|
return r.inner.Stat(path)
|
|
}
|
|
|
|
// TestCutoverNoSedDirectly verifies that the cutover does NOT use
|
|
// `sed -i` (the old unsafe approach). The mock runner records all
|
|
// calls; none should be `sed` (REQ-158, P09 T5).
|
|
func TestCutoverNoSedDirectly(t *testing.T) {
|
|
cfs := newMockCutoverFS()
|
|
cfs.files["/etc/traefik/traefik.yml"] = []byte("entrypoint:\n - :443\n")
|
|
cutoverFSOverride = cfs
|
|
t.Cleanup(func() { cutoverFSOverride = nil })
|
|
|
|
runner := &mockUpgradeRunner{
|
|
outputs: make(map[string][]byte),
|
|
}
|
|
prevHTTP := httpClientOverride
|
|
httpClientOverride = func(url string) (int, error) { return 200, nil }
|
|
t.Cleanup(func() { httpClientOverride = prevHTTP })
|
|
|
|
var buf bytes.Buffer
|
|
_, _ = performCutover(context.Background(), runner, &buf, false)
|
|
|
|
for _, call := range runner.calls {
|
|
if call.name == "sed" {
|
|
t.Errorf("cutover should not use 'sed' (uses atomic rename now), found call: %s %v", call.name, call.args)
|
|
}
|
|
}
|
|
}
|