91b49cb5ed
- 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
226 lines
6.8 KiB
Go
226 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/docker-watcher/internal/crypto"
|
|
"github.com/alexei/docker-watcher/internal/store"
|
|
)
|
|
|
|
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
|
|
func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) {
|
|
stageID := chi.URLParam(r, "stage")
|
|
|
|
// Verify stage exists.
|
|
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
slog.Error("failed to get stage", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
instances, err := s.store.GetInstancesByStageID(stageID)
|
|
if err != nil {
|
|
slog.Error("failed to list instances", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, instances)
|
|
}
|
|
|
|
// deployRequest is the expected JSON body for triggering a deploy.
|
|
type deployRequest struct {
|
|
ImageTag string `json:"image_tag"`
|
|
}
|
|
|
|
// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances (trigger deploy).
|
|
func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
stageID := chi.URLParam(r, "stage")
|
|
|
|
// Verify project exists.
|
|
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
slog.Error("failed to get project", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Verify stage exists.
|
|
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
slog.Error("failed to get stage", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
var req deployRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.ImageTag == "" {
|
|
respondError(w, http.StatusBadRequest, "image_tag is required")
|
|
return
|
|
}
|
|
|
|
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
|
|
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]string{
|
|
"status": "deploying",
|
|
"deploy_id": deployID,
|
|
"project_id": projectID,
|
|
"stage_id": stageID,
|
|
"image_tag": req.ImageTag,
|
|
})
|
|
}
|
|
|
|
// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}.
|
|
func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
|
|
instanceID := chi.URLParam(r, "iid")
|
|
|
|
inst, err := s.store.GetInstanceByID(instanceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "instance")
|
|
return
|
|
}
|
|
slog.Error("failed to get instance", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Remove the Docker container if it has one.
|
|
if inst.ContainerID != "" {
|
|
if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil {
|
|
slog.Error("remove container", "container_id", inst.ContainerID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Delete NPM proxy host if it has one.
|
|
if inst.NpmProxyID > 0 {
|
|
settings, err := s.store.GetSettings()
|
|
if err == nil {
|
|
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
|
|
if err == nil {
|
|
if authErr := s.npm.Authenticate(r.Context(), settings.NpmEmail, npmPassword); authErr == nil {
|
|
if delErr := s.npm.DeleteProxyHost(r.Context(), inst.NpmProxyID); delErr != nil {
|
|
slog.Warn("delete proxy host on instance removal", "proxy_id", inst.NpmProxyID, "error", delErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete instance record.
|
|
if err := s.store.DeleteInstance(instanceID); err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to delete instance")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID})
|
|
}
|
|
|
|
// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop.
|
|
func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) {
|
|
s.controlInstance(w, r, "stop")
|
|
}
|
|
|
|
// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start.
|
|
func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) {
|
|
s.controlInstance(w, r, "start")
|
|
}
|
|
|
|
// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart.
|
|
func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) {
|
|
s.controlInstance(w, r, "restart")
|
|
}
|
|
|
|
// controlInstance performs a stop/start/restart action on an instance's container.
|
|
func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) {
|
|
instanceID := chi.URLParam(r, "iid")
|
|
|
|
inst, err := s.store.GetInstanceByID(instanceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "instance")
|
|
return
|
|
}
|
|
slog.Error("failed to get instance", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
if inst.ContainerID == "" {
|
|
respondError(w, http.StatusBadRequest, "instance has no container")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
var controlErr error
|
|
var newStatus string
|
|
|
|
switch action {
|
|
case "stop":
|
|
controlErr = s.docker.StopContainer(ctx, inst.ContainerID, 10)
|
|
newStatus = "stopped"
|
|
case "start":
|
|
controlErr = s.docker.StartContainer(ctx, inst.ContainerID)
|
|
newStatus = "running"
|
|
case "restart":
|
|
controlErr = s.docker.RestartContainer(ctx, inst.ContainerID, 10)
|
|
newStatus = "running"
|
|
default:
|
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action))
|
|
return
|
|
}
|
|
|
|
if controlErr != nil {
|
|
slog.Error("failed to control instance", "action", action, "instance_id", instanceID, "error", controlErr)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Update status in store.
|
|
if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil {
|
|
slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "error", err)
|
|
}
|
|
|
|
// Track last_alive_at when container becomes running.
|
|
if newStatus == "running" {
|
|
if err := s.store.UpdateLastAliveAt(instanceID); err != nil {
|
|
slog.Error("update last_alive_at", "instance_id", instanceID, "error", err)
|
|
}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{
|
|
"instance_id": instanceID,
|
|
"action": action,
|
|
"status": newStatus,
|
|
})
|
|
}
|
|
|
|
// DeployTriggerer is the interface for triggering deployments.
|
|
type DeployTriggerer interface {
|
|
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
|
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
|
|
}
|