package api import ( "context" "errors" "io" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/store" ) // ── List / Get ───────────────────────────────────────────────────────── func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) { stacks, err := s.store.GetAllStacks() if err != nil { respondError(w, http.StatusInternalServerError, "failed to list stacks") return } respondJSON(w, http.StatusOK, stacks) } func (s *Server) getStack(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") st, err := s.store.GetStackByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "stack") return } respondError(w, http.StatusInternalServerError, "failed to get stack") return } respondJSON(w, http.StatusOK, st) } // ── Create ────────────────────────────────────────────────────────── type createStackRequest struct { Name string `json:"name"` Description string `json:"description"` YAML string `json:"yaml"` Deploy bool `json:"deploy"` // if true, deploy immediately after create } func (s *Server) createStack(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available (docker compose missing?)") return } var req createStackRequest if !decodeJSON(w, r, &req) { return } if req.Name == "" || req.YAML == "" { respondError(w, http.StatusBadRequest, "name and yaml are required") return } author := authorFromRequest(r) ctx := r.Context() st, rev, err := s.stackManager.Create(ctx, req.Name, req.Description, req.YAML, author) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } if req.Deploy { // Deploy asynchronously so the client gets a fast response. go func(stackID, revID string) { bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() _ = s.stackManager.Deploy(bgCtx, stackID, revID) }(st.ID, rev.ID) } respondJSON(w, http.StatusCreated, map[string]any{ "stack": st, "revision": rev, }) } // ── Update (metadata only) ───────────────────────────────────────── type updateStackRequest struct { Name string `json:"name"` Description string `json:"description"` } func (s *Server) updateStack(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") existing, err := s.store.GetStackByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "stack") return } respondError(w, http.StatusInternalServerError, "failed to get stack") return } var req updateStackRequest if !decodeJSON(w, r, &req) { return } if req.Name != "" { existing.Name = req.Name } existing.Description = req.Description if err := s.store.UpdateStack(existing); err != nil { respondError(w, http.StatusInternalServerError, "failed to update stack") return } respondJSON(w, http.StatusOK, existing) } // ── Delete ────────────────────────────────────────────────────────── func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") removeVolumes := r.URL.Query().Get("remove_volumes") == "true" if err := s.stackManager.Delete(r.Context(), id, removeVolumes); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "stack") return } respondError(w, http.StatusInternalServerError, "failed to delete stack: "+err.Error()) return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } // ── Revisions ────────────────────────────────────────────────────── func (s *Server) listStackRevisions(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") revs, err := s.store.GetStackRevisionsByStackID(id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to list revisions") return } respondJSON(w, http.StatusOK, revs) } func (s *Server) getStackRevision(w http.ResponseWriter, r *http.Request) { revID := chi.URLParam(r, "revId") rev, err := s.store.GetStackRevisionByID(revID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "revision") return } respondError(w, http.StatusInternalServerError, "failed to get revision") return } respondJSON(w, http.StatusOK, rev) } type newRevisionRequest struct { YAML string `json:"yaml"` } func (s *Server) createStackRevision(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") var req newRevisionRequest if !decodeJSON(w, r, &req) { return } if req.YAML == "" { respondError(w, http.StatusBadRequest, "yaml is required") return } author := authorFromRequest(r) // Deploy asynchronously; return the revision immediately. ctx := r.Context() rev, err := s.stackManager.NewRevisionAndDeployAsync(ctx, id, req.YAML, author) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusAccepted, rev) } func (s *Server) rollbackStack(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") revID := chi.URLParam(r, "revId") author := authorFromRequest(r) rev, err := s.stackManager.RollbackAsync(r.Context(), id, revID, author) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } respondJSON(w, http.StatusAccepted, rev) } // ── Control ────────────────────────────────────────────────────── func (s *Server) stopStack(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") if err := s.stackManager.Stop(r.Context(), id); err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, map[string]string{"status": "stopped"}) } func (s *Server) startStack(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") if err := s.stackManager.Start(r.Context(), id); err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, map[string]string{"status": "running"}) } func (s *Server) getStackServices(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") services, err := s.stackManager.Services(r.Context(), id) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusOK, services) } func (s *Server) getStackLogs(w http.ResponseWriter, r *http.Request) { if s.stackManager == nil { respondError(w, http.StatusServiceUnavailable, "stack manager not available") return } id := chi.URLParam(r, "id") service := r.URL.Query().Get("service") tail := 200 if t := r.URL.Query().Get("tail"); t != "" { if n, err := strconv.Atoi(t); err == nil && n > 0 { tail = n } } logs, err := s.stackManager.Logs(r.Context(), id, service, tail) if err != nil { respondError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, logs) } // authorFromRequest best-effort returns the username of the acting user. // Falls back to "system" if no auth context is present. func authorFromRequest(r *http.Request) string { if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" { return claims.Username } return "system" }