package api import ( "context" "errors" "fmt" "log/slog" "net/http" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" ) // listInstances handles GET /api/projects/{id}/stages/{stage}/instances. // Reads the normalized container index — the legacy `instances` table is gone. // JSON shape stays Container-shaped (id, container_id, image_tag, subdomain, // state, port, etc.), so the frontend type may show some renamed fields // (status→state, last_alive_at→last_seen_at). func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) { stageID := chi.URLParam(r, "stage") 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 } containers, err := s.store.ListContainersByStageID(stageID) if err != nil { slog.Error("failed to list containers", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Reconcile container state with Docker's actual state — covers the // case where a container was killed externally between deployer writes // and the next reconciler tick. ctx := r.Context() for i, c := range containers { if c.ContainerID == "" || c.State == "removing" { continue } running, err := s.docker.IsContainerRunning(ctx, c.ContainerID) if err != nil { continue } actual := "stopped" if running { actual = "running" } if c.State != actual { containers[i].State = actual _ = s.store.UpdateContainerState(c.ID, actual) } } respondJSON(w, http.StatusOK, containers) } // 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. func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") stageID := chi.URLParam(r, "stage") 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 } 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}. // {iid} is the container row ID (same UUID as the legacy instance ID). func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "iid") c, err := s.store.GetContainerByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "container") return } slog.Error("failed to get container", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } // Remove the Docker container if it has one. if c.ContainerID != "" { if err := s.docker.RemoveContainer(r.Context(), c.ContainerID, true); err != nil { slog.Error("remove container", "container_id", c.ContainerID, "error", err) } } // Delete proxy route if it has one. if c.ProxyRouteID != "" { if err := s.proxyProvider.DeleteRoute(r.Context(), c.ProxyRouteID); err != nil { slog.Warn("delete proxy route on container removal", "route_id", c.ProxyRouteID, "error", err) } } // Delete container row. if err := s.store.DeleteContainer(id); err != nil { respondError(w, http.StatusInternalServerError, "failed to delete container") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } // 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 a container. func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) { id := chi.URLParam(r, "iid") c, err := s.store.GetContainerByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "container") return } slog.Error("failed to get container", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } if c.ContainerID == "" { respondError(w, http.StatusBadRequest, "container row has no docker container bound") return } ctx := r.Context() var controlErr error var newState string switch action { case "stop": controlErr = s.docker.StopContainer(ctx, c.ContainerID, 10) newState = "stopped" case "start": controlErr = s.docker.StartContainer(ctx, c.ContainerID) newState = "running" case "restart": controlErr = s.docker.RestartContainer(ctx, c.ContainerID, 10) newState = "running" default: respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action)) return } if controlErr != nil { slog.Error("failed to control container", "action", action, "id", id, "error", controlErr) respondError(w, http.StatusInternalServerError, "internal server error") return } if err := s.store.UpdateContainerState(id, newState); err != nil { slog.Error("update container state", "id", id, "state", newState, "error", err) } respondJSON(w, http.StatusOK, map[string]string{ "instance_id": id, "action": action, "status": newState, }) } // 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) }