Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions cmd/engage.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ var (
engageCCPort int
engageAutoInit bool
engageForeground bool
engageReload bool
)

const (
Expand Down Expand Up @@ -82,6 +83,7 @@ func init() {
engageCmd.Flags().IntVar(&engageCCPort, "cc-port", 8080, "Port for the command center")
engageCmd.Flags().BoolVar(&engageAutoInit, "init", false, "Auto-initialize Squadron if not already initialized")
engageCmd.Flags().BoolVar(&engageForeground, "foreground", false, "Run in foreground (default: run as background service)")
engageCmd.Flags().BoolVarP(&engageReload, "reload", "r", false, "Reload the config of an already-running squadron (no-op if not running)")
}

func runEngage(cmd *cobra.Command, args []string) {
Expand All @@ -95,6 +97,19 @@ func runEngage(cmd *cobra.Command, args []string) {
os.Exit(1)
}

running, pid := daemon.IsRunning(engageConfigPath)
switch {
case running && engageReload:
reloadRunningSquadron(pid)
return
case running:
fmt.Fprintf(os.Stderr, "Error: squadron is already running (PID %d).\n", pid)
fmt.Fprintln(os.Stderr, "Use 'squadron engage -r' to reload the config, or 'squadron disengage' to stop it.")
os.Exit(1)
case engageReload:
fmt.Println("Squadron is not running — ignoring -r and starting it now.")
}

if warning, err := validateConfigDir(engageConfigPath); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
Expand Down Expand Up @@ -392,6 +407,22 @@ func runEngage(cmd *cobra.Command, args []string) {
client.Close()
}()

reloads := make(chan os.Signal, 1)
signal.Notify(reloads, syscall.SIGHUP)
go func() {
for {
select {
case <-shutdown:
return
case <-reloads:
if err := client.ReloadConfig(); err != nil {
log.Printf("Config reload failed: %v", err)
daemon.SignalFailed(engageConfigPath, err)
}
}
}
}()

// Periodic sweep of expired per-run mission folders. Runs hourly, reads
// the live config so new run_folder bases show up after a reload.
go runFolderCleanupLoop(shutdown, client.GetConfig)
Expand Down Expand Up @@ -503,6 +534,35 @@ func isContainer() bool {
return os.Getenv("SQUADRON_CONTAINER") == "1"
}

func reloadRunningSquadron(pid int) {
absConfigPath, err := filepath.Abs(engageConfigPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving config path: %v\n", err)
os.Exit(1)
}

fmt.Printf("Squadron is already running (PID %d). Reloading config from %s...\n", pid, absConfigPath)

daemon.ClearReady(absConfigPath)

if _, err := daemon.Reload(absConfigPath); err != nil {
fmt.Fprintf(os.Stderr, "Error signaling squadron (PID %d): %v\n", pid, err)
os.Exit(1)
}

sp := startSpinner("Validating and applying")
ready := daemon.WaitReady(absConfigPath, 30*time.Second, 500*time.Millisecond)
sp.Stop()

if !ready.OK {
fmt.Fprintf(os.Stderr, "Config reload failed: %s\n", ready.Error)
fmt.Fprintf(os.Stderr, "Squadron is still running with the previous config (PID %d). Fix the error above and re-run 'squadron engage'.\n", pid)
os.Exit(1)
}

fmt.Println("Config reloaded successfully.")
}

func hasHCLFiles(configPath string) bool {
info, err := os.Stat(configPath)
if err != nil {
Expand Down
30 changes: 30 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,36 @@ func Fork(configPath string, extraFlags []string) (int, error) {
return pid, nil
}

// Reload sends SIGHUP to the running daemon to trigger a config reload.
func Reload(configPath string) (int, error) {
absConfig, err := filepath.Abs(configPath)
if err != nil {
return 0, fmt.Errorf("could not resolve config path: %w", err)
}

pidPath := PidFilePath(absConfig)
data, err := os.ReadFile(pidPath)
if err != nil {
return 0, fmt.Errorf("no PID file found — squadron may not be running")
}

pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0, fmt.Errorf("invalid PID file")
}

process, err := os.FindProcess(pid)
if err != nil {
return 0, fmt.Errorf("process %d not found", pid)
}

if err := process.Signal(syscall.SIGHUP); err != nil {
return 0, fmt.Errorf("could not signal process %d: %w", pid, err)
}

return pid, nil
}

// Stop reads the PID file and gracefully stops the background process.
func Stop(configPath string) error {
absConfig, err := filepath.Abs(configPath)
Expand Down
68 changes: 68 additions & 0 deletions internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package daemon
import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"testing"
"time"
)
Expand Down Expand Up @@ -220,6 +222,72 @@ func TestIsRunning_LiveProcess(t *testing.T) {
}
}

func TestReload_NoPidFile(t *testing.T) {
dir := t.TempDir()
if _, err := Reload(dir); err == nil {
t.Fatal("Reload should error when no PID file exists")
}
}

func TestReload_InvalidPidFile(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".squadron"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(PidFilePath(dir), []byte("not-a-pid"), 0644); err != nil {
t.Fatal(err)
}
if _, err := Reload(dir); err == nil {
t.Fatal("Reload should error for malformed PID file")
}
}

func TestReload_StaleProcess(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".squadron"), 0755); err != nil {
t.Fatal(err)
}
// A high PID that almost certainly doesn't exist.
if err := os.WriteFile(PidFilePath(dir), []byte("999999"), 0644); err != nil {
t.Fatal(err)
}
if _, err := Reload(dir); err == nil {
t.Fatal("Reload should error when target process does not exist")
}
}

func TestReload_DeliversSighup(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".squadron"), 0755); err != nil {
t.Fatal(err)
}

// Use the current test process as the SIGHUP target.
pid := os.Getpid()
if err := os.WriteFile(PidFilePath(dir), []byte(fmt.Sprintf("%d", pid)), 0644); err != nil {
t.Fatal(err)
}

sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGHUP)
defer signal.Stop(sigs)

gotPID, err := Reload(dir)
if err != nil {
t.Fatalf("Reload returned error: %v", err)
}
if gotPID != pid {
t.Errorf("returned pid = %d, want %d", gotPID, pid)
}

select {
case <-sigs:
// got it
case <-time.After(2 * time.Second):
t.Fatal("did not receive SIGHUP within timeout")
}
}

func TestResolveConfigDir(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "squadron.hcl")
Expand Down
Loading