go-process/registry.go

416 lines
10 KiB
Go
Raw Permalink Normal View History

package process
import (
"path"
"strconv"
"syscall"
"time"
"dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// DaemonEntry records a running daemon in the registry.
//
// entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234}
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.
//
// reg := process.NewRegistry("/tmp/process-daemons")
type Registry struct {
dir string
}
// NewRegistry creates a registry backed by the given directory.
//
// reg := process.NewRegistry("/tmp/process-daemons")
func NewRegistry(dir string) *Registry {
return &Registry{dir: dir}
}
// DefaultRegistry returns a registry using ~/.core/daemons/.
//
// reg := process.DefaultRegistry()
func DefaultRegistry() *Registry {
home, err := userHomeDir()
if err != nil {
home = tempDir()
}
return NewRegistry(path.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 := coreio.Local.EnsureDir(r.dir); err != nil {
return core.E("registry.register", "failed to create registry directory", err)
}
data, err := marshalDaemonEntry(entry)
if err != nil {
return core.E("registry.register", "failed to marshal entry", err)
}
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil {
return core.E("registry.register", "failed to write entry file", err)
}
return nil
}
// Unregister removes a daemon entry from the registry.
func (r *Registry) Unregister(code, daemon string) error {
if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil {
return core.E("registry.unregister", "failed to delete entry file", err)
}
return nil
}
// 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 := coreio.Local.Read(path)
if err != nil {
return nil, false
}
entry, err := unmarshalDaemonEntry(data)
if err != nil {
_ = coreio.Local.Delete(path)
return nil, false
}
if !isAlive(entry.PID) {
_ = coreio.Local.Delete(path)
return nil, false
}
return &entry, true
}
// List returns all alive daemon entries, pruning any with dead PIDs.
func (r *Registry) List() ([]DaemonEntry, error) {
if !coreio.Local.Exists(r.dir) {
return nil, nil
}
entries, err := coreio.Local.List(r.dir)
if err != nil {
return nil, core.E("registry.list", "failed to list registry directory", err)
}
var alive []DaemonEntry
for _, entryFile := range entries {
if entryFile.IsDir() || !core.HasSuffix(entryFile.Name(), ".json") {
continue
}
path := path.Join(r.dir, entryFile.Name())
data, err := coreio.Local.Read(path)
if err != nil {
continue
}
entry, err := unmarshalDaemonEntry(data)
if err != nil {
_ = coreio.Local.Delete(path)
continue
}
if !isAlive(entry.PID) {
_ = coreio.Local.Delete(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 := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json"
return path.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 := processHandle(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}
func sanitizeRegistryComponent(value string) string {
buf := make([]byte, len(value))
for i := 0; i < len(value); i++ {
if value[i] == '/' {
buf[i] = '-'
continue
}
buf[i] = value[i]
}
return string(buf)
}
func marshalDaemonEntry(entry DaemonEntry) (string, error) {
fields := []struct {
key string
value string
}{
{key: "code", value: quoteJSONString(entry.Code)},
{key: "daemon", value: quoteJSONString(entry.Daemon)},
{key: "pid", value: strconv.Itoa(entry.PID)},
}
if entry.Health != "" {
fields = append(fields, struct {
key string
value string
}{key: "health", value: quoteJSONString(entry.Health)})
}
if entry.Project != "" {
fields = append(fields, struct {
key string
value string
}{key: "project", value: quoteJSONString(entry.Project)})
}
if entry.Binary != "" {
fields = append(fields, struct {
key string
value string
}{key: "binary", value: quoteJSONString(entry.Binary)})
}
fields = append(fields, struct {
key string
value string
}{
key: "started",
value: quoteJSONString(entry.Started.Format(time.RFC3339Nano)),
})
builder := core.NewBuilder()
builder.WriteString("{\n")
for i, field := range fields {
builder.WriteString(core.Concat(" ", quoteJSONString(field.key), ": ", field.value))
if i < len(fields)-1 {
builder.WriteString(",")
}
builder.WriteString("\n")
}
builder.WriteString("}")
return builder.String(), nil
}
func unmarshalDaemonEntry(data string) (DaemonEntry, error) {
values, err := parseJSONObject(data)
if err != nil {
return DaemonEntry{}, err
}
entry := DaemonEntry{
Code: values["code"],
Daemon: values["daemon"],
Health: values["health"],
Project: values["project"],
Binary: values["binary"],
}
pidValue, ok := values["pid"]
if !ok {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing pid", nil)
}
entry.PID, err = strconv.Atoi(pidValue)
if err != nil {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid pid", err)
}
startedValue, ok := values["started"]
if !ok {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing started", nil)
}
entry.Started, err = time.Parse(time.RFC3339Nano, startedValue)
if err != nil {
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid started timestamp", err)
}
return entry, nil
}
func parseJSONObject(data string) (map[string]string, error) {
trimmed := core.Trim(data)
if trimmed == "" {
return nil, core.E("Registry.parseJSONObject", "empty JSON object", nil)
}
if trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' {
return nil, core.E("Registry.parseJSONObject", "invalid JSON object", nil)
}
values := make(map[string]string)
index := skipJSONSpace(trimmed, 1)
for index < len(trimmed) {
if trimmed[index] == '}' {
return values, nil
}
key, next, err := parseJSONString(trimmed, index)
if err != nil {
return nil, err
}
index = skipJSONSpace(trimmed, next)
if index >= len(trimmed) || trimmed[index] != ':' {
return nil, core.E("Registry.parseJSONObject", "missing key separator", nil)
}
index = skipJSONSpace(trimmed, index+1)
if index >= len(trimmed) {
return nil, core.E("Registry.parseJSONObject", "missing value", nil)
}
var value string
if trimmed[index] == '"' {
value, index, err = parseJSONString(trimmed, index)
if err != nil {
return nil, err
}
} else {
start := index
for index < len(trimmed) && trimmed[index] != ',' && trimmed[index] != '}' {
index++
}
value = core.Trim(trimmed[start:index])
}
values[key] = value
index = skipJSONSpace(trimmed, index)
if index >= len(trimmed) {
break
}
if trimmed[index] == ',' {
index = skipJSONSpace(trimmed, index+1)
continue
}
if trimmed[index] == '}' {
return values, nil
}
return nil, core.E("Registry.parseJSONObject", "invalid object separator", nil)
}
return nil, core.E("Registry.parseJSONObject", "unterminated JSON object", nil)
}
func parseJSONString(data string, start int) (string, int, error) {
if start >= len(data) || data[start] != '"' {
return "", 0, core.E("Registry.parseJSONString", "expected quoted string", nil)
}
builder := core.NewBuilder()
for index := start + 1; index < len(data); index++ {
ch := data[index]
if ch == '"' {
return builder.String(), index + 1, nil
}
if ch != '\\' {
builder.WriteByte(ch)
continue
}
index++
if index >= len(data) {
return "", 0, core.E("Registry.parseJSONString", "unterminated escape sequence", nil)
}
switch data[index] {
case '"', '\\', '/':
builder.WriteByte(data[index])
case 'b':
builder.WriteByte('\b')
case 'f':
builder.WriteByte('\f')
case 'n':
builder.WriteByte('\n')
case 'r':
builder.WriteByte('\r')
case 't':
builder.WriteByte('\t')
case 'u':
if index+4 >= len(data) {
return "", 0, core.E("Registry.parseJSONString", "short unicode escape", nil)
}
r, err := strconv.ParseInt(data[index+1:index+5], 16, 32)
if err != nil {
return "", 0, core.E("Registry.parseJSONString", "invalid unicode escape", err)
}
builder.WriteRune(rune(r))
index += 4
default:
return "", 0, core.E("Registry.parseJSONString", "invalid escape sequence", nil)
}
}
return "", 0, core.E("Registry.parseJSONString", "unterminated string", nil)
}
func skipJSONSpace(data string, index int) int {
for index < len(data) {
switch data[index] {
case ' ', '\n', '\r', '\t':
index++
default:
return index
}
}
return index
}
func quoteJSONString(value string) string {
builder := core.NewBuilder()
builder.WriteByte('"')
for i := 0; i < len(value); i++ {
switch value[i] {
case '\\', '"':
builder.WriteByte('\\')
builder.WriteByte(value[i])
case '\b':
builder.WriteString(`\b`)
case '\f':
builder.WriteString(`\f`)
case '\n':
builder.WriteString(`\n`)
case '\r':
builder.WriteString(`\r`)
case '\t':
builder.WriteString(`\t`)
default:
if value[i] < 0x20 {
builder.WriteString(core.Sprintf("\\u%04x", value[i]))
continue
}
builder.WriteByte(value[i])
}
}
builder.WriteByte('"')
return builder.String()
}