Files
tiny-forge/internal/api/deploys.go
T
alexei.dolgolyov 91b49cb5ed feat: expanded health checks, deploy filtering, per-project notifications, error sanitization, and audit trail
- Expand health endpoint to check DB, Docker, and NPM connectivity (FUNC-M4)
- Add project_id, stage_id, offset query params to deploys endpoint (FUNC-M5, FUNC-M6)
- Add notification_url field to Stage model for per-project overrides (FUNC-M2)
- Add NPM Ping method for health checking
- Sanitize all internal error messages in API handlers (SEC-M4)
- Add audit trail events for admin actions (FUNC-M3)
- Add EventLog event type to event bus
2026-04-04 13:10:10 +03:00

185 lines
4.9 KiB
Go

package api
import (
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/alexei/docker-watcher/internal/docker"
"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
}
}
offsetStr := r.URL.Query().Get("offset")
offset := 0
if offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
offset = parsed
}
}
projectID := r.URL.Query().Get("project_id")
stageID := r.URL.Query().Get("stage_id")
deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset)
if err != nil {
slog.Error("failed to list deploys", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusOK, deploys)
}
// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go.
// The new handler supports both SSE streaming and JSON fallback via Accept header.
// 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 {
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
// Try to inspect anyway in case the image is already local.
}
info, err := s.docker.InspectImage(ctx, req.Image)
if err != nil {
slog.Error("failed to inspect image", "image", req.Image, "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
port := docker.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 {
slog.Error("failed to create project", "error", err)
respondError(w, http.StatusInternalServerError, "internal server 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 {
slog.Error("failed to create stage", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
// Trigger deploy asynchronously.
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
if err != nil {
slog.Error("failed to trigger deploy", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respondJSON(w, http.StatusAccepted, map[string]any{
"project": project,
"stage": stage,
"tag": req.Tag,
"deploy_id": deployID,
"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, ""
}