135 lines
3.5 KiB
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")
|
|
}
|
|
}
|