Files
server-deploy/internal/state/store_test.go

135 lines
3.5 KiB
Go

package state
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const testHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func TestExclusiveSession(t *testing.T) {
dir := t.TempDir()
s, err := Acquire(dir, "host-one")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
if second, err := Acquire(dir, "host-one"); second != nil || !errors.Is(err, ErrBusy) {
t.Fatalf("lock bypass: %v", err)
}
other, err := Acquire(t.TempDir(), "host-two")
if err != nil {
t.Fatal(err)
}
other.Close()
if err := s.Close(); err != nil {
t.Fatal(err)
}
if _, _, err := s.Begin("op-one", testHash); !errors.Is(err, ErrClosed) {
t.Fatal("used closed session")
}
next, err := Acquire(dir, "host-one")
if err != nil {
t.Fatal(err)
}
next.Close()
}
func TestInvalidHost(t *testing.T) {
for _, host := range []string{"", "../host", "Host", strings.Repeat("a", 49)} {
if s, err := Acquire(t.TempDir(), host); err == nil {
s.Close()
t.Fatal("accepted invalid host")
}
}
}
func TestStatePathsRejectSymlinks(t *testing.T) {
for _, name := range []string{"host.lock", "state.json"} {
t.Run(name, func(t *testing.T) {
dir, outside := t.TempDir(), filepath.Join(t.TempDir(), "outside")
if err := os.WriteFile(outside, []byte("protected"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(dir, name)); err != nil {
t.Skipf("symlink privilege unavailable: %v", err)
}
if s, err := Acquire(dir, "host-one"); err == nil {
s.Close()
t.Fatal("accepted symlink state")
}
content, err := os.ReadFile(outside)
if err != nil || string(content) != "protected" {
t.Fatal("modified outside file")
}
})
}
}
// The subprocess exits without Close: the OS must release its lock, but its
// running operation must remain recorded. No mock can test this boundary.
func TestProcessDeathPreservesRunningOperation(t *testing.T) {
if os.Getenv("DEPLOYCTL_STATE_CHILD") == "1" {
s, err := Acquire(os.Getenv("DEPLOYCTL_STATE_DIR"), "host-one")
if err != nil {
t.Fatal(err)
}
op, _, err := s.Begin("op-one", testHash)
if err != nil {
t.Fatal(err)
}
if _, err = s.Advance(op.ID, op.Revision, Running); err != nil {
t.Fatal(err)
}
fmt.Println("READY")
bufio.NewReader(os.Stdin).ReadByte()
os.Exit(3)
}
dir := t.TempDir()
cmd := exec.Command(os.Args[0], "-test.run=^TestProcessDeathPreservesRunningOperation$")
cmd.Env = append(os.Environ(), "DEPLOYCTL_STATE_CHILD=1", "DEPLOYCTL_STATE_DIR="+dir)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
defer stdin.Close()
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(line) != "READY" {
t.Fatalf("child failed: %q %v", line, err)
}
if _, err := Acquire(dir, "host-one"); !errors.Is(err, ErrBusy) {
t.Fatalf("cross-process lock bypass: %v", err)
}
if err := cmd.Process.Kill(); err != nil {
t.Fatal(err)
}
cmd.Wait()
s, err := Acquire(dir, "host-one")
if err != nil {
t.Fatal(err)
}
defer s.Close()
op, created, err := s.Begin("op-one", testHash)
if err != nil || created || op.Status != Running || op.Revision != 2 {
t.Fatalf("lost interrupted task: %+v %v", op, err)
}
if _, _, err := s.Begin("op-two", testHash); !errors.Is(err, ErrUnresolved) {
t.Fatal("allowed writes before reconciliation")
}
}