agent-orchestrator/backend/internal/cli/status.go

233 lines
5.9 KiB
Go

package cli
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/spf13/cobra"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
"github.com/aoagents/agent-orchestrator/backend/internal/daemonmeta"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
)
const probeTimeout = 2 * time.Second
type statusOptions struct {
json bool
}
type daemonState string
const (
stateReady daemonState = "ready"
stateStopped daemonState = "stopped"
stateStale daemonState = "stale"
stateUnhealthy daemonState = "unhealthy"
stateNotReady daemonState = "not_ready"
)
type daemonStatus struct {
State daemonState `json:"state"`
PID int `json:"pid,omitempty"`
Port int `json:"port,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
Uptime string `json:"uptime,omitempty"`
RunFile string `json:"runFile"`
DataDir string `json:"dataDir"`
Health string `json:"health,omitempty"`
Ready string `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
owned bool
}
type probeResult struct {
Status string `json:"status"`
Service string `json:"service"`
PID int `json:"pid"`
ExecutablePath string `json:"executablePath,omitempty"`
WorkingDirectory string `json:"workingDirectory,omitempty"`
}
func newStatusCommand(ctx *commandContext) *cobra.Command {
var opts statusOptions
cmd := &cobra.Command{
Use: "status",
Short: "Show AO daemon status",
Args: noArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := ctx.inspectDaemon(cmd.Context())
if err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), st)
}
return writeStatus(cmd, st)
},
}
cmd.Flags().BoolVar(&opts.json, "json", false, "Output status as JSON")
return cmd
}
func (c *commandContext) inspectDaemon(ctx context.Context) (daemonStatus, error) {
cfg, err := config.Load()
if err != nil {
return daemonStatus{}, err
}
st := daemonStatus{State: stateStopped, RunFile: cfg.RunFilePath, DataDir: cfg.DataDir}
info, err := runfile.Read(cfg.RunFilePath)
if err != nil {
return daemonStatus{}, err
}
if info == nil {
return st, nil
}
st.PID = info.PID
st.Port = info.Port
startedAt := info.StartedAt
st.StartedAt = &startedAt
st.Uptime = formatUptime(c.deps.Now().Sub(info.StartedAt))
if !c.deps.ProcessAlive(info.PID) {
st.State = stateStale
st.Error = "run-file points to a dead process"
return st, nil
}
health, err := c.readProbe(ctx, info.Port, "healthz")
if err != nil {
st.State = stateUnhealthy
st.Error = err.Error()
return st, nil
}
if err := verifyProbeOwner(health, info.PID, "healthz"); err != nil {
st.State = stateStale
st.Error = err.Error()
return st, nil
}
st.owned = true
st.Health = health.Status
if health.Status != "ok" {
st.State = stateUnhealthy
return st, nil
}
ready, err := c.readProbe(ctx, info.Port, "readyz")
if err != nil {
st.State = stateNotReady
st.Error = err.Error()
return st, nil
}
if err := verifyProbeOwner(ready, info.PID, "readyz"); err != nil {
st.State = stateStale
st.owned = false
st.Error = err.Error()
return st, nil
}
st.Ready = ready.Status
if ready.Status == string(stateReady) {
st.State = stateReady
return st, nil
}
st.State = stateNotReady
return st, nil
}
func (c *commandContext) readProbe(ctx context.Context, port int, path string) (probeResult, error) {
reqCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, fmt.Sprintf("http://%s:%d/%s", config.LoopbackHost, port, path), http.NoBody)
if err != nil {
return probeResult{}, err
}
resp, err := c.deps.HTTPClient.Do(req)
if err != nil {
return probeResult{}, fmt.Errorf("%s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return probeResult{}, fmt.Errorf("%s: HTTP %d", path, resp.StatusCode)
}
var body probeResult
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return probeResult{}, fmt.Errorf("%s: decode response: %w", path, err)
}
if body.Status == "" {
return probeResult{}, fmt.Errorf("%s: missing status", path)
}
return body, nil
}
func verifyProbeOwner(probe probeResult, wantPID int, path string) error {
if probe.Service != daemonmeta.ServiceName {
return fmt.Errorf("%s: response is not from AO daemon", path)
}
if probe.PID != wantPID {
return fmt.Errorf("%s: daemon pid %d does not match run-file pid %d", path, probe.PID, wantPID)
}
return nil
}
func writeStatus(cmd *cobra.Command, st daemonStatus) error {
out := cmd.OutOrStdout()
if _, err := fmt.Fprintf(out, "AO daemon: %s\n", st.State); err != nil {
return err
}
if st.PID != 0 {
if _, err := fmt.Fprintf(out, " pid: %d\n", st.PID); err != nil {
return err
}
}
if st.Port != 0 {
if _, err := fmt.Fprintf(out, " port: %d\n", st.Port); err != nil {
return err
}
}
if st.StartedAt != nil && !st.StartedAt.IsZero() {
if _, err := fmt.Fprintf(out, " started: %s\n", st.StartedAt.Format(time.RFC3339)); err != nil {
return err
}
}
if st.Uptime != "" {
if _, err := fmt.Fprintf(out, " uptime: %s\n", st.Uptime); err != nil {
return err
}
}
if _, err := fmt.Fprintf(out, " run file: %s\n", st.RunFile); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " data dir: %s\n", st.DataDir); err != nil {
return err
}
if st.Health != "" {
if _, err := fmt.Fprintf(out, " healthz: %s\n", st.Health); err != nil {
return err
}
}
if st.Ready != "" {
if _, err := fmt.Fprintf(out, " readyz: %s\n", st.Ready); err != nil {
return err
}
}
if st.Error != "" {
if _, err := fmt.Fprintf(out, " error: %s\n", st.Error); err != nil {
return err
}
}
return nil
}
func formatUptime(d time.Duration) string {
if d < 0 {
d = 0
}
return d.Round(time.Second).String()
}