mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The rule table mapped HOG_ -> FN_ ahead of any POSTHOG rule, so POSTHOG_FOO split into POST + HOG_ and came out POSTFN_FOO. Nothing sets that. The live pods set INSIGHTS_SKIP_MIGRATION_CHECKS, which bin/migrate-check reads on the container entrypoint, so the misspelling does not degrade a feature -- it runs migrate --check against a database whose history this tree does not share and the container never starts. Both replacements are measured against the shipped tree rather than chosen: every HOG_ name with a counterpart there is spelled INSIGHTS_ (HOG_QL -> INSIGHTS_QL, HOG_FUNCTION_TEMPLATES -> INSIGHTS_FUNCTION_TEMPLATES, 12 of 12 determinate cases), and FN_ appears in it nowhere. Upstream carries 95 HOG_ tokens and zero FN_ ones, so repairing FN_ back out is exactly the inverse of the bad rule and cannot collide with an upstream name. The rule is fixed for the next convergence, and the tree it already produced is repaired in the same commit -- the codemod is idempotent against its own output, so it cannot undo this itself.
380 lines
11 KiB
Go
380 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"reflect"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func stubQuery(t *testing.T, fn func(map[string]any, time.Duration) (map[string]any, error)) {
|
|
t.Helper()
|
|
previous := queryDetached
|
|
queryDetached = fn
|
|
t.Cleanup(func() {
|
|
queryDetached = previous
|
|
})
|
|
}
|
|
|
|
func captureStdout(t *testing.T, fn func() int) (int, string) {
|
|
t.Helper()
|
|
reader, writer, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatalf("pipe: %v", err)
|
|
}
|
|
|
|
previous := os.Stdout
|
|
os.Stdout = writer
|
|
code := fn()
|
|
_ = writer.Close()
|
|
os.Stdout = previous
|
|
|
|
var output bytes.Buffer
|
|
if _, err := io.Copy(&output, reader); err != nil {
|
|
t.Fatalf("read stdout: %v", err)
|
|
}
|
|
return code, output.String()
|
|
}
|
|
|
|
func TestClassify_sortsProcessNames(t *testing.T) {
|
|
procs := map[string]any{
|
|
"worker": map[string]any{"status": "running", "ready": false},
|
|
"plugin": map[string]any{"status": "crashed", "ready": false},
|
|
"web": map[string]any{"status": "pending", "ready": false},
|
|
}
|
|
|
|
verdict, crashed, notReady := classify(procs)
|
|
|
|
if verdict != "crashed" {
|
|
t.Fatalf("verdict: got %q, want %q", verdict, "crashed")
|
|
}
|
|
if !reflect.DeepEqual(crashed, []string{"plugin"}) {
|
|
t.Fatalf("crashed: got %v, want %v", crashed, []string{"plugin"})
|
|
}
|
|
if !reflect.DeepEqual(notReady, []string{"web (pending)", "worker (running)"}) {
|
|
t.Fatalf("notReady: got %v, want %v", notReady, []string{"web (pending)", "worker (running)"})
|
|
}
|
|
}
|
|
|
|
func TestRunWait_jsonDaemonError(t *testing.T) {
|
|
stubQuery(t, func(map[string]any, time.Duration) (map[string]any, error) {
|
|
return map[string]any{"ok": false, "error": "boom"}, nil
|
|
})
|
|
|
|
code, output := captureStdout(t, func() int {
|
|
return runWait(1, true)
|
|
})
|
|
|
|
if code != 3 {
|
|
t.Fatalf("exit code: got %d, want 3", code)
|
|
}
|
|
if !strings.Contains(output, `"verdict":"not_reachable"`) {
|
|
t.Fatalf("output missing not_reachable verdict: %q", output)
|
|
}
|
|
if !strings.Contains(output, `"error":"boom"`) {
|
|
t.Fatalf("output missing daemon error: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRunWait_shortTimeoutBeforeBindIsNotReachable(t *testing.T) {
|
|
stubQuery(t, func(map[string]any, time.Duration) (map[string]any, error) {
|
|
return nil, errors.New("dial unix: no such file")
|
|
})
|
|
|
|
code, output := captureStdout(t, func() int {
|
|
return runWait(0, true)
|
|
})
|
|
|
|
if code != 3 {
|
|
t.Fatalf("exit code: got %d, want 3", code)
|
|
}
|
|
if !strings.Contains(output, `"verdict":"not_reachable"`) {
|
|
t.Fatalf("output missing not_reachable verdict: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestPhrocsStopped_sandboxSuppressesDockerHint(t *testing.T) {
|
|
// In a sandbox, docker infra is managed externally, so telling the user
|
|
// to run `insightscli docker:services:down` points at infra phrocs doesn't own.
|
|
t.Setenv("INSIGHTS_SANDBOX", "1")
|
|
_, out := captureStdout(t, func() int { return phrocsStopped("phrocs stopped") })
|
|
if strings.Contains(out, "docker:services:down") {
|
|
t.Fatalf("sandbox must not print the docker teardown hint: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestPhrocsStopped_printsDockerHintOutsideSandbox(t *testing.T) {
|
|
t.Setenv("INSIGHTS_SANDBOX", "")
|
|
_, out := captureStdout(t, func() int { return phrocsStopped("phrocs stopped") })
|
|
if !strings.Contains(out, "docker:services:down") {
|
|
t.Fatalf("expected the docker teardown hint outside a sandbox: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRunStop_quitPathRemovesPidfile(t *testing.T) {
|
|
t.Chdir(t.TempDir())
|
|
if err := os.MkdirAll(generatedDir(), 0o755); err != nil {
|
|
t.Fatalf("mkdir generated dir: %v", err)
|
|
}
|
|
|
|
lockFile, err := os.OpenFile(pidLockFilePath(), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
t.Fatalf("open lock: %v", err)
|
|
}
|
|
defer func() { _ = lockFile.Close() }()
|
|
if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
t.Fatalf("flock: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("sleep", "30")
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start sleep: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
if err := os.WriteFile(pidFilePath(), []byte(fmt.Sprintf("%d\n", cmd.Process.Pid)), 0o644); err != nil {
|
|
t.Fatalf("write pidfile: %v", err)
|
|
}
|
|
|
|
sock, err := detachedSocketPath()
|
|
if err != nil {
|
|
t.Fatalf("detached socket path: %v", err)
|
|
}
|
|
ln, err := net.Listen("unix", sock)
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = ln.Close()
|
|
_ = os.Remove(sock)
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
line, err := bufio.NewReader(conn).ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if !strings.Contains(line, `"cmd":"quit"`) {
|
|
return
|
|
}
|
|
_, _ = conn.Write([]byte(`{"ok":true}` + "\n"))
|
|
_ = ln.Close()
|
|
_ = os.Remove(sock)
|
|
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
|
|
}()
|
|
|
|
code, _ := captureStdout(t, func() int {
|
|
return runStop(1)
|
|
})
|
|
|
|
if code != 0 {
|
|
t.Fatalf("exit code: got %d, want 0", code)
|
|
}
|
|
if _, err := os.Stat(pidFilePath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("pidfile should be removed, stat err=%v", err)
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("quit server did not finish")
|
|
}
|
|
}
|
|
|
|
func TestStopViaPidfile_doesNotSignalUnlockedPidfile(t *testing.T) {
|
|
t.Chdir(t.TempDir())
|
|
if err := os.MkdirAll(generatedDir(), 0o755); err != nil {
|
|
t.Fatalf("mkdir generated dir: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("sleep", "30")
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("start sleep: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
if err := os.WriteFile(pidFilePath(), []byte(fmt.Sprintf("%d\n", cmd.Process.Pid)), 0o644); err != nil {
|
|
t.Fatalf("write pidfile: %v", err)
|
|
}
|
|
|
|
code, output := captureStdout(t, func() int {
|
|
return stopViaPidfile("", time.Now().Add(time.Second))
|
|
})
|
|
|
|
if code != 0 {
|
|
t.Fatalf("exit code: got %d, want 0", code)
|
|
}
|
|
if !strings.Contains(output, "no detached phrocs running") {
|
|
t.Fatalf("output: got %q, want no detached message", output)
|
|
}
|
|
if !pidAlive(cmd.Process.Pid) {
|
|
t.Fatalf("stale pidfile process %d was signaled", cmd.Process.Pid)
|
|
}
|
|
if _, err := os.Stat(pidFilePath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("pidfile should be removed, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestStopViaPidfile_missingGeneratedDirIsIdempotent(t *testing.T) {
|
|
t.Chdir(t.TempDir())
|
|
|
|
code, output := captureStdout(t, func() int {
|
|
return stopViaPidfile("", time.Now().Add(time.Second))
|
|
})
|
|
|
|
if code != 0 {
|
|
t.Fatalf("exit code: got %d, want 0", code)
|
|
}
|
|
if !strings.Contains(output, "no detached phrocs running") {
|
|
t.Fatalf("output: got %q, want no detached message", output)
|
|
}
|
|
}
|
|
|
|
// Regression: a one-shot proc that exits cleanly (status="done") must not
|
|
// block `phrocs wait` forever. Pre-fix `classify` only treated "crashed" as
|
|
// terminal, so a config with any one-shot setup proc would always time out.
|
|
func TestClassify_doneCountsAsReady(t *testing.T) {
|
|
procs := map[string]any{
|
|
"migrate": map[string]any{"status": "done", "ready": false},
|
|
"web": map[string]any{"status": "running", "ready": true},
|
|
}
|
|
|
|
verdict, crashed, notReady := classify(procs)
|
|
|
|
if verdict != "ready" {
|
|
t.Fatalf("verdict: got %q, want %q", verdict, "ready")
|
|
}
|
|
if len(crashed) != 0 {
|
|
t.Fatalf("crashed: got %v, want empty", crashed)
|
|
}
|
|
if len(notReady) != 0 {
|
|
t.Fatalf("notReady: got %v, want empty", notReady)
|
|
}
|
|
}
|
|
|
|
// Regression: a proc configured `autostart: false` sits in status="stopped"
|
|
// indefinitely because the manager intentionally never launches it. Treating
|
|
// "stopped" as not-ready would cause `phrocs wait` to time out on configs
|
|
// that mix autostart and on-demand procs (storybook, generate-demo-data,
|
|
// migrate-flags-read-store, etc.).
|
|
func TestClassify_stoppedCountsAsReady(t *testing.T) {
|
|
procs := map[string]any{
|
|
"storybook": map[string]any{"status": "stopped", "ready": false},
|
|
"web": map[string]any{"status": "running", "ready": true},
|
|
}
|
|
|
|
verdict, crashed, notReady := classify(procs)
|
|
|
|
if verdict != "ready" {
|
|
t.Fatalf("verdict: got %q, want %q", verdict, "ready")
|
|
}
|
|
if len(crashed) != 0 {
|
|
t.Fatalf("crashed: got %v, want empty", crashed)
|
|
}
|
|
if len(notReady) != 0 {
|
|
t.Fatalf("notReady: got %v, want empty", notReady)
|
|
}
|
|
}
|
|
|
|
// runWait must surface an empty-config response as a distinct "no_procs"
|
|
// verdict (exit 0) rather than spinning until the deadline and printing
|
|
// "still not ready: " with an empty list.
|
|
func TestRunWait_emptyConfigReportsNoProcs(t *testing.T) {
|
|
stubQuery(t, func(map[string]any, time.Duration) (map[string]any, error) {
|
|
return map[string]any{"ok": true, "processes": map[string]any{}}, nil
|
|
})
|
|
|
|
code, _ := captureStdout(t, func() int {
|
|
return runWait(1, true)
|
|
})
|
|
|
|
if code != 0 {
|
|
t.Fatalf("exit code: got %d, want 0", code)
|
|
}
|
|
}
|
|
|
|
// cleanIfStale must hold the pidfile flock during pidfile removal so a fresh
|
|
// `phrocs --detach` racing in between detection and cleanup can't have its
|
|
// pidfile clobbered. We exercise the contract: when the lock is already held
|
|
// elsewhere, cleanIfStale returns (false, nil) and leaves the pidfile alone.
|
|
func TestCleanIfStale_doesNotTouchLiveDetached(t *testing.T) {
|
|
t.Chdir(t.TempDir())
|
|
if err := os.MkdirAll(generatedDir(), 0o755); err != nil {
|
|
t.Fatalf("mkdir generated dir: %v", err)
|
|
}
|
|
|
|
holder, err := os.OpenFile(pidLockFilePath(), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
t.Fatalf("open lock: %v", err)
|
|
}
|
|
defer func() { _ = holder.Close() }()
|
|
if err := syscall.Flock(int(holder.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
t.Fatalf("flock: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(pidFilePath(), []byte("12345\n"), 0o644); err != nil {
|
|
t.Fatalf("write pidfile: %v", err)
|
|
}
|
|
|
|
cleaned, err := cleanIfStale("")
|
|
if err != nil {
|
|
t.Fatalf("cleanIfStale: %v", err)
|
|
}
|
|
if cleaned {
|
|
t.Fatalf("cleaned: got true while lock is held; pidfile would have been clobbered")
|
|
}
|
|
if _, err := os.Stat(pidFilePath()); err != nil {
|
|
t.Fatalf("pidfile should still exist, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestCleanIfStale_removesStalePidfile(t *testing.T) {
|
|
t.Chdir(t.TempDir())
|
|
if err := os.MkdirAll(generatedDir(), 0o755); err != nil {
|
|
t.Fatalf("mkdir generated dir: %v", err)
|
|
}
|
|
// Create lock file but don't hold flock — simulates a previous detached
|
|
// that has exited (kernel released the lock on exit).
|
|
lf, err := os.OpenFile(pidLockFilePath(), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
t.Fatalf("create lock: %v", err)
|
|
}
|
|
_ = lf.Close()
|
|
if err := os.WriteFile(pidFilePath(), []byte("99999\n"), 0o644); err != nil {
|
|
t.Fatalf("write pidfile: %v", err)
|
|
}
|
|
|
|
cleaned, err := cleanIfStale("")
|
|
if err != nil {
|
|
t.Fatalf("cleanIfStale: %v", err)
|
|
}
|
|
if !cleaned {
|
|
t.Fatalf("cleaned: got false, want true (lock was free)")
|
|
}
|
|
if _, err := os.Stat(pidFilePath()); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("pidfile should be removed, stat err=%v", err)
|
|
}
|
|
}
|