package plugin import ( "context" "encoding/json" "fmt" "sync" ) // Source is the contract for one deployable shape (image, compose, static, // ...). Implementations are stateless: every method receives Deps so the // same value can serve concurrent deploys safely. // // A Source owns the full lifecycle of its containers — it is expected to // reconcile rows in the containers index, register/deregister proxy // routes via Deps.Proxy, and manage DNS via Deps.DNS. The deployer // pipeline only chooses the right Source and feeds it a DeploymentIntent. type Source interface { // Kind is the registration key (e.g. "image", "compose", "static"). Kind() string // Validate type-checks a raw config blob before it is persisted. // Return a user-friendly error — the message is shown in the UI. Validate(cfg json.RawMessage) error // Deploy executes one deployment of w using intent. Whether this is a // fresh start, an update, or a no-op is the Source's call: e.g. an // image source short-circuits if the requested tag already runs. Deploy(ctx context.Context, deps Deps, w Workload, intent DeploymentIntent) error // Teardown removes everything Deploy created (containers, proxy // routes, DNS, source-specific state). Idempotent. Teardown(ctx context.Context, deps Deps, w Workload) error // Reconcile brings the containers index in sync with reality. Called // by the periodic reconciler — must be cheap when nothing changed. Reconcile(ctx context.Context, deps Deps, w Workload) error } var ( sourcesMu sync.RWMutex sources = map[string]Source{} ) // RegisterSource installs s under s.Kind(). Panics on duplicate // registration: that always indicates a bug in init() ordering, not a // recoverable runtime condition. func RegisterSource(s Source) { sourcesMu.Lock() defer sourcesMu.Unlock() k := s.Kind() if _, dup := sources[k]; dup { panic(fmt.Sprintf("plugin: source %q already registered", k)) } sources[k] = s } // GetSource returns the Source registered for kind, or an error mentioning // the kind that was missing — useful when a workload row references a // kind whose package was not blank-imported. func GetSource(kind string) (Source, error) { sourcesMu.RLock() defer sourcesMu.RUnlock() s, ok := sources[kind] if !ok { return nil, fmt.Errorf("plugin: no source registered for kind %q", kind) } return s, nil } // Schemaer is the optional interface a Source or Trigger may implement // to surface a sample config blob. The /api/hooks/kinds/{kind}/schema // endpoint uses this so frontends can render kind-aware forms without // hardcoding samples per call-site. Plugins that don't implement it // produce an empty object on the wire. type Schemaer interface { SchemaSample() any } // SchemaSampleFor returns the typed sample value declared by the plugin // registered under kind, or nil if no sample is published. func SchemaSampleFor(kind string) (any, bool) { sourcesMu.RLock() if s, ok := sources[kind]; ok { sourcesMu.RUnlock() if sm, ok := s.(Schemaer); ok { return sm.SchemaSample(), true } return nil, true } sourcesMu.RUnlock() triggersMu.RLock() defer triggersMu.RUnlock() if t, ok := triggers[kind]; ok { if sm, ok := t.(Schemaer); ok { return sm.SchemaSample(), true } return nil, true } return nil, false } // SourceKinds returns all registered source kinds, sorted for stable // listing in /api/workloads/source-kinds. func SourceKinds() []string { sourcesMu.RLock() defer sourcesMu.RUnlock() out := make([]string, 0, len(sources)) for k := range sources { out = append(out, k) } sortStrings(out) return out }