feat(docker-watcher): phase 7 - deployer & health checker
Deploy orchestrator with full pipeline: pull → create container → start → network → NPM proxy → health check. Rollback on failure, multi-instance support, max_instances enforcement, webhook notifications. Fix NPM auth in rollback and error logging in removeInstance.
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -387,12 +387,12 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett
|
|||||||
// Delete NPM proxy host.
|
// Delete NPM proxy host.
|
||||||
if inst.NpmProxyID > 0 {
|
if inst.NpmProxyID > 0 {
|
||||||
npmPassword, err := d.decryptNpmPassword(settings.NpmPassword)
|
npmPassword, err := d.decryptNpmPassword(settings.NpmPassword)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr == nil {
|
log.Printf("deployer: decrypt npm password for proxy cleanup: %v", err)
|
||||||
if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
|
} else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil {
|
||||||
log.Printf("deployer: delete proxy host %d: %v", inst.NpmProxyID, delErr)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,17 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
|
|||||||
|
|
||||||
// Delete the NPM proxy host if it was created.
|
// Delete the NPM proxy host if it was created.
|
||||||
if npmProxyID > 0 {
|
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)
|
log.Printf("rollback: delete proxy host %d: %v", npmProxyID, err)
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error")
|
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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 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 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 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 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 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 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 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 4: NPM 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 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 6: Webhook Handler | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 6: Webhook Handler | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 7: Deployer & Health | backend | ⬜ Not Started | ⬜ | ⏭️ 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 | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
| Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 7: Deployer & Health Checker
|
# Phase 7: Deployer & Health Checker
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** backend
|
**Domain:** backend
|
||||||
|
|
||||||
@@ -9,17 +9,17 @@ Implement the core deployment orchestrator: pull → start container → configu
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Define deployer service struct — depends on Docker client, NPM client, store, notifier
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] 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
|
- [x] Task 9: Implement notification webhook — POST to configured URL on deploy success/failure
|
||||||
- [ ] Task 10: Create deploy history records in store (status, timestamps, logs)
|
- [x] 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 11: Implement deploy log streaming — append log entries during deploy for real-time visibility
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `internal/deployer/deployer.go` — main deploy orchestrator
|
- `internal/deployer/deployer.go` — main deploy orchestrator
|
||||||
|
|||||||
Reference in New Issue
Block a user