From b62238477414fc2d49655c8a01fb1d031534191c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 13 Apr 2026 00:12:51 +0300 Subject: [PATCH] feat: persistent storage for Deno static sites - Add storage_enabled and storage_limit_mb columns to static_sites. - Create/attach Docker volumes (tinyforge-site-{name}-data) for Deno sites with storage enabled, mounted at /app/data. - Grant --allow-write=/app/data in Deno container CMD. - Add storage usage API endpoint (GET /api/sites/{id}/storage). - Show storage section in site detail page with usage bar. - Add storage toggle and limit field to new site wizard. - Use ConfirmDialog for secret deletion instead of inline delete. --- internal/api/router.go | 1 + internal/api/static_sites.go | 42 +++++++++++++ internal/docker/exec.go | 44 ++++++++++++++ internal/docker/volume.go | 83 ++++++++++++++++++++++++++ internal/staticsite/deno/template.go | 6 +- internal/staticsite/manager.go | 27 +++++++++ internal/store/models.go | 2 + internal/store/static_sites.go | 22 ++++--- internal/store/store.go | 3 + web/src/lib/types.ts | 8 +++ web/src/routes/sites/[id]/+page.svelte | 72 ++++++++++++++++++++-- web/src/routes/sites/new/+page.svelte | 32 +++++++++- 12 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 internal/docker/exec.go create mode 100644 internal/docker/volume.go diff --git a/internal/api/router.go b/internal/api/router.go index a56cb4a..402539a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -255,6 +255,7 @@ func (s *Server) Router() chi.Router { r.Route("/sites/{id}", func(r chi.Router) { r.Get("/", s.getStaticSite) r.Get("/secrets", s.listStaticSiteSecrets) + r.Get("/storage", s.getStaticSiteStorage) // Admin-only mutations. r.Group(func(r chi.Router) { diff --git a/internal/api/static_sites.go b/internal/api/static_sites.go index 388e797..f59badb 100644 --- a/internal/api/static_sites.go +++ b/internal/api/static_sites.go @@ -61,6 +61,8 @@ type createStaticSiteRequest struct { RenderMarkdown bool `json:"render_markdown"` SyncTrigger string `json:"sync_trigger"` TagPattern string `json:"tag_pattern"` + StorageEnabled bool `json:"storage_enabled"` + StorageLimitMB int `json:"storage_limit_mb"` } func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) { @@ -109,6 +111,8 @@ func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) { RenderMarkdown: req.RenderMarkdown, SyncTrigger: req.SyncTrigger, TagPattern: req.TagPattern, + StorageEnabled: req.StorageEnabled, + StorageLimitMB: req.StorageLimitMB, Status: "idle", } @@ -174,6 +178,8 @@ func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) { } existing.RenderMarkdown = req.RenderMarkdown existing.TagPattern = req.TagPattern + existing.StorageEnabled = req.StorageEnabled + existing.StorageLimitMB = req.StorageLimitMB // Update access token only if a new one is provided. if req.AccessToken != "" { @@ -604,6 +610,42 @@ func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request respondJSON(w, http.StatusOK, map[string]string{"provider": provider}) } +// ── Storage Usage ────────────────────────────────────────────────── + +func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + site, err := s.store.GetStaticSiteByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + respondError(w, http.StatusInternalServerError, "failed to get static site") + return + } + + if !site.StorageEnabled { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": false, + "used_bytes": 0, + "limit_mb": 0, + }) + return + } + + usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to inspect storage usage") + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": true, + "used_bytes": usage.UsedBytes, + "limit_mb": site.StorageLimitMB, + }) +} + // maskToken returns a masked version of a token string for API responses. func maskToken(token string) string { if token == "" { diff --git a/internal/docker/exec.go b/internal/docker/exec.go new file mode 100644 index 0000000..0aefcff --- /dev/null +++ b/internal/docker/exec.go @@ -0,0 +1,44 @@ +package docker + +import ( + "bytes" + "context" + "fmt" + "io" + + "github.com/moby/moby/client" +) + +// ExecInContainer runs a command inside a running container and returns the combined output. +func (c *Client) ExecInContainer(ctx context.Context, containerID string, cmd []string) (string, error) { + exec, err := c.api.ExecCreate(ctx, containerID, client.ExecCreateOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", fmt.Errorf("exec create: %w", err) + } + + attach, err := c.api.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{}) + if err != nil { + return "", fmt.Errorf("exec attach: %w", err) + } + defer attach.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, attach.Reader); err != nil { + return "", fmt.Errorf("exec read output: %w", err) + } + + // Check exit code. + inspect, err := c.api.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{}) + if err != nil { + return buf.String(), nil // best effort — return output even if inspect fails + } + if inspect.ExitCode != 0 { + return "", fmt.Errorf("exec exited with code %d", inspect.ExitCode) + } + + return buf.String(), nil +} diff --git a/internal/docker/volume.go b/internal/docker/volume.go new file mode 100644 index 0000000..ecf421c --- /dev/null +++ b/internal/docker/volume.go @@ -0,0 +1,83 @@ +package docker + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/moby/moby/client" +) + +// SiteVolumeName returns the deterministic volume name for a static site. +func SiteVolumeName(siteName string) string { + return fmt.Sprintf("tinyforge-site-%s-data", siteName) +} + +// EnsureSiteVolume creates a named volume for a static site if it doesn't already exist. +// Returns the volume name. +func (c *Client) EnsureSiteVolume(ctx context.Context, siteName string) (string, error) { + name := SiteVolumeName(siteName) + + // Check if volume already exists. + _, err := c.api.VolumeInspect(ctx, name, client.VolumeInspectOptions{}) + if err == nil { + return name, nil + } + + // Create the volume. + _, err = c.api.VolumeCreate(ctx, client.VolumeCreateOptions{ + Name: name, + Labels: map[string]string{ + "tinyforge.static-site": "true", + "tinyforge.site-name": siteName, + }, + }) + if err != nil { + return "", fmt.Errorf("create volume %s: %w", name, err) + } + + return name, nil +} + +// RemoveSiteVolume removes the named volume for a static site. +func (c *Client) RemoveSiteVolume(ctx context.Context, siteName string) error { + name := SiteVolumeName(siteName) + _, err := c.api.VolumeRemove(ctx, name, client.VolumeRemoveOptions{Force: true}) + if err != nil { + return fmt.Errorf("remove volume %s: %w", name, err) + } + return nil +} + +// StorageUsage holds the disk usage info for a site volume. +type StorageUsage struct { + UsedBytes int64 `json:"used_bytes"` +} + +// InspectSiteStorageUsage execs `du -sb /app/data` inside the container to get usage. +// Returns 0 if the container is not running or the path doesn't exist. +func (c *Client) InspectSiteStorageUsage(ctx context.Context, containerID string) (StorageUsage, error) { + if containerID == "" { + return StorageUsage{}, nil + } + + output, err := c.ExecInContainer(ctx, containerID, []string{"du", "-sb", "/app/data"}) + if err != nil { + // Container not running or path doesn't exist — return zero. + return StorageUsage{}, nil + } + + // du output: "12345\t/app/data\n" + parts := strings.Fields(strings.TrimSpace(output)) + if len(parts) == 0 { + return StorageUsage{}, nil + } + + bytes, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return StorageUsage{}, nil + } + + return StorageUsage{UsedBytes: bytes}, nil +} diff --git a/internal/staticsite/deno/template.go b/internal/staticsite/deno/template.go index cd253c3..b080aa0 100644 --- a/internal/staticsite/deno/template.go +++ b/internal/staticsite/deno/template.go @@ -231,15 +231,19 @@ COPY public/ /app/public/ COPY api/ /app/api/ COPY router.ts /app/router.ts +# Create data directory for persistent storage (mounted as volume when enabled). +RUN mkdir -p /app/data + # Cache dependencies. RUN deno install --entrypoint router.ts EXPOSE 8000 -CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "router.ts"] +CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write=/app/data", "--allow-env", "router.ts"] ` } + // GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites. func GenerateStaticDockerfile() string { return `FROM nginx:alpine diff --git a/internal/staticsite/manager.go b/internal/staticsite/manager.go index 9c59188..a83c1d6 100644 --- a/internal/staticsite/manager.go +++ b/internal/staticsite/manager.go @@ -10,6 +10,8 @@ import ( "strconv" "time" + "github.com/moby/moby/api/types/mount" + "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/events" @@ -198,6 +200,22 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { containerName := fmt.Sprintf("dw-site-%s", site.Name) + // Prepare volume mounts for persistent storage. + var mounts []mount.Mount + if site.StorageEnabled && mode == "deno" { + volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name) + if volErr != nil { + slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr) + } else { + mounts = append(mounts, mount.Mount{ + Type: mount.TypeVolume, + Source: volName, + Target: "/app/data", + }) + slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName) + } + } + // Create and start new container. containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{ Name: containerName, @@ -206,6 +224,7 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { ExposedPorts: []string{containerPort + "/tcp"}, NetworkName: networkName, NetworkID: networkID, + Mounts: mounts, Labels: map[string]string{ "tinyforge.static-site": site.ID, "tinyforge.static-site-name": site.Name, @@ -229,6 +248,7 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { ExposedPorts: []string{containerPort + "/tcp"}, NetworkName: networkName, NetworkID: networkID, + Mounts: mounts, Labels: map[string]string{ "tinyforge.static-site": site.ID, "tinyforge.static-site-name": site.Name, @@ -346,6 +366,13 @@ func (m *Manager) Remove(ctx context.Context, siteID string) error { } } + // Remove storage volume if it was enabled (best effort). + if site.StorageEnabled { + if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil { + slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err) + } + } + return nil } diff --git a/internal/store/models.go b/internal/store/models.go index 17c6275..5ca66cb 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -217,6 +217,8 @@ type StaticSite struct { LastSyncAt string `json:"last_sync_at"` LastCommitSHA string `json:"last_commit_sha"` Error string `json:"error"` + StorageEnabled bool `json:"storage_enabled"` + StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go index 9e49009..be4a5a5 100644 --- a/internal/store/static_sites.go +++ b/internal/store/static_sites.go @@ -12,7 +12,7 @@ import ( const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path, access_token, domain, mode, render_markdown, sync_trigger, tag_pattern, container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error, - created_at, updated_at` + storage_enabled, storage_limit_mb, created_at, updated_at` // CreateStaticSite inserts a new static site and returns it. func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) { @@ -22,12 +22,13 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) { _, err := s.db.Exec( `INSERT INTO static_sites (`+staticSiteCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode, BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt, - site.LastCommitSHA, site.Error, site.CreatedAt, site.UpdatedAt, + site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB, + site.CreatedAt, site.UpdatedAt, ) if err != nil { return StaticSite{}, fmt.Errorf("insert static site: %w", err) @@ -100,11 +101,12 @@ func (s *Store) UpdateStaticSite(site StaticSite) error { result, err := s.db.Exec( `UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?, folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?, - sync_trigger=?, tag_pattern=?, updated_at=? + sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, updated_at=? WHERE id=?`, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode, BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, + BoolToInt(site.StorageEnabled), site.StorageLimitMB, site.UpdatedAt, site.ID, ) if err != nil { @@ -168,35 +170,39 @@ func (s *Store) DeleteStaticSite(id string) error { // scanStaticSiteRow scans a static site from a *sql.Row. func scanStaticSiteRow(row *sql.Row) (StaticSite, error) { var site StaticSite - var renderMarkdown int + var renderMarkdown, storageEnabled int err := row.Scan( &site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName, &site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode, &renderMarkdown, &site.SyncTrigger, &site.TagPattern, &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, - &site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt, + &site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB, + &site.CreatedAt, &site.UpdatedAt, ) if err != nil { return StaticSite{}, err } site.RenderMarkdown = renderMarkdown != 0 + site.StorageEnabled = storageEnabled != 0 return site, nil } // scanStaticSiteRows scans a static site from a *sql.Rows cursor. func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) { var site StaticSite - var renderMarkdown int + var renderMarkdown, storageEnabled int err := rows.Scan( &site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName, &site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode, &renderMarkdown, &site.SyncTrigger, &site.TagPattern, &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, - &site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt, + &site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB, + &site.CreatedAt, &site.UpdatedAt, ) if err != nil { return StaticSite{}, fmt.Errorf("scan static site: %w", err) } site.RenderMarkdown = renderMarkdown != 0 + site.StorageEnabled = storageEnabled != 0 return site, nil } diff --git a/internal/store/store.go b/internal/store/store.go index 8650439..a39368b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -125,6 +125,9 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`, // Add provider column to static_sites (2026-04-11). `ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`, + // Add persistent storage columns to static_sites (2026-04-12). + `ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`, } for _, m := range migrations { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 1dd92ff..6d422f4 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -358,10 +358,18 @@ export interface StaticSite { last_sync_at: string; last_commit_sha: string; error: string; + storage_enabled: boolean; + storage_limit_mb: number; created_at: string; updated_at: string; } +export interface StaticSiteStorageUsage { + enabled: boolean; + used_bytes: number; + limit_mb: number; +} + export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped'; export type GitProvider = '' | 'gitea' | 'github' | 'gitlab'; diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte index 8b96c85..f463388 100644 --- a/web/src/routes/sites/[id]/+page.svelte +++ b/web/src/routes/sites/[id]/+page.svelte @@ -1,5 +1,5 @@