98839cd8a8
Nuevo paquete internal/api con servidor HTTP stdlib (sin gin/echo):
- Auth Bearer via AGENTS_API_KEY con subtle.ConstantTimeCompare
- REST: GET /health (sin auth), GET/POST /agents, /agents/{id}, /{id}/{start,stop,restart,logs}
- SSE: /sse/status (broadcast diffs cada 2s) y /sse/agents/{id}/logs (tail -f)
- Pubsub in-memory (TODO: NATS cuando haya 2do cliente)
- Tail de logfiles: retroalimenta ultimos 50KB + poll 200ms para streaming
Integracion en cmd/launcher/main.go:
- Flag --api-port (0=desactivado, 8487 en produccion)
- Flag --api-key (override de AGENTS_API_KEY env var)
- Si apiPort>0 y sin clave, WARN y deshabilita en vez de fallar
Systemd unit en systemd/agents_and_robots.service:
- Restart=always (no on-failure — evita que exit limpio mate el service)
- EnvironmentFile para AGENTS_API_KEY y demas tokens
- WorkingDirectory=/home/ubuntu/CodeProyects/agents_and_robots
app.md v0.2.0:
- port: 8487, health_endpoint: /health (fix drift anterior donde era null)
- e2e_checks: build, tests, smoke_health, smoke_auth
- Documentacion Traefik+DNS pendiente humano post-merge
Tests: 12 tests unitarios en internal/api (auth, health, bus, agents, logs)
Smoke: /health 200, /agents sin auth 401, /agents con key 200 — verificado local
Co-Authored-By: fn-constructor (agent)
272 lines
6.3 KiB
Go
272 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/enmanuel/agents/shell/process"
|
|
)
|
|
|
|
// --- Response types ---
|
|
|
|
// AgentResponse is the JSON representation of an agent.
|
|
type AgentResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Desc string `json:"desc"`
|
|
Enabled bool `json:"enabled"`
|
|
Running bool `json:"running"`
|
|
PID int `json:"pid,omitempty"`
|
|
Instances int `json:"instances"`
|
|
ConfigPath string `json:"config_path"`
|
|
}
|
|
|
|
// AgentDetailResponse extends AgentResponse with logs.
|
|
type AgentDetailResponse struct {
|
|
AgentResponse
|
|
Logs []string `json:"logs"`
|
|
}
|
|
|
|
func agentResponse(s process.AgentStatus) AgentResponse {
|
|
return AgentResponse{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
Version: s.Version,
|
|
Desc: s.Desc,
|
|
Enabled: s.Enabled,
|
|
Running: s.Running,
|
|
PID: s.PID,
|
|
Instances: s.Instances,
|
|
ConfigPath: s.ConfigPath,
|
|
}
|
|
}
|
|
|
|
// --- Health ---
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "time": time.Now().UTC().Format(time.RFC3339)})
|
|
}
|
|
|
|
// --- List agents ---
|
|
|
|
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
|
|
statuses, err := s.mgr.StatusAll()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("scan: %v", err))
|
|
return
|
|
}
|
|
resp := make([]AgentResponse, 0, len(statuses))
|
|
for _, st := range statuses {
|
|
resp = append(resp, agentResponse(st))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// --- Get single agent ---
|
|
|
|
func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
statuses, err := s.mgr.StatusAll()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("scan: %v", err))
|
|
return
|
|
}
|
|
|
|
var found *process.AgentStatus
|
|
for i, st := range statuses {
|
|
if st.ID == id {
|
|
found = &statuses[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return
|
|
}
|
|
|
|
n := 200
|
|
if qn := r.URL.Query().Get("n"); qn != "" {
|
|
if parsed, err := strconv.Atoi(qn); err == nil && parsed > 0 {
|
|
n = parsed
|
|
}
|
|
}
|
|
|
|
logs, _ := s.mgr.LogTail(id, n)
|
|
writeJSON(w, http.StatusOK, AgentDetailResponse{
|
|
AgentResponse: agentResponse(*found),
|
|
Logs: logs,
|
|
})
|
|
}
|
|
|
|
// --- Start agent ---
|
|
|
|
func (s *Server) handleStartAgent(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
agents, err := s.mgr.Scan()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("scan: %v", err))
|
|
return
|
|
}
|
|
|
|
var info *process.AgentInfo
|
|
for i, a := range agents {
|
|
if a.ID == id {
|
|
info = &agents[i]
|
|
break
|
|
}
|
|
}
|
|
if info == nil {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return
|
|
}
|
|
|
|
if err := s.mgr.Start(*info); err != nil {
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("start: %v", err))
|
|
return
|
|
}
|
|
s.logger.Info("agent started via api", "id", id)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "started", "id": id})
|
|
}
|
|
|
|
// --- Stop agent ---
|
|
|
|
func (s *Server) handleStopAgent(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if err := s.mgr.Stop(id); err != nil {
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("stop: %v", err))
|
|
return
|
|
}
|
|
s.logger.Info("agent stopped via api", "id", id)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped", "id": id})
|
|
}
|
|
|
|
// --- Restart agent ---
|
|
|
|
func (s *Server) handleRestartAgent(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
|
|
// Stop first (ignore not-running error)
|
|
_ = s.mgr.Stop(id)
|
|
|
|
// Wait up to 3s for process to die
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if !s.mgr.IsRunning(id) {
|
|
break
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
|
|
// Find agent info for Start
|
|
agents, err := s.mgr.Scan()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, fmt.Sprintf("scan: %v", err))
|
|
return
|
|
}
|
|
|
|
var info *process.AgentInfo
|
|
for i, a := range agents {
|
|
if a.ID == id {
|
|
info = &agents[i]
|
|
break
|
|
}
|
|
}
|
|
if info == nil {
|
|
writeError(w, http.StatusNotFound, "agent not found")
|
|
return
|
|
}
|
|
|
|
if err := s.mgr.Start(*info); err != nil {
|
|
writeError(w, http.StatusConflict, fmt.Sprintf("restart/start: %v", err))
|
|
return
|
|
}
|
|
s.logger.Info("agent restarted via api", "id", id)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted", "id": id})
|
|
}
|
|
|
|
// --- Agent logs snapshot ---
|
|
|
|
func (s *Server) handleAgentLogs(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
n := 200
|
|
if qn := r.URL.Query().Get("n"); qn != "" {
|
|
if parsed, err := strconv.Atoi(qn); err == nil && parsed > 0 {
|
|
n = parsed
|
|
}
|
|
}
|
|
|
|
logs, err := s.mgr.LogTail(id, n)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, fmt.Sprintf("logs: %v", err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "lines": logs, "count": len(logs)})
|
|
}
|
|
|
|
// --- SSE: status broadcast ---
|
|
|
|
func (s *Server) handleSSEStatus(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
flusher.Flush()
|
|
|
|
sub := s.bus.Subscribe("status")
|
|
defer s.bus.Unsubscribe("status", sub)
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ev, ok := <-sub:
|
|
if !ok {
|
|
return
|
|
}
|
|
data, _ := json.Marshal(ev)
|
|
fmt.Fprintf(w, "event: status\ndata: %s\n\n", data)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- SSE: agent log tail ---
|
|
|
|
func (s *Server) handleSSEAgentLogs(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
|
|
logPath := s.mgr.LogPath(id)
|
|
if logPath == "" {
|
|
http.Error(w, "agent not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
flusher.Flush()
|
|
|
|
ctx := r.Context()
|
|
tailLogFile(ctx, logPath, w, flusher)
|
|
}
|