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 } // Reconcile instance statuses with Docker's actual state. ctx := r.Context() for i, inst := range instances { if inst.ContainerID == "" || inst.Status == "removing" { continue } running, err := s.docker.IsContainerRunning(ctx, inst.ContainerID) if err != nil { continue // Docker unreachable, keep stored status. } actualStatus := "stopped" if running { actualStatus = "running" } if inst.Status != actualStatus { instances[i].Status = actualStatus _ = s.store.UpdateInstanceStatus(inst.ID, actualStatus) } } 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) }