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 } 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 } deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag) if err != nil { respondError(w, http.StatusInternalServerError, "failed to trigger deploy: "+err.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 } 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 { 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 } 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 { slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "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) }