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.
192 lines
5.5 KiB
Go
192 lines
5.5 KiB
Go
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
|
|
}
|