feat: add daemon Registry for tracking running daemons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
953a95f26f
commit
2a26948d44
2 changed files with 265 additions and 0 deletions
138
registry.go
Normal file
138
registry.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DaemonEntry records a running daemon in the registry.
|
||||
type DaemonEntry struct {
|
||||
Code string `json:"code"`
|
||||
Daemon string `json:"daemon"`
|
||||
PID int `json:"pid"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Binary string `json:"binary,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
}
|
||||
|
||||
// Registry tracks running daemons via JSON files in a directory.
|
||||
type Registry struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewRegistry creates a registry backed by the given directory.
|
||||
func NewRegistry(dir string) *Registry {
|
||||
return &Registry{dir: dir}
|
||||
}
|
||||
|
||||
// DefaultRegistry returns a registry using ~/.core/daemons/.
|
||||
func DefaultRegistry() *Registry {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = os.TempDir()
|
||||
}
|
||||
return NewRegistry(filepath.Join(home, ".core", "daemons"))
|
||||
}
|
||||
|
||||
// Register writes a daemon entry to the registry directory.
|
||||
// If Started is zero, it is set to the current time.
|
||||
// The directory is created if it does not exist.
|
||||
func (r *Registry) Register(entry DaemonEntry) error {
|
||||
if entry.Started.IsZero() {
|
||||
entry.Started = time.Now()
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(r.dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(r.entryPath(entry.Code, entry.Daemon), data, 0644)
|
||||
}
|
||||
|
||||
// Unregister removes a daemon entry from the registry.
|
||||
func (r *Registry) Unregister(code, daemon string) error {
|
||||
return os.Remove(r.entryPath(code, daemon))
|
||||
}
|
||||
|
||||
// Get reads a single daemon entry and checks whether its process is alive.
|
||||
// If the process is dead, the stale file is removed and (nil, false) is returned.
|
||||
func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
||||
path := r.entryPath(code, daemon)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if !isAlive(entry.PID) {
|
||||
_ = os.Remove(path)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &entry, true
|
||||
}
|
||||
|
||||
// List returns all alive daemon entries, pruning any with dead PIDs.
|
||||
func (r *Registry) List() ([]DaemonEntry, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(r.dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var alive []DaemonEntry
|
||||
for _, path := range matches {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
|
||||
if !isAlive(entry.PID) {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
|
||||
alive = append(alive, entry)
|
||||
}
|
||||
|
||||
return alive, nil
|
||||
}
|
||||
|
||||
// entryPath returns the filesystem path for a daemon entry.
|
||||
func (r *Registry) entryPath(code, daemon string) string {
|
||||
name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json"
|
||||
return filepath.Join(r.dir, name)
|
||||
}
|
||||
|
||||
// isAlive checks whether a process with the given PID is running.
|
||||
func isAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
127
registry_test.go
Normal file
127
registry_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
started := time.Now().UTC().Truncate(time.Second)
|
||||
entry := DaemonEntry{
|
||||
Code: "myapp",
|
||||
Daemon: "worker",
|
||||
PID: os.Getpid(),
|
||||
Health: "healthy",
|
||||
Project: "test-project",
|
||||
Binary: "/usr/bin/worker",
|
||||
Started: started,
|
||||
}
|
||||
|
||||
err := reg.Register(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := reg.Get("myapp", "worker")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "myapp", got.Code)
|
||||
assert.Equal(t, "worker", got.Daemon)
|
||||
assert.Equal(t, os.Getpid(), got.PID)
|
||||
assert.Equal(t, "healthy", got.Health)
|
||||
assert.Equal(t, "test-project", got.Project)
|
||||
assert.Equal(t, "/usr/bin/worker", got.Binary)
|
||||
assert.Equal(t, started, got.Started)
|
||||
}
|
||||
|
||||
func TestRegistry_Unregister(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
entry := DaemonEntry{
|
||||
Code: "myapp",
|
||||
Daemon: "server",
|
||||
PID: os.Getpid(),
|
||||
}
|
||||
|
||||
err := reg.Register(entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
// File should exist
|
||||
path := filepath.Join(dir, "myapp-server.json")
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = reg.Unregister("myapp", "server")
|
||||
require.NoError(t, err)
|
||||
|
||||
// File should be gone
|
||||
_, err = os.Stat(path)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_List(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
err := reg.Register(DaemonEntry{Code: "app1", Daemon: "web", PID: os.Getpid()})
|
||||
require.NoError(t, err)
|
||||
err = reg.Register(DaemonEntry{Code: "app2", Daemon: "api", PID: os.Getpid()})
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := reg.List()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 2)
|
||||
}
|
||||
|
||||
func TestRegistry_List_PrunesStale(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
err := reg.Register(DaemonEntry{Code: "dead", Daemon: "proc", PID: 999999999})
|
||||
require.NoError(t, err)
|
||||
|
||||
// File should exist before listing
|
||||
path := filepath.Join(dir, "dead-proc.json")
|
||||
_, err = os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := reg.List()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
|
||||
// Stale file should be removed
|
||||
_, err = os.Stat(path)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_Get_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
got, ok := reg.Get("nope", "missing")
|
||||
assert.Nil(t, got)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRegistry_CreatesDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons")
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()})
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := os.Stat(dir)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.IsDir())
|
||||
}
|
||||
|
||||
func TestDefaultRegistry(t *testing.T) {
|
||||
reg := DefaultRegistry()
|
||||
assert.NotNil(t, reg)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue