feat: add auto update
This commit is contained in:
@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -801,3 +802,246 @@ func TestContainerAction_Success(t *testing.T) {
|
||||
t.Error("expected command_id in response")
|
||||
}
|
||||
}
|
||||
|
||||
// newCancelledRequest creates a request with an already-cancelled context.
|
||||
func newCancelledRequest(method, target string, body *bytes.Reader) *http.Request {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, target, body)
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// ── FsList ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFsList_AgentNotFound(t *testing.T) {
|
||||
h, _, _, _ := newTestHandler(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/list?path=/tmp", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsList_Timeout(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
|
||||
|
||||
// Use cancelled context to force immediate timeout on the agent wait.
|
||||
req := newCancelledRequest(http.MethodGet, "/api/v1/agents/a1/fs/list?path=/tmp", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Either 504 (timeout) or 404 (send failed because channel was full/cancelled).
|
||||
if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 504 or 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestFsList_MissingPath(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/api/v1/agents/{agentID}/fs/list", h.FsList)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/list", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── FsRead ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFsRead_AgentNotFound(t *testing.T) {
|
||||
h, _, _, _ := newTestHandler(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/ghost/fs/read?path=/etc/hosts", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsRead_MissingPath(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/api/v1/agents/{agentID}/fs/read", h.FsRead)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/a1/fs/read", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── FsWrite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFsWrite_AgentNotFound(t *testing.T) {
|
||||
h, _, _, _ := newTestHandler(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"path": "/tmp/test.txt", "content": "hello"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/write", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsWrite_MissingPath(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/fs/write", h.FsWrite)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"content": "hello"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/write", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── FsMkdir ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFsMkdir_AgentNotFound(t *testing.T) {
|
||||
h, _, _, _ := newTestHandler(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"path": "/opt/stacks/nouveau-dossier"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/fs/mkdir", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsMkdir_InvalidBody(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/fs/mkdir", h.FsMkdir)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/fs/mkdir", bytes.NewReader([]byte("not-json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ComposeAction ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestComposeAction_AgentNotFound(t *testing.T) {
|
||||
h, _, _, _ := newTestHandler(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAction_InvalidAction(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "restart"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAction_MissingFields(t *testing.T) {
|
||||
h, _, _, _ := newTestHandler(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"action": "up"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents/ghost/compose", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAction_Timeout(t *testing.T) {
|
||||
h, _, reg, _ := newTestHandler(t)
|
||||
reg.Register("a1", "h", "a", "ip", "arch", "os")
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/api/v1/agents/{agentID}/compose", h.ComposeAction)
|
||||
|
||||
bodyBytes, _ := json.Marshal(map[string]string{"path": "/opt/stack", "action": "up"})
|
||||
req := newCancelledRequest(http.MethodPost, "/api/v1/agents/a1/compose", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusGatewayTimeout && w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 504 or 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -185,6 +186,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
|
||||
AgentID string `json:"agent_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Alias string `json:"alias"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
ID string `json:"id"`
|
||||
Tags []string `json:"tags"`
|
||||
Size int64 `json:"size"`
|
||||
@ -197,8 +199,9 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
|
||||
AgentID: agent.ID,
|
||||
Hostname: agent.Hostname,
|
||||
Alias: agent.Alias,
|
||||
IPAddress: agent.IPAddress,
|
||||
ID: img.GetId(),
|
||||
Tags: img.GetTags(),
|
||||
Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(),
|
||||
Size: img.GetSize(),
|
||||
CreatedAt: img.GetCreatedAt(),
|
||||
})
|
||||
@ -214,6 +217,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
||||
AgentID string `json:"agent_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Alias string `json:"alias"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
@ -225,6 +229,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
||||
AgentID: agent.ID,
|
||||
Hostname: agent.Hostname,
|
||||
Alias: agent.Alias,
|
||||
IPAddress: agent.IPAddress,
|
||||
Name: vol.GetName(),
|
||||
Driver: vol.GetDriver(),
|
||||
Mountpoint: vol.GetMountpoint(),
|
||||
@ -238,25 +243,27 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||
type networkDTO struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Alias string `json:"alias"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Scope string `json:"scope"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Alias string `json:"alias"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
var out []networkDTO
|
||||
for _, agent := range h.registry.List() {
|
||||
for _, net := range agent.Networks {
|
||||
out = append(out, networkDTO{
|
||||
AgentID: agent.ID,
|
||||
Hostname: agent.Hostname,
|
||||
Alias: agent.Alias,
|
||||
ID: net.GetId(),
|
||||
Name: net.GetName(),
|
||||
Driver: net.GetDriver(),
|
||||
Scope: net.GetScope(),
|
||||
AgentID: agent.ID,
|
||||
Hostname: agent.Hostname,
|
||||
Alias: agent.Alias,
|
||||
IPAddress: agent.IPAddress,
|
||||
ID: net.GetId(),
|
||||
Name: net.GetName(),
|
||||
Driver: net.GetDriver(),
|
||||
Scope: net.GetScope(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -390,6 +397,292 @@ func (h *Handler) EventsWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── File system & Compose ─────────────────────────────────────────────────────
|
||||
|
||||
// sendFileCmd sends a file/compose command to an agent and waits for the response.
|
||||
// It uses the request context with an added 30s deadline so the handler can be
|
||||
// tested by cancelling the context.
|
||||
func (h *Handler) sendFileCmd(r *http.Request, agentID string, msg *agentv1.ServerMessage, cmdID string) (*agentv1.FileResult, error) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
return h.registry.SendAndWaitCtx(ctx, agentID, msg, cmdID)
|
||||
}
|
||||
|
||||
// FsList handles GET /api/v1/agents/{agentID}/fs/list?path=/some/dir
|
||||
func (h *Handler) FsList(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
path := r.URL.Query().Get("path")
|
||||
if path == "" {
|
||||
http.Error(w, "path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmdID := uuid.NewString()
|
||||
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
|
||||
Payload: &agentv1.ServerMessage_ListDir{
|
||||
ListDir: &agentv1.ListDirCommand{
|
||||
CommandId: cmdID,
|
||||
Path: path,
|
||||
},
|
||||
},
|
||||
}, cmdID)
|
||||
if err != nil {
|
||||
if err.Error() == "agent not connected" {
|
||||
http.Error(w, "agent not connected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !result.Success {
|
||||
http.Error(w, result.Error, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Content is JSON-encoded list of entries from the agent
|
||||
var entries json.RawMessage = result.Content
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(entries)
|
||||
}
|
||||
|
||||
// FsRead handles GET /api/v1/agents/{agentID}/fs/read?path=/some/file
|
||||
func (h *Handler) FsRead(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
path := r.URL.Query().Get("path")
|
||||
if path == "" {
|
||||
http.Error(w, "path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmdID := uuid.NewString()
|
||||
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
|
||||
Payload: &agentv1.ServerMessage_ReadFile{
|
||||
ReadFile: &agentv1.ReadFileCommand{
|
||||
CommandId: cmdID,
|
||||
Path: path,
|
||||
},
|
||||
},
|
||||
}, cmdID)
|
||||
if err != nil {
|
||||
if err.Error() == "agent not connected" {
|
||||
http.Error(w, "agent not connected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !result.Success {
|
||||
http.Error(w, result.Error, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]string{"content": string(result.Content)})
|
||||
}
|
||||
|
||||
// FsWrite handles POST /api/v1/agents/{agentID}/fs/write
|
||||
func (h *Handler) FsWrite(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" {
|
||||
http.Error(w, "path and content required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmdID := uuid.NewString()
|
||||
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
|
||||
Payload: &agentv1.ServerMessage_WriteFile{
|
||||
WriteFile: &agentv1.WriteFileCommand{
|
||||
CommandId: cmdID,
|
||||
Path: body.Path,
|
||||
Content: []byte(body.Content),
|
||||
},
|
||||
},
|
||||
}, cmdID)
|
||||
if err != nil {
|
||||
if err.Error() == "agent not connected" {
|
||||
http.Error(w, "agent not connected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !result.Success {
|
||||
http.Error(w, result.Error, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// FsMkdir handles POST /api/v1/agents/{agentID}/fs/mkdir
|
||||
func (h *Handler) FsMkdir(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" {
|
||||
http.Error(w, "path required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmdID := uuid.NewString()
|
||||
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
|
||||
Payload: &agentv1.ServerMessage_CreateDir{
|
||||
CreateDir: &agentv1.CreateDirCommand{
|
||||
CommandId: cmdID,
|
||||
Path: body.Path,
|
||||
},
|
||||
},
|
||||
}, cmdID)
|
||||
if err != nil {
|
||||
if err.Error() == "agent not connected" {
|
||||
http.Error(w, "agent not connected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !result.Success {
|
||||
http.Error(w, result.Error, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ComposeAction handles POST /api/v1/agents/{agentID}/compose
|
||||
func (h *Handler) ComposeAction(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Path == "" || body.Action == "" {
|
||||
http.Error(w, "path and action required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validActions := map[string]bool{"up": true, "down": true, "pull": true}
|
||||
if !validActions[body.Action] {
|
||||
http.Error(w, "action must be one of: up, down, pull", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmdID := uuid.NewString()
|
||||
result, err := h.sendFileCmd(r, agentID, &agentv1.ServerMessage{
|
||||
Payload: &agentv1.ServerMessage_ExecCompose{
|
||||
ExecCompose: &agentv1.ExecComposeCommand{
|
||||
CommandId: cmdID,
|
||||
Path: body.Path,
|
||||
Action: body.Action,
|
||||
},
|
||||
},
|
||||
}, cmdID)
|
||||
if err != nil {
|
||||
if err.Error() == "agent not connected" {
|
||||
http.Error(w, "agent not connected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "timeout waiting for agent", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
if !result.Success {
|
||||
jsonErr, _ := json.Marshal(map[string]string{"error": result.Error, "output": string(result.Content)})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write(jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]any{"ok": true, "output": string(result.Content)})
|
||||
}
|
||||
|
||||
// ── Auto-update policies ──────────────────────────────────────────────────────
|
||||
|
||||
// GetAutoUpdatePolicy handles GET /api/v1/agents/{agentID}/containers/{containerID}/auto-update
|
||||
func (h *Handler) GetAutoUpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
containerID := chi.URLParam(r, "containerID")
|
||||
|
||||
p, err := h.store.GetAutoUpdatePolicy(agentID, containerID)
|
||||
if err != nil {
|
||||
http.Error(w, "store error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if p == nil {
|
||||
jsonOK(w, map[string]any{"enabled": false, "interval_minutes": 1440})
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"enabled": p.Enabled,
|
||||
"interval_minutes": p.IntervalMinutes,
|
||||
"last_checked_at": p.LastCheckedAt,
|
||||
"last_updated_at": p.LastUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// PutAutoUpdatePolicy handles PUT /api/v1/agents/{agentID}/containers/{containerID}/auto-update
|
||||
func (h *Handler) PutAutoUpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
containerID := chi.URLParam(r, "containerID")
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.IntervalMinutes < 60 || body.IntervalMinutes > 43200 {
|
||||
http.Error(w, "interval_minutes must be between 60 and 43200", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p := &store.AutoUpdatePolicy{
|
||||
AgentID: agentID,
|
||||
ContainerID: containerID,
|
||||
Enabled: body.Enabled,
|
||||
IntervalMinutes: body.IntervalMinutes,
|
||||
}
|
||||
if err := h.store.UpsertAutoUpdatePolicy(p); err != nil {
|
||||
http.Error(w, "store error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"enabled": p.Enabled,
|
||||
"interval_minutes": p.IntervalMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateNow handles POST /api/v1/agents/{agentID}/containers/{containerID}/update-now
|
||||
func (h *Handler) UpdateNow(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := chi.URLParam(r, "agentID")
|
||||
containerID := chi.URLParam(r, "containerID")
|
||||
|
||||
cmdID := uuid.NewString()
|
||||
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
|
||||
Payload: &agentv1.ServerMessage_UpdateContainer{
|
||||
UpdateContainer: &agentv1.UpdateContainerCommand{
|
||||
CommandId: cmdID,
|
||||
ContainerId: containerID,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !sent {
|
||||
http.Error(w, "agent not connected", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
h.registry.RegisterPendingUpdate(agentID, cmdID, containerID)
|
||||
jsonOK(w, map[string]string{"command_id": cmdID})
|
||||
}
|
||||
|
||||
func jsonOK(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
|
||||
@ -52,6 +52,14 @@ func NewRouter(h *Handler) http.Handler {
|
||||
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
|
||||
r.Get("/events", h.EventsWS)
|
||||
r.Get("/agents/{agentID}/fs/list", h.FsList)
|
||||
r.Get("/agents/{agentID}/fs/read", h.FsRead)
|
||||
r.Post("/agents/{agentID}/fs/write", h.FsWrite)
|
||||
r.Post("/agents/{agentID}/fs/mkdir", h.FsMkdir)
|
||||
r.Post("/agents/{agentID}/compose", h.ComposeAction)
|
||||
r.Get("/agents/{agentID}/containers/{containerID}/auto-update", h.GetAutoUpdatePolicy)
|
||||
r.Put("/agents/{agentID}/containers/{containerID}/auto-update", h.PutAutoUpdatePolicy)
|
||||
r.Post("/agents/{agentID}/containers/{containerID}/update-now", h.UpdateNow)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user