// 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) }