Skip to content
Draft
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
54 changes: 30 additions & 24 deletions .goreleaser.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 37 additions & 3 deletions control-plane/internal/cli/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"os"
"os/exec" // Added missing import
"os/exec"
"path/filepath"
"runtime"
"strings"

"github.com/Agent-Field/agentfield/control-plane/internal/logger"
"github.com/Agent-Field/agentfield/control-plane/internal/packages"
Expand Down Expand Up @@ -104,16 +106,48 @@ func (lv *LogViewer) ViewLogs(agentNodeName string) error {

// tailLogs shows the last N lines of the log file
func (lv *LogViewer) tailLogs(logFile string, lines int) error {
cmd := exec.Command("tail", "-n", fmt.Sprintf("%d", lines), logFile)
cmd := tailCommand(logFile, lines, false)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// followLogs follows the log file in real-time
func (lv *LogViewer) followLogs(logFile string) error {
cmd := exec.Command("tail", "-f", logFile)
cmd := tailCommand(logFile, 10, true)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// tailCommand builds the platform command that prints the last n lines of a
// file, optionally following it.
func tailCommand(logFile string, n int, follow bool) *exec.Cmd {
program, args := tailCommandArgs(runtime.GOOS, logFile, n, follow)
return exec.Command(program, args...)
}

// tailCommandArgs returns the program and arguments that tail a log file on
// the given GOOS. Unix uses tail(1); Windows has no tail, so PowerShell's
// Get-Content stands in (compile-verified only, not yet tested on a real
// Windows machine). Pure so both platform branches are unit-testable anywhere.
func tailCommandArgs(goos, logFile string, n int, follow bool) (string, []string) {
if goos == "windows" {
script := fmt.Sprintf("Get-Content -LiteralPath %s -Tail %d", psSingleQuote(logFile), n)
if follow {
script += " -Wait"
}
return "powershell", []string{"-NoProfile", "-Command", script}
}
args := []string{"-n", fmt.Sprintf("%d", n)}
if follow {
args = append(args, "-f")
}
return "tail", append(args, logFile)
}

// psSingleQuote quotes s as a PowerShell single-quoted string literal, where
// the only escape is doubling embedded single quotes.
func psSingleQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
92 changes: 92 additions & 0 deletions control-plane/internal/cli/logs_tail_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cli

import (
"reflect"
"runtime"
"testing"
)

// Contract: `af logs` tails via tail(1) on Unix and via PowerShell
// Get-Content on Windows (which has no tail), preserving the requested line
// count, the follow flag, and safe quoting of the log path. The helper is
// parameterized by GOOS so both platform branches are exercised regardless of
// the host running the tests.
func TestTailCommandArgs(t *testing.T) {
cases := []struct {
name string
goos string
file string
n int
follow bool
wantProg string
wantArgs []string
}{
{
name: "unix no follow", goos: "linux",
file: "/var/log/agent.log", n: 7,
wantProg: "tail",
wantArgs: []string{"-n", "7", "/var/log/agent.log"},
},
{
name: "unix follow", goos: "darwin",
file: "/var/log/agent.log", n: 10, follow: true,
wantProg: "tail",
wantArgs: []string{"-n", "10", "-f", "/var/log/agent.log"},
},
{
name: "windows no follow", goos: "windows",
file: `C:\logs\agent.log`, n: 7,
wantProg: "powershell",
wantArgs: []string{
"-NoProfile", "-Command",
`Get-Content -LiteralPath 'C:\logs\agent.log' -Tail 7`,
},
},
{
name: "windows follow with quote escaping", goos: "windows",
file: `C:\it's here\agent.log`, n: 10, follow: true,
wantProg: "powershell",
wantArgs: []string{
"-NoProfile", "-Command",
`Get-Content -LiteralPath 'C:\it''s here\agent.log' -Tail 10 -Wait`,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
prog, args := tailCommandArgs(tc.goos, tc.file, tc.n, tc.follow)
if prog != tc.wantProg {
t.Fatalf("program = %q; want %q", prog, tc.wantProg)
}
if !reflect.DeepEqual(args, tc.wantArgs) {
t.Fatalf("args = %q; want %q", args, tc.wantArgs)
}
})
}
}

// Contract: tailCommand builds an exec.Cmd from the host platform's
// tailCommandArgs — the first Args entry is the program itself.
func TestTailCommandUsesHostGOOS(t *testing.T) {
cmd := tailCommand("/var/log/agent.log", 5, false)
prog, args := tailCommandArgs(runtime.GOOS, "/var/log/agent.log", 5, false)
want := append([]string{prog}, args...)
if !reflect.DeepEqual(cmd.Args, want) {
t.Fatalf("cmd.Args = %q; want %q", cmd.Args, want)
}
}

// Contract: psSingleQuote produces a PowerShell single-quoted literal where
// embedded single quotes are doubled — the only escape that quoting form has.
func TestPSSingleQuote(t *testing.T) {
cases := map[string]string{
`C:\logs\agent.log`: `'C:\logs\agent.log'`,
`C:\it's here\a.log`: `'C:\it''s here\a.log'`,
``: `''`,
}
for in, want := range cases {
if got := psSingleQuote(in); got != want {
t.Errorf("psSingleQuote(%q) = %s; want %s", in, got, want)
}
}
}
21 changes: 21 additions & 0 deletions control-plane/internal/cli/proc_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//go:build !windows

package cli

import (
"os"
"syscall"
)

// signalGracefulStop asks a process to shut down gracefully. On Unix this
// sends SIGINT (os.Interrupt), matching the historical `af stop` behaviour.
// Callers fall back to process.Kill() when it returns an error.
func signalGracefulStop(process *os.Process) error {
return process.Signal(os.Interrupt)
}

// isProcessAlive reports whether the process is still running. On Unix,
// signal 0 probes for liveness without delivering an actual signal.
func isProcessAlive(process *os.Process) bool {
return process.Signal(syscall.Signal(0)) == nil
}
52 changes: 52 additions & 0 deletions control-plane/internal/cli/proc_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//go:build !windows

package cli

import (
"os"
"os/exec"
"testing"
)

// Contract: isProcessAlive must report true for a live process and false once
// the process has exited. `af stop` uses this to decide whether the graceful
// shutdown worked or a force-kill is needed.
func TestIsProcessAlive(t *testing.T) {
self, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess(self): %v", err)
}
if !isProcessAlive(self) {
t.Fatal("isProcessAlive(current process) = false; want true")
}

cmd := exec.Command("true")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("wait: %v", err)
}
if isProcessAlive(cmd.Process) {
t.Fatal("isProcessAlive(exited process) = true; want false")
}
}

// Contract: signalGracefulStop must deliver an interrupt that terminates a
// well-behaved (default-signal-disposition) child process.
func TestSignalGracefulStop(t *testing.T) {
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if err := signalGracefulStop(cmd.Process); err != nil {
t.Fatalf("signalGracefulStop: %v", err)
}
// The child dies from the signal, so Wait returns a non-nil ExitError.
if err := cmd.Wait(); err == nil {
t.Fatal("child exited cleanly; want interrupt-terminated")
}
if isProcessAlive(cmd.Process) {
t.Fatal("process still alive after graceful stop")
}
}
38 changes: 38 additions & 0 deletions control-plane/internal/cli/proc_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//go:build windows

package cli

// NOTE: compile-verified only (GOOS=windows cross-build); not yet exercised on
// a real Windows machine.

import (
"os"
"os/exec"
"strconv"
"strings"
)

// signalGracefulStop asks a process to shut down gracefully. Windows cannot
// deliver SIGINT/SIGTERM to an unrelated process (os.Process.Signal only
// supports os.Kill there), so use `taskkill` without /F, which requests the
// target to close. Callers fall back to process.Kill() when it returns an
// error.
func signalGracefulStop(process *os.Process) error {
return exec.Command("taskkill", "/PID", strconv.Itoa(process.Pid)).Run()
}

// isProcessAlive reports whether the process is still running. On Windows
// os.FindProcess always succeeds and signal-0 probing is unsupported, so ask
// tasklist whether the PID is present. When the probe itself fails, report
// not-alive so callers skip the force-kill rather than killing blindly.
func isProcessAlive(process *os.Process) bool {
out, err := exec.Command(
"tasklist", "/FI", "PID eq "+strconv.Itoa(process.Pid), "/NH", "/FO", "CSV",
).Output()
if err != nil {
return false
}
// CSV rows quote every field; a live PID appears as "...","<pid>",...
// A no-match run prints an INFO message instead and still exits 0.
return strings.Contains(string(out), `"`+strconv.Itoa(process.Pid)+`"`)
}
7 changes: 3 additions & 4 deletions control-plane/internal/cli/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"net/http"
"os"
"path/filepath"
"syscall"
"time"

"github.com/Agent-Field/agentfield/control-plane/internal/packages"
Expand Down Expand Up @@ -130,8 +129,8 @@ func (as *AgentNodeStopper) StopAgentNode(agentNodeName string) error {
if !httpShutdownSuccess {
fmt.Printf("🔄 Falling back to process signal shutdown for agent %s\n", agentNodeName)

// Send SIGTERM for graceful shutdown
if err := process.Signal(os.Interrupt); err != nil {
// Ask for graceful shutdown (SIGINT on Unix, taskkill on Windows)
if err := signalGracefulStop(process); err != nil {
// If graceful shutdown fails, force kill
if err := process.Kill(); err != nil {
return fmt.Errorf("failed to kill process: %w", err)
Expand All @@ -141,7 +140,7 @@ func (as *AgentNodeStopper) StopAgentNode(agentNodeName string) error {
time.Sleep(3 * time.Second)

// Check if process is still running
if err := process.Signal(syscall.Signal(0)); err == nil {
if isProcessAlive(process) {
// Process still running, force kill
fmt.Printf("⚠️ Process still running, force killing agent %s\n", agentNodeName)
if err := process.Kill(); err != nil {
Expand Down
Loading
Loading