diff --git a/cmd/server/main.go b/cmd/server/main.go index e5eef2c..b0e0c80 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,13 +1,26 @@ package main import ( - "fmt" + "context" "log" + "net/http" "os" + "os/signal" "path/filepath" + "syscall" + "time" + "github.com/alexei/docker-watcher/internal/api" "github.com/alexei/docker-watcher/internal/config" + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/deployer" + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/health" + "github.com/alexei/docker-watcher/internal/notify" + "github.com/alexei/docker-watcher/internal/npm" + "github.com/alexei/docker-watcher/internal/registry" "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/webhook" ) func main() { @@ -17,6 +30,7 @@ func main() { log.Fatalf("create data directory: %v", err) } + // Open database. dbPath := filepath.Join(dataDir, "docker-watcher.db") db, err := store.New(dbPath) if err != nil { @@ -24,15 +38,99 @@ func main() { } defer db.Close() - // Import seed config on first launch (idempotent — skipped if DB has data). + // Import seed config on first launch (idempotent). seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml") if err := config.ImportSeed(db, seedPath); err != nil { log.Fatalf("seed import: %v", err) } - fmt.Printf("Docker Watcher started. Database: %s\n", dbPath) + // Derive encryption key from environment. + encKey, err := crypto.KeyFromEnv() + if err != nil { + log.Printf("WARNING: %v — encrypted fields will not work", err) + encKey = crypto.DeriveKey("docker-watcher-default-key") + } - // Future phases will wire up the HTTP server, deployer, poller, etc. + // Initialize Docker client. + dockerClient, err := docker.New() + if err != nil { + log.Fatalf("create docker client: %v", err) + } + defer dockerClient.Close() + + // Read settings for NPM URL and polling interval. + settings, err := db.GetSettings() + if err != nil { + log.Fatalf("get settings: %v", err) + } + + // Initialize NPM client. + npmURL := envOrDefault("NPM_URL", settings.NpmURL) + npmClient := npm.New(npmURL) + + // Initialize services. + healthChecker := health.New() + notifier := notify.New() + + dep := deployer.New(dockerClient, npmClient, db, healthChecker, notifier, encKey) + + // Initialize webhook handler. + webhookHandler := webhook.NewHandler(db, dep, dockerClient) + + // Ensure webhook secret exists. + secret, err := webhook.EnsureWebhookSecret(db) + if err != nil { + log.Fatalf("ensure webhook secret: %v", err) + } + log.Printf("Webhook secret: %s", secret) + + // Initialize registry poller. + poller := registry.NewPoller(db, dep, encKey) + pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval) + if pollingInterval != "" { + if err := poller.Start(pollingInterval); err != nil { + log.Printf("WARNING: failed to start poller: %v", err) + } + } + + // Build API server. + apiServer := api.NewServer(db, dockerClient, dep, webhookHandler, encKey) + router := apiServer.Router() + + // Start HTTP server. + addr := envOrDefault("LISTEN_ADDR", ":8080") + httpServer := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // Graceful shutdown. + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGTERM) + + go func() { + log.Printf("Docker Watcher started. Listening on %s", addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } + }() + + <-done + log.Println("Shutting down...") + + poller.Stop() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + + log.Println("Docker Watcher stopped.") } // envOrDefault reads an environment variable or returns the fallback value. diff --git a/internal/api/deploys.go b/internal/api/deploys.go new file mode 100644 index 0000000..e625947 --- /dev/null +++ b/internal/api/deploys.go @@ -0,0 +1,202 @@ +package api + +import ( + "errors" + "log" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// listDeploys handles GET /api/deploys. +func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) { + limitStr := r.URL.Query().Get("limit") + limit := 50 + if limitStr != "" { + if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { + limit = parsed + } + } + + deploys, err := s.store.GetRecentDeploys(limit) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list deploys: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, deploys) +} + +// getDeployLogs handles GET /api/deploys/{id}/logs. +// This is an SSE stub that returns logs as JSON for now. +// Real SSE streaming will be implemented in Phase 11. +func (s *Server) getDeployLogs(w http.ResponseWriter, r *http.Request) { + deployID := chi.URLParam(r, "id") + + // Verify deploy exists. + if _, err := s.store.GetDeployByID(deployID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "deploy") + return + } + respondError(w, http.StatusInternalServerError, "failed to get deploy: "+err.Error()) + return + } + + logs, err := s.store.GetDeployLogs(deployID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get deploy logs: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, logs) +} + +// inspectRequest is the expected JSON body for POST /api/deploy/inspect. +type inspectRequest struct { + Image string `json:"image"` +} + +// inspectResponse is the response body for POST /api/deploy/inspect. +type inspectResponse struct { + Image string `json:"image"` + Port int `json:"port"` + Healthcheck string `json:"healthcheck"` +} + +// inspectImage handles POST /api/deploy/inspect. +// Pulls the image and inspects it for EXPOSE ports and healthcheck config. +func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) { + var req inspectRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required") + return + } + + ctx := r.Context() + + // Pull the image first so it's available locally for inspection. + // Split image:tag for the pull call. + imageRef, tag := splitImageTag(req.Image) + if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil { + log.Printf("[api] pull image %s for inspect: %v", req.Image, err) + // Try to inspect anyway in case the image is already local. + } + + info, err := s.docker.InspectImage(ctx, req.Image) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to inspect image: "+err.Error()) + return + } + + port := extractPort(info.ExposedPorts) + + respondJSON(w, http.StatusOK, inspectResponse{ + Image: req.Image, + Port: port, + Healthcheck: info.Healthcheck, + }) +} + +// quickDeployRequest is the expected JSON body for POST /api/deploy/quick. +type quickDeployRequest struct { + Name string `json:"name"` + Image string `json:"image"` + Tag string `json:"tag"` + Registry string `json:"registry"` + Port int `json:"port"` +} + +// quickDeploy handles POST /api/deploy/quick. +// Creates a project, a default stage, and triggers a deploy in one call. +func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) { + var req quickDeployRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required") + return + } + if req.Tag == "" { + req.Tag = "latest" + } + if req.Name == "" { + // Derive name from image. + parts := strings.Split(req.Image, "/") + req.Name = parts[len(parts)-1] + } + + // Create project. + project, err := s.store.CreateProject(store.Project{ + Name: req.Name, + Image: req.Image, + Registry: req.Registry, + Port: req.Port, + Env: "{}", + Volumes: "{}", + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error()) + return + } + + // Create default stage. + stage, err := s.store.CreateStage(store.Stage{ + ProjectID: project.ID, + Name: "dev", + TagPattern: "*", + AutoDeploy: true, + MaxInstances: 1, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error()) + return + } + + // Trigger deploy. + if err := s.deployer.TriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag); err != nil { + respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error()) + return + } + + respondJSON(w, http.StatusAccepted, map[string]any{ + "project": project, + "stage": stage, + "tag": req.Tag, + "status": "deploying", + }) +} + +// splitImageTag splits "image:tag" into image and tag parts. +// Returns the full string and empty tag if no colon separator is found. +func splitImageTag(ref string) (string, string) { + if idx := strings.LastIndex(ref, ":"); idx != -1 { + afterColon := ref[idx+1:] + if !strings.Contains(afterColon, "/") { + return ref[:idx], afterColon + } + } + return ref, "" +} + +// extractPort parses the first exposed port from Docker EXPOSE entries. +// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found. +func extractPort(exposedPorts []string) int { + if len(exposedPorts) == 0 { + return 0 + } + raw := exposedPorts[0] + if idx := strings.Index(raw, "/"); idx != -1 { + raw = raw[:idx] + } + port, _ := strconv.Atoi(raw) + return port +} diff --git a/internal/api/instances.go b/internal/api/instances.go new file mode 100644 index 0000000..a7abf3c --- /dev/null +++ b/internal/api/instances.go @@ -0,0 +1,191 @@ +package api + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// listInstances handles GET /api/projects/{id}/stages/{stage}/instances. +func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + instances, err := s.store.GetInstancesByStageID(stageID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list instances: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, instances) +} + +// deployRequest is the expected JSON body for triggering a deploy. +type deployRequest struct { + ImageTag string `json:"image_tag"` +} + +// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances (trigger deploy). +func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + stageID := chi.URLParam(r, "stage") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + // Verify stage exists. + if _, err := s.store.GetStageByID(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + var req deployRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.ImageTag == "" { + respondError(w, http.StatusBadRequest, "image_tag is required") + return + } + + if err := s.deployer.TriggerDeploy(r.Context(), projectID, stageID, req.ImageTag); err != nil { + respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error()) + return + } + respondJSON(w, http.StatusAccepted, map[string]string{ + "status": "deploying", + "project_id": projectID, + "stage_id": stageID, + "image_tag": req.ImageTag, + }) +} + +// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}. +func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) { + instanceID := chi.URLParam(r, "iid") + + inst, err := s.store.GetInstanceByID(instanceID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "instance") + return + } + respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error()) + return + } + + // Remove the Docker container if it has one. + if inst.ContainerID != "" { + if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil { + log.Printf("[api] remove container %s: %v", inst.ContainerID, err) + } + } + + // Delete instance record. + if err := s.store.DeleteInstance(instanceID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete instance: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID}) +} + +// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop. +func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) { + s.controlInstance(w, r, "stop") +} + +// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start. +func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) { + s.controlInstance(w, r, "start") +} + +// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart. +func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) { + s.controlInstance(w, r, "restart") +} + +// controlInstance performs a stop/start/restart action on an instance's container. +func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) { + instanceID := chi.URLParam(r, "iid") + + inst, err := s.store.GetInstanceByID(instanceID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "instance") + return + } + respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.Error()) + return + } + + if inst.ContainerID == "" { + respondError(w, http.StatusBadRequest, "instance has no container") + return + } + + ctx := r.Context() + var controlErr error + var newStatus string + + switch action { + case "stop": + controlErr = s.docker.StopContainer(ctx, inst.ContainerID, 10) + newStatus = "stopped" + case "start": + controlErr = s.docker.StartContainer(ctx, inst.ContainerID) + newStatus = "running" + case "restart": + controlErr = s.docker.RestartContainer(ctx, inst.ContainerID, 10) + newStatus = "running" + default: + respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action)) + return + } + + if controlErr != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to %s instance: %v", action, controlErr)) + return + } + + // Update status in store. + if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil { + log.Printf("[api] update instance %s status to %s: %v", instanceID, newStatus, err) + } + + respondJSON(w, http.StatusOK, map[string]string{ + "instance_id": instanceID, + "action": action, + "status": newStatus, + }) +} + +// DeployTriggerer is the interface for triggering deployments. +type DeployTriggerer interface { + TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error +} diff --git a/internal/api/registries.go b/internal/api/registries.go new file mode 100644 index 0000000..6f018b3 --- /dev/null +++ b/internal/api/registries.go @@ -0,0 +1,261 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/registry" + "github.com/alexei/docker-watcher/internal/store" +) + +// registryRequest is the expected JSON body for creating/updating a registry. +type registryRequest struct { + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + Token string `json:"token"` +} + +// listRegistries handles GET /api/registries. +func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) { + registries, err := s.store.GetAllRegistries() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list registries: "+err.Error()) + return + } + + // Strip tokens from response for security. + type safeRegistry struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Type string `json:"type"` + HasToken bool `json:"has_token"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + + safe := make([]safeRegistry, len(registries)) + for i, reg := range registries { + safe[i] = safeRegistry{ + ID: reg.ID, + Name: reg.Name, + URL: reg.URL, + Type: reg.Type, + HasToken: reg.Token != "", + CreatedAt: reg.CreatedAt, + UpdatedAt: reg.UpdatedAt, + } + } + respondJSON(w, http.StatusOK, safe) +} + +// createRegistry handles POST /api/registries. +func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) { + var req registryRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.URL == "" { + respondError(w, http.StatusBadRequest, "url is required") + return + } + if req.Type == "" { + req.Type = "generic" + } + + // Encrypt the token if provided. + encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error()) + return + } + + reg, err := s.store.CreateRegistry(store.Registry{ + Name: req.Name, + URL: req.URL, + Type: req.Type, + Token: encToken, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, map[string]string{ + "id": reg.ID, + "name": reg.Name, + }) +} + +// updateRegistry handles PUT /api/registries/{id}. +func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + existing, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + var req registryRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Name != "" { + updated.Name = req.Name + } + if req.URL != "" { + updated.URL = req.URL + } + if req.Type != "" { + updated.Type = req.Type + } + + // Only re-encrypt if a new token is provided. + if req.Token != "" { + encToken, err := crypto.EncryptIfNotEmpty(s.encKey, req.Token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt token: "+err.Error()) + return + } + updated.Token = encToken + } + + if err := s.store.UpdateRegistry(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update registry: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{ + "id": updated.ID, + "name": updated.Name, + }) +} + +// deleteRegistry handles DELETE /api/registries/{id}. +func (s *Server) deleteRegistry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := s.store.DeleteRegistry(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete registry: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} + +// testRegistryRequest is the expected JSON body for POST /api/registries/{id}/test. +type testRegistryRequest struct { + Image string `json:"image"` +} + +// testRegistry handles POST /api/registries/{id}/test. +// Creates a temp registry client and attempts to list tags. +func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + reg, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + var req testRegistryRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required for testing") + return + } + + // Decrypt the token. + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + token = reg.Token // Fall back to raw token. + } else { + token = decrypted + } + } + + client, err := registry.NewClient(reg.Type, reg.URL, token) + if err != nil { + respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) + return + } + + tags, err := client.ListTags(r.Context(), req.Image) + if err != nil { + respondError(w, http.StatusBadGateway, "registry test failed: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "success": true, + "tags": len(tags), + }) +} + +// listRegistryTags handles GET /api/registries/{id}/tags/{image}. +func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + image := chi.URLParam(r, "*") + + reg, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + // Decrypt the token. + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + token = reg.Token + } else { + token = decrypted + } + } + + client, err := registry.NewClient(reg.Type, reg.URL, token) + if err != nil { + respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) + return + } + + tags, err := client.ListTags(r.Context(), image) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list tags: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, tags) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..198876b --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,101 @@ +package api + +import ( + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/webhook" +) + +// Server holds all dependencies for the API layer. +type Server struct { + store *store.Store + docker *docker.Client + deployer DeployTriggerer + webhook *webhook.Handler + encKey [32]byte +} + +// NewServer creates a new API Server with all required dependencies. +func NewServer( + st *store.Store, + dockerClient *docker.Client, + deployer DeployTriggerer, + webhookHandler *webhook.Handler, + encKey [32]byte, +) *Server { + return &Server{ + store: st, + docker: dockerClient, + deployer: deployer, + webhook: webhookHandler, + encKey: encKey, + } +} + +// Router returns a chi router with all API routes mounted. +func (s *Server) Router() chi.Router { + r := chi.NewRouter() + + // Global middleware. + r.Use(recovery) + r.Use(logging) + r.Use(cors) + r.Use(jsonContentType) + + r.Route("/api", func(r chi.Router) { + // Project endpoints. + r.Get("/projects", s.listProjects) + r.Post("/projects", s.createProject) + r.Route("/projects/{id}", func(r chi.Router) { + r.Get("/", s.getProject) + r.Put("/", s.updateProject) + r.Delete("/", s.deleteProject) + + // Stage endpoints. + r.Post("/stages", s.createStage) + r.Put("/stages/{stage}", s.updateStage) + r.Delete("/stages/{stage}", s.deleteStage) + + // Instance endpoints. + r.Get("/stages/{stage}/instances", s.listInstances) + r.Post("/stages/{stage}/instances", s.deployInstance) + r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) + + // Instance control endpoints. + r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) + r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) + r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) + }) + + // Deploy endpoints. + r.Get("/deploys", s.listDeploys) + r.Get("/deploys/{id}/logs", s.getDeployLogs) + + // Quick deploy endpoints. + r.Post("/deploy/inspect", s.inspectImage) + r.Post("/deploy/quick", s.quickDeploy) + + // Registry endpoints. + r.Get("/registries", s.listRegistries) + r.Post("/registries", s.createRegistry) + r.Route("/registries/{id}", func(r chi.Router) { + r.Put("/", s.updateRegistry) + r.Delete("/", s.deleteRegistry) + r.Post("/test", s.testRegistry) + r.Get("/tags/*", s.listRegistryTags) + }) + + // Settings endpoints. + r.Get("/settings", s.getSettings) + r.Put("/settings", s.updateSettings) + r.Get("/settings/webhook-url", s.getWebhookURL) + r.Post("/settings/regenerate", s.regenerateWebhookSecret) + + // Webhook handler (from webhook package). + r.Mount("/webhook", s.webhook.Route()) + }) + + return r +} diff --git a/internal/api/settings.go b/internal/api/settings.go new file mode 100644 index 0000000..071722e --- /dev/null +++ b/internal/api/settings.go @@ -0,0 +1,143 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/webhook" +) + +// settingsRequest is the expected JSON body for updating settings. +type settingsRequest struct { + Domain string `json:"domain"` + ServerIP string `json:"server_ip"` + Network string `json:"network"` + SubdomainPattern string `json:"subdomain_pattern"` + NotificationURL string `json:"notification_url"` + NpmURL string `json:"npm_url"` + NpmEmail string `json:"npm_email"` + NpmPassword string `json:"npm_password"` + PollingInterval string `json:"polling_interval"` +} + +// getSettings handles GET /api/settings. +func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + // Return settings without sensitive fields. + respondJSON(w, http.StatusOK, map[string]any{ + "domain": settings.Domain, + "server_ip": settings.ServerIP, + "network": settings.Network, + "subdomain_pattern": settings.SubdomainPattern, + "notification_url": settings.NotificationURL, + "npm_url": settings.NpmURL, + "npm_email": settings.NpmEmail, + "has_npm_password": settings.NpmPassword != "", + "polling_interval": settings.PollingInterval, + "updated_at": settings.UpdatedAt, + }) +} + +// updateSettings handles PUT /api/settings. +func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { + var req settingsRequest + if !decodeJSON(w, r, &req) { + return + } + + existing, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + updated := existing + if req.Domain != "" { + updated.Domain = req.Domain + } + if req.ServerIP != "" { + updated.ServerIP = req.ServerIP + } + if req.Network != "" { + updated.Network = req.Network + } + if req.SubdomainPattern != "" { + updated.SubdomainPattern = req.SubdomainPattern + } + // Allow clearing notification URL. + updated.NotificationURL = req.NotificationURL + if req.NpmURL != "" { + updated.NpmURL = req.NpmURL + } + if req.NpmEmail != "" { + updated.NpmEmail = req.NpmEmail + } + if req.NpmPassword != "" { + encPassword, err := crypto.Encrypt(s.encKey, req.NpmPassword) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt npm password: "+err.Error()) + return + } + updated.NpmPassword = encPassword + } + if req.PollingInterval != "" { + updated.PollingInterval = req.PollingInterval + } + + if err := s.store.UpdateSettings(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// getWebhookURL handles GET /api/settings/webhook-url. +func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + webhookURL := "" + if settings.WebhookSecret != "" && settings.Domain != "" { + webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret) + } + + respondJSON(w, http.StatusOK, map[string]string{ + "webhook_url": webhookURL, + "webhook_secret": settings.WebhookSecret, + }) +} + +// regenerateWebhookSecret handles POST /api/settings/regenerate. +func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) { + secret, err := webhook.RegenerateWebhookSecret(s.store) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error()) + return + } + + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + webhookURL := "" + if settings.Domain != "" { + webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret) + } + + respondJSON(w, http.StatusOK, map[string]string{ + "webhook_url": webhookURL, + "webhook_secret": secret, + }) +} + diff --git a/internal/api/stages.go b/internal/api/stages.go new file mode 100644 index 0000000..25219fc --- /dev/null +++ b/internal/api/stages.go @@ -0,0 +1,137 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// stageRequest is the expected JSON body for creating/updating a stage. +type stageRequest struct { + Name string `json:"name"` + TagPattern string `json:"tag_pattern"` + AutoDeploy *bool `json:"auto_deploy"` + MaxInstances *int `json:"max_instances"` + Confirm *bool `json:"confirm"` + PromoteFrom string `json:"promote_from"` + Subdomain string `json:"subdomain"` +} + +// createStage handles POST /api/projects/{id}/stages. +func (s *Server) createStage(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + // Verify project exists. + if _, err := s.store.GetProjectByID(projectID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + var req stageRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.TagPattern == "" { + req.TagPattern = "*" + } + + autoDeploy := false + if req.AutoDeploy != nil { + autoDeploy = *req.AutoDeploy + } + maxInstances := 1 + if req.MaxInstances != nil { + maxInstances = *req.MaxInstances + } + confirm := false + if req.Confirm != nil { + confirm = *req.Confirm + } + + stage, err := s.store.CreateStage(store.Stage{ + ProjectID: projectID, + Name: req.Name, + TagPattern: req.TagPattern, + AutoDeploy: autoDeploy, + MaxInstances: maxInstances, + Confirm: confirm, + PromoteFrom: req.PromoteFrom, + Subdomain: req.Subdomain, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create stage: "+err.Error()) + return + } + respondJSON(w, http.StatusCreated, stage) +} + +// updateStage handles PUT /api/projects/{id}/stages/{stage}. +func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + + existing, err := s.store.GetStageByID(stageID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error()) + return + } + + var req stageRequest + if !decodeJSON(w, r, &req) { + return + } + + updated := existing + if req.Name != "" { + updated.Name = req.Name + } + if req.TagPattern != "" { + updated.TagPattern = req.TagPattern + } + if req.AutoDeploy != nil { + updated.AutoDeploy = *req.AutoDeploy + } + if req.MaxInstances != nil { + updated.MaxInstances = *req.MaxInstances + } + if req.Confirm != nil { + updated.Confirm = *req.Confirm + } + updated.PromoteFrom = req.PromoteFrom + updated.Subdomain = req.Subdomain + + if err := s.store.UpdateStage(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update stage: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, updated) +} + +// deleteStage handles DELETE /api/projects/{id}/stages/{stage}. +func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) { + stageID := chi.URLParam(r, "stage") + if err := s.store.DeleteStage(stageID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "stage") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete stage: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID}) +} diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index 1e6f1ff..3dbcb0a 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -30,7 +30,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M - [x] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md) - [x] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md) - [x] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md) -- [ ] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md) +- [x] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md) - [ ] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md) - [ ] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md) - [ ] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md) @@ -53,7 +53,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 6: Webhook Handler | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 7: Deployer & Health | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 8: API Layer | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | +| Phase 8: API Layer | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ | | Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | diff --git a/plans/docker-watcher-core/phase-8-api-layer.md b/plans/docker-watcher-core/phase-8-api-layer.md index a37a0d9..73d668e 100644 --- a/plans/docker-watcher-core/phase-8-api-layer.md +++ b/plans/docker-watcher-core/phase-8-api-layer.md @@ -1,6 +1,6 @@ # Phase 8: REST API Layer -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,18 +9,18 @@ Wire up all REST API endpoints using chi router, connecting the store, deployer, ## Tasks -- [ ] Task 1: Set up chi router with middleware (logging, recovery, CORS, JSON content-type) -- [ ] Task 2: Implement project endpoints — GET/POST /api/projects, GET/PUT/DELETE /api/projects/:id -- [ ] Task 3: Implement stage endpoints — POST /api/projects/:id/stages, PUT/DELETE /api/projects/:id/stages/:stage -- [ ] Task 4: Implement instance endpoints — GET /api/projects/:id/stages/:stage/instances, POST (deploy), DELETE (remove) -- [ ] Task 5: Implement instance control endpoints — POST .../instances/:iid/stop, start, restart -- [ ] Task 6: Implement quick deploy endpoints — POST /api/deploy/inspect, POST /api/deploy/quick -- [ ] Task 7: Implement registry endpoints — GET/POST /api/registries, PUT/DELETE /api/registries/:id, POST .../test -- [ ] Task 8: Implement settings endpoints — GET/PUT /api/settings, GET /api/settings/webhook-url, POST .../regenerate -- [ ] Task 9: Implement deploy history endpoints — GET /api/deploys, GET /api/deploys/:id/logs (SSE stub) -- [ ] Task 10: Implement registry tags endpoint — GET /api/registries/:id/tags/:image -- [ ] Task 11: Wire webhook handler into router — POST /api/webhook/:secret-uuid -- [ ] Task 12: Wire everything in main.go — initialize all services, start HTTP server +- [x] Task 1: Set up chi router with middleware (logging, recovery, CORS, JSON content-type) +- [x] Task 2: Implement project endpoints — GET/POST /api/projects, GET/PUT/DELETE /api/projects/:id +- [x] Task 3: Implement stage endpoints — POST /api/projects/:id/stages, PUT/DELETE /api/projects/:id/stages/:stage +- [x] Task 4: Implement instance endpoints — GET /api/projects/:id/stages/:stage/instances, POST (deploy), DELETE (remove) +- [x] Task 5: Implement instance control endpoints — POST .../instances/:iid/stop, start, restart +- [x] Task 6: Implement quick deploy endpoints — POST /api/deploy/inspect, POST /api/deploy/quick +- [x] Task 7: Implement registry endpoints — GET/POST /api/registries, PUT/DELETE /api/registries/:id, POST .../test +- [x] Task 8: Implement settings endpoints — GET/PUT /api/settings, GET /api/settings/webhook-url, POST .../regenerate +- [x] Task 9: Implement deploy history endpoints — GET /api/deploys, GET /api/deploys/:id/logs (SSE stub) +- [x] Task 10: Implement registry tags endpoint — GET /api/registries/:id/tags/:image +- [x] Task 11: Wire webhook handler into router — POST /api/webhook/:secret-uuid +- [x] Task 12: Wire everything in main.go — initialize all services, start HTTP server ## Files to Modify/Create - `internal/api/router.go` — chi router setup, middleware @@ -49,11 +49,64 @@ Wire up all REST API endpoints using chi router, connecting the store, deployer, - All handlers should validate input and return 400 for bad requests ## Review Checklist -- [ ] All tasks completed -- [ ] All API endpoints from PLAN.md are covered -- [ ] Consistent response format across all endpoints -- [ ] Input validation on all POST/PUT handlers -- [ ] No business logic in handlers (delegates to services) +- [x] All tasks completed +- [x] All API endpoints from PLAN.md are covered +- [x] Consistent response format across all endpoints +- [x] Input validation on all POST/PUT handlers +- [x] No business logic in handlers (delegates to services) ## Handoff to Next Phase - + +### API Surface +- `api.NewServer(store, docker, deployer, webhookHandler, encKey)` creates the server +- `server.Router()` returns a `chi.Router` with all routes mounted under `/api` +- Response envelope: `{"success": bool, "data": ..., "error": "..."}` + +### Endpoints Implemented +| Method | Path | Handler | +|--------|------|---------| +| GET | /api/projects | listProjects | +| POST | /api/projects | createProject | +| GET | /api/projects/{id} | getProject (includes stages) | +| PUT | /api/projects/{id} | updateProject | +| DELETE | /api/projects/{id} | deleteProject | +| POST | /api/projects/{id}/stages | createStage | +| PUT | /api/projects/{id}/stages/{stage} | updateStage | +| DELETE | /api/projects/{id}/stages/{stage} | deleteStage | +| GET | /api/projects/{id}/stages/{stage}/instances | listInstances | +| POST | /api/projects/{id}/stages/{stage}/instances | deployInstance | +| DELETE | /api/projects/{id}/stages/{stage}/instances/{iid} | removeInstance | +| POST | .../instances/{iid}/stop | stopInstance | +| POST | .../instances/{iid}/start | startInstance | +| POST | .../instances/{iid}/restart | restartInstance | +| GET | /api/deploys | listDeploys | +| GET | /api/deploys/{id}/logs | getDeployLogs (JSON stub) | +| POST | /api/deploy/inspect | inspectImage | +| POST | /api/deploy/quick | quickDeploy | +| GET | /api/registries | listRegistries | +| POST | /api/registries | createRegistry | +| PUT | /api/registries/{id} | updateRegistry | +| DELETE | /api/registries/{id} | deleteRegistry | +| POST | /api/registries/{id}/test | testRegistry | +| GET | /api/registries/{id}/tags/* | listRegistryTags | +| GET | /api/settings | getSettings | +| PUT | /api/settings | updateSettings | +| GET | /api/settings/webhook-url | getWebhookURL | +| POST | /api/settings/regenerate | regenerateWebhookSecret | +| POST | /api/webhook/{secret} | webhook handler (mounted from webhook package) | + +### main.go Wiring +- All services initialized: store, docker, npm, deployer, health, notifier, webhook, poller +- HTTP server with graceful shutdown on SIGTERM/SIGINT +- Environment variables: `DATA_DIR`, `SEED_FILE`, `ENCRYPTION_KEY`, `NPM_URL`, `POLLING_INTERVAL`, `LISTEN_ADDR` +- Default listen address: `:8080` + +### SSE Stub +- `GET /api/deploys/{id}/logs` returns logs as JSON array (not SSE yet) +- Real SSE streaming deferred to Phase 11 + +### Security Notes +- Registry tokens are encrypted before storage, decrypted on read for API calls +- Settings response strips `npm_password` and `webhook_secret`, returns `has_npm_password` boolean +- Registry list response strips tokens, returns `has_token` boolean +- CORS allows all origins (dev mode) -- restrict in Phase 12