feat(static): inline static-source plugin; drop phantom-row adapter
Build / build (push) Successful in 10m43s

Lift the static-site deploy pipeline from internal/staticsite/manager.go
into internal/workload/plugin/source/static/ so plugin-native static
workloads operate directly on plugin.Workload + the containers table +
workload_env. The cmd/server/static_backend.go phantom-row adapter is
gone; the legacy static_sites table is no longer touched on plugin
deploys.

Backend
- new state.go: runtimeState (last_commit_sha, last_sync_at,
  last_error, status) persisted in containers.extra_json under the
  deterministic row id <workloadID>:site
- per-workload sync.Mutex serializes saveState read-modify-write so
  parallel deploys for the same workload can't race container_id /
  proxy_route_id writes
- extra_json round-trips through map[string]json.RawMessage so
  unknown keys survive — typed runtimeStateKeys are stripped before
  merge so clearing a typed field actually drops the key
- new env.go reads workload_env (replaces static_site_secrets for
  plugin-native sites); decrypt-failure logs and skips one entry
  rather than failing the whole deploy
- new build.go ports prepareDenoBuild + prepareStaticBuild + copyDir;
  copyDir uses filepath.WalkDir + Lstat to refuse symlinks and
  non-regular files
- new deploy.go is the ~300-line core; intent.Reason gates force vs
  skip-if-no-changes; success-path saveState failure rolls back
  container + proxy route and writes "failed" state (no orphans)
- new teardown.go combines Remove + Stop; idempotent on
  never-deployed workloads
- new reconcile.go refreshes container state from Docker; flips
  runtimeState.Status to failed when the container is missing/crashed

Hardening (from go-reviewer + security-reviewer subagent passes;
1 CRITICAL + 5 HIGH + 3 MEDIUM addressed before merge)
- path-traversal defense in all 3 providers (gitea_content,
  github_provider, gitlab_provider): reject tree entries whose
  resolved local path escapes destDir
- verifyDownloadInsideRoot walks the build dir post-download as a
  second line of defense
- sanitizeError redacts the access token, collapses to one line, and
  clamps to 240 bytes before persisting to extra_json or fanning out
  to the notification webhook
- container/image/volume names suffixed with workload-id short prefix
  (workload name is not UNIQUE in schema)
- primaryDomain reads settings.Domain to complete a bare subdomain
  face into a full FQDN (matches legacy Manager behavior)
- ctx-aware health-check sleep
- json.Marshal for event metadata (was fmt.Sprintf JSON template)
- strings.HasPrefix for failed-status detection (was brittle slice
  expression)

Wire-up
- cmd/server/main.go: removed wireStaticBackend(...) call; existing
  blank import on _ ".../source/static" drives init() registration
- cmd/server/static_backend.go deleted

Doc
- WORKLOAD_REFACTOR_TODO: static port marked DONE; next focus is
  the hard legacy cutover (drop /api/projects, /api/stacks,
  /api/sites, /api/stages + their tables, internal/stack +
  internal/staticsite packages, frontend /projects /stacks /sites)

Behavior notes for operators
- plugin-native static workloads no longer write to static_sites;
  legacy /api/sites/* still serves original rows unchanged
- legacy tinyforge.static-site / .static-site-name container labels
  dropped on plugin deploys; canonical tinyforge.workload.id / .kind
  cover ownership
- container/image/volume names gained an 8-char ID suffix
  (e.g. dw-site-mysite-a1b2c3d4); legacy-deployed sites keep the
  old shape until redeployed through the plugin path
This commit is contained in:
2026-05-16 02:56:23 +03:00
parent 2aff22f565
commit 234c3c711e
14 changed files with 1344 additions and 254 deletions
+5 -5
View File
@@ -348,11 +348,11 @@ func main() {
// Initialize static site manager and health checker.
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, notifier, encKey)
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
// Wire the plugin static source's backend to the manager. After this
// call the "static" kind appears in /api/hooks/kinds and the /apps/new
// picker; before it, the source registers no kind, so the frontend
// silently omits it.
wireStaticBackend(db, staticSiteMgr)
// The plugin static source registers itself eagerly in its init()
// now that the deploy pipeline is implemented inline (see
// internal/workload/plugin/source/static). The legacy Manager kept
// here keeps the /api/sites/* HTTP routes alive during the cutover
// window.
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
if err := staticSiteHealth.Start("2m"); err != nil {
slog.Warn("failed to start static site health checker", "error", err)
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/workload/plugin"
"github.com/alexei/tinyforge/internal/workload/plugin/source/static"
)
// staticBackend is the bridge between the plugin static source and the
// existing staticsite.Manager. The Manager operates on store.StaticSite
// rows keyed by site ID; this adapter keeps a phantom static_sites row
// for every plugin-native static workload (row ID = workload ID) so the
// Manager's deploy pipeline runs unchanged.
//
// The phantom row carries no UI weight — the legacy /api/static_sites
// endpoints will still surface it during the cutover window, which is
// fine: it lets operators inspect state through the existing legacy UI
// until /apps grows the equivalent screens. When the legacy cutover
// finishes, we can rewrite the static source to operate against the
// containers table directly and drop this adapter.
type staticBackend struct {
store *store.Store
mgr *staticsite.Manager
}
func newStaticBackend(st *store.Store, mgr *staticsite.Manager) *staticBackend {
return &staticBackend{store: st, mgr: mgr}
}
func (b *staticBackend) Deploy(ctx context.Context, _ plugin.Deps, w plugin.Workload, _ plugin.DeploymentIntent) error {
cfg, err := plugin.SourceConfigOf[static.Config](w)
if err != nil {
return fmt.Errorf("static backend: decode config: %w", err)
}
site, err := b.syncPhantomSite(w, cfg)
if err != nil {
return err
}
return b.mgr.Deploy(ctx, site.ID, true /* force */)
}
func (b *staticBackend) Teardown(ctx context.Context, _ plugin.Deps, w plugin.Workload) error {
// Stop best-effort (the row may not exist yet if Deploy never ran).
if _, err := b.store.GetStaticSiteByID(w.ID); err == nil {
if err := b.mgr.Stop(ctx, w.ID); err != nil {
// Log via the manager's own pipeline; we keep going so the
// phantom row is always dropped.
_ = err
}
_ = b.store.DeleteStaticSite(w.ID)
}
return nil
}
func (b *staticBackend) Reconcile(_ context.Context, _ plugin.Deps, w plugin.Workload) error {
// The staticsite.HealthChecker already polls every site row; no
// per-tick work is needed here. Reconcile becomes a no-op until the
// inline port lands.
_ = w
return nil
}
// syncPhantomSite upserts a store.StaticSite keyed on the workload ID,
// translating the plugin Config into the legacy shape. It is also where
// we shape the "single public face" expectation of the legacy table into
// a single domain string.
func (b *staticBackend) syncPhantomSite(w plugin.Workload, cfg static.Config) (store.StaticSite, error) {
domain := ""
for _, f := range w.PublicFaces {
// Pick the first enabled face. The API validator already caps
// faces at one for v1, but iterate defensively.
if f.Subdomain != "" || f.Domain != "" {
d := f.Domain
sub := f.Subdomain
switch {
case sub != "" && d != "":
domain = sub + "." + d
case sub == "" && d != "":
domain = d
case sub != "" && d == "":
// Domain falls back to settings.domain inside the
// Manager. Leave empty — Manager handles it.
domain = sub
}
break
}
}
site := store.StaticSite{
ID: w.ID,
Name: w.Name,
Provider: cfg.Provider,
GiteaURL: cfg.BaseURL,
RepoOwner: cfg.RepoOwner,
RepoName: cfg.RepoName,
Branch: cfg.Branch,
FolderPath: cfg.FolderPath,
AccessToken: cfg.AccessToken,
Domain: domain,
Mode: cfg.Mode,
RenderMarkdown: cfg.RenderMarkdown,
SyncTrigger: "manual",
StorageEnabled: cfg.StorageEnabled,
StorageLimitMB: cfg.StorageLimitMB,
NotificationURL: w.NotificationURL,
NotificationSecret: w.NotificationSecret,
WebhookSecret: w.WebhookSecret,
WebhookSigningSecret: w.WebhookSigningSecret,
WebhookRequireSignature: w.WebhookRequireSignature,
}
if err := b.store.UpsertStaticSiteWithID(site); err != nil {
return store.StaticSite{}, fmt.Errorf("static backend: sync phantom site: %w", err)
}
return site, nil
}
// wireStaticBackend installs the adapter so the plugin static source
// becomes deployable. Called once from main() after the staticsite
// Manager is constructed. Safe to call multiple times only because
// static.SetBackend itself panics on the second call — keeping the
// invariant explicit.
func wireStaticBackend(st *store.Store, mgr *staticsite.Manager) {
static.SetBackend(newStaticBackend(st, mgr))
}
// Unused but kept so the json import is referenced if we ever need to
// inspect raw SourceConfig blobs here for debugging.
var _ = json.Marshal