407 lines
9.1 KiB
Go
407 lines
9.1 KiB
Go
//go:build unix
|
|
|
|
package instanceguard
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/store"
|
|
)
|
|
|
|
func TestAcquireSameResourceAcrossAliases(t *testing.T) {
|
|
base := t.TempDir()
|
|
real := filepath.Join(base, "real")
|
|
if err := os.Mkdir(real, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(base, "link")
|
|
if err := os.Symlink(real, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
held, err := Acquire(link)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer held.Release()
|
|
if _, err := Acquire(real); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("symlink alias: %v", err)
|
|
}
|
|
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(base); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(wd) })
|
|
if _, err := Acquire(filepath.Join(".", "real")); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("relative path: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHardlinkRejected(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
st, err := store.OpenSQLite(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Link(db, filepath.Join(dir, "alias.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(dir); !errors.Is(err, ErrHardlink) {
|
|
t.Fatalf("hardlink: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDatabaseReplacementRefusedBeforeOpen(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
release()
|
|
_ = st
|
|
if err := os.Remove(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := os.ReadFile(db + ".lock")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec, err := parseRecord(string(raw))
|
|
if err != nil || rec == nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 16; i++ {
|
|
if err := os.WriteFile(db, []byte("replaced"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, ino, exists, err := statFile(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists && ino != rec.DBIno {
|
|
break
|
|
}
|
|
if err := os.Remove(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "inode-burn-"+string(rune('a'+i))), []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lease.Release()
|
|
if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBReplaced) {
|
|
t.Fatalf("replaced db: %v", err)
|
|
}
|
|
if _, err := os.Stat(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMissingRecordedDatabaseRefused(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, release, err := openAndBind(t, dir, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
if err := os.Remove(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lease.Release()
|
|
if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBMissing) {
|
|
t.Fatalf("missing db: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRebuiltLockRefusedUntilAdopt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "inst-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
_ = st.Close()
|
|
if err := os.Remove(filepath.Join(dir, "agent-system.db.lock")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := lease.AutomaticRefusal(); err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"})
|
|
_ = opened.Close()
|
|
lease.Release()
|
|
if !errors.Is(err, ErrLockRebuilt) {
|
|
t.Fatalf("rebuilt lock: %v", err)
|
|
}
|
|
|
|
adopted, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer adopted.Release()
|
|
opened, err = store.OpenSQLite(adopted.DBPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer opened.Close()
|
|
if err := Bind(opened, adopted, BindOptions{Adopt: true, InstanceID: "inst-a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestClearedLockRecordRefusedWhenInodeStays(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "inst-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
_ = st.Close()
|
|
lockPath := filepath.Join(dir, "agent-system.db.lock")
|
|
if err := os.Truncate(lockPath, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"})
|
|
_ = opened.Close()
|
|
lease.Release()
|
|
if !errors.Is(err, ErrLockRebuilt) {
|
|
t.Fatalf("cleared lock: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInstanceMismatchPreservesEmpty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, release, err := openAndBind(t, dir, "inst-a")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
_ = st.Close()
|
|
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
t.Fatal(err)
|
|
}
|
|
err = Bind(opened, lease, BindOptions{InstanceID: "inst-b"})
|
|
_ = opened.Close()
|
|
lease.Release()
|
|
if !errors.Is(err, ErrInstanceMismatch) {
|
|
t.Fatalf("mismatch: %v", err)
|
|
}
|
|
|
|
lease, err = Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lease.Release()
|
|
opened, err = store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer opened.Close()
|
|
if err := Bind(opened, lease, BindOptions{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
marker, ok, err := opened.LoadInstanceGuard()
|
|
if err != nil || !ok || marker.InstanceID != "inst-a" {
|
|
t.Fatalf("empty id should keep marker: ok=%v id=%q err=%v", ok, marker.InstanceID, err)
|
|
}
|
|
}
|
|
|
|
func TestLockReleasedAfterKill(t *testing.T) {
|
|
if os.Getenv("U8_GUARD_ROLE") == "hold" {
|
|
lease, err := Acquire(os.Getenv("U8_GUARD_DIR"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(os.Getenv("U8_GUARD_READY"), []byte("ready"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(30 * time.Second)
|
|
lease.Release()
|
|
return
|
|
}
|
|
dir := t.TempDir()
|
|
ready := filepath.Join(dir, "ready")
|
|
cmd := exec.Command(os.Args[0], "-test.run=TestLockReleasedAfterKill")
|
|
cmd.Env = append(os.Environ(), "U8_GUARD_ROLE=hold", "U8_GUARD_DIR="+dir, "U8_GUARD_READY="+ready)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}
|
|
})
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(ready); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if _, err := os.Stat(ready); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(dir); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("held: %v", err)
|
|
}
|
|
if err := cmd.Process.Kill(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = cmd.Process.Wait()
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease.Release()
|
|
}
|
|
|
|
func TestMissingDataDirIsCreatedBeforeLock(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "nested", "data")
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lease.Release()
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestLockSymlinkCannotAliasDatabase(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := filepath.Join(dir, "agent-system.db")
|
|
st, err := store.OpenSQLite(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(db + ".lock"); err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(db, db+".lock"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := os.ReadFile(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(dir); !errors.Is(err, ErrLockAliased) {
|
|
t.Fatalf("symlink lock: %v", err)
|
|
}
|
|
after, err := os.ReadFile(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(before) != string(after) {
|
|
t.Fatal("aliased lock wrote into the database")
|
|
}
|
|
}
|
|
|
|
func TestSymlinkedDatabaseLocksResolvedFile(t *testing.T) {
|
|
base := t.TempDir()
|
|
realDir := filepath.Join(base, "real")
|
|
aliasDir := filepath.Join(base, "alias")
|
|
if err := os.Mkdir(realDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(aliasDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(realDir, "agent-system.db"), []byte{}, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(filepath.Join(realDir, "agent-system.db"), filepath.Join(aliasDir, "agent-system.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
held, err := Acquire(aliasDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer held.Release()
|
|
if _, err := os.Stat(filepath.Join(realDir, "agent-system.db.lock")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Acquire(realDir); !errors.Is(err, ErrBusy) {
|
|
t.Fatalf("resolved db lock: %v", err)
|
|
}
|
|
}
|
|
|
|
func openAndBind(t *testing.T, dir, instanceID string) (*store.Store, func(), error) {
|
|
t.Helper()
|
|
lease, err := Acquire(dir)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := lease.AutomaticRefusal(); err != nil {
|
|
lease.Release()
|
|
return nil, nil, err
|
|
}
|
|
st, err := store.OpenSQLite(lease.DBPath())
|
|
if err != nil {
|
|
lease.Release()
|
|
return nil, nil, err
|
|
}
|
|
if err := Bind(st, lease, BindOptions{InstanceID: instanceID}); err != nil {
|
|
_ = st.Close()
|
|
lease.Release()
|
|
return nil, nil, err
|
|
}
|
|
return st, func() {
|
|
_ = st.Close()
|
|
lease.Release()
|
|
}, nil
|
|
}
|