Files
tiny-forge/cmd/server/static_backend.go
T
alexei.dolgolyov 8d6a527a2b refactor(workload): plugin architecture wave + apps UI + volume scopes
Completes the workload-first refactor's plugin layer:

- internal/workload/plugin/ — Source/Trigger plugin contract,
  registry, types (Workload, DeploymentIntent, InboundEvent,
  PublicFace). Self-registering init() pattern + blank-import
  in cmd/server/main.go.
- Source plugins: image (blue-green with multi-face proxy routing),
  compose, static. Trigger plugins: registry, git, manual.
- internal/deployer/dispatch.go — DispatchPlugin/Teardown/Reconcile
  seam routing the legacy deployer through plugins.
- internal/api/workload_*.go — REST surface: workloads, env,
  volumes, chain (parent/children), promote-from. hooks.go
  serves /api/hooks/kinds/{kind}/schema for the wizard.
- internal/store: workload_env (encrypt-at-rest secrets) and
  workload_volumes tables, keyed on workload_id.
- cmd/server/static_backend.go — phantom-row adapter delegating
  the static source plugin to the legacy staticsite.Manager
  (deleted at hard cutover once the static inline port lands).
- web/src/routes/apps/ — /apps list + /apps/new wizard +
  /apps/[id] detail with kind-aware compose / image / static
  forms (Advanced JSON toggle), env panel, volumes panel,
  webhook panel, chain panel, manual deploy.

Volume scope generalization (v2 resolver):

- internal/volume.ResolveWorkloadPath (workload-keyed, sits
  next to legacy ResolvePath). Honors all VolumeScope values:
  absolute, ephemeral, instance, stage, project, project_named,
  named. internal/workload/plugin/source/image/image.go
  computeMounts wires settings + imageTag through. Coverage in
  internal/volume/resolver_test.go (portable Linux/Windows via
  t.TempDir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:17:41 +03:00

134 lines
4.7 KiB
Go

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