diff --git a/.env.example b/.env.example deleted file mode 100644 index 4b8d562..0000000 --- a/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Mot de passe Gitea (optionnel — si absent, le script le demande interactivement) -GITEA_PASSWORD=ton-mot-de-passe - -# Serveur -JWT_SECRET=change-me-to-a-random-secret -ADMIN_USER=admin -ADMIN_PASSWORD=change-me - -# Token pour l'agent local (même VM que le serveur) -LOCAL_AGENT_TOKEN=change-me-to-a-random-token - -# Agents distants uniquement (docker-compose.agent.yml) -CONTAINARR_SERVER_URL=http://:9090 -CONTAINARR_AGENT_TOKEN= diff --git a/.gitignore b/.gitignore index 0f67a1c..7f30789 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ push.sh *.db-shm .env .env.* -!.env.example # ── OS ──────────────────────────────────────────────────────────────────────── .DS_Store diff --git a/docker-compose.agent.yml b/docker-compose.agent.yml index b75392d..2995e46 100644 --- a/docker-compose.agent.yml +++ b/docker-compose.agent.yml @@ -5,6 +5,6 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro environment: - CONTAINARR_SERVER_URL: "${CONTAINARR_SERVER_URL}" - CONTAINARR_AGENT_TOKEN: "${CONTAINARR_AGENT_TOKEN}" + CONTAINARR_SERVER_URL: "http://:9090" + CONTAINARR_AGENT_TOKEN: "" RUST_LOG: "info" diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 7ea71ca..61c00ad 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -11,22 +11,9 @@ services: DB_PATH: /data/containarr.db HTTP_ADDR: ":8080" GRPC_ADDR: ":9090" - JWT_SECRET: "${JWT_SECRET}" - ADMIN_USER: "${ADMIN_USER}" - ADMIN_PASSWORD: "${ADMIN_PASSWORD}" - BOOTSTRAP_TOKENS: "local:${LOCAL_AGENT_TOKEN}" - - agent: - image: gitea.anthonybouteiller.ovh/blomios/containarr-agent:latest - restart: unless-stopped - depends_on: - - server - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - environment: - CONTAINARR_SERVER_URL: "http://server:9090" - CONTAINARR_AGENT_TOKEN: "${LOCAL_AGENT_TOKEN}" - RUST_LOG: "info" + JWT_SECRET: "change-me-to-a-random-secret" + ADMIN_USER: "admin" + ADMIN_PASSWORD: "change-me" volumes: containarr-data: diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 4a6c8d3..f884afe 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -488,6 +488,77 @@ func TestListContainers_WithData(t *testing.T) { } } +// ── DeleteAgent ─────────────────────────────────────────────────────────────── + +func TestDeleteAgent_Success(t *testing.T) { + h, s, reg, _ := newTestHandler(t) + + _ = s.CreateAgentToken("a1", "t1", "host1") + reg.Register("a1", "host1", "", "ip", "arch", "os") + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}", h.DeleteAgent) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d — body: %s", w.Code, w.Body.String()) + } + + // Agent must be gone from store + _, err := s.GetAgent("a1") + if err == nil { + t.Error("expected store error after deletion, got nil") + } + + // Agent must be gone from registry + _, ok := reg.Get("a1") + if ok { + t.Error("expected agent to be deregistered from registry") + } +} + +func TestDeleteAgent_NotInRegistry(t *testing.T) { + h, s, _, _ := newTestHandler(t) + + _ = s.CreateAgentToken("a1", "t1", "host1") + // Agent not registered in registry (offline agent) + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}", h.DeleteAgent) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204 even when not in registry, got %d — body: %s", w.Code, w.Body.String()) + } + + _, err := s.GetAgent("a1") + if err == nil { + t.Error("expected store error after deletion, got nil") + } +} + +func TestDeleteAgent_NonExistent(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}", h.DeleteAgent) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // DELETE on a non-existent ID is still 204 (idempotent) + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204 for non-existent agent, got %d — body: %s", w.Code, w.Body.String()) + } +} + // ── ContainerAction ─────────────────────────────────────────────────────────── func TestContainerAction_AgentNotConnected(t *testing.T) { diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index e1a0f8b..498bad3 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -72,6 +72,16 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { jsonOK(w, out) } +func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + if err := h.store.DeleteAgent(agentID); err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + h.registry.Deregister(agentID) + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) { agentID := chi.URLParam(r, "agentID") var body struct { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index e4af86c..7f58f15 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -44,6 +44,7 @@ func NewRouter(h *Handler) http.Handler { r.Get("/agents", h.ListAgents) r.Post("/agents/token", h.CreateAgentToken) r.Patch("/agents/{agentID}", h.UpdateAgent) + r.Delete("/agents/{agentID}", h.DeleteAgent) r.Get("/containers", h.ListContainers) r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction) r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS) diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 2795be5..2c6b047 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -138,6 +138,11 @@ func (s *Store) UpdateAgentAlias(id, alias string) error { return err } +func (s *Store) DeleteAgent(id string) error { + _, err := s.db.Exec(`DELETE FROM agents WHERE id = ?`, id) + return err +} + // ── Users ───────────────────────────────────────────────────────────────────── func (s *Store) GetUserHash(username string) (string, error) { diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go index adeade8..57cd312 100644 --- a/server/internal/store/store_test.go +++ b/server/internal/store/store_test.go @@ -195,6 +195,54 @@ func TestUpdateAgentAlias(t *testing.T) { } } +func TestDeleteAgent(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateAgentToken("a1", "t1", "host1"); err != nil { + t.Fatalf("CreateAgentToken: %v", err) + } + + if err := s.DeleteAgent("a1"); err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + + _, err := s.GetAgent("a1") + if err == nil { + t.Error("expected error after deletion, got nil") + } +} + +func TestDeleteAgent_NotFound(t *testing.T) { + s := newTestStore(t) + + // Deleting a non-existent agent should not error (DELETE is idempotent at SQL level) + if err := s.DeleteAgent("nonexistent"); err != nil { + t.Fatalf("DeleteAgent on missing id: %v", err) + } +} + +func TestDeleteAgent_RemovesFromList(t *testing.T) { + s := newTestStore(t) + + _ = s.CreateAgentToken("a1", "t1", "host1") + _ = s.CreateAgentToken("a2", "t2", "host2") + + if err := s.DeleteAgent("a1"); err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + + agents, err := s.ListAgents() + if err != nil { + t.Fatalf("ListAgents: %v", err) + } + if len(agents) != 1 { + t.Fatalf("expected 1 agent after deletion, got %d", len(agents)) + } + if agents[0].ID != "a2" { + t.Errorf("expected remaining agent to be a2, got %q", agents[0].ID) + } +} + func TestCreateAgentToken_IdempotentIgnore(t *testing.T) { s := newTestStore(t) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ea10c00..6f98c30 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -58,6 +58,21 @@ async function apiFetch(input: string, init: RequestInit = {}): Promise { + const r = await apiFetch(`${BASE}/agents/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostname }), + }); + if (!r.ok) throw new Error(`create token: ${r.status}`); + return r.json(); +} + +export async function deleteAgent(id: string): Promise { + const r = await apiFetch(`${BASE}/agents/${id}`, { method: "DELETE" }); + if (!r.ok) throw new Error(`delete agent: ${r.status}`); +} + export async function fetchAgents(): Promise { const r = await apiFetch(`${BASE}/agents`); if (!r.ok) throw new Error(`agents: ${r.status}`); diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 5b1374d..6de139c 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -1,6 +1,6 @@