feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user