diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..6f597e1 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,69 @@ +package api + +import ( + "log" + "net/http" + "runtime/debug" + "time" +) + +// logging is an HTTP middleware that logs every request with method, path, +// status code, and duration. +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + wrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + + next.ServeHTTP(wrapped, r) + + log.Printf("[api] %s %s %d %s", r.Method, r.URL.Path, wrapped.status, time.Since(start)) + }) +} + +// recovery is an HTTP middleware that catches panics and returns a 500 response. +func recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("[api] panic: %v\n%s", err, debug.Stack()) + respondError(w, http.StatusInternalServerError, "internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +// cors is an HTTP middleware that sets permissive CORS headers for development. +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// jsonContentType is an HTTP middleware that sets the default Content-Type to JSON. +func jsonContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} + +// statusRecorder wraps http.ResponseWriter to capture the status code. +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} diff --git a/internal/api/projects.go b/internal/api/projects.go new file mode 100644 index 0000000..029f19d --- /dev/null +++ b/internal/api/projects.go @@ -0,0 +1,153 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" +) + +// projectRequest is the expected JSON body for creating/updating a project. +type projectRequest struct { + Name string `json:"name"` + Registry string `json:"registry"` + Image string `json:"image"` + Port int `json:"port"` + Healthcheck string `json:"healthcheck"` + Env string `json:"env"` + Volumes string `json:"volumes"` +} + +// listProjects handles GET /api/projects. +func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { + projects, err := s.store.GetAllProjects() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list projects: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, projects) +} + +// createProject handles POST /api/projects. +func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { + var req projectRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "name is required") + return + } + if req.Image == "" { + respondError(w, http.StatusBadRequest, "image is required") + return + } + if req.Env == "" { + req.Env = "{}" + } + if req.Volumes == "" { + req.Volumes = "{}" + } + + project, err := s.store.CreateProject(store.Project{ + Name: req.Name, + Registry: req.Registry, + Image: req.Image, + Port: req.Port, + Healthcheck: req.Healthcheck, + Env: req.Env, + Volumes: req.Volumes, + }) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create project: "+err.Error()) + return + } + respondJSON(w, http.StatusCreated, project) +} + +// getProject handles GET /api/projects/{id}. +func (s *Server) getProject(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + project, err := s.store.GetProjectByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + return + } + + // Also fetch stages for this project. + stages, err := s.store.GetStagesByProjectID(id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get stages: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "project": project, + "stages": stages, + }) +} + +// updateProject handles PUT /api/projects/{id}. +func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + existing, err := s.store.GetProjectByID(id) + if 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 projectRequest + if !decodeJSON(w, r, &req) { + return + } + + // Apply updates to existing project, preserving fields not provided. + updated := existing + if req.Name != "" { + updated.Name = req.Name + } + if req.Image != "" { + updated.Image = req.Image + } + updated.Registry = req.Registry + updated.Port = req.Port + updated.Healthcheck = req.Healthcheck + if req.Env != "" { + updated.Env = req.Env + } + if req.Volumes != "" { + updated.Volumes = req.Volumes + } + + if err := s.store.UpdateProject(updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update project: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, updated) +} + +// deleteProject handles DELETE /api/projects/{id}. +func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := s.store.DeleteProject(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete project: "+err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} diff --git a/internal/api/response.go b/internal/api/response.go new file mode 100644 index 0000000..812a11f --- /dev/null +++ b/internal/api/response.go @@ -0,0 +1,47 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" +) + +// envelope is the standard API response wrapper. +type envelope struct { + Success bool `json:"success"` + Data any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// respondJSON writes a JSON success response with the given status code and data. +func respondJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil { + log.Printf("[api] encode response: %v", err) + } +} + +// respondError writes a JSON error response with the given status code and message. +func respondError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(envelope{Success: false, Error: msg}); err != nil { + log.Printf("[api] encode error response: %v", err) + } +} + +// respondNotFound writes a 404 JSON error response for the given entity type. +func respondNotFound(w http.ResponseWriter, entity string) { + respondError(w, http.StatusNotFound, entity+" not found") +} + +// decodeJSON reads and decodes the request body into the given value. +// Returns false and writes a 400 error response if decoding fails. +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return false + } + return true +} diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 4ea1468..acd70a0 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -387,12 +387,12 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett // Delete NPM proxy host. if inst.NpmProxyID > 0 { npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) - if err == nil { - if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr == nil { - if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { - log.Printf("deployer: delete proxy host %d: %v", inst.NpmProxyID, delErr) - } - } + if err != nil { + log.Printf("deployer: decrypt npm password for proxy cleanup: %v", err) + } else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil { + log.Printf("deployer: authenticate npm for proxy cleanup: %v", authErr) + } else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { + log.Printf("deployer: delete proxy host %d: %v", inst.NpmProxyID, delErr) } } diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go index bbed9da..fb6937a 100644 --- a/internal/deployer/rollback.go +++ b/internal/deployer/rollback.go @@ -24,7 +24,17 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st // Delete the NPM proxy host if it was created. if npmProxyID > 0 { - if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil { + settings, err := d.store.GetSettings() + if err != nil { + log.Printf("rollback: get settings for npm auth: %v", err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to get settings for proxy cleanup: %v", err), "error") + } else if npmPassword, err := d.decryptNpmPassword(settings.NpmPassword); err != nil { + log.Printf("rollback: decrypt npm password: %v", err) + d.logDeploy(deployID, "Rollback: failed to decrypt NPM password for proxy cleanup", "error") + } else if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { + log.Printf("rollback: authenticate npm: %v", err) + d.logDeploy(deployID, "Rollback: failed to authenticate NPM for proxy cleanup", "error") + } else if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil { log.Printf("rollback: delete proxy host %d: %v", npmProxyID, err) d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error") } else { diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index 02253c1..1e6f1ff 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -29,7 +29,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M - [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md) - [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) -- [ ] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.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) - [ ] 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) @@ -51,8 +51,8 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | -| Phase 6: Webhook Handler | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ | -| Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ 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 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | diff --git a/plans/docker-watcher-core/phase-7-deployer.md b/plans/docker-watcher-core/phase-7-deployer.md index 5882f2f..d54e54a 100644 --- a/plans/docker-watcher-core/phase-7-deployer.md +++ b/plans/docker-watcher-core/phase-7-deployer.md @@ -1,6 +1,6 @@ # Phase 7: Deployer & Health Checker -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,17 +9,17 @@ Implement the core deployment orchestrator: pull → start container → configu ## Tasks -- [ ] Task 1: Define deployer service struct — depends on Docker client, NPM client, store, notifier -- [ ] Task 2: Implement deploy flow: pull image → create container → start → connect to network → configure proxy → health check -- [ ] Task 3: Implement subdomain generation per convention: `stage-{stage}-{project}` for default, `stage-{stage}-{project}-{tag}` for specific -- [ ] Task 4: Sanitize tags for DNS (dots → dashes, lowercase, truncate) -- [ ] Task 5: Implement health checker — HTTP GET to `http://container:{port}{healthcheck_path}` with retries and timeout -- [ ] Task 6: Implement rollback on health check failure — remove new container, delete NPM proxy host, update instance status -- [ ] Task 7: Implement multi-instance support — multiple tags of same project/stage can run simultaneously -- [ ] Task 8: Implement max_instances enforcement — remove oldest instance when limit reached -- [ ] Task 9: Implement notification webhook — POST to configured URL on deploy success/failure -- [ ] Task 10: Create deploy history records in store (status, timestamps, logs) -- [ ] Task 11: Implement deploy log streaming — append log entries during deploy for real-time visibility +- [x] Task 1: Define deployer service struct — depends on Docker client, NPM client, store, notifier +- [x] Task 2: Implement deploy flow: pull image → create container → start → connect to network → configure proxy → health check +- [x] Task 3: Implement subdomain generation per convention: `stage-{stage}-{project}` for default, `stage-{stage}-{project}-{tag}` for specific +- [x] Task 4: Sanitize tags for DNS (dots → dashes, lowercase, truncate) +- [x] Task 5: Implement health checker — HTTP GET to `http://container:{port}{healthcheck_path}` with retries and timeout +- [x] Task 6: Implement rollback on health check failure — remove new container, delete NPM proxy host, update instance status +- [x] Task 7: Implement multi-instance support — multiple tags of same project/stage can run simultaneously +- [x] Task 8: Implement max_instances enforcement — remove oldest instance when limit reached +- [x] Task 9: Implement notification webhook — POST to configured URL on deploy success/failure +- [x] Task 10: Create deploy history records in store (status, timestamps, logs) +- [x] Task 11: Implement deploy log streaming — append log entries during deploy for real-time visibility ## Files to Modify/Create - `internal/deployer/deployer.go` — main deploy orchestrator