97d4243cfe
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.
203 lines
5.3 KiB
Go
203 lines
5.3 KiB
Go
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
|
|
}
|