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.
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
"github.com/alexei/tinyforge/internal/stale"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
@@ -44,6 +45,7 @@ type Server struct {
|
||||
onDNSProviderChanged DNSProviderChangedFunc
|
||||
|
||||
staticSiteManager *staticsite.Manager
|
||||
stackManager *stack.Manager
|
||||
backupEngine *backup.Engine
|
||||
dbPath string
|
||||
shutdownFunc func() // called after restore to trigger graceful shutdown
|
||||
@@ -90,6 +92,11 @@ func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) {
|
||||
s.staticSiteManager = mgr
|
||||
}
|
||||
|
||||
// SetStackManager sets the docker-compose stack manager on the server.
|
||||
func (s *Server) SetStackManager(mgr *stack.Manager) {
|
||||
s.stackManager = mgr
|
||||
}
|
||||
|
||||
// SetStaleScanner sets the stale scanner on the server.
|
||||
// Called after both the API server and scanner are initialized.
|
||||
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
||||
@@ -250,6 +257,27 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
|
||||
})
|
||||
})
|
||||
// Stacks (docker-compose).
|
||||
r.Get("/stacks", s.listStacks)
|
||||
r.Route("/stacks/{id}", func(r chi.Router) {
|
||||
r.Get("/", s.getStack)
|
||||
r.Get("/revisions", s.listStackRevisions)
|
||||
r.Get("/revisions/{revId}", s.getStackRevision)
|
||||
r.Get("/services", s.getStackServices)
|
||||
r.Get("/logs", s.getStackLogs)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Put("/", s.updateStack)
|
||||
r.Delete("/", s.deleteStack)
|
||||
r.Post("/revisions", s.createStackRevision)
|
||||
r.Post("/rollback/{revId}", s.rollbackStack)
|
||||
r.Post("/stop", s.stopStack)
|
||||
r.Post("/start", s.startStack)
|
||||
})
|
||||
})
|
||||
r.With(auth.AdminOnly).Post("/stacks", s.createStack)
|
||||
|
||||
// Static sites.
|
||||
r.Get("/sites", s.listStaticSites)
|
||||
r.Route("/sites/{id}", func(r chi.Router) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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"
|
||||
}
|
||||
@@ -24,6 +24,9 @@ const (
|
||||
|
||||
// EventStaticSiteStatus is emitted when a static site status changes.
|
||||
EventStaticSiteStatus EventType = "static_site_status"
|
||||
|
||||
// EventStackStatus is emitted when a compose stack status changes.
|
||||
EventStackStatus EventType = "stack_status"
|
||||
)
|
||||
|
||||
// Event is a single event published on the bus.
|
||||
@@ -74,6 +77,14 @@ type StaticSiteStatusPayload struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// StackStatusPayload is the payload for EventStackStatus events.
|
||||
type StackStatusPayload struct {
|
||||
StackID string `json:"stack_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Subscriber is a channel that receives events.
|
||||
type Subscriber chan Event
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package stack provides docker-compose ("stack") management for Tinyforge.
|
||||
//
|
||||
// Stacks are a first-class concept distinct from single-container Projects:
|
||||
// users upload a docker-compose YAML, which is stored as an append-only
|
||||
// revision and deployed via the `docker compose` CLI. Rollback is a new
|
||||
// revision whose YAML is copied from an older one, redeployed.
|
||||
package stack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compose is a thin wrapper around the `docker compose` CLI.
|
||||
// It intentionally shells out rather than using the Docker SDK: compose has
|
||||
// no first-class Go SDK, and the CLI is the canonical interface.
|
||||
type Compose struct {
|
||||
binary string // path to `docker` binary; defaults to "docker"
|
||||
}
|
||||
|
||||
// NewCompose returns a Compose wrapper. If binary is empty, "docker" is used.
|
||||
func NewCompose(binary string) *Compose {
|
||||
if binary == "" {
|
||||
binary = "docker"
|
||||
}
|
||||
return &Compose{binary: binary}
|
||||
}
|
||||
|
||||
// Available returns nil if `docker compose version` succeeds.
|
||||
func (c *Compose) Available(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, c.binary, "compose", "version")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker compose not available: %w (output: %s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Up runs `docker compose -p <projectName> -f <yamlPath> up -d`.
|
||||
// Returns combined stdout+stderr for log persistence.
|
||||
func (c *Compose) Up(ctx context.Context, projectName, yamlPath string) (string, error) {
|
||||
return c.run(ctx, projectName, "-f", yamlPath, "up", "-d", "--remove-orphans")
|
||||
}
|
||||
|
||||
// Down runs `docker compose -p <projectName> down`.
|
||||
// removeVolumes controls whether named volumes are also removed (`-v`).
|
||||
func (c *Compose) Down(ctx context.Context, projectName string, removeVolumes bool) (string, error) {
|
||||
args := []string{"down", "--remove-orphans"}
|
||||
if removeVolumes {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
return c.run(ctx, projectName, args...)
|
||||
}
|
||||
|
||||
// Stop runs `docker compose -p <projectName> stop`.
|
||||
func (c *Compose) Stop(ctx context.Context, projectName string) (string, error) {
|
||||
return c.run(ctx, projectName, "stop")
|
||||
}
|
||||
|
||||
// Start runs `docker compose -p <projectName> start`.
|
||||
func (c *Compose) Start(ctx context.Context, projectName string) (string, error) {
|
||||
return c.run(ctx, projectName, "start")
|
||||
}
|
||||
|
||||
// Service is a single row of `docker compose ps --format json`.
|
||||
type Service struct {
|
||||
Name string `json:"Name"`
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Health string `json:"Health"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
}
|
||||
|
||||
// Ps runs `docker compose -p <projectName> -f <yamlPath> ps --format json`
|
||||
// and returns one Service per running+stopped service. yamlPath may be empty
|
||||
// (compose uses stored state when known).
|
||||
func (c *Compose) Ps(ctx context.Context, projectName, yamlPath string) ([]Service, error) {
|
||||
args := []string{}
|
||||
if yamlPath != "" {
|
||||
args = append(args, "-f", yamlPath)
|
||||
}
|
||||
args = append(args, "ps", "--format", "json", "--all")
|
||||
out, err := c.run(ctx, projectName, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsePsOutput(out), nil
|
||||
}
|
||||
|
||||
// Logs runs `docker compose -p <projectName> logs --no-color --tail=<n> <service>`.
|
||||
// If service is empty, logs for all services are returned.
|
||||
func (c *Compose) Logs(ctx context.Context, projectName, service string, tail int) (string, error) {
|
||||
args := []string{"logs", "--no-color", fmt.Sprintf("--tail=%d", tail)}
|
||||
if service != "" {
|
||||
args = append(args, service)
|
||||
}
|
||||
return c.run(ctx, projectName, args...)
|
||||
}
|
||||
|
||||
// run executes `docker compose -p <projectName> <args...>` and returns combined output.
|
||||
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
|
||||
full := append([]string{"compose", "-p", projectName}, args...)
|
||||
cmd := exec.CommandContext(ctx, c.binary, full...)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
err := cmd.Run()
|
||||
out := buf.String()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("docker compose %s: %w (output: %s)",
|
||||
strings.Join(args, " "), err, strings.TrimSpace(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// Manager orchestrates the stack deployment pipeline: validate YAML, persist
|
||||
// a revision, write YAML to disk, run `docker compose up`, update status.
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
compose *Compose
|
||||
eventBus *events.Bus
|
||||
workDir string // where per-stack YAML files are written
|
||||
}
|
||||
|
||||
// NewManager constructs a stack Manager. workDir is the directory where
|
||||
// per-stack YAML files are written; it is created if missing.
|
||||
func NewManager(st *store.Store, compose *Compose, eventBus *events.Bus, workDir string) (*Manager, error) {
|
||||
if workDir == "" {
|
||||
workDir = filepath.Join(os.TempDir(), "tinyforge-stacks")
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create stack workdir: %w", err)
|
||||
}
|
||||
return &Manager{
|
||||
store: st,
|
||||
compose: compose,
|
||||
eventBus: eventBus,
|
||||
workDir: workDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Available reports whether the underlying `docker compose` CLI is usable.
|
||||
func (m *Manager) Available(ctx context.Context) error {
|
||||
return m.compose.Available(ctx)
|
||||
}
|
||||
|
||||
// Create inserts a new stack + its initial revision. Does NOT deploy.
|
||||
func (m *Manager) Create(ctx context.Context, name, description, yamlText, author string) (store.Stack, store.StackRevision, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return store.Stack{}, store.StackRevision{}, fmt.Errorf("name is required")
|
||||
}
|
||||
spec, err := Parse(yamlText)
|
||||
if err != nil {
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
if err := Validate(spec); err != nil {
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
|
||||
st := store.Stack{
|
||||
Name: name,
|
||||
Description: description,
|
||||
ComposeProjectName: composeProjectName(name),
|
||||
Status: "stopped",
|
||||
}
|
||||
st, err = m.store.CreateStack(st)
|
||||
if err != nil {
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
|
||||
rev, err := m.store.CreateStackRevision(store.StackRevision{
|
||||
StackID: st.ID,
|
||||
YAML: yamlText,
|
||||
Author: author,
|
||||
})
|
||||
if err != nil {
|
||||
// Best-effort cleanup of the stack row.
|
||||
_ = m.store.DeleteStack(st.ID)
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
return st, rev, nil
|
||||
}
|
||||
|
||||
// Deploy brings up the stack for the given revision. Updates stack + revision
|
||||
// status transitions: deploying → running | failed. Blocking.
|
||||
func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rev, err := m.store.GetStackRevisionByID(revisionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev.StackID != stackID {
|
||||
return fmt.Errorf("revision %s does not belong to stack %s", revisionID, stackID)
|
||||
}
|
||||
|
||||
deploy, err := m.store.CreateStackDeploy(store.StackDeploy{
|
||||
StackID: stackID,
|
||||
RevisionID: revisionID,
|
||||
Status: "deploying",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.store.UpdateStackRevisionStatus(rev.ID, "deploying", deploy.ID)
|
||||
m.setStatus(st, "deploying", "")
|
||||
|
||||
yamlPath, err := m.writeYAML(st.ID, rev.Revision, rev.YAML)
|
||||
if err != nil {
|
||||
m.failDeploy(st, deploy, rev, fmt.Sprintf("write yaml: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
out, upErr := m.compose.Up(ctx, st.ComposeProjectName, yamlPath)
|
||||
if upErr != nil {
|
||||
m.failDeploy(st, deploy, rev, fmt.Sprintf("compose up: %v\n%s", upErr, out))
|
||||
return upErr
|
||||
}
|
||||
|
||||
// Success.
|
||||
deploy.Status = "success"
|
||||
deploy.Log = out
|
||||
deploy.FinishedAt = store.Now()
|
||||
_ = m.store.UpdateStackDeploy(deploy)
|
||||
_ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID)
|
||||
_ = m.store.SetStackCurrentRevision(st.ID, rev.ID)
|
||||
m.setStatus(st, "running", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRevisionAndDeploy appends a new revision (validating YAML first) and deploys it.
|
||||
func (m *Manager) NewRevisionAndDeploy(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
|
||||
spec, err := Parse(yamlText)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if err := Validate(spec); err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
rev, err := m.store.CreateStackRevision(store.StackRevision{
|
||||
StackID: stackID,
|
||||
YAML: yamlText,
|
||||
Author: author,
|
||||
})
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if err := m.Deploy(ctx, stackID, rev.ID); err != nil {
|
||||
return rev, err
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// NewRevisionAndDeployAsync creates a revision and triggers deploy in a goroutine.
|
||||
// Returns the created revision immediately.
|
||||
func (m *Manager) NewRevisionAndDeployAsync(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
|
||||
spec, err := Parse(yamlText)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if err := Validate(spec); err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
rev, err := m.store.CreateStackRevision(store.StackRevision{
|
||||
StackID: stackID,
|
||||
YAML: yamlText,
|
||||
Author: author,
|
||||
})
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
go func(stackID, revID string) {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := m.Deploy(bgCtx, stackID, revID); err != nil {
|
||||
slog.Warn("stack: async deploy failed", "stack", stackID, "revision", revID, "error", err)
|
||||
}
|
||||
}(stackID, rev.ID)
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// RollbackAsync creates a copy-revision from a target and deploys asynchronously.
|
||||
func (m *Manager) RollbackAsync(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
|
||||
target, err := m.store.GetStackRevisionByID(targetRevisionID)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if target.StackID != stackID {
|
||||
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
|
||||
}
|
||||
return m.NewRevisionAndDeployAsync(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
|
||||
}
|
||||
|
||||
// Rollback creates a new revision whose YAML is copied from the given prior
|
||||
// revision, then deploys it. Keeps history append-only.
|
||||
func (m *Manager) Rollback(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
|
||||
target, err := m.store.GetStackRevisionByID(targetRevisionID)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if target.StackID != stackID {
|
||||
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
|
||||
}
|
||||
return m.NewRevisionAndDeploy(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
|
||||
}
|
||||
|
||||
// Stop runs `docker compose stop` without removing containers.
|
||||
func (m *Manager) Stop(ctx context.Context, stackID string) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.compose.Stop(ctx, st.ComposeProjectName); err != nil {
|
||||
return err
|
||||
}
|
||||
m.setStatus(st, "stopped", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs `docker compose start` on existing containers.
|
||||
func (m *Manager) Start(ctx context.Context, stackID string) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.compose.Start(ctx, st.ComposeProjectName); err != nil {
|
||||
return err
|
||||
}
|
||||
m.setStatus(st, "running", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete tears down the stack and removes the DB row. If removeVolumes is
|
||||
// true, named volumes are also deleted (`compose down -v`). Destructive.
|
||||
func (m *Manager) Delete(ctx context.Context, stackID string, removeVolumes bool) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.compose.Down(ctx, st.ComposeProjectName, removeVolumes); err != nil {
|
||||
// Log but continue — DB row must not be orphaned.
|
||||
slog.Warn("stack: compose down failed", "stack", st.Name, "error", err)
|
||||
}
|
||||
// Best-effort YAML cleanup.
|
||||
_ = os.RemoveAll(filepath.Join(m.workDir, st.ID))
|
||||
return m.store.DeleteStack(stackID)
|
||||
}
|
||||
|
||||
// Services returns current service state for a stack.
|
||||
func (m *Manager) Services(ctx context.Context, stackID string) ([]Service, error) {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yamlPath := ""
|
||||
if st.CurrentRevisionID != "" {
|
||||
if rev, err := m.store.GetStackRevisionByID(st.CurrentRevisionID); err == nil {
|
||||
yamlPath, _ = m.writeYAML(st.ID, rev.Revision, rev.YAML)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return m.compose.Ps(ctx, st.ComposeProjectName, yamlPath)
|
||||
}
|
||||
|
||||
// Logs returns the last `tail` log lines for a service (or all services if empty).
|
||||
func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) (string, error) {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tail <= 0 {
|
||||
tail = 200
|
||||
}
|
||||
return m.compose.Logs(ctx, st.ComposeProjectName, service, tail)
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
func (m *Manager) setStatus(st store.Stack, status, errMsg string) {
|
||||
_ = m.store.UpdateStackStatus(st.ID, status, errMsg)
|
||||
if m.eventBus != nil {
|
||||
m.eventBus.Publish(events.Event{
|
||||
Type: events.EventStackStatus,
|
||||
Payload: events.StackStatusPayload{
|
||||
StackID: st.ID,
|
||||
Name: st.Name,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) failDeploy(st store.Stack, d store.StackDeploy, rev store.StackRevision, errMsg string) {
|
||||
d.Status = "failed"
|
||||
d.Error = errMsg
|
||||
d.FinishedAt = store.Now()
|
||||
_ = m.store.UpdateStackDeploy(d)
|
||||
_ = m.store.UpdateStackRevisionStatus(rev.ID, "failed", d.ID)
|
||||
m.setStatus(st, "failed", errMsg)
|
||||
}
|
||||
|
||||
// writeYAML writes yaml to <workDir>/<stackID>/rev-<n>.yml and returns the path.
|
||||
func (m *Manager) writeYAML(stackID string, revision int, yamlText string) (string, error) {
|
||||
dir := filepath.Join(m.workDir, stackID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("rev-%d.yml", revision))
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// composeProjectName sanitises a user-provided stack name into something
|
||||
// `docker compose -p` will accept: lowercase, digits, dashes only.
|
||||
func composeProjectName(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
name = nonProjectChars.ReplaceAllString(name, "-")
|
||||
name = strings.Trim(name, "-")
|
||||
if name == "" {
|
||||
name = "stack"
|
||||
}
|
||||
return "tinyforge-" + name
|
||||
}
|
||||
|
||||
var nonProjectChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
func itoa(n int) string { return fmt.Sprintf("%d", n) }
|
||||
@@ -0,0 +1,38 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parsePsOutput handles both formats emitted by `docker compose ps --format json`:
|
||||
// newer versions emit NDJSON (one object per line); older versions emit a single JSON array.
|
||||
func parsePsOutput(out string) []Service {
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Array form.
|
||||
if strings.HasPrefix(out, "[") {
|
||||
var arr []Service
|
||||
if err := json.Unmarshal([]byte(out), &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
// NDJSON form: one object per line.
|
||||
var services []Service
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
var svc Service
|
||||
if err := json.Unmarshal([]byte(line), &svc); err != nil {
|
||||
continue
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
return services
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ComposeSpec is a minimal, lenient representation of a compose file.
|
||||
// We only decode fields we need for validation + label-based proxy routing;
|
||||
// everything else is preserved as-is and passed to `docker compose`.
|
||||
type ComposeSpec struct {
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Services map[string]ServiceSpec `yaml:"services"`
|
||||
}
|
||||
|
||||
// ServiceSpec captures the subset of compose service fields we inspect.
|
||||
type ServiceSpec struct {
|
||||
Image string `yaml:"image,omitempty"`
|
||||
Ports []any `yaml:"ports,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Privileged bool `yaml:"privileged,omitempty"`
|
||||
}
|
||||
|
||||
// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
|
||||
func Parse(yamlText string) (ComposeSpec, error) {
|
||||
var spec ComposeSpec
|
||||
if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil {
|
||||
return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err)
|
||||
}
|
||||
if len(spec.Services) == 0 {
|
||||
return ComposeSpec{}, fmt.Errorf("compose file has no services")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Validate enforces Tinyforge-level constraints beyond compose schema validity.
|
||||
// Current rules:
|
||||
// - No service may set `privileged: true`.
|
||||
// - Every service must declare an image (compose supports build: too, but
|
||||
// Tinyforge v1 disallows building from context to avoid arbitrary-code exec).
|
||||
func Validate(spec ComposeSpec) error {
|
||||
for name, svc := range spec.Services {
|
||||
if svc.Privileged {
|
||||
return fmt.Errorf("service %q: privileged mode is not allowed", name)
|
||||
}
|
||||
if svc.Image == "" {
|
||||
return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -234,6 +234,44 @@ type StaticSiteSecret struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Stack represents a docker-compose stack managed as a single deployable unit.
|
||||
type Stack struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ComposeProjectName string `json:"compose_project_name"` // `-p` arg for docker compose
|
||||
Status string `json:"status"` // stopped, deploying, running, failed
|
||||
Error string `json:"error"`
|
||||
CurrentRevisionID string `json:"current_revision_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StackRevision is an append-only record of a YAML version for a stack.
|
||||
// Rollback = insert a new revision whose YAML is copied from an older one.
|
||||
type StackRevision struct {
|
||||
ID string `json:"id"`
|
||||
StackID string `json:"stack_id"`
|
||||
Revision int `json:"revision"` // monotonic per stack
|
||||
YAML string `json:"yaml"`
|
||||
Author string `json:"author"`
|
||||
DeployID string `json:"deploy_id"`
|
||||
Status string `json:"status"` // pending, success, failed
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// StackDeploy records a deployment attempt of a specific revision.
|
||||
type StackDeploy struct {
|
||||
ID string `json:"id"`
|
||||
StackID string `json:"stack_id"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
Status string `json:"status"` // pending, deploying, success, failed, rolled_back
|
||||
Log string `json:"log"`
|
||||
Error string `json:"error"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
}
|
||||
|
||||
// EventLog represents a persistent event log entry.
|
||||
type EventLog struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const stackCols = `id, name, description, compose_project_name, status, error,
|
||||
current_revision_id, created_at, updated_at`
|
||||
|
||||
// CreateStack inserts a new stack and returns it.
|
||||
func (s *Store) CreateStack(st Stack) (Stack, error) {
|
||||
st.ID = uuid.New().String()
|
||||
st.CreatedAt = Now()
|
||||
st.UpdatedAt = st.CreatedAt
|
||||
if st.Status == "" {
|
||||
st.Status = "stopped"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO stacks (`+stackCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
|
||||
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Stack{}, fmt.Errorf("insert stack: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GetStackByID returns a single stack by its ID.
|
||||
func (s *Store) GetStackByID(id string) (Stack, error) {
|
||||
st, err := scanStackRow(s.db.QueryRow(
|
||||
`SELECT `+stackCols+` FROM stacks WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return Stack{}, fmt.Errorf("query stack: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GetAllStacks returns every stack ordered by name.
|
||||
func (s *Store) GetAllStacks() ([]Stack, error) {
|
||||
rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query stacks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Stack{}
|
||||
for rows.Next() {
|
||||
st, err := scanStackRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStack updates the mutable metadata fields (name, description).
|
||||
func (s *Store) UpdateStack(st Stack) error {
|
||||
st.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`,
|
||||
st.Name, st.Description, st.UpdatedAt, st.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStackStatus updates the deployment status + error fields.
|
||||
func (s *Store) UpdateStackStatus(id, status, errMsg string) error {
|
||||
now := Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`,
|
||||
status, errMsg, now, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack status: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStackCurrentRevision updates the current_revision_id pointer.
|
||||
func (s *Store) SetStackCurrentRevision(id, revisionID string) error {
|
||||
now := Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`,
|
||||
revisionID, now, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack revision pointer: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys.
|
||||
func (s *Store) DeleteStack(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM stacks WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete stack: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStackRow(row *sql.Row) (Stack, error) {
|
||||
var st Stack
|
||||
err := row.Scan(
|
||||
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
|
||||
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
|
||||
)
|
||||
return st, err
|
||||
}
|
||||
|
||||
func scanStackRows(rows *sql.Rows) (Stack, error) {
|
||||
var st Stack
|
||||
err := rows.Scan(
|
||||
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
|
||||
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Stack{}, fmt.Errorf("scan stack: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// --- Stack revisions ---
|
||||
|
||||
const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at`
|
||||
|
||||
// CreateStackRevision inserts a new revision with the next monotonic revision number.
|
||||
func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) {
|
||||
r.ID = uuid.New().String()
|
||||
r.CreatedAt = Now()
|
||||
if r.Status == "" {
|
||||
r.Status = "pending"
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return StackRevision{}, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var next int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`,
|
||||
r.StackID,
|
||||
).Scan(&next); err != nil {
|
||||
return StackRevision{}, fmt.Errorf("next revision: %w", err)
|
||||
}
|
||||
r.Revision = next
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO stack_revisions (`+stackRevisionCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt,
|
||||
); err != nil {
|
||||
return StackRevision{}, fmt.Errorf("insert revision: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return StackRevision{}, fmt.Errorf("commit revision: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetStackRevisionByID returns a single revision by ID.
|
||||
func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) {
|
||||
r, err := scanStackRevisionRow(s.db.QueryRow(
|
||||
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return StackRevision{}, fmt.Errorf("query revision: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetStackRevisionsByStackID returns revisions newest-first.
|
||||
func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ?
|
||||
ORDER BY revision DESC`,
|
||||
stackID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query revisions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []StackRevision{}
|
||||
for rows.Next() {
|
||||
r, err := scanStackRevisionRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStackRevisionStatus updates status + deploy_id linkage.
|
||||
func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`,
|
||||
status, deployID, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update revision status: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("revision %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStackRevisionRow(row *sql.Row) (StackRevision, error) {
|
||||
var r StackRevision
|
||||
err := row.Scan(
|
||||
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
|
||||
)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) {
|
||||
var r StackRevision
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StackRevision{}, fmt.Errorf("scan revision: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- Stack deploys ---
|
||||
|
||||
const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at`
|
||||
|
||||
// CreateStackDeploy inserts a new deploy record.
|
||||
func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) {
|
||||
d.ID = uuid.New().String()
|
||||
d.StartedAt = Now()
|
||||
if d.Status == "" {
|
||||
d.Status = "pending"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO stack_deploys (`+stackDeployCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetStackDeployByID returns a single deploy by ID.
|
||||
func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) {
|
||||
d, err := scanStackDeployRow(s.db.QueryRow(
|
||||
`SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// UpdateStackDeploy updates status, log, error, finished_at.
|
||||
func (s *Store) UpdateStackDeploy(d StackDeploy) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`,
|
||||
d.Status, d.Log, d.Error, d.FinishedAt, d.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack deploy: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStackDeployRow(row *sql.Row) (StackDeploy, error) {
|
||||
var d StackDeploy
|
||||
err := row.Scan(
|
||||
&d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt,
|
||||
)
|
||||
return d, err
|
||||
}
|
||||
@@ -130,6 +130,48 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
|
||||
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||
// schema constant so older databases pick them up on restart.
|
||||
stackTables := []string{
|
||||
`CREATE TABLE IF NOT EXISTS stacks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
compose_project_name TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
current_revision_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS stack_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL,
|
||||
yaml TEXT NOT NULL,
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
deploy_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(stack_id, revision)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS stack_deploys (
|
||||
id TEXT PRIMARY KEY,
|
||||
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
|
||||
revision_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
}
|
||||
for _, t := range stackTables {
|
||||
if _, err := s.db.Exec(t); err != nil {
|
||||
return fmt.Errorf("create stack table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
// Ignore errors from already-applied migrations (duplicate column).
|
||||
_, _ = s.db.Exec(m)
|
||||
@@ -150,6 +192,8 @@ func (s *Store) runMigrations() error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user