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,191 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"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
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
instances, err := s.store.GetInstancesByStageID(stageID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list instances: "+err.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
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Verify stage exists.
|
||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get stage: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req deployRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ImageTag == "" {
|
||||
respondError(w, http.StatusBadRequest, "image_tag is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deployer.TriggerDeploy(r.Context(), projectID, stageID, req.ImageTag); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{
|
||||
"status": "deploying",
|
||||
"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
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.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 {
|
||||
log.Printf("[api] remove container %s: %v", inst.ContainerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete instance record.
|
||||
if err := s.store.DeleteInstance(instanceID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete instance: "+err.Error())
|
||||
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
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get instance: "+err.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 {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to %s instance: %v", action, controlErr))
|
||||
return
|
||||
}
|
||||
|
||||
// Update status in store.
|
||||
if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil {
|
||||
log.Printf("[api] update instance %s status to %s: %v", instanceID, newStatus, 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
|
||||
}
|
||||
Reference in New Issue
Block a user