From 75424a5f2528a808dcbc8f489795345bcbd8064b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 03:48:37 +0300 Subject: [PATCH] feat: docker-compose stacks with Forge-themed UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- MINI-CI-FEATURE-IDEAS.md | 16 +- cmd/server/main.go | 16 + internal/api/router.go | 28 + internal/api/stacks.go | 285 +++++++ internal/events/bus.go | 11 + internal/stack/compose.go | 118 +++ internal/stack/manager.go | 334 +++++++++ internal/stack/parse.go | 38 + internal/stack/validate.go | 52 ++ internal/store/models.go | 38 + internal/store/stacks.go | 324 ++++++++ internal/store/store.go | 44 ++ web/package-lock.json | 30 + web/package.json | 6 +- web/src/app.css | 5 + web/src/lib/api.ts | 76 ++ web/src/lib/i18n/en.json | 3 +- web/src/lib/i18n/ru.json | 3 +- web/src/lib/types.ts | 34 + web/src/routes/+layout.svelte | 5 +- web/src/routes/stacks/+page.svelte | 582 +++++++++++++++ web/src/routes/stacks/[id]/+page.svelte | 952 ++++++++++++++++++++++++ web/src/routes/stacks/new/+page.svelte | 621 ++++++++++++++++ 23 files changed, 3603 insertions(+), 18 deletions(-) create mode 100644 internal/api/stacks.go create mode 100644 internal/stack/compose.go create mode 100644 internal/stack/manager.go create mode 100644 internal/stack/parse.go create mode 100644 internal/stack/validate.go create mode 100644 internal/store/stacks.go create mode 100644 web/src/routes/stacks/+page.svelte create mode 100644 web/src/routes/stacks/[id]/+page.svelte create mode 100644 web/src/routes/stacks/new/+page.svelte diff --git a/MINI-CI-FEATURE-IDEAS.md b/MINI-CI-FEATURE-IDEAS.md index 93f9f1f..83d9f1f 100644 --- a/MINI-CI-FEATURE-IDEAS.md +++ b/MINI-CI-FEATURE-IDEAS.md @@ -2,18 +2,6 @@ Feature ideas for evolving the project from a Docker container watcher into a self-hosted mini CI/deployment platform for local developers. -## Name Candidates - -| Name | Vibe | Domain feel | -|---|---|---| -| **Shipyard** | Where you build and launch ships (deployments). Nautical, memorable. | `shipyard.dev` | -| **Dockside** | Nods to Docker heritage, but broader — "the place beside the dock." | `dockside.dev` | -| **Launchpad** | CI/CD connotation, action-oriented. | `launchpad.run` | -| **Portside** | Same nautical lane as Portainer, but fresh. | `portside.dev` | -| **Homeport** | Self-hosted feel, "home" + "port" (Docker). | `homeport.dev` | -| **Tinyforge** | Small but powerful — a forge for building/deploying. | `tinyforge.dev` | -| **Deployr** | Blunt, says exactly what it does. | `deployr.dev` | -| **Runwell** | "Run things well." Simple, positive. | `runwell.dev` | ## Build Pipeline @@ -29,8 +17,8 @@ Feature ideas for evolving the project from a Docker container watcher into a se ## Developer Experience -- **CLI tool** — `shipyard deploy`, `shipyard logs`, `shipyard status` from the terminal for developers who prefer the shell. -- **`.shipyard.yml` project config** — a declarative file in the repo root that defines how to build, which env vars to inject, health check paths, proxy rules. One file, full deploy config. +- **CLI tool** — `Tinyforge deploy`, `Tinyforge logs`, `Tinyforge status` from the terminal for developers who prefer the shell. +- **`.Tinyforge.yml` project config** — a declarative file in the repo root that defines how to build, which env vars to inject, health check paths, proxy rules. One file, full deploy config. - **Environment promotion** — one-click promote from `dev` to `staging` to `prod`. Builds on the existing multi-stage project model by adding a promotion workflow. ## Observability diff --git a/cmd/server/main.go b/cmd/server/main.go index 2d6d323..102025b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -32,6 +32,7 @@ import ( "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/registry" "github.com/alexei/tinyforge/internal/stale" + "github.com/alexei/tinyforge/internal/stack" "github.com/alexei/tinyforge/internal/staticsite" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" @@ -288,9 +289,24 @@ func main() { slog.Warn("failed to start static site health checker", "error", err) } + // Initialize stack (docker-compose) manager. Disabled gracefully if + // `docker compose` is not available on the host. + stackWorkDir := filepath.Join(filepath.Dir(dbPath), "stacks") + stackMgr, err := stack.NewManager(db, stack.NewCompose(""), eventBus, stackWorkDir) + if err != nil { + slog.Warn("failed to init stack manager", "error", err) + stackMgr = nil + } else if err := stackMgr.Available(context.Background()); err != nil { + slog.Warn("docker compose not available — stacks feature disabled", "error", err) + stackMgr = nil + } + // Build API server. apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey) apiServer.SetStaticSiteManager(staticSiteMgr) + if stackMgr != nil { + apiServer.SetStackManager(stackMgr) + } apiServer.SetStaleScanner(staleScanner) apiServer.SetBackupEngine(backupEngine) apiServer.SetDBPath(dbPath) diff --git a/internal/api/router.go b/internal/api/router.go index 402539a..3a8ac34 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) { diff --git a/internal/api/stacks.go b/internal/api/stacks.go new file mode 100644 index 0000000..6725705 --- /dev/null +++ b/internal/api/stacks.go @@ -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" +} diff --git a/internal/events/bus.go b/internal/events/bus.go index 3b7c4ef..f6ab3aa 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -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 diff --git a/internal/stack/compose.go b/internal/stack/compose.go new file mode 100644 index 0000000..054fdf2 --- /dev/null +++ b/internal/stack/compose.go @@ -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 -f 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 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 stop`. +func (c *Compose) Stop(ctx context.Context, projectName string) (string, error) { + return c.run(ctx, projectName, "stop") +} + +// Start runs `docker compose -p 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 -f 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 logs --no-color --tail= `. +// 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 ` 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 +} diff --git a/internal/stack/manager.go b/internal/stack/manager.go new file mode 100644 index 0000000..7affa72 --- /dev/null +++ b/internal/stack/manager.go @@ -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 //rev-.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) } diff --git a/internal/stack/parse.go b/internal/stack/parse.go new file mode 100644 index 0000000..d7a59e8 --- /dev/null +++ b/internal/stack/parse.go @@ -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 +} diff --git a/internal/stack/validate.go b/internal/stack/validate.go new file mode 100644 index 0000000..11e1c6b --- /dev/null +++ b/internal/stack/validate.go @@ -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 +} diff --git a/internal/store/models.go b/internal/store/models.go index 5ca66cb..eeec2bc 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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"` diff --git a/internal/store/stacks.go b/internal/store/stacks.go new file mode 100644 index 0000000..eff23f2 --- /dev/null +++ b/internal/store/stacks.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index a39368b..8b9f151 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 { diff --git a/web/package-lock.json b/web/package-lock.json index 50e17b7..9b24bdb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "tinyforge-web", "version": "0.1.0", + "dependencies": { + "@fontsource/instrument-serif": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.15.0", @@ -435,6 +439,22 @@ "node": ">=18" } }, + "node_modules/@fontsource/instrument-serif": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz", + "integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2275,6 +2295,16 @@ "dev": true, "optional": true }, + "@fontsource/instrument-serif": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz", + "integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==" + }, + "@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==" + }, "@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/web/package.json b/web/package.json index e759246..9c0d5aa 100644 --- a/web/package.json +++ b/web/package.json @@ -19,5 +19,9 @@ "typescript": "^5.7.0", "vite": "^6.0.0" }, - "type": "module" + "type": "module", + "dependencies": { + "@fontsource/instrument-serif": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8" + } } diff --git a/web/src/app.css b/web/src/app.css index a8e7941..c42057b 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,5 +1,10 @@ @import 'tailwindcss'; @import '$lib/styles/tokens.css'; +@import '@fontsource/instrument-serif/400.css'; +@import '@fontsource/instrument-serif/400-italic.css'; +@import '@fontsource/jetbrains-mono/400.css'; +@import '@fontsource/jetbrains-mono/500.css'; +@import '@fontsource/jetbrains-mono/700.css'; /* ── Base Styles ──────────────────────────────────────────────────── */ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index bdafa9f..d8bad5c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -771,4 +771,80 @@ export function getStaticSiteStorage( return get(`/api/sites/${siteId}/storage`); } +// ── Stacks (docker-compose) ───────────────────────────────────────── + +import type { Stack, StackRevision, StackService } from './types'; + +export function listStacks(signal?: AbortSignal): Promise { + return get('/api/stacks', signal); +} + +export function getStack(id: string, signal?: AbortSignal): Promise { + return get(`/api/stacks/${id}`, signal); +} + +export function createStack(data: { + name: string; + description?: string; + yaml: string; + deploy?: boolean; +}): Promise<{ stack: Stack; revision: StackRevision }> { + return post<{ stack: Stack; revision: StackRevision }>('/api/stacks', data); +} + +export function updateStack(id: string, data: { name?: string; description?: string }): Promise { + return put(`/api/stacks/${id}`, data); +} + +export function deleteStack(id: string, removeVolumes = false): Promise<{ deleted: string }> { + const qs = removeVolumes ? '?remove_volumes=true' : ''; + return del<{ deleted: string }>(`/api/stacks/${id}${qs}`); +} + +export function listStackRevisions(id: string, signal?: AbortSignal): Promise { + return get(`/api/stacks/${id}/revisions`, signal); +} + +export function getStackRevision(id: string, revId: string): Promise { + return get(`/api/stacks/${id}/revisions/${revId}`); +} + +export function createStackRevision(id: string, yaml: string): Promise { + return post(`/api/stacks/${id}/revisions`, { yaml }); +} + +export function rollbackStack(id: string, revId: string): Promise { + return post(`/api/stacks/${id}/rollback/${revId}`); +} + +export function stopStack(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/stacks/${id}/stop`); +} + +export function startStack(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/stacks/${id}/start`); +} + +export function getStackServices(id: string, signal?: AbortSignal): Promise { + return get(`/api/stacks/${id}/services`, signal); +} + +export async function getStackLogs( + id: string, + service?: string, + tail = 200 +): Promise { + const params = new URLSearchParams(); + if (service) params.set('service', service); + params.set('tail', String(tail)); + const token = getAuthToken(); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(`/api/stacks/${id}/logs?${params.toString()}`, { headers }); + if (!res.ok) { + throw new ApiError(`Failed to fetch logs: ${res.status}`, res.status); + } + return res.text(); +} + export { ApiError }; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 719fcb9..7a2b73d 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -18,7 +18,8 @@ "settings": "Settings", "logout": "Log out", "dns": "DNS Records", - "sites": "Sites" + "sites": "Sites", + "stacks": "Stacks" }, "dashboard": { "title": "Dashboard", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 60645b0..09fe1a3 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -18,7 +18,8 @@ "settings": "Настройки", "logout": "Выйти", "dns": "DNS-записи", - "sites": "Сайты" + "sites": "Сайты", + "stacks": "Стеки" }, "dashboard": { "title": "Панель управления", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6d422f4..3ff29dc 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -372,6 +372,40 @@ export interface StaticSiteStorageUsage { export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped'; +export type StackStatus = 'stopped' | 'deploying' | 'running' | 'failed'; + +export interface Stack { + id: string; + name: string; + description: string; + compose_project_name: string; + status: StackStatus; + error: string; + current_revision_id: string; + created_at: string; + updated_at: string; +} + +export interface StackRevision { + id: string; + stack_id: string; + revision: number; + yaml: string; + author: string; + deploy_id: string; + status: string; + created_at: string; +} + +export interface StackService { + Name: string; + Service: string; + State: string; + Status: string; + Health: string; + ExitCode: number; +} + export type GitProvider = '' | 'gitea' | 'github' | 'gitlab'; /** An encrypted environment variable for a static site's Deno backend. */ diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index ea3cc9d..d57af58 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,7 +6,7 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons'; + import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox } from '$lib/components/icons'; import { goto } from '$app/navigation'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth'; @@ -24,6 +24,7 @@ { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' }, { href: '/projects', labelKey: 'nav.projects', icon: 'projects' }, { href: '/sites', labelKey: 'nav.sites', icon: 'globe' }, + { href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks' }, { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' }, { href: '/events', labelKey: 'nav.events', icon: 'events' }, @@ -163,6 +164,8 @@ {:else if item.icon === 'globe'} + {:else if item.icon === 'stacks'} + {:else if item.icon === 'deploy'} {:else if item.icon === 'proxies'} diff --git a/web/src/routes/stacks/+page.svelte b/web/src/routes/stacks/+page.svelte new file mode 100644 index 0000000..23555c6 --- /dev/null +++ b/web/src/routes/stacks/+page.svelte @@ -0,0 +1,582 @@ + + +
+ + +
+
+ + + THE FORGE + // + STACKS + + +
+ +

+ Stacks. +

+

+ Compose blueprints, forged as atomic units. + Spin up services, iterate on revisions, roll back without breaking a sweat. +

+ +
+
TOTAL
{loading ? '—' : String(stacks.length).padStart(2, '0')}
+
RUNNING
{loading ? '—' : stacks.filter(s=>s.status==='running').length}
+
FORGING
{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}
+
FAILED
s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}
+
+
+ + {#if error} +
ERR{error}
+ {/if} + + {#if loading} +
+ {#each Array(3) as _, i} +
+ {/each} +
+ {:else if stacks.length === 0} +
+
+ +
+

The anvil is cold.

+

Upload a docker-compose.yml to forge your first stack.

+ + New stack + +
+ {:else} +
+ {#each stacks as s, i (s.id)} + {@const sm = statusMeta(s.status)} +
+ + + + + +
+ [{String(i + 1).padStart(2, '0')} / {String(stacks.length).padStart(2, '0')}] + + + {sm.label} + +
+ + {s.name} + {#if s.description} +

{s.description}

+ {:else} +

No description

+ {/if} + + {#if s.error} +
{s.error}
+ {/if} + +
+ Updated + {fmtTime(s.updated_at)} +
+ +
+ {#if s.status === 'running'} + + {:else} + + {/if} + + Open +
+
+ {/each} +
+ {/if} +
+ + { confirmDelete = null; deleteRemoveVolumes = false; }} +/> + + diff --git a/web/src/routes/stacks/[id]/+page.svelte b/web/src/routes/stacks/[id]/+page.svelte new file mode 100644 index 0000000..e4ad1e0 --- /dev/null +++ b/web/src/routes/stacks/[id]/+page.svelte @@ -0,0 +1,952 @@ + + +
+ + + + + STACKS + + + {#if loading && !stack} +
+ + Loading blueprint… +
+ {:else if error && !stack} +
ERR{error}
+ {:else if stack} + {@const sm = statusMeta(stack.status)} +
+
+ + THE FORGE + // + {stack.id.slice(0, 16)} + // + + {sm.label} + +
+ +
+
+

{stack.name}

+ {#if stack.description} +

{stack.description}

+ {:else} +

No description

+ {/if} + + COMPOSE PROJECT + {stack.compose_project_name} + +
+ +
+ + {#if stack.status === 'running'} + + {:else} + + {/if} + +
+
+ + {#if stack.error} +
+ FAULT + {stack.error} +
+ {/if} +
+ + +
+
+ Services + {String(services.length).padStart(2,'0')} + in blueprint +
+
+ Running + + {String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')} + + active containers +
+
+ Revisions + {String(revisions.length).padStart(2,'0')} + in history +
+
+ Current + + R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')} + + deployed +
+
+ + +
+
+

Services.

+ {services.length} on the floor +
+ {#if services.length === 0} +

— no containers running —

+ {:else} +
    + {#each services as svc (svc.Name)} + {@const st = serviceState(svc.State)} +
  • + +
    +
    {svc.Service}
    +
    {svc.Name}
    +
    +
    + {svc.State} + {svc.Status} +
    +
  • + {/each} +
+ {/if} +
+ + +
+
+ + + +
+ + {#if tab === 'yaml'} +
+
+ Current revision + {#if !editing} + + {/if} +
+ {#if editing} + +
+ + +
+ {:else if revisions[0]} +
+
+ + docker-compose.yml +
+
{revisions[0].yaml}
+
+ {/if} +
+ {:else if tab === 'revisions'} +
+
    + {#each revisions as rev (rev.id)} +
  1. +
    +
    +
    + R{rev.revision.toString().padStart(2, '0')} + {#if rev.id === stack.current_revision_id} + CURRENT + {/if} + {rev.status} + {fmtTime(rev.created_at)} +
    +
    + by {rev.author || 'operator'} +
    + {#if rev.id !== stack.current_revision_id} + + {/if} +
    +
  2. + {/each} +
+
+ {:else if tab === 'logs'} +
+
+ + +
+ {#if logsText} +
+
+ + + + ~/forge/{stack.name}{logsService ? '/' + logsService : ''}.log +
+
{logsText}
+
+ {:else} +

— no logs loaded. tap fetch. —

+ {/if} +
+ {/if} +
+ {/if} +
+ + (confirmRollback = null)} +/> + + { confirmDelete = false; deleteRemoveVolumes = false; }} +/> + + diff --git a/web/src/routes/stacks/new/+page.svelte b/web/src/routes/stacks/new/+page.svelte new file mode 100644 index 0000000..5495b00 --- /dev/null +++ b/web/src/routes/stacks/new/+page.svelte @@ -0,0 +1,621 @@ + + +
+ + + + + STACKS + + +
+ + + THE FORGE + // + NEW BLUEPRINT + +

+ Forge a
new stack. +

+

+ Upload or paste a docker-compose.yml. All services in the blueprint + deploy as a single atomic unit. +

+
+ +
+ + + + + + {#if error} +
ERR{error}
+ {/if} + +
+ + +

Lowercase, hyphenated. Used as the compose project name.

+
+ +
+ + +
+ +
+
+ 03 + Compose YAML + required + + + + +
+ + {#if !yaml} + + {/if} + +
+
+ + docker-compose.yml +
+
+ + +
+
+ {lineCount} lines + · + {byteCount} bytes + · + YAML + +
+
+
+ + + +
+ Cancel + +
+
+
+ +