7d6719da12
Replace direct npm.Client usage throughout the codebase with the proxy.Provider interface, enabling pluggable proxy backends. The deployer, API layer, and proxy manager now use provider-agnostic route management (ConfigureRoute/DeleteRoute) instead of NPM-specific API calls. Adds ProxyRouteID (string) to Instance model and ProxyProvider setting to Settings, with SQLite migrations for backward compatibility.
217 lines
6.4 KiB
Go
217 lines
6.4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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
|
|
}
|
|
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 proxy route if it has one.
|
|
if inst.ProxyRouteID != "" {
|
|
if err := s.proxyProvider.DeleteRoute(r.Context(), inst.ProxyRouteID); err != nil {
|
|
slog.Warn("delete proxy route on instance removal", "route_id", inst.ProxyRouteID, "error", err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|