75424a5f25
Build / build (push) Successful in 10m42s
Adds a new Stacks feature: upload/edit docker-compose YAML, deploy as atomic units, browse revisions, roll back, and stream logs. Backend in internal/stack + internal/api/stacks.go, persistent storage in internal/store/stacks.go. Stacks pages (list, new, detail) use a modern Forge aesthetic — Instrument Serif display type, JetBrains Mono for meta/code, indigo ember accents, dot-grid hero, registration marks on hover, terminal panel for logs. Palette is sourced from the app's existing design tokens so the feature remains consistent with the rest of Tinyforge. Fonts self-hosted via @fontsource/instrument-serif and @fontsource/jetbrains-mono to satisfy the strict CSP.
286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
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"
|
|
}
|