Files
tiny-forge/internal/api/stacks.go
T
alexei.dolgolyov 75424a5f25
Build / build (push) Successful in 10m42s
feat: docker-compose stacks with Forge-themed UI
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.
2026-04-16 03:48:37 +03:00

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"
}