fetch_ml/internal/tracking/plugin.go

209 lines
4.7 KiB
Go

package tracking
import (
"context"
"fmt"
"sync"
"github.com/jfraeys/fetch_ml/internal/logging"
)
// ToolMode represents the provisioning mode for a tracking tool.
type ToolMode string
const (
// ModeSidecar provisions the tool as a sidecar container.
ModeSidecar ToolMode = "sidecar"
// ModeRemote points to a remotely managed instance (no local provisioning).
ModeRemote ToolMode = "remote"
// ModeDisabled skips provisioning entirely.
ModeDisabled ToolMode = "disabled"
)
// ToolConfig specifies how a plugin should be provisioned for a task.
type ToolConfig struct {
Enabled bool
Mode ToolMode
Settings map[string]any
}
// Plugin defines the behaviour every tracking integration must implement.
type Plugin interface {
Name() string
ProvisionSidecar(ctx context.Context, taskID string, config ToolConfig) (map[string]string, error)
Teardown(ctx context.Context, taskID string) error
HealthCheck(ctx context.Context, config ToolConfig) bool
}
// Registry keeps track of registered plugins and their lifecycle per task.
type Registry struct {
logger *logging.Logger
mu sync.Mutex
plugins map[string]Plugin
active map[string][]string
}
// NewRegistry returns a new plugin registry.
func NewRegistry(logger *logging.Logger) *Registry {
return &Registry{
logger: logger,
plugins: make(map[string]Plugin),
active: make(map[string][]string),
}
}
// Register adds a plugin to the registry.
func (r *Registry) Register(p Plugin) {
r.mu.Lock()
defer r.mu.Unlock()
r.plugins[p.Name()] = p
}
// Get retrieves a plugin by name.
func (r *Registry) Get(name string) (Plugin, bool) {
r.mu.Lock()
defer r.mu.Unlock()
p, ok := r.plugins[name]
return p, ok
}
// ProvisionAll provisions configured plugins for a task and merges their environment variables.
func (r *Registry) ProvisionAll(
ctx context.Context,
taskID string,
configs map[string]ToolConfig,
) (map[string]string, error) {
if len(configs) == 0 {
return nil, nil
}
env := make(map[string]string)
var provisioned []string
for name, cfg := range configs {
if !cfg.Enabled || cfg.Mode == ModeDisabled {
continue
}
plugin, ok := r.Get(name)
if !ok {
return nil, fmt.Errorf("tracking plugin %s not registered", name)
}
settingsEnv, err := plugin.ProvisionSidecar(ctx, taskID, cfg)
if err != nil {
r.rollback(ctx, taskID, provisioned)
return nil, fmt.Errorf("failed to provision %s: %w", name, err)
}
for k, v := range settingsEnv {
env[k] = v
}
if cfg.Mode == ModeSidecar {
provisioned = append(provisioned, name)
}
}
if len(provisioned) > 0 {
r.mu.Lock()
r.active[taskID] = append(r.active[taskID], provisioned...)
r.mu.Unlock()
}
return env, nil
}
// TeardownAll stops every plugin that was provisioned for a task.
func (r *Registry) TeardownAll(ctx context.Context, taskID string) {
r.mu.Lock()
plugins := r.active[taskID]
delete(r.active, taskID)
r.mu.Unlock()
for _, name := range plugins {
plugin, ok := r.Get(name)
if !ok {
continue
}
if err := plugin.Teardown(ctx, taskID); err != nil && r.logger != nil {
r.logger.Warn("tracking teardown failed", "plugin", name, "task_id", taskID, "error", err)
}
}
}
func (r *Registry) rollback(ctx context.Context, taskID string, provisioned []string) {
for i := len(provisioned) - 1; i >= 0; i-- {
name := provisioned[i]
plugin, ok := r.Get(name)
if !ok {
continue
}
if err := plugin.Teardown(ctx, taskID); err != nil && r.logger != nil {
r.logger.Warn("rollback failed", "plugin", name, "task_id", taskID, "error", err)
}
}
}
// PortAllocator manages dynamic port assignments for sidecars.
type PortAllocator struct {
mu sync.Mutex
start int
end int
next int
used map[int]bool
}
// NewPortAllocator creates a new allocator for a port range.
func NewPortAllocator(start, end int) *PortAllocator {
if start <= 0 || end <= 0 || end <= start {
start = 5500
end = 5600
}
return &PortAllocator{
start: start,
end: end,
next: start,
used: make(map[int]bool),
}
}
// Allocate reserves the next available port.
func (p *PortAllocator) Allocate() (int, error) {
p.mu.Lock()
defer p.mu.Unlock()
for i := 0; i < p.end-p.start; i++ {
port := p.next
p.next++
if p.next >= p.end {
p.next = p.start
}
if !p.used[port] {
p.used[port] = true
return port, nil
}
}
return 0, fmt.Errorf("no ports available in range %d-%d", p.start, p.end)
}
// Release frees a previously allocated port.
func (p *PortAllocator) Release(port int) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.used, port)
}
// StringSetting safely reads a string from plugin settings.
func StringSetting(settings map[string]any, key string) string {
if settings == nil {
return ""
}
if v, ok := settings[key]; ok {
if str, ok := v.(string); ok {
return str
}
}
return ""
}