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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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