feat: add core start/stop/list/restart for manifest-driven daemons
Reads .core/manifest.yaml, resolves daemon specs, and manages lifecycle via the go-process registry (~/.core/daemons/). Supports health checks, detached process launch, and per-project daemon tracking. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
10a1c8ce07
commit
fd8cd31b76
2 changed files with 295 additions and 0 deletions
270
cmd/service/cmd.go
Normal file
270
cmd/service/cmd.go
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
"forge.lthn.ai/core/go-process"
|
||||||
|
"forge.lthn.ai/core/go-scm/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddServiceCommands registers core start/stop/list/restart as top-level commands.
|
||||||
|
func AddServiceCommands(root *cli.Command) {
|
||||||
|
startCmd := cli.NewCommand("start", "Start a project daemon",
|
||||||
|
"Reads .core/manifest.yaml and starts the named daemon (or the default).\n"+
|
||||||
|
"The daemon runs detached in the background.",
|
||||||
|
func(cmd *cli.Command, args []string) error {
|
||||||
|
return runStart(args)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stopCmd := cli.NewCommand("stop", "Stop a project daemon",
|
||||||
|
"Stops the named daemon for the current project, or all daemons if no name given.",
|
||||||
|
func(cmd *cli.Command, args []string) error {
|
||||||
|
return runStop(args)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
listCmd := cli.NewCommand("list", "List running daemons",
|
||||||
|
"Shows all running daemons tracked in ~/.core/daemons/.",
|
||||||
|
func(cmd *cli.Command, args []string) error {
|
||||||
|
return runList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
restartCmd := cli.NewCommand("restart", "Restart a project daemon",
|
||||||
|
"Stops then starts the named daemon.",
|
||||||
|
func(cmd *cli.Command, args []string) error {
|
||||||
|
if err := runStop(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runStart(args)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
root.AddCommand(startCmd, stopCmd, listCmd, restartCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStart(args []string) error {
|
||||||
|
m, projectDir, err := findManifest()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
daemonName, spec, err := resolveDaemon(m, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := process.DefaultRegistry()
|
||||||
|
|
||||||
|
// Check if already running.
|
||||||
|
if _, ok := reg.Get(m.Code, daemonName); ok {
|
||||||
|
return fmt.Errorf("%s/%s is already running", m.Code, daemonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve binary.
|
||||||
|
binary := spec.Binary
|
||||||
|
if binary == "" {
|
||||||
|
return fmt.Errorf("daemon %q has no binary specified", daemonName)
|
||||||
|
}
|
||||||
|
|
||||||
|
binPath, err := exec.LookPath(binary)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("binary %q not found in PATH: %w", binary, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch detached.
|
||||||
|
cmd := exec.Command(binPath, spec.Args...)
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
cmd.Env = append(os.Environ(), "CORE_DAEMON=1")
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
cmd.Stdin = nil
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start %s: %w", daemonName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := cmd.Process.Pid
|
||||||
|
_ = cmd.Process.Release()
|
||||||
|
|
||||||
|
// Wait for health if configured.
|
||||||
|
health := spec.Health
|
||||||
|
if health != "" && health != "127.0.0.1:0" {
|
||||||
|
if process.WaitForHealth(health, 5000) {
|
||||||
|
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d, health %s)", m.Code, daemonName, pid, health))
|
||||||
|
} else {
|
||||||
|
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d, health not yet ready)", m.Code, daemonName, pid))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d)", m.Code, daemonName, pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register in the daemon registry.
|
||||||
|
if err := reg.Register(process.DaemonEntry{
|
||||||
|
Code: m.Code,
|
||||||
|
Daemon: daemonName,
|
||||||
|
PID: pid,
|
||||||
|
Health: health,
|
||||||
|
Project: projectDir,
|
||||||
|
Binary: binPath,
|
||||||
|
}); err != nil {
|
||||||
|
cli.LogWarn(fmt.Sprintf("Daemon started but registry failed: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStop(args []string) error {
|
||||||
|
reg := process.DefaultRegistry()
|
||||||
|
|
||||||
|
m, _, err := findManifest()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a specific daemon name was given, stop only that one.
|
||||||
|
if len(args) > 0 {
|
||||||
|
return stopDaemon(reg, m.Code, args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// No args: stop all daemons for this project.
|
||||||
|
entries, err := reg.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Code == m.Code {
|
||||||
|
if err := stopDaemon(reg, e.Code, e.Daemon); err != nil {
|
||||||
|
cli.LogError(fmt.Sprintf("Failed to stop %s/%s: %v", e.Code, e.Daemon, err))
|
||||||
|
} else {
|
||||||
|
stopped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stopped == 0 {
|
||||||
|
cli.LogInfo("No running daemons for " + m.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopDaemon(reg *process.Registry, code, daemon string) error {
|
||||||
|
entry, ok := reg.Get(code, daemon)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s/%s is not running", code, daemon)
|
||||||
|
}
|
||||||
|
|
||||||
|
proc, err := os.FindProcess(entry.PID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("process %d not found: %w", entry.PID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return fmt.Errorf("failed to signal PID %d: %w", entry.PID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for process to exit, escalate to SIGKILL after 30s.
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if _, still := reg.Get(code, daemon); !still {
|
||||||
|
cli.LogInfo(fmt.Sprintf("Stopped %s/%s (PID %d)", code, daemon, entry.PID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.LogWarn(fmt.Sprintf("%s/%s did not stop within 30s, sending SIGKILL", code, daemon))
|
||||||
|
_ = proc.Signal(syscall.SIGKILL)
|
||||||
|
_ = reg.Unregister(code, daemon)
|
||||||
|
cli.LogInfo(fmt.Sprintf("Killed %s/%s (PID %d)", code, daemon, entry.PID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runList() error {
|
||||||
|
reg := process.DefaultRegistry()
|
||||||
|
entries, err := reg.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("No running daemons")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-20s %-12s %-8s %-24s %s\n", "CODE", "DAEMON", "PID", "HEALTH", "PROJECT")
|
||||||
|
for _, e := range entries {
|
||||||
|
project := e.Project
|
||||||
|
if project == "" {
|
||||||
|
project = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s %-12s %-8d %-24s %s\n", e.Code, e.Daemon, e.PID, e.Health, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findManifest walks from cwd up to / looking for .core/manifest.yaml.
|
||||||
|
func findManifest() (*manifest.Manifest, string, error) {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
path := filepath.Join(dir, ".core", "manifest.yaml")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
m, err := manifest.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("invalid manifest at %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return m, dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("no .core/manifest.yaml found (checked cwd and parent directories)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDaemon finds the daemon entry by name or returns the default.
|
||||||
|
func resolveDaemon(m *manifest.Manifest, args []string) (string, manifest.DaemonSpec, error) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
name := args[0]
|
||||||
|
spec, ok := m.Daemons[name]
|
||||||
|
if !ok {
|
||||||
|
return "", manifest.DaemonSpec{}, fmt.Errorf("daemon %q not found in manifest (available: %v)", name, daemonNames(m))
|
||||||
|
}
|
||||||
|
return name, spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name, spec, ok := m.DefaultDaemon()
|
||||||
|
if !ok {
|
||||||
|
return "", manifest.DaemonSpec{}, fmt.Errorf("no default daemon in manifest (use: core start <name>)")
|
||||||
|
}
|
||||||
|
return name, spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func daemonNames(m *manifest.Manifest) []string {
|
||||||
|
var names []string
|
||||||
|
for name := range m.Daemons {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
25
main.go
25
main.go
|
|
@ -8,8 +8,32 @@ import (
|
||||||
"forge.lthn.ai/core/cli/cmd/module"
|
"forge.lthn.ai/core/cli/cmd/module"
|
||||||
"forge.lthn.ai/core/cli/cmd/pkgcmd"
|
"forge.lthn.ai/core/cli/cmd/pkgcmd"
|
||||||
"forge.lthn.ai/core/cli/cmd/plugin"
|
"forge.lthn.ai/core/cli/cmd/plugin"
|
||||||
|
"forge.lthn.ai/core/cli/cmd/service"
|
||||||
"forge.lthn.ai/core/cli/cmd/session"
|
"forge.lthn.ai/core/cli/cmd/session"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
||||||
|
// Ecosystem command packages — self-register via init() + cli.RegisterCommands()
|
||||||
|
_ "forge.lthn.ai/core/agent/cmd/agent"
|
||||||
|
_ "forge.lthn.ai/core/agent/cmd/dispatch"
|
||||||
|
_ "forge.lthn.ai/core/agent/cmd/taskgit"
|
||||||
|
_ "forge.lthn.ai/core/go-ansible/cmd/ansible"
|
||||||
|
_ "forge.lthn.ai/core/go-api/cmd/api"
|
||||||
|
_ "forge.lthn.ai/core/go-build/cmd/build"
|
||||||
|
_ "forge.lthn.ai/core/go-build/cmd/ci"
|
||||||
|
_ "forge.lthn.ai/core/go-build/cmd/sdk"
|
||||||
|
_ "forge.lthn.ai/core/go-container/cmd/vm"
|
||||||
|
_ "forge.lthn.ai/core/go-crypt/cmd/crypt"
|
||||||
|
_ "forge.lthn.ai/core/go-devops/cmd/deploy"
|
||||||
|
_ "forge.lthn.ai/core/go-devops/cmd/dev"
|
||||||
|
_ "forge.lthn.ai/core/go-devops/cmd/docs"
|
||||||
|
_ "forge.lthn.ai/core/go-devops/cmd/gitcmd"
|
||||||
|
_ "forge.lthn.ai/core/go-devops/cmd/setup"
|
||||||
|
_ "forge.lthn.ai/core/go-infra/cmd/monitor"
|
||||||
|
_ "forge.lthn.ai/core/go-infra/cmd/prod"
|
||||||
|
_ "forge.lthn.ai/core/go-scm/cmd/collect"
|
||||||
|
_ "forge.lthn.ai/core/go-scm/cmd/forge"
|
||||||
|
_ "forge.lthn.ai/core/go-scm/cmd/gitea"
|
||||||
|
_ "forge.lthn.ai/core/lint/cmd/qa"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -22,5 +46,6 @@ func main() {
|
||||||
cli.WithCommands("plugin", plugin.AddPluginCommands),
|
cli.WithCommands("plugin", plugin.AddPluginCommands),
|
||||||
cli.WithCommands("session", session.AddSessionCommands),
|
cli.WithCommands("session", session.AddSessionCommands),
|
||||||
cli.WithCommands("go", gocmd.AddGoCommands),
|
cli.WithCommands("go", gocmd.AddGoCommands),
|
||||||
|
cli.WithCommands("service", service.AddServiceCommands),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue