f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
1574 lines
46 KiB
Go
1574 lines
46 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gofrs/flock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/kit/daemon"
|
|
)
|
|
|
|
// requirePOSIXSignals skips the test on platforms without POSIX signal
|
|
// semantics, where graceful SIGTERM termination and zombie-based liveness
|
|
// checks do not apply.
|
|
func requirePOSIXSignals(t *testing.T, reason string) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip(reason)
|
|
}
|
|
}
|
|
|
|
func setStartProbeTickForTest(t *testing.T, tick time.Duration) {
|
|
t.Helper()
|
|
require.Positive(t, tick)
|
|
old := time.Duration(atomic.SwapInt64(&startProbeTickNanos, int64(tick)))
|
|
t.Cleanup(func() {
|
|
atomic.StoreInt64(&startProbeTickNanos, int64(old))
|
|
})
|
|
}
|
|
|
|
// startSleepProcess starts a long-lived child process and reaps it during
|
|
// cleanup. The returned PID is alive for the duration of the test, and once
|
|
// signalled the child becomes a zombie that daemon.ProcessAlive still reports
|
|
// as alive until cleanup reaps it.
|
|
func startSleepProcess(t *testing.T) int {
|
|
t.Helper()
|
|
return startProcessKilledOnCleanup(t, exec.Command("sleep", "60"))
|
|
}
|
|
|
|
// startTERMIgnoringProcess starts a child that ignores SIGTERM, so it survives
|
|
// a graceful stop and drives the force-kill escalation path.
|
|
func startTERMIgnoringProcess(t *testing.T) int {
|
|
t.Helper()
|
|
cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; exec sleep 60")
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cmd.Start())
|
|
ready, err := bufio.NewReader(stdout).ReadString('\n')
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ready", strings.TrimSpace(ready))
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
return cmd.Process.Pid
|
|
}
|
|
|
|
// startReapedTERMIgnoringProcess starts a child that has installed its SIGTERM
|
|
// disposition before returning. The child is reaped concurrently so a test can
|
|
// distinguish a running process from one that stopDaemonProcess force-killed.
|
|
func startReapedTERMIgnoringProcess(t *testing.T) (int, <-chan struct{}) {
|
|
t.Helper()
|
|
cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; exec sleep 60")
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cmd.Start())
|
|
ready, err := bufio.NewReader(stdout).ReadString('\n')
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ready", strings.TrimSpace(ready))
|
|
|
|
reaped := make(chan struct{})
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
close(reaped)
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
<-reaped
|
|
})
|
|
return cmd.Process.Pid, reaped
|
|
}
|
|
|
|
func startProcessKilledOnCleanup(t *testing.T, cmd *exec.Cmd) int {
|
|
t.Helper()
|
|
require.NoError(t, cmd.Start())
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
return cmd.Process.Pid
|
|
}
|
|
|
|
// startReapedSleepProcess starts a long-lived child and reaps it concurrently,
|
|
// so once the process is signalled it leaves the zombie state and
|
|
// daemon.ProcessAlive reports it as dead. The returned channel closes when the
|
|
// child has been reaped.
|
|
func startReapedSleepProcess(t *testing.T) (int, <-chan struct{}) {
|
|
t.Helper()
|
|
cmd := exec.Command("sleep", "60")
|
|
require.NoError(t, cmd.Start())
|
|
reaped := make(chan struct{})
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
close(reaped)
|
|
}()
|
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
return cmd.Process.Pid, reaped
|
|
}
|
|
|
|
// deadPID returns a PID that daemon.ProcessAlive reports as dead. Reaping a
|
|
// just-started process is not portable here: on Windows, OpenProcess can still
|
|
// succeed briefly for a terminated process object.
|
|
func deadPID(t *testing.T) int {
|
|
t.Helper()
|
|
for _, pid := range []int{99999999, 999999999, 1 << 30} {
|
|
if !daemon.ProcessAlive(pid) {
|
|
return pid
|
|
}
|
|
}
|
|
t.Skip("could not find an unused PID for stale runtime test")
|
|
return 0
|
|
}
|
|
|
|
// onlyLiveRuntimeRecord asserts exactly one live runtime record exists in dir
|
|
// and returns it.
|
|
func onlyLiveRuntimeRecord(t *testing.T, dir string) daemon.RuntimeRecord {
|
|
t.Helper()
|
|
records := liveDaemonRecords(dir)
|
|
require.Len(t, records, 1)
|
|
return records[0]
|
|
}
|
|
|
|
type testDaemonEndpoint struct {
|
|
Host string
|
|
Port int
|
|
Addr string
|
|
}
|
|
|
|
func writeRuntimeRecordForTest(
|
|
dataDir string, rec daemon.RuntimeRecord,
|
|
) (string, error) {
|
|
if rec.Service == "" {
|
|
rec.Service = daemonService
|
|
}
|
|
if rec.Metadata == nil {
|
|
rec.Metadata = map[string]string{}
|
|
}
|
|
return runtimeStore(dataDir).Write(rec)
|
|
}
|
|
|
|
// writeRuntimeRecordFixture writes rec to dir, failing the test on error and
|
|
// registering cleanup that removes the runtime record.
|
|
func writeRuntimeRecordFixture(
|
|
t *testing.T, dir string, rec daemon.RuntimeRecord,
|
|
) string {
|
|
t.Helper()
|
|
path, err := writeRuntimeRecordForTest(dir, rec)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { RemoveDaemonRuntime(dir) })
|
|
return path
|
|
}
|
|
|
|
// runtimeRecordOption mutates a daemon.RuntimeRecord built by
|
|
// daemonRuntimeRecord.
|
|
type runtimeRecordOption func(*daemon.RuntimeRecord)
|
|
|
|
func withRuntimeVersion(v string) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) { rec.Version = v }
|
|
}
|
|
|
|
func withRuntimeStartedAt(startedAt time.Time) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) { rec.StartedAt = startedAt }
|
|
}
|
|
|
|
func withRuntimePID(pid int) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) { rec.PID = pid }
|
|
}
|
|
|
|
func withRuntimeAPIVersion(v int) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) {
|
|
rec.Metadata[runtimeAPIVersion] = strconv.Itoa(v)
|
|
}
|
|
}
|
|
|
|
func withRuntimeNoSync(noSync bool) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) {
|
|
rec.Metadata[runtimeNoSync] = strconv.FormatBool(noSync)
|
|
}
|
|
}
|
|
|
|
func withRuntimeReadOnly(readOnly bool) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) {
|
|
rec.Metadata[runtimeReadOnly] = strconv.FormatBool(readOnly)
|
|
}
|
|
}
|
|
|
|
func withRuntimeRequireAuth(requireAuth bool) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) {
|
|
rec.Metadata[runtimeRequireAuth] = strconv.FormatBool(requireAuth)
|
|
}
|
|
}
|
|
|
|
func withRuntimeMetadata(key, value string) runtimeRecordOption {
|
|
return func(rec *daemon.RuntimeRecord) {
|
|
rec.Metadata[key] = value
|
|
}
|
|
}
|
|
|
|
// daemonRuntimeRecord builds a runtime record for the given address with the
|
|
// metadata fields the daemon writes. It defaults to a live, current-API,
|
|
// writable record; options override individual fields.
|
|
func daemonRuntimeRecord(
|
|
host string, port int, opts ...runtimeRecordOption,
|
|
) daemon.RuntimeRecord {
|
|
rec := daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Network: daemon.NetworkTCP,
|
|
Address: net.JoinHostPort(host, strconv.Itoa(port)),
|
|
Service: daemonService,
|
|
Version: "test",
|
|
StartedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
runtimeHost: host,
|
|
runtimePort: strconv.Itoa(port),
|
|
runtimeReadOnly: "false",
|
|
runtimeAPIVersion: strconv.Itoa(daemonAPIVersion),
|
|
runtimeDataVersion: strconv.Itoa(db.CurrentDataVersion()),
|
|
},
|
|
}
|
|
for _, opt := range opts {
|
|
opt(&rec)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func runtimeRecordForEndpoint(
|
|
endpoint testDaemonEndpoint, opts ...runtimeRecordOption,
|
|
) daemon.RuntimeRecord {
|
|
return daemonRuntimeRecord(endpoint.Host, endpoint.Port, opts...)
|
|
}
|
|
|
|
func runtimePathForTest(dataDir string, pid int) string {
|
|
path, err := runtimeStore(dataDir).Path(pid)
|
|
if err != nil {
|
|
return filepath.Join(dataDir, fmt.Sprintf("daemon.%d.json", pid))
|
|
}
|
|
return path
|
|
}
|
|
|
|
func runtimeTestDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "runtime")
|
|
_, err := runtimeStore(dir).LockPath()
|
|
require.NoError(t, err)
|
|
return dir
|
|
}
|
|
|
|
// TestSameProcessStartMarkerNeverReportsExternal hammers the race between
|
|
// markDaemonStarting's flock-acquire-then-register sequence and the
|
|
// isExternalDaemonStarting probe: a start marker owned by this process must
|
|
// never be classified as an external daemon startup, even mid-acquisition.
|
|
// Misclassification makes waitForExternalServeStartup return before the
|
|
// same-process owner publishes its runtime record, which surfaced as a flaky
|
|
// "serve --background is already in progress" error in
|
|
// TestEnsureBackgroundServeConcurrentLaunchConvergesOnDaemon.
|
|
func TestSameProcessStartMarkerNeverReportsExternal(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
require.NoError(t, os.MkdirAll(dir, 0o700))
|
|
|
|
stop := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
MarkDaemonStarting(dir)
|
|
UnmarkDaemonStarting(dir)
|
|
}
|
|
})
|
|
t.Cleanup(func() {
|
|
close(stop)
|
|
wg.Wait()
|
|
UnmarkDaemonStarting(dir)
|
|
})
|
|
|
|
deadline := time.Now().Add(300 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
require.False(t, isExternalDaemonStarting(dir),
|
|
"same-process start marker reported as external")
|
|
}
|
|
}
|
|
|
|
func holdExternalDaemonStartLock(t *testing.T, dataDir string) func() {
|
|
t.Helper()
|
|
stdin := startExternalDaemonStartLockHelper(t, dataDir)
|
|
|
|
var once sync.Once
|
|
unlock := func() {
|
|
once.Do(func() { _ = stdin.Close() })
|
|
}
|
|
t.Cleanup(unlock)
|
|
return unlock
|
|
}
|
|
|
|
func startExternalDaemonStartLockHelper(t *testing.T, dataDir string) io.Closer {
|
|
t.Helper()
|
|
cmd := exec.Command(
|
|
os.Args[0],
|
|
"-test.run=^TestHoldExternalDaemonStartLockHelperProcess$",
|
|
)
|
|
cmd.Env = append(
|
|
os.Environ(),
|
|
"AGENTSVIEW_HOLD_EXTERNAL_START_LOCK_HELPER=1",
|
|
"AGENTSVIEW_HOLD_EXTERNAL_START_LOCK_DATA_DIR="+dataDir,
|
|
)
|
|
stdin, err := cmd.StdinPipe()
|
|
require.NoError(t, err)
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
cmd.Stderr = os.Stderr
|
|
require.NoError(t, cmd.Start())
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
t.Cleanup(func() {
|
|
_ = stdin.Close()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
_ = cmd.Process.Kill()
|
|
<-done
|
|
}
|
|
})
|
|
|
|
line, err := bufio.NewReader(stdout).ReadString('\n')
|
|
if err != nil {
|
|
_ = cmd.Process.Kill()
|
|
require.NoError(t, err)
|
|
}
|
|
if strings.TrimSpace(line) != "ready" {
|
|
_ = cmd.Process.Kill()
|
|
require.Equal(t, "ready", strings.TrimSpace(line))
|
|
}
|
|
require.Eventually(t, func() bool {
|
|
return isExternalDaemonStarting(dataDir)
|
|
}, 2*time.Second, 10*time.Millisecond,
|
|
"external daemon start lock should be visible to parent")
|
|
return stdin
|
|
}
|
|
|
|
func holdExternalBackgroundLaunchLock(t *testing.T, dataDir string) func() {
|
|
t.Helper()
|
|
stdin := startExternalBackgroundLaunchLockHelper(t, dataDir)
|
|
|
|
var once sync.Once
|
|
unlock := func() {
|
|
once.Do(func() { _ = stdin.Close() })
|
|
}
|
|
t.Cleanup(unlock)
|
|
return unlock
|
|
}
|
|
|
|
func startExternalBackgroundLaunchLockHelper(
|
|
t *testing.T,
|
|
dataDir string,
|
|
) io.Closer {
|
|
t.Helper()
|
|
cmd := exec.Command(
|
|
os.Args[0],
|
|
"-test.run=^TestHoldExternalBackgroundLaunchLockHelperProcess$",
|
|
)
|
|
cmd.Env = append(
|
|
os.Environ(),
|
|
"AGENTSVIEW_HOLD_EXTERNAL_BACKGROUND_LAUNCH_LOCK_HELPER=1",
|
|
"AGENTSVIEW_HOLD_EXTERNAL_BACKGROUND_LAUNCH_LOCK_DATA_DIR="+dataDir,
|
|
)
|
|
stdin, err := cmd.StdinPipe()
|
|
require.NoError(t, err)
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
cmd.Stderr = os.Stderr
|
|
require.NoError(t, cmd.Start())
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- cmd.Wait() }()
|
|
t.Cleanup(func() {
|
|
_ = stdin.Close()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
_ = cmd.Process.Kill()
|
|
<-done
|
|
}
|
|
})
|
|
|
|
line, err := bufio.NewReader(stdout).ReadString('\n')
|
|
if err != nil {
|
|
_ = cmd.Process.Kill()
|
|
require.NoError(t, err)
|
|
}
|
|
if strings.TrimSpace(line) != "ready" {
|
|
_ = cmd.Process.Kill()
|
|
require.Equal(t, "ready", strings.TrimSpace(line))
|
|
}
|
|
require.Eventually(t, func() bool {
|
|
return isBackgroundLaunchActive(dataDir)
|
|
}, 2*time.Second, 10*time.Millisecond,
|
|
"external background launch lock should be visible to parent")
|
|
return stdin
|
|
}
|
|
|
|
func TestHoldExternalDaemonStartLockHelperProcess(t *testing.T) {
|
|
if os.Getenv("AGENTSVIEW_HOLD_EXTERNAL_START_LOCK_HELPER") != "1" {
|
|
return
|
|
}
|
|
dataDir := os.Getenv("AGENTSVIEW_HOLD_EXTERNAL_START_LOCK_DATA_DIR")
|
|
require.NotEmpty(t, dataDir)
|
|
lockPath, err := runtimeStore(dataDir).LockPath()
|
|
require.NoError(t, err)
|
|
lock := flock.New(lockPath)
|
|
locked, err := lock.TryLock()
|
|
require.NoError(t, err)
|
|
require.True(t, locked)
|
|
fmt.Println("ready")
|
|
_, _ = io.Copy(io.Discard, os.Stdin)
|
|
require.NoError(t, lock.Unlock())
|
|
}
|
|
|
|
func TestHoldExternalBackgroundLaunchLockHelperProcess(t *testing.T) {
|
|
if os.Getenv(
|
|
"AGENTSVIEW_HOLD_EXTERNAL_BACKGROUND_LAUNCH_LOCK_HELPER",
|
|
) != "1" {
|
|
return
|
|
}
|
|
dataDir := os.Getenv(
|
|
"AGENTSVIEW_HOLD_EXTERNAL_BACKGROUND_LAUNCH_LOCK_DATA_DIR",
|
|
)
|
|
require.NotEmpty(t, dataDir)
|
|
require.NoError(t, os.MkdirAll(dataDir, 0o700))
|
|
lock := flock.New(backgroundLaunchLockPath(dataDir))
|
|
locked, err := lock.TryLock()
|
|
require.NoError(t, err)
|
|
require.True(t, locked)
|
|
fmt.Println("ready")
|
|
_, _ = io.Copy(io.Discard, os.Stdin)
|
|
require.NoError(t, lock.Unlock())
|
|
}
|
|
|
|
func serverEndpoint(t *testing.T, ts *httptest.Server) testDaemonEndpoint {
|
|
t.Helper()
|
|
u, err := url.Parse(ts.URL)
|
|
require.NoError(t, err)
|
|
host, portText, err := net.SplitHostPort(u.Host)
|
|
require.NoError(t, err)
|
|
port, err := strconv.Atoi(portText)
|
|
require.NoError(t, err)
|
|
return testDaemonEndpoint{
|
|
Host: host,
|
|
Port: port,
|
|
Addr: net.JoinHostPort(host, portText),
|
|
}
|
|
}
|
|
|
|
func newPingDaemon(t *testing.T) testDaemonEndpoint {
|
|
t.Helper()
|
|
ts := httptest.NewServer(daemon.NewPingHandler(daemon.PingHandlerOptions{
|
|
Service: daemonService,
|
|
Version: "test",
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
return serverEndpoint(t, ts)
|
|
}
|
|
|
|
func newPingDaemonWithProbeSignal(t *testing.T) (testDaemonEndpoint, <-chan struct{}) {
|
|
t.Helper()
|
|
pinged := make(chan struct{})
|
|
var pingedOnce sync.Once
|
|
ping := daemon.NewPingHandler(daemon.PingHandlerOptions{
|
|
Service: daemonService,
|
|
Version: "test",
|
|
})
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
pingedOnce.Do(func() { close(pinged) })
|
|
ping.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
return serverEndpoint(t, ts), pinged
|
|
}
|
|
|
|
func testPingServer(t *testing.T) (host string, port int) {
|
|
t.Helper()
|
|
endpoint := newPingDaemon(t)
|
|
return endpoint.Host, endpoint.Port
|
|
}
|
|
|
|
func writeStartupFallbackFixture(
|
|
t *testing.T, dir, host string, port, pid int, createTime string,
|
|
) {
|
|
t.Helper()
|
|
MarkDaemonStarting(dir)
|
|
t.Cleanup(func() { UnmarkDaemonStarting(dir) })
|
|
state := startupState{
|
|
PID: pid,
|
|
StartedAt: time.Now().Add(-time.Minute),
|
|
Phase: "starting HTTP server",
|
|
Host: host,
|
|
Port: port,
|
|
RuntimeError: "permission denied writing runtime record",
|
|
CreateTime: createTime,
|
|
APIVersion: daemonAPIVersion,
|
|
DataVersion: db.CurrentDataVersion(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
data, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(startupStatePath(dir), data, 0o600))
|
|
}
|
|
|
|
func failRuntimeRecordListing(t *testing.T) {
|
|
t.Helper()
|
|
oldList := listDaemonRuntimeRecords
|
|
listDaemonRuntimeRecords = func(daemon.RuntimeStore) ([]daemon.RuntimeRecord, error) {
|
|
return nil, errors.New("store unavailable")
|
|
}
|
|
t.Cleanup(func() { listDaemonRuntimeRecords = oldList })
|
|
}
|
|
|
|
func TestFindDaemonRuntime_UsesStartupFallbackWhenRuntimeStoreInspectionFails(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(
|
|
t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10),
|
|
)
|
|
failRuntimeRecordListing(t)
|
|
|
|
rt := FindDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
assert.True(t, rt.RuntimeFallback)
|
|
assert.Equal(t, port, rt.Port)
|
|
}
|
|
|
|
func TestFindIncompatibleDaemonRuntime_UsesStartupFallbackWhenRuntimeStoreInspectionFails(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(
|
|
t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10),
|
|
)
|
|
state := readStartupState(dir)
|
|
require.NotNil(t, state)
|
|
state.APIVersion = daemonAPIVersion + 1
|
|
data, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(startupStatePath(dir), data, 0o600))
|
|
failRuntimeRecordListing(t)
|
|
|
|
rt, compatErr := FindIncompatibleDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
require.Error(t, compatErr)
|
|
assert.True(t, rt.RuntimeFallback)
|
|
assert.ErrorContains(t, compatErr, "API version")
|
|
}
|
|
|
|
func TestWritableDaemonFallbackResolver(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10))
|
|
|
|
rt := FindWritableDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
assert.True(t, rt.RuntimeFallback)
|
|
assert.Equal(t, port, rt.Port)
|
|
|
|
state := readStartupState(dir)
|
|
require.NotNil(t, state)
|
|
state.CreateTime = "1"
|
|
data, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(startupStatePath(dir), data, 0o600))
|
|
assert.Nil(t, FindWritableDaemonRuntime(dir), "stale fallback must fail closed")
|
|
}
|
|
|
|
func TestFindWritableDaemonRuntime_StartupFallbackSurvivesStartLockProbeError(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
state := startupState{
|
|
PID: os.Getpid(),
|
|
StartedAt: time.Now().Add(-time.Minute),
|
|
Phase: "starting HTTP server",
|
|
Host: host,
|
|
Port: port,
|
|
RuntimeError: "permission denied writing runtime record",
|
|
CreateTime: strconv.FormatInt(createTime, 10),
|
|
APIVersion: daemonAPIVersion,
|
|
DataVersion: db.CurrentDataVersion(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
data, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(startupStatePath(dir), data, 0o600))
|
|
|
|
oldTryLock := startLockTryLock
|
|
startLockTryLock = func(*flock.Flock) (bool, error) {
|
|
return false, errors.New("simulated lock probe failure")
|
|
}
|
|
t.Cleanup(func() { startLockTryLock = oldTryLock })
|
|
|
|
rt := FindWritableDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
assert.True(t, rt.RuntimeFallback)
|
|
assert.True(t, IsLocalDaemonActive(dir))
|
|
}
|
|
|
|
func TestLocalWritableDaemonRecordsWithFallbackPreservesReadOnlyRecords(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
readOnlyHost, readOnlyPort := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeRuntimeRecordFixture(t, dir, daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Service: daemonService,
|
|
Version: "test",
|
|
Network: daemon.NetworkTCP,
|
|
Address: net.JoinHostPort(readOnlyHost, strconv.Itoa(readOnlyPort)),
|
|
StartedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
runtimeHost: readOnlyHost,
|
|
runtimePort: strconv.Itoa(readOnlyPort),
|
|
runtimeReadOnly: "true",
|
|
runtimeAPIVersion: strconv.Itoa(daemonAPIVersion),
|
|
runtimeDataVersion: strconv.Itoa(db.CurrentDataVersion()),
|
|
runtimeCreateTime: strconv.FormatInt(createTime, 10),
|
|
},
|
|
})
|
|
host, port := testPingServer(t)
|
|
writeStartupFallbackFixture(t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10))
|
|
|
|
records, fallback := localWritableDaemonRecordsWithFallback(dir, "")
|
|
require.True(t, fallback)
|
|
require.Len(t, records, 2)
|
|
|
|
readOnlySeen := false
|
|
writableSeen := false
|
|
for _, rec := range records {
|
|
rt := daemonRuntimeFromRecord(rec)
|
|
if rt.ReadOnly {
|
|
readOnlySeen = true
|
|
assert.Equal(t, readOnlyPort, rt.Port)
|
|
continue
|
|
}
|
|
writableSeen = true
|
|
assert.Equal(t, port, rt.Port)
|
|
}
|
|
assert.True(t, readOnlySeen)
|
|
assert.True(t, writableSeen)
|
|
}
|
|
|
|
func TestFindDaemonRuntime_IgnoresIncompatibleStartupStateFallback(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeStartupFallbackFixture(
|
|
t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10),
|
|
)
|
|
state := readStartupState(dir)
|
|
require.NotNil(t, state)
|
|
state.APIVersion = daemonAPIVersion + 1
|
|
data, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(startupStatePath(dir), data, 0o600))
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir))
|
|
rt, compatErr := FindIncompatibleDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
require.Error(t, compatErr)
|
|
assert.Contains(t, compatErr.Error(), "API version")
|
|
assert.True(t, rt.RuntimeFallback)
|
|
}
|
|
|
|
func TestWriteDaemonRuntimeFailurePreservesUpdateLaunchArgs(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
MarkDaemonStarting(dir)
|
|
t.Cleanup(func() { UnmarkDaemonStarting(dir) })
|
|
newStartupStateWriter(dir, time.Now).SetPhase("starting HTTP server")
|
|
host, port := testPingServer(t)
|
|
runtimePath, err := runtimeStore(dir).Path(os.Getpid())
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.Mkdir(runtimePath, 0o700))
|
|
|
|
_, err = WriteDaemonRuntimeWithAuthAndNoSync(
|
|
dir, host, port, "test", false, true, true,
|
|
)
|
|
require.Error(t, err)
|
|
|
|
rt := FindWritableDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
assert.Equal(t, "test", rt.Record.Version)
|
|
state := readStartupState(dir)
|
|
require.NotNil(t, state)
|
|
assert.True(t, state.RequireAuthKnown)
|
|
assert.True(t, state.RequireAuth)
|
|
assert.True(t, state.NoSyncKnown)
|
|
assert.True(t, state.NoSync)
|
|
|
|
oldStop := stopDaemonRuntimeForUpgrade
|
|
stopDaemonRuntimeForUpgrade = func(_ config.Config, _ *DaemonRuntime) error { return nil }
|
|
t.Cleanup(func() { stopDaemonRuntimeForUpgrade = oldStop })
|
|
result, err := stopWritableDaemonsForUpdate(config.Config{DataDir: dir})
|
|
require.NoError(t, err)
|
|
assert.True(t, result.Stopped)
|
|
args := restartDaemonAfterUpdateArgs(config.Config{}, result)
|
|
assert.Contains(t, args, "--require-auth")
|
|
assert.Contains(t, args, "--no-sync")
|
|
}
|
|
|
|
func TestWriteDaemonRuntimeFailurePreservesManagedCaddyIdentity(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
MarkDaemonStarting(dir)
|
|
t.Cleanup(func() { UnmarkDaemonStarting(dir) })
|
|
newStartupStateWriter(dir, time.Now).SetPhase("starting HTTP server")
|
|
host, port := testPingServer(t)
|
|
runtimePath, err := runtimeStore(dir).Path(os.Getpid())
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.Mkdir(runtimePath, 0o700))
|
|
caddyCreateTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
|
|
_, err = WriteDaemonRuntimeWithAuthAndNoSync(
|
|
dir, host, port, "test", false, false, false, os.Getpid(),
|
|
)
|
|
require.Error(t, err)
|
|
|
|
state := readStartupState(dir)
|
|
require.NotNil(t, state)
|
|
assert.Equal(t, os.Getpid(), state.CaddyPID)
|
|
assert.Equal(t, strconv.FormatInt(caddyCreateTime, 10), state.CaddyCreateTime)
|
|
|
|
rt := FindWritableDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
assert.Equal(t, strconv.Itoa(os.Getpid()), rt.Record.Metadata[runtimeCaddyPID])
|
|
assert.Equal(t, strconv.FormatInt(caddyCreateTime, 10), rt.Record.Metadata[runtimeCaddyCreateTime])
|
|
}
|
|
|
|
func TestStopWritableDaemonsForUpdateIgnoresStaleWritableRecordBeforeFallback(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
staleHost, stalePort := testPingServer(t)
|
|
createTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
writeRuntimeRecordFixture(t, dir, daemon.RuntimeRecord{
|
|
PID: os.Getpid(),
|
|
Service: daemonService,
|
|
Version: "stale",
|
|
Network: daemon.NetworkTCP,
|
|
Address: net.JoinHostPort(staleHost, strconv.Itoa(stalePort)),
|
|
StartedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
runtimeHost: staleHost,
|
|
runtimePort: strconv.Itoa(stalePort),
|
|
runtimeAPIVersion: strconv.Itoa(daemonAPIVersion),
|
|
runtimeDataVersion: strconv.Itoa(db.CurrentDataVersion()),
|
|
runtimeCreateTime: "1",
|
|
},
|
|
})
|
|
host, port := testPingServer(t)
|
|
writeStartupFallbackFixture(
|
|
t, dir, host, port, os.Getpid(), strconv.FormatInt(createTime, 10),
|
|
)
|
|
|
|
oldStop := stopDaemonRuntimeForUpgrade
|
|
var stopped *DaemonRuntime
|
|
stopDaemonRuntimeForUpgrade = func(_ config.Config, rt *DaemonRuntime) error {
|
|
stopped = rt
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { stopDaemonRuntimeForUpgrade = oldStop })
|
|
|
|
result, err := stopWritableDaemonsForUpdate(config.Config{DataDir: dir})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stopped)
|
|
assert.True(t, result.Stopped)
|
|
assert.Equal(t, port, stopped.Port)
|
|
assert.Equal(t, strconv.FormatInt(createTime, 10), stopped.Record.Metadata[runtimeCreateTime])
|
|
}
|
|
|
|
func newAuthenticatedPingDaemon(t *testing.T, token string) testDaemonEndpoint {
|
|
t.Helper()
|
|
ping := daemon.NewPingHandler(daemon.PingHandlerOptions{
|
|
Service: daemonService,
|
|
Version: "test",
|
|
})
|
|
ts := httptest.NewServer(http.HandlerFunc(func(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) {
|
|
if r.Header.Get("Authorization") != "Bearer "+token {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
ping.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(ts.Close)
|
|
return serverEndpoint(t, ts)
|
|
}
|
|
|
|
func testAuthenticatedPingServer(
|
|
t *testing.T, token string,
|
|
) (host string, port int) {
|
|
t.Helper()
|
|
endpoint := newAuthenticatedPingDaemon(t, token)
|
|
return endpoint.Host, endpoint.Port
|
|
}
|
|
|
|
func writeLiveRuntime(
|
|
t *testing.T,
|
|
dir string,
|
|
readOnly bool,
|
|
opts ...runtimeRecordOption,
|
|
) (testDaemonEndpoint, string) {
|
|
t.Helper()
|
|
endpoint := newPingDaemon(t)
|
|
allOpts := append([]runtimeRecordOption{withRuntimeReadOnly(readOnly)}, opts...)
|
|
path := writeRuntimeRecordFixture(
|
|
t, dir, runtimeRecordForEndpoint(endpoint, allOpts...),
|
|
)
|
|
return endpoint, path
|
|
}
|
|
|
|
func readRuntimeRecord(t *testing.T, path string) daemon.RuntimeRecord {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
var rec daemon.RuntimeRecord
|
|
require.NoError(t, json.Unmarshal(data, &rec))
|
|
return rec
|
|
}
|
|
|
|
func assertPathRemoved(t *testing.T, path string, msgAndArgs ...any) {
|
|
t.Helper()
|
|
_, err := os.Stat(path)
|
|
assert.True(t, os.IsNotExist(err), msgAndArgs...)
|
|
}
|
|
|
|
func assertRuntimeRecordRemoved(
|
|
t *testing.T,
|
|
dataDir string,
|
|
pid int,
|
|
msgAndArgs ...any,
|
|
) {
|
|
t.Helper()
|
|
if len(msgAndArgs) == 0 {
|
|
msgAndArgs = []any{"runtime record should be removed"}
|
|
}
|
|
assertPathRemoved(t, runtimePathForTest(dataDir, pid), msgAndArgs...)
|
|
}
|
|
|
|
func requireFoundRuntime(
|
|
t *testing.T, dataDir string, authTokens ...string,
|
|
) *DaemonRuntime {
|
|
t.Helper()
|
|
rt := FindDaemonRuntime(dataDir, authTokens...)
|
|
require.NotNil(t, rt, "expected running server")
|
|
return rt
|
|
}
|
|
|
|
func requireMigratedIncompatibleRuntime(
|
|
t *testing.T, dataDir string,
|
|
) *DaemonRuntime {
|
|
t.Helper()
|
|
rt, err := FindIncompatibleDaemonRuntime(dataDir)
|
|
require.NotNil(t, rt, "expected incompatible runtime")
|
|
require.Error(t, err)
|
|
return rt
|
|
}
|
|
|
|
func writeLegacyRuntimeStateForTest(
|
|
t *testing.T,
|
|
dataDir string,
|
|
state legacyStateFile,
|
|
) string {
|
|
t.Helper()
|
|
if state.Port <= 0 {
|
|
state.Port = 59999
|
|
}
|
|
path := filepath.Join(dataDir, fmt.Sprintf("server.%d.json", state.Port))
|
|
data, err := json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(path, data, 0o644))
|
|
return path
|
|
}
|
|
|
|
func writeProbeableLegacyRuntime(
|
|
t *testing.T,
|
|
dataDir string,
|
|
state legacyStateFile,
|
|
) (testDaemonEndpoint, string) {
|
|
t.Helper()
|
|
endpoint := newPingDaemon(t)
|
|
if state.PID == 0 {
|
|
state.PID = os.Getpid()
|
|
}
|
|
if state.Host == "" {
|
|
state.Host = endpoint.Host
|
|
}
|
|
if state.Port == 0 {
|
|
state.Port = endpoint.Port
|
|
}
|
|
return endpoint, writeLegacyRuntimeStateForTest(t, dataDir, state)
|
|
}
|
|
|
|
func writeAuthenticatedProbeableLegacyRuntime(
|
|
t *testing.T,
|
|
dataDir string,
|
|
token string,
|
|
state legacyStateFile,
|
|
) (testDaemonEndpoint, string) {
|
|
t.Helper()
|
|
endpoint := newAuthenticatedPingDaemon(t, token)
|
|
if state.PID == 0 {
|
|
state.PID = os.Getpid()
|
|
}
|
|
if state.Host == "" {
|
|
state.Host = endpoint.Host
|
|
}
|
|
if state.Port == 0 {
|
|
state.Port = endpoint.Port
|
|
}
|
|
return endpoint, writeLegacyRuntimeStateForTest(t, dataDir, state)
|
|
}
|
|
|
|
func rewriteLegacyState(
|
|
t *testing.T,
|
|
path string,
|
|
mutate func(*legacyStateFile),
|
|
) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
var state legacyStateFile
|
|
require.NoError(t, json.Unmarshal(data, &state))
|
|
mutate(&state)
|
|
data, err = json.Marshal(state)
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(path, data, 0o644))
|
|
}
|
|
|
|
func TestWriteAndRemoveDaemonRuntime(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
endpoint := newPingDaemon(t)
|
|
|
|
path, err := WriteDaemonRuntimeWithAuthAndNoSync(
|
|
dir, endpoint.Host, endpoint.Port, "1.0.0", false, true, true,
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, runtimePathForTest(dir, os.Getpid()), path)
|
|
|
|
rec := readRuntimeRecord(t, path)
|
|
assert.Equal(t, daemonService, rec.Service)
|
|
assert.Equal(t, "1.0.0", rec.Version)
|
|
assert.Equal(t, os.Getpid(), rec.PID)
|
|
assert.Equal(t, daemon.NetworkTCP, rec.Network)
|
|
assert.Equal(t, endpoint.Addr, rec.Address)
|
|
assert.Equal(t, "true", rec.Metadata[runtimeRequireAuth])
|
|
assert.Equal(t, "true", rec.Metadata[runtimeNoSync])
|
|
assert.Equal(t, strconv.Itoa(daemonAPIVersion), rec.Metadata[runtimeAPIVersion])
|
|
assert.Equal(t, strconv.Itoa(db.CurrentDataVersion()), rec.Metadata[runtimeDataVersion])
|
|
|
|
rt := daemonRuntimeFromRecord(rec)
|
|
assert.True(t, rt.RequireAuth)
|
|
assert.True(t, rt.RequireAuthKnown)
|
|
assert.True(t, rt.NoSync)
|
|
|
|
RemoveDaemonRuntime(dir)
|
|
assertPathRemoved(t, path, "runtime record not removed")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_NoFiles(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
assert.Nil(t, FindDaemonRuntime(dir))
|
|
}
|
|
|
|
func TestFindDaemonRuntime_StaleFile(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
_, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 9999,
|
|
withRuntimePID(deadPID(t)),
|
|
withRuntimeVersion("1.0.0"),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir), "expected nil for stale PID")
|
|
assert.False(t, IsDaemonActive(dir), "dead runtime record should not be active")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_InvalidJSON(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
path := runtimePathForTest(dir, os.Getpid())
|
|
require.NoError(t, os.WriteFile(path, []byte("not json"), 0o644))
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir), "expected nil for invalid JSON")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_IgnoresNonRuntimeFiles(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
require.NoError(t, os.WriteFile(
|
|
runtimePathForTest(dir, os.Getpid())+".tmp",
|
|
[]byte("{}"), 0o644,
|
|
))
|
|
require.NoError(t, os.WriteFile(
|
|
dir+"/config.json",
|
|
[]byte("{}"), 0o644,
|
|
))
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir))
|
|
}
|
|
|
|
func TestFindDaemonRuntime_LiveProcess(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
endpoint, _ := writeLiveRuntime(t, dir, false, withRuntimeVersion("1.0.0"))
|
|
|
|
result := requireFoundRuntime(t, dir)
|
|
assert.Equal(t, endpoint.Port, result.Port)
|
|
assert.Equal(t, os.Getpid(), result.Record.PID)
|
|
assert.False(t, result.ReadOnly)
|
|
}
|
|
|
|
func TestFindDaemonRuntime_IgnoresIncompatibleRuntime(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
writeLiveRuntime(t, dir, false,
|
|
withRuntimeVersion("old"),
|
|
withRuntimeAPIVersion(0),
|
|
)
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir))
|
|
rt, compatErr := FindIncompatibleDaemonRuntime(dir)
|
|
require.NotNil(t, rt)
|
|
require.Error(t, compatErr)
|
|
assert.Contains(t, compatErr.Error(), "API version")
|
|
assert.True(t, IsLocalDaemonActive(dir),
|
|
"incompatible writable daemon still owns the local archive")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_ReadOnly(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
writeLiveRuntime(t, dir, true, withRuntimeVersion("1.0.0"))
|
|
|
|
result := requireFoundRuntime(t, dir)
|
|
assert.True(t, result.ReadOnly)
|
|
assert.False(t, IsLocalDaemonActive(dir))
|
|
assert.True(t, IsDaemonActive(dir))
|
|
}
|
|
|
|
func TestFindDaemonRuntime_BindAllMetadata(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
endpoint := newPingDaemon(t)
|
|
_, err := WriteDaemonRuntime(dir, "0.0.0.0", endpoint.Port, "1.0.0", false)
|
|
require.NoError(t, err)
|
|
|
|
result := requireFoundRuntime(t, dir)
|
|
assert.Equal(t, "0.0.0.0", result.Host)
|
|
assert.Equal(t, endpoint.Port, result.Port)
|
|
}
|
|
|
|
func TestFindDaemonRuntime_UsesAuthToken(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testAuthenticatedPingServer(t, "secret")
|
|
_, err := WriteDaemonRuntime(dir, host, port, "1.0.0", false)
|
|
require.NoError(t, err)
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir),
|
|
"expected no discovery without bearer token")
|
|
|
|
result := FindDaemonRuntime(dir, "secret")
|
|
require.NotNil(t, result, "expected discovery with bearer token")
|
|
assert.Equal(t, port, result.Port)
|
|
assert.False(t, result.ReadOnly)
|
|
}
|
|
|
|
func TestIsDaemonActive_LivePIDNoPingClaimsOwnership(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
writeRuntimeRecordFixture(t, dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 59999,
|
|
withRuntimeVersion("1.0.0"),
|
|
))
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir), "expected no discoverable daemon")
|
|
assert.True(t, IsDaemonActive(dir),
|
|
"live runtime record should still suppress writes")
|
|
assert.True(t, IsLocalDaemonActive(dir),
|
|
"live writable runtime record should claim the SQLite archive")
|
|
}
|
|
|
|
func TestIsDaemonActive_DeadPIDDaemonRuntime(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
pid := deadPID(t)
|
|
path, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 59994,
|
|
withRuntimePID(pid),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
assert.False(t, IsDaemonActive(dir), "expected false for dead PID runtime record")
|
|
assertPathRemoved(t, path, "dead runtime record not cleaned up")
|
|
}
|
|
|
|
func TestIsDaemonActive_StartLock(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
|
|
require.False(t, IsDaemonActive(dir), "expected false with no files")
|
|
|
|
MarkDaemonStarting(dir)
|
|
require.True(t, IsDaemonActive(dir), "expected true with start lock")
|
|
|
|
UnmarkDaemonStarting(dir)
|
|
require.False(t, IsDaemonActive(dir), "expected false after start lock released")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_MigratesLegacyWritableStateFile(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
startedAt := time.Now().UTC().Add(-time.Minute)
|
|
endpoint, legacyPath := writeProbeableLegacyRuntime(t, dir, legacyStateFile{
|
|
Version: "legacy",
|
|
StartedAt: startedAt.Format(time.RFC3339Nano),
|
|
})
|
|
|
|
result := FindDaemonRuntime(dir)
|
|
require.Nil(t, result, "legacy state without compatibility metadata is incompatible")
|
|
incompatible := requireMigratedIncompatibleRuntime(t, dir)
|
|
assert.Equal(t, os.Getpid(), incompatible.Record.PID)
|
|
assert.Equal(t, endpoint.Host, incompatible.Host)
|
|
assert.Equal(t, endpoint.Port, incompatible.Port)
|
|
assert.Equal(t, "legacy", incompatible.Record.Version)
|
|
assert.False(t, incompatible.ReadOnly)
|
|
assert.True(t, IsDaemonActive(dir))
|
|
assert.True(t, IsLocalDaemonActive(dir))
|
|
|
|
assertPathRemoved(t, legacyPath,
|
|
"migrated legacy state file should be removed")
|
|
runtimePath := runtimePathForTest(dir, os.Getpid())
|
|
assert.FileExists(t, runtimePath, "kit runtime record should be written")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_MigratesLegacyReadOnlyStateFile(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
_, legacyPath := writeProbeableLegacyRuntime(t, dir, legacyStateFile{
|
|
ReadOnly: true,
|
|
})
|
|
|
|
result := FindDaemonRuntime(dir)
|
|
require.Nil(t, result, "read-only legacy state without compatibility metadata is incompatible")
|
|
incompatible := requireMigratedIncompatibleRuntime(t, dir)
|
|
assert.True(t, incompatible.ReadOnly)
|
|
assert.False(t, IsLocalDaemonActive(dir))
|
|
assert.True(t, IsDaemonActive(dir))
|
|
assertPathRemoved(t, legacyPath,
|
|
"migrated legacy state file should be removed")
|
|
}
|
|
|
|
func TestFindDaemonRuntime_LegacyStateFileUsesPortFromName(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
endpoint, legacyPath := writeProbeableLegacyRuntime(t, dir, legacyStateFile{})
|
|
rewriteLegacyState(t, legacyPath, func(state *legacyStateFile) {
|
|
state.Port = 0
|
|
})
|
|
|
|
result, compatErr := FindIncompatibleDaemonRuntime(dir)
|
|
require.NotNil(t, result, "expected legacy port from file name to migrate")
|
|
require.Error(t, compatErr)
|
|
assert.Equal(t, endpoint.Port, result.Port)
|
|
}
|
|
|
|
func TestIsLocalDaemonActive_LegacyDeadPIDStateFileRemoved(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
pid := deadPID(t)
|
|
path := writeLegacyRuntimeStateForTest(t, dir, legacyStateFile{PID: pid})
|
|
|
|
assert.False(t, IsLocalDaemonActive(dir))
|
|
assertPathRemoved(t, path, "dead legacy state file not removed")
|
|
assertRuntimeRecordRemoved(t, dir, pid,
|
|
"dead legacy state should not become a kit runtime record")
|
|
}
|
|
|
|
func TestIsLocalDaemonActive_UnprobeableLegacyStateFileDoesNotSuppressWrites(
|
|
t *testing.T,
|
|
) {
|
|
dir := runtimeTestDir(t)
|
|
path := writeLegacyRuntimeStateForTest(t, dir, legacyStateFile{
|
|
PID: os.Getpid(),
|
|
Host: "127.0.0.1",
|
|
Port: 59999,
|
|
})
|
|
|
|
assert.Nil(t, FindDaemonRuntime(dir), "unprobeable legacy state must not migrate")
|
|
assert.False(t, IsDaemonActive(dir))
|
|
assert.False(t, IsLocalDaemonActive(dir))
|
|
assert.FileExists(t, path, "unprobeable live legacy state should be left intact")
|
|
assertRuntimeRecordRemoved(t, dir, os.Getpid(),
|
|
"unprobeable legacy state should not become a kit runtime record")
|
|
}
|
|
|
|
func TestIsDaemonStarting_LegacyStartupLock(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(dir, fmt.Sprintf("%s%d", legacyStartupLockPrefix, os.Getpid())),
|
|
[]byte(strconv.Itoa(os.Getpid())),
|
|
0o644,
|
|
))
|
|
|
|
assert.True(t, IsDaemonStarting(dir))
|
|
assert.True(t, IsLocalDaemonActive(dir))
|
|
}
|
|
|
|
func TestLiveWritableRuntimeWithMismatchedCreateTimeIsRemoved(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
liveCreateTime, ok := processCreateTimeMillis(os.Getpid())
|
|
if !ok {
|
|
t.Skip("process create time is unavailable on this platform")
|
|
}
|
|
_, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 9,
|
|
withRuntimeMetadata(
|
|
runtimeCreateTime, strconv.FormatInt(liveCreateTime+1, 10),
|
|
),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
assert.False(t, hasLiveWritableDaemonRuntime(dir))
|
|
assert.False(t, IsLocalDaemonActive(dir))
|
|
assertRuntimeRecordRemoved(t, dir, os.Getpid(),
|
|
"mismatched create-time runtime record should be removed")
|
|
}
|
|
|
|
func TestCompareProcessCreateTime(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
recorded string
|
|
live int64
|
|
liveOK bool
|
|
want processCreateTimeState
|
|
}{
|
|
{name: "exact match", recorded: "1234", live: 1234, liveOK: true, want: processCreateTimeMatch},
|
|
{name: "proven mismatch", recorded: "1234", live: 1235, liveOK: true, want: processCreateTimeMismatch},
|
|
{name: "missing recorded value", recorded: "", live: 1234, liveOK: true, want: processCreateTimeUnknown},
|
|
{name: "malformed recorded value", recorded: "not-a-time", live: 1234, liveOK: true, want: processCreateTimeUnknown},
|
|
{name: "zero recorded value", recorded: "0", live: 1234, liveOK: true, want: processCreateTimeUnknown},
|
|
{name: "negative recorded value", recorded: "-1", live: 1234, liveOK: true, want: processCreateTimeUnknown},
|
|
{name: "live lookup unavailable", recorded: "1234", live: 0, liveOK: false, want: processCreateTimeUnknown},
|
|
{name: "nonpositive live value", recorded: "1234", live: 0, liveOK: true, want: processCreateTimeUnknown},
|
|
{name: "negative live value", recorded: "1234", live: -1, liveOK: true, want: processCreateTimeUnknown},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, compareProcessCreateTime(
|
|
tt.recorded, tt.live, tt.liveOK,
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWritableDaemonRecordsFiltersAndCleansRuntimeRecords(t *testing.T) {
|
|
liveCreateTime, ok := processCreateTimeMillis(os.Getpid())
|
|
if !ok {
|
|
t.Skip("process create time is unavailable on this platform")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
options []runtimeRecordOption
|
|
wantRecords int
|
|
wantFile bool
|
|
}{
|
|
{
|
|
name: "matching writable record",
|
|
options: []runtimeRecordOption{withRuntimeMetadata(runtimeCreateTime, strconv.FormatInt(liveCreateTime, 10))},
|
|
wantRecords: 1,
|
|
wantFile: true,
|
|
},
|
|
{
|
|
name: "missing create time is preserved as unknown",
|
|
wantRecords: 1,
|
|
wantFile: true,
|
|
},
|
|
{
|
|
name: "malformed create time is preserved as unknown",
|
|
options: []runtimeRecordOption{withRuntimeMetadata(runtimeCreateTime, "not-a-time")},
|
|
wantRecords: 1,
|
|
wantFile: true,
|
|
},
|
|
{
|
|
name: "zero create time is preserved as unknown",
|
|
options: []runtimeRecordOption{withRuntimeMetadata(runtimeCreateTime, "0")},
|
|
wantRecords: 1,
|
|
wantFile: true,
|
|
},
|
|
{
|
|
name: "negative create time is preserved as unknown",
|
|
options: []runtimeRecordOption{withRuntimeMetadata(runtimeCreateTime, "-1")},
|
|
wantRecords: 1,
|
|
wantFile: true,
|
|
},
|
|
{
|
|
name: "proven mismatch is removed",
|
|
options: []runtimeRecordOption{withRuntimeMetadata(runtimeCreateTime, strconv.FormatInt(liveCreateTime+1, 10))},
|
|
wantRecords: 0,
|
|
wantFile: false,
|
|
},
|
|
{
|
|
name: "read only record is ignored but preserved",
|
|
options: []runtimeRecordOption{withRuntimeReadOnly(true)},
|
|
wantRecords: 0,
|
|
wantFile: true,
|
|
},
|
|
{
|
|
name: "another service is ignored but preserved",
|
|
options: []runtimeRecordOption{func(rec *daemon.RuntimeRecord) { rec.Service = "other" }},
|
|
wantRecords: 0,
|
|
wantFile: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
path, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 9, tt.options...,
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
records, err := writableDaemonRecords(dir, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, records, tt.wantRecords)
|
|
if tt.wantRecords > 0 {
|
|
assert.Equal(t, os.Getpid(), records[0].PID)
|
|
assert.Equal(t, path, records[0].SourcePath)
|
|
}
|
|
if tt.wantFile {
|
|
assert.FileExists(t, path)
|
|
} else {
|
|
assertPathRemoved(t, path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWritableDaemonRecordsCleansDeadRecord(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
pid := deadPID(t)
|
|
path, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 9, withRuntimePID(pid),
|
|
))
|
|
require.NoError(t, err)
|
|
|
|
records, err := writableDaemonRecords(dir, "")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, records)
|
|
assertPathRemoved(t, path, "dead runtime record should be cleaned up")
|
|
}
|
|
|
|
func TestWritableDaemonRecordsReturnsEveryLiveWritableRecord(t *testing.T) {
|
|
requirePOSIXSignals(t, "requires a long-lived child process")
|
|
dir := runtimeTestDir(t)
|
|
pids := []int{os.Getpid(), startSleepProcess(t)}
|
|
wantPaths := make(map[int]string, len(pids))
|
|
for i, pid := range pids {
|
|
path, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 10+i, withRuntimePID(pid),
|
|
))
|
|
require.NoError(t, err)
|
|
wantPaths[pid] = path
|
|
}
|
|
|
|
records, err := writableDaemonRecords(dir, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, records, len(pids))
|
|
for _, rec := range records {
|
|
assert.Equal(t, wantPaths[rec.PID], rec.SourcePath)
|
|
delete(wantPaths, rec.PID)
|
|
}
|
|
assert.Empty(t, wantPaths, "all live writable records should be returned")
|
|
}
|
|
|
|
func TestWritableDaemonRecordsMigratesLegacyRuntime(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
endpoint, legacyPath := writeProbeableLegacyRuntime(t, dir, legacyStateFile{})
|
|
|
|
records, err := writableDaemonRecords(dir, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, records, 1)
|
|
assert.Equal(t, endpoint.Port, daemonRuntimeFromRecord(records[0]).Port)
|
|
assert.Equal(t, runtimePathForTest(dir, os.Getpid()), records[0].SourcePath)
|
|
assertPathRemoved(t, legacyPath, "migrated legacy record should be removed")
|
|
}
|
|
|
|
func TestWritableDaemonRecordsReportsMismatchedRecordRemovalFailure(
|
|
t *testing.T,
|
|
) {
|
|
requirePOSIXSignals(t, "directory removal permissions require POSIX semantics")
|
|
dir := runtimeTestDir(t)
|
|
liveCreateTime, ok := processCreateTimeMillis(os.Getpid())
|
|
require.True(t, ok)
|
|
path, err := writeRuntimeRecordForTest(dir, daemonRuntimeRecord(
|
|
"127.0.0.1", 9,
|
|
withRuntimeMetadata(
|
|
runtimeCreateTime, strconv.FormatInt(liveCreateTime+1, 10),
|
|
),
|
|
))
|
|
require.NoError(t, err)
|
|
stored, err := runtimeStore(dir).List()
|
|
require.NoError(t, err)
|
|
require.Len(t, stored, 1)
|
|
require.NoError(t, os.Chmod(dir, 0o500))
|
|
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
|
|
|
|
records, err := writableDaemonRecordsFromStore(
|
|
staticRuntimeRecordStore{records: stored},
|
|
)
|
|
assert.Nil(t, records)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "remove mismatched daemon runtime record")
|
|
assert.ErrorContains(t, err, path)
|
|
assert.FileExists(t, path, "failed cleanup must leave the record recoverable")
|
|
}
|
|
|
|
type staticRuntimeRecordStore struct {
|
|
records []daemon.RuntimeRecord
|
|
}
|
|
|
|
func (staticRuntimeRecordStore) CleanupDead() (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (s staticRuntimeRecordStore) List() ([]daemon.RuntimeRecord, error) {
|
|
return s.records, nil
|
|
}
|
|
|
|
type cleanupFailingRuntimeRecordStore struct {
|
|
err error
|
|
}
|
|
|
|
func (s cleanupFailingRuntimeRecordStore) CleanupDead() (int, error) {
|
|
return 0, s.err
|
|
}
|
|
|
|
func (cleanupFailingRuntimeRecordStore) List() ([]daemon.RuntimeRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type listFailingRuntimeRecordStore struct {
|
|
err error
|
|
}
|
|
|
|
func (listFailingRuntimeRecordStore) CleanupDead() (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (s listFailingRuntimeRecordStore) List() ([]daemon.RuntimeRecord, error) {
|
|
return nil, s.err
|
|
}
|
|
|
|
func TestWritableDaemonRecordsSurfacesRuntimeStoreErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
store daemonRuntimeRecordStore
|
|
want string
|
|
}{
|
|
{
|
|
name: "cleanup failure",
|
|
store: cleanupFailingRuntimeRecordStore{err: errors.New("cleanup failed")},
|
|
want: "cleanup failed",
|
|
},
|
|
{
|
|
name: "list failure",
|
|
store: listFailingRuntimeRecordStore{err: errors.New("list failed")},
|
|
want: "list failed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
records, err := writableDaemonRecordsFromStore(tt.store)
|
|
assert.Nil(t, records)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStartLock_OwnProcess(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
|
|
require.False(t, isDaemonStarting(dir), "expected false before lock written")
|
|
|
|
MarkDaemonStarting(dir)
|
|
require.True(t, isDaemonStarting(dir), "expected true after lock written")
|
|
|
|
UnmarkDaemonStarting(dir)
|
|
require.False(t, isDaemonStarting(dir), "expected false after start lock released")
|
|
}
|
|
|
|
func TestWaitForDaemonStartup_AlreadyRunning(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
writeLiveRuntime(t, dir, false, withRuntimeVersion("1.0.0"))
|
|
|
|
assert.True(t, WaitForDaemonStartup(dir, 100*time.Millisecond),
|
|
"expected true, server is running")
|
|
}
|
|
|
|
func TestWaitForDaemonStartup_LockClearsNoServer(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
|
|
assert.False(t, WaitForDaemonStartup(dir, 100*time.Millisecond),
|
|
"expected false, no start lock and no server")
|
|
}
|
|
|
|
func TestProbeHostForDial(t *testing.T) {
|
|
tests := []struct {
|
|
host string
|
|
want string
|
|
}{
|
|
{"", "127.0.0.1"},
|
|
{"0.0.0.0", "127.0.0.1"},
|
|
{"::", "::1"},
|
|
{"127.0.0.1", "127.0.0.1"},
|
|
{"192.168.1.100", "192.168.1.100"},
|
|
}
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, probeHostForDial(tt.host),
|
|
"probeHostForDial(%q)", tt.host)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRuntime_ReadOnlyPersisted(t *testing.T) {
|
|
dir := runtimeTestDir(t)
|
|
host, port := testPingServer(t)
|
|
|
|
path, err := WriteDaemonRuntime(dir, host, port, "test", true)
|
|
require.NoError(t, err)
|
|
|
|
rec := readRuntimeRecord(t, path)
|
|
assert.Equal(t, "true", rec.Metadata[runtimeReadOnly])
|
|
assert.Equal(t, strconv.Itoa(port), rec.Metadata[runtimePort])
|
|
assert.Equal(t, "test", rec.Version)
|
|
}
|