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