package deployer import ( "context" "fmt" "github.com/alexei/tinyforge/internal/metrics" "github.com/alexei/tinyforge/internal/workload/plugin" ) // DispatchPlugin routes a DeploymentIntent for w to the matching Source // plugin. This is the new unified deploy path; the legacy executeDeploy // remains in place until Phase 6 ports image-deploy logic into // source/image. While both exist, callers must pick: webhook/registry // triggers + image deploys still go through the legacy path, while // /api/hooks/generic + the unified webhook ingress go through here. func (d *Deployer) DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error { if err := d.beginDispatch(); err != nil { metrics.DeploysTotal.Inc(w.SourceKind, "rejected_draining") return err } defer d.activeWg.Done() src, err := plugin.GetSource(w.SourceKind) if err != nil { // Unknown source: use the constant "unknown" sentinel for the // label so a typo-spam attack can't grow the metrics map with // one series per bogus source_kind. The actual user-supplied // value still surfaces via the wrapped error / event log. metrics.DeploysTotal.Inc("unknown", "unknown_source") return fmt.Errorf("dispatch %s: %w", w.Name, err) } err = src.Deploy(ctx, d.PluginDeps(), w, intent) outcome := "success" if err != nil { outcome = "failure" } metrics.DeploysTotal.Inc(w.SourceKind, outcome) return err } // DispatchTeardown routes a teardown call to the matching Source plugin. // Used when a workload is deleted. Tracked via activeWg so Drain() honours // in-progress teardowns just like deploys. func (d *Deployer) DispatchTeardown(ctx context.Context, w plugin.Workload) error { if err := d.beginDispatch(); err != nil { return err } defer d.activeWg.Done() src, err := plugin.GetSource(w.SourceKind) if err != nil { return fmt.Errorf("dispatch teardown %s: %w", w.Name, err) } return src.Teardown(ctx, d.PluginDeps(), w) } // DispatchReconcile routes a Reconcile call. Periodic reconciler iterates // every Workload and calls this; idle Sources should make it a cheap // no-op. Tracked via activeWg so a long-running reconcile blocks Drain(). func (d *Deployer) DispatchReconcile(ctx context.Context, w plugin.Workload) error { if err := d.beginDispatch(); err != nil { // Silent skip — reconcile is a periodic tick, not a user-initiated // action, so we don't want to surface "draining" errors back to the // reconciler loop. The next tick after restart will catch up. Routing // through beginDispatch keeps the activeWg.Add atomic with the drain // check (see Drain) instead of a bare shuttingDown.Load + Add race. return nil } defer d.activeWg.Done() src, err := plugin.GetSource(w.SourceKind) if err != nil { return fmt.Errorf("dispatch reconcile %s: %w", w.Name, err) } return src.Reconcile(ctx, d.PluginDeps(), w) } // PluginDeps captures the Deployer's existing dependencies in the bundle // shape Sources expect. Reads d.dns under the RWMutex since proxy/DNS // can be hot-swapped at runtime when settings change. Exported so the // API layer can hand the same Deps to Trigger.Match — passing zero-Deps // to triggers would silently nil-panic the moment any Trigger touches // deps.Store / deps.Crypto for signature verification. func (d *Deployer) PluginDeps() plugin.Deps { d.dnsMu.RLock() dnsProvider := d.dns d.dnsMu.RUnlock() d.proxyMu.RLock() proxyProvider := d.proxy d.proxyMu.RUnlock() return plugin.Deps{ Store: d.store, Docker: d.docker, Proxy: proxyProvider, DNS: dnsProvider, Health: d.health, Notifier: d.notifier, Events: d.eventBus, EncKey: d.encKey, } }