feat(docker-watcher): phase 8 - REST API layer
All REST endpoints wired with chi router: projects, stages, instances, deploys, registries, settings, quick deploy, webhook. Full main.go wiring with graceful shutdown. Consistent JSON envelope responses. Sensitive fields stripped from API responses.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user