8d6a527a2b
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>
148 lines
5.0 KiB
Go
148 lines
5.0 KiB
Go
// Package static implements the "static" source: a git-folder-backed
|
|
// deployable that can serve plain files or run a Deno backend. Builds an
|
|
// image from the cloned folder and runs one container.
|
|
//
|
|
// The full deploy pipeline lives in internal/staticsite (git providers,
|
|
// markdown rendering, Dockerfile codegen, Deno scaffolding, image build,
|
|
// proxy registration) and is wired in via a function variable so that
|
|
// neither this package nor staticsite has to depend on the other.
|
|
//
|
|
// cmd/server/main.go (or any caller with access to both packages)
|
|
// populates DeployFn / TeardownFn / ReconcileFn at startup; until then,
|
|
// Source methods return an explicit error so misconfiguration surfaces
|
|
// loudly instead of silently failing.
|
|
package static
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
|
)
|
|
|
|
// Config is the per-workload source config blob. Mirrors the fields that
|
|
// used to live on the static_sites table, less anything moved to Workload
|
|
// (notification config, webhook secrets, public_face).
|
|
type Config struct {
|
|
Provider string `json:"provider"` // "gitea" | "github" | "gitlab"; "" = autodetect
|
|
BaseURL string `json:"base_url"` // e.g. https://git.example.com
|
|
RepoOwner string `json:"repo_owner"`
|
|
RepoName string `json:"repo_name"`
|
|
Branch string `json:"branch"`
|
|
FolderPath string `json:"folder_path"` // path within repo
|
|
AccessToken string `json:"access_token"` // encrypted; optional for public repos
|
|
Mode string `json:"mode"` // "static" | "deno"
|
|
RenderMarkdown bool `json:"render_markdown"`
|
|
StorageEnabled bool `json:"storage_enabled"`
|
|
StorageLimitMB int `json:"storage_limit_mb"`
|
|
}
|
|
|
|
// Backend captures the deploy lifecycle of a static site. main.go wires
|
|
// an implementation that adapts internal/staticsite.Manager to this
|
|
// interface; the plugin contract sees only this shape so it stays
|
|
// independent of any specific manager type.
|
|
type Backend interface {
|
|
Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error
|
|
Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error
|
|
Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error
|
|
}
|
|
|
|
var (
|
|
backendMu sync.RWMutex
|
|
backend Backend
|
|
backendSet atomic.Bool
|
|
)
|
|
|
|
// SetBackend wires the staticsite-package adapter into this Source AND
|
|
// registers the source with the plugin registry. MUST be called exactly
|
|
// once from cmd/server/main.go before any plugin invocation. Subsequent
|
|
// calls panic — a swapped backend at runtime is a trust-boundary
|
|
// inversion (a future plugin loaded via blank import could replace
|
|
// deploy/teardown logic that handles git tokens).
|
|
func SetBackend(b Backend) {
|
|
if !backendSet.CompareAndSwap(false, true) {
|
|
panic("static: backend already wired (SetBackend may be called once)")
|
|
}
|
|
backendMu.Lock()
|
|
backend = b
|
|
backendMu.Unlock()
|
|
plugin.RegisterSource(&source{})
|
|
}
|
|
|
|
func currentBackend() (Backend, error) {
|
|
backendMu.RLock()
|
|
defer backendMu.RUnlock()
|
|
if backend == nil {
|
|
return nil, fmt.Errorf("static source: backend not wired; call static.SetBackend from main.go")
|
|
}
|
|
return backend, nil
|
|
}
|
|
|
|
type source struct{}
|
|
|
|
// Static source registers itself only after SetBackend is called from
|
|
// main.go. Eager init() registration would advertise "static" via
|
|
// /api/hooks/kinds before there is anything to dispatch to — frontends
|
|
// would render it in pickers and operators would hit "backend not wired"
|
|
// at deploy time. Lazy registration keeps the kind invisible until it's
|
|
// actually usable.
|
|
|
|
func (*source) Kind() string { return "static" }
|
|
|
|
func (*source) SchemaSample() any {
|
|
return Config{
|
|
Provider: "gitea",
|
|
BaseURL: "https://git.example.com",
|
|
RepoOwner: "owner",
|
|
RepoName: "pages",
|
|
Branch: "main",
|
|
FolderPath: "",
|
|
Mode: "static",
|
|
}
|
|
}
|
|
|
|
func (*source) Validate(cfg json.RawMessage) error {
|
|
var c Config
|
|
if len(cfg) == 0 {
|
|
return fmt.Errorf("static source: config is required")
|
|
}
|
|
if err := json.Unmarshal(cfg, &c); err != nil {
|
|
return fmt.Errorf("static source: invalid json: %w", err)
|
|
}
|
|
if strings.TrimSpace(c.RepoOwner) == "" || strings.TrimSpace(c.RepoName) == "" {
|
|
return fmt.Errorf("static source: repo_owner and repo_name are required")
|
|
}
|
|
if c.Mode != "" && c.Mode != "static" && c.Mode != "deno" {
|
|
return fmt.Errorf("static source: mode must be \"static\" or \"deno\"")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
|
b, err := currentBackend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.Deploy(ctx, deps, w, intent)
|
|
}
|
|
|
|
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
|
b, err := currentBackend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.Teardown(ctx, deps, w)
|
|
}
|
|
|
|
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
|
b, err := currentBackend()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.Reconcile(ctx, deps, w)
|
|
}
|