feat(static): inline static-source plugin; drop phantom-row adapter
Build / build (push) Successful in 10m43s
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:
+5
-5
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user