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 + +
+
+
+ +