1683 lines
47 KiB
Go
1683 lines
47 KiB
Go
package pluginmanager
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/tursom/mc-gateway/plugin/api"
|
|
)
|
|
|
|
const (
|
|
circuitClosed = "closed"
|
|
circuitOpen = "open"
|
|
)
|
|
|
|
var (
|
|
metricNamePattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.:-]{0,127}$`)
|
|
storeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,255}$`)
|
|
)
|
|
|
|
type traceContextKey struct{}
|
|
|
|
type traceContext struct {
|
|
PluginID string
|
|
TraceID string
|
|
ConnectionID string
|
|
HandlerID string
|
|
}
|
|
|
|
type Operations struct {
|
|
repo Repository
|
|
root string
|
|
|
|
mu sync.RWMutex
|
|
plugins map[string]*PluginOperations
|
|
|
|
eventQueue chan queuedEvent
|
|
queued atomic.Uint64
|
|
dropped atomic.Uint64
|
|
deadLetter atomic.Uint64
|
|
|
|
subscriberQueue chan queuedEvent
|
|
subscriberQueued atomic.Uint64
|
|
subscriberDropped atomic.Uint64
|
|
subscriberDeadLetter atomic.Uint64
|
|
|
|
subscriberMu sync.RWMutex
|
|
subscribers []*subscriberHandler
|
|
}
|
|
|
|
type queuedEvent struct {
|
|
pluginID string
|
|
name string
|
|
fields map[string]string
|
|
dropped bool
|
|
reason string
|
|
traceID string
|
|
connectionID string
|
|
}
|
|
|
|
type PluginOperations struct {
|
|
parent *Operations
|
|
pluginID string
|
|
artifactID string
|
|
manifest Manifest
|
|
|
|
mu sync.Mutex
|
|
eventSchemas map[string]map[string]bool
|
|
metricSchemas map[string]MetricSpec
|
|
taskSpecs map[string]TaskSpec
|
|
externalSpecs map[string]ExternalSpec
|
|
dataQuota int64
|
|
dataSpec DataStoreSpec
|
|
fileQuota int64
|
|
fileSpecs map[string]FileStoreSpec
|
|
eventValues map[string]map[string]map[string]struct{}
|
|
eventSummaries map[string]*EventSummary
|
|
metricSummaries map[string]*CustomMetricSummary
|
|
externals map[string]*externalRuntime
|
|
tasks map[string]*taskRuntime
|
|
}
|
|
|
|
type externalRuntime struct {
|
|
spec ExternalSpec
|
|
|
|
requests atomic.Uint64
|
|
errors atomic.Uint64
|
|
inflight atomic.Int64
|
|
durationCount atomic.Uint64
|
|
durationSumMS atomic.Uint64
|
|
consecutiveFailures atomic.Uint64
|
|
|
|
mu sync.Mutex
|
|
circuitUntil time.Time
|
|
recentError string
|
|
lastStatus string
|
|
lastSeenAt int64
|
|
}
|
|
|
|
type taskRuntime struct {
|
|
pluginID string
|
|
spec TaskSpec
|
|
task api.BackgroundTask
|
|
confirmToken string
|
|
|
|
mu sync.Mutex
|
|
cancel context.CancelFunc
|
|
running bool
|
|
schedulerOn bool
|
|
lastRunAt int64
|
|
nextRunAt int64
|
|
lastDurationMS int64
|
|
lastError string
|
|
skipped uint64
|
|
consecutiveFailures uint64
|
|
}
|
|
|
|
func NewOperations(repo Repository, root string) *Operations {
|
|
ops := &Operations{
|
|
repo: repo,
|
|
root: root,
|
|
plugins: make(map[string]*PluginOperations),
|
|
eventQueue: make(chan queuedEvent, DefaultEventQueueLimit),
|
|
subscriberQueue: make(chan queuedEvent, DefaultEventQueueLimit),
|
|
}
|
|
go ops.consumeEvents()
|
|
go ops.consumeSubscriberEvents()
|
|
return ops
|
|
}
|
|
|
|
func (o *Operations) SetSubscribers(subscribers []*subscriberHandler) {
|
|
o.subscriberMu.Lock()
|
|
defer o.subscriberMu.Unlock()
|
|
o.subscribers = append([]*subscriberHandler(nil), subscribers...)
|
|
}
|
|
|
|
func (o *Operations) SubscriberDeadLetters() uint64 {
|
|
return o.subscriberDeadLetter.Load()
|
|
}
|
|
|
|
func (o *Operations) DropSubscriberDeadLetters() uint64 {
|
|
return o.subscriberDeadLetter.Swap(0)
|
|
}
|
|
|
|
func (o *Operations) ForPlugin(pluginID, artifactID string, manifest Manifest) *PluginOperations {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
po := o.plugins[pluginID]
|
|
if po == nil {
|
|
po = &PluginOperations{
|
|
parent: o,
|
|
pluginID: pluginID,
|
|
eventValues: make(map[string]map[string]map[string]struct{}),
|
|
eventSummaries: make(map[string]*EventSummary),
|
|
metricSummaries: make(map[string]*CustomMetricSummary),
|
|
externals: make(map[string]*externalRuntime),
|
|
tasks: make(map[string]*taskRuntime),
|
|
}
|
|
o.plugins[pluginID] = po
|
|
}
|
|
po.configure(artifactID, manifest)
|
|
return po
|
|
}
|
|
|
|
func (o *Operations) StopPlugin(pluginID string) {
|
|
o.mu.RLock()
|
|
po := o.plugins[pluginID]
|
|
o.mu.RUnlock()
|
|
if po != nil {
|
|
po.stopTasks()
|
|
}
|
|
}
|
|
|
|
func (o *Operations) StartTasks(pluginID string) {
|
|
o.mu.RLock()
|
|
po := o.plugins[pluginID]
|
|
o.mu.RUnlock()
|
|
if po != nil {
|
|
po.StartTasks(pluginID)
|
|
}
|
|
}
|
|
|
|
func (o *Operations) consumeEvents() {
|
|
for event := range o.eventQueue {
|
|
o.queued.Add(1)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
err := o.repo.SaveEvent(ctx, EventSummary{
|
|
PluginID: event.pluginID,
|
|
Name: event.name,
|
|
Fields: event.fields,
|
|
}, event.dropped, event.reason, event.traceID, event.connectionID)
|
|
cancel()
|
|
if err != nil {
|
|
o.deadLetter.Add(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (o *Operations) queueEvent(event queuedEvent) {
|
|
select {
|
|
case o.eventQueue <- event:
|
|
default:
|
|
o.dropped.Add(1)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
_ = o.repo.SaveEvent(ctx, EventSummary{
|
|
PluginID: event.pluginID,
|
|
Name: event.name,
|
|
Fields: event.fields,
|
|
}, true, "event_queue_full", event.traceID, event.connectionID)
|
|
cancel()
|
|
}
|
|
o.queueSubscriberEvent(event)
|
|
}
|
|
|
|
func (o *Operations) queueSubscriberEvent(event queuedEvent) {
|
|
o.subscriberMu.RLock()
|
|
hasSubscribers := len(o.subscribers) > 0
|
|
o.subscriberMu.RUnlock()
|
|
if !hasSubscribers || event.dropped {
|
|
return
|
|
}
|
|
select {
|
|
case o.subscriberQueue <- event:
|
|
o.subscriberQueued.Add(1)
|
|
default:
|
|
o.subscriberDropped.Add(1)
|
|
}
|
|
}
|
|
|
|
func (o *Operations) consumeSubscriberEvents() {
|
|
for event := range o.subscriberQueue {
|
|
o.subscriberMu.RLock()
|
|
subscribers := append([]*subscriberHandler(nil), o.subscribers...)
|
|
o.subscriberMu.RUnlock()
|
|
for _, subscriber := range subscribers {
|
|
o.deliverSubscriberEvent(subscriber, event)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (o *Operations) deliverSubscriberEvent(subscriber *subscriberHandler, event queuedEvent) {
|
|
req := api.EventDeliveryRequest{
|
|
PluginID: event.pluginID,
|
|
Name: event.name,
|
|
Fields: copyStringMap(event.fields),
|
|
TraceID: event.traceID,
|
|
ConnectionID: event.connectionID,
|
|
Mode: subscriber.mode,
|
|
}
|
|
accepted, err := subscriber.accepts(req)
|
|
if err != nil || !accepted {
|
|
return
|
|
}
|
|
maxRetry := subscriber.maxRetry
|
|
if subscriber.mode == api.DeliveryBestEffort {
|
|
maxRetry = 1
|
|
}
|
|
if maxRetry <= 0 {
|
|
maxRetry = DefaultSubscriberMaxRetry
|
|
}
|
|
for attempt := 1; attempt <= maxRetry; attempt++ {
|
|
req.Attempt = attempt
|
|
result, err := subscriber.invoke(req)
|
|
if err == nil && (result.OK || !result.Retry) {
|
|
return
|
|
}
|
|
if attempt < maxRetry {
|
|
time.Sleep(DefaultSubscriberRetryDelay)
|
|
}
|
|
}
|
|
o.subscriberDeadLetter.Add(1)
|
|
_ = o.repo.RecordOperation(context.Background(), subscriber.pluginID, subscriber.artifactID, "event_subscriber_delivery", "dead_letter", "system", "event subscriber delivery failed", map[string]any{
|
|
"event_plugin_id": event.pluginID,
|
|
"event_name": event.name,
|
|
"subscriber_mode": subscriber.mode,
|
|
})
|
|
}
|
|
|
|
func (po *PluginOperations) configure(artifactID string, manifest Manifest) {
|
|
po.mu.Lock()
|
|
defer po.mu.Unlock()
|
|
po.artifactID = artifactID
|
|
po.manifest = manifest
|
|
|
|
po.eventSchemas = make(map[string]map[string]bool)
|
|
for _, spec := range manifest.Events {
|
|
if spec.Name == "" {
|
|
continue
|
|
}
|
|
fields := make(map[string]bool, len(spec.Fields))
|
|
for _, field := range spec.Fields {
|
|
fields[field] = true
|
|
}
|
|
po.eventSchemas[spec.Name] = fields
|
|
}
|
|
po.metricSchemas = make(map[string]MetricSpec)
|
|
for _, spec := range manifest.CustomMetrics {
|
|
if spec.Name != "" {
|
|
po.metricSchemas[spec.Name] = spec
|
|
}
|
|
}
|
|
po.taskSpecs = make(map[string]TaskSpec)
|
|
for _, spec := range manifest.BackgroundTasks {
|
|
if spec.ID != "" {
|
|
po.taskSpecs[spec.ID] = spec
|
|
}
|
|
}
|
|
po.externalSpecs = make(map[string]ExternalSpec)
|
|
for _, spec := range manifest.ExternalDeps {
|
|
if spec.Name != "" {
|
|
po.externalSpecs[spec.Name] = spec
|
|
if po.externals[spec.Name] == nil {
|
|
po.externals[spec.Name] = &externalRuntime{spec: spec}
|
|
} else {
|
|
po.externals[spec.Name].spec = spec
|
|
}
|
|
}
|
|
}
|
|
po.dataQuota = DefaultPluginDataQuota
|
|
po.dataSpec = DataStoreSpec{QuotaBytes: DefaultPluginDataQuota}
|
|
if len(manifest.DataStores) > 0 {
|
|
po.dataSpec = manifest.DataStores[0]
|
|
if po.dataSpec.QuotaBytes > 0 {
|
|
po.dataQuota = po.dataSpec.QuotaBytes
|
|
}
|
|
}
|
|
po.fileQuota = DefaultPluginFileQuota
|
|
po.fileSpecs = make(map[string]FileStoreSpec)
|
|
for _, spec := range manifest.FileStores {
|
|
if spec.Namespace != "" {
|
|
po.fileSpecs[spec.Namespace] = spec
|
|
if spec.QuotaBytes > 0 && spec.QuotaBytes < po.fileQuota {
|
|
po.fileQuota = spec.QuotaBytes
|
|
}
|
|
}
|
|
}
|
|
if len(po.fileSpecs) == 0 {
|
|
for _, namespace := range []string{"data", "cache", "tmp", "log", "diagnostic"} {
|
|
po.fileSpecs[namespace] = FileStoreSpec{Namespace: namespace, QuotaBytes: DefaultPluginFileQuota}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (po *PluginOperations) EmitEvent(ctx context.Context, name string, fields map[string]string) error {
|
|
trace := traceFromContext(ctx)
|
|
clean, dropReason, err := po.validateEvent(name, fields)
|
|
if err != nil {
|
|
po.parent.queueEvent(queuedEvent{
|
|
pluginID: po.pluginID, name: name, fields: clean, dropped: true,
|
|
reason: dropReason, traceID: trace.TraceID, connectionID: trace.ConnectionID,
|
|
})
|
|
return err
|
|
}
|
|
po.mu.Lock()
|
|
summary := po.eventSummaries[name]
|
|
if summary == nil {
|
|
summary = &EventSummary{PluginID: po.pluginID, Name: name}
|
|
po.eventSummaries[name] = summary
|
|
}
|
|
summary.Count++
|
|
summary.Fields = clean
|
|
summary.LastSeenAt = time.Now().Unix()
|
|
po.mu.Unlock()
|
|
po.parent.queueEvent(queuedEvent{
|
|
pluginID: po.pluginID, name: name, fields: clean,
|
|
traceID: trace.TraceID, connectionID: trace.ConnectionID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (po *PluginOperations) ObserveMetric(ctx context.Context, name string, value float64, labels map[string]string) error {
|
|
_ = ctx
|
|
clean, metricType, err := po.validateMetric(name, labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
po.mu.Lock()
|
|
defer po.mu.Unlock()
|
|
summary := po.metricSummaries[name]
|
|
if summary == nil {
|
|
summary = &CustomMetricSummary{PluginID: po.pluginID, Name: name, Type: metricType}
|
|
po.metricSummaries[name] = summary
|
|
}
|
|
summary.Count++
|
|
summary.LastValue = value
|
|
summary.Labels = clean
|
|
summary.LastSeenAt = time.Now().Unix()
|
|
return nil
|
|
}
|
|
|
|
func (po *PluginOperations) Logger() api.Logger {
|
|
return pluginLogger{ops: po}
|
|
}
|
|
|
|
func (po *PluginOperations) DataStore() api.DataStore {
|
|
return pluginDataStore{ops: po}
|
|
}
|
|
|
|
func (po *PluginOperations) FileStore() api.FileStore {
|
|
return pluginFileStore{ops: po}
|
|
}
|
|
|
|
func (po *PluginOperations) ExternalClient(name string) api.ExternalClient {
|
|
po.mu.Lock()
|
|
defer po.mu.Unlock()
|
|
runtime := po.externals[name]
|
|
if runtime == nil {
|
|
spec := po.externalSpecs[name]
|
|
runtime = &externalRuntime{spec: spec}
|
|
po.externals[name] = runtime
|
|
}
|
|
return pluginExternalClient{ops: po, name: name, runtime: runtime}
|
|
}
|
|
|
|
func (po *PluginOperations) RegisterBackgroundTask(task api.BackgroundTask) error {
|
|
if task.ID == "" {
|
|
return errors.New("background task id is required")
|
|
}
|
|
if !metricNamePattern.MatchString(task.ID) {
|
|
return fmt.Errorf("invalid background task id %q", task.ID)
|
|
}
|
|
if task.Run == nil {
|
|
return fmt.Errorf("background task %q run function is required", task.ID)
|
|
}
|
|
po.mu.Lock()
|
|
defer po.mu.Unlock()
|
|
spec := po.taskSpecs[task.ID]
|
|
if len(po.taskSpecs) > 0 && spec.ID == "" {
|
|
return fmt.Errorf("background task %q is not declared by manifest", task.ID)
|
|
}
|
|
if spec.ID == "" {
|
|
spec = TaskSpec{ID: task.ID, Name: task.Name}
|
|
}
|
|
if task.Name == "" {
|
|
task.Name = spec.Name
|
|
}
|
|
if task.Interval == 0 && spec.Interval != "" {
|
|
task.Interval, _ = time.ParseDuration(spec.Interval)
|
|
}
|
|
if task.Timeout == 0 && spec.Timeout != "" {
|
|
task.Timeout, _ = time.ParseDuration(spec.Timeout)
|
|
}
|
|
if task.Jitter == 0 && spec.Jitter != "" {
|
|
task.Jitter, _ = time.ParseDuration(spec.Jitter)
|
|
}
|
|
if !task.RunOnStart {
|
|
task.RunOnStart = spec.RunOnStart
|
|
}
|
|
if !task.Manual {
|
|
task.Manual = spec.Manual || spec.Mode == "manual"
|
|
}
|
|
if task.Timeout <= 0 {
|
|
task.Timeout = DefaultHandlerTimeout
|
|
}
|
|
rt := po.tasks[task.ID]
|
|
if rt == nil {
|
|
rt = &taskRuntime{
|
|
pluginID: po.pluginID,
|
|
spec: spec,
|
|
confirmToken: randomToken(),
|
|
}
|
|
po.tasks[task.ID] = rt
|
|
}
|
|
rt.task = task
|
|
rt.spec = spec
|
|
return nil
|
|
}
|
|
|
|
func (po *PluginOperations) StartTasks(pluginID string) {
|
|
if pluginID != po.pluginID {
|
|
return
|
|
}
|
|
po.mu.Lock()
|
|
tasks := make([]*taskRuntime, 0, len(po.tasks))
|
|
for _, task := range po.tasks {
|
|
tasks = append(tasks, task)
|
|
}
|
|
po.mu.Unlock()
|
|
for _, task := range tasks {
|
|
po.startTask(task)
|
|
}
|
|
}
|
|
|
|
func (po *PluginOperations) stopTasks() {
|
|
po.mu.Lock()
|
|
tasks := make([]*taskRuntime, 0, len(po.tasks))
|
|
for _, task := range po.tasks {
|
|
tasks = append(tasks, task)
|
|
}
|
|
po.mu.Unlock()
|
|
for _, task := range tasks {
|
|
task.mu.Lock()
|
|
if task.cancel != nil {
|
|
task.cancel()
|
|
task.cancel = nil
|
|
}
|
|
task.schedulerOn = false
|
|
task.nextRunAt = 0
|
|
task.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (po *PluginOperations) startTask(task *taskRuntime) {
|
|
task.mu.Lock()
|
|
if task.schedulerOn {
|
|
task.mu.Unlock()
|
|
return
|
|
}
|
|
task.schedulerOn = true
|
|
interval := task.task.Interval
|
|
runOnStart := task.task.RunOnStart
|
|
task.mu.Unlock()
|
|
|
|
if runOnStart {
|
|
go po.runTask(task)
|
|
}
|
|
if interval <= 0 || task.task.Manual {
|
|
return
|
|
}
|
|
go func() {
|
|
for {
|
|
delay := interval + deterministicJitter(task.task.Jitter, task.task.ID)
|
|
task.mu.Lock()
|
|
if !task.schedulerOn {
|
|
task.mu.Unlock()
|
|
return
|
|
}
|
|
task.nextRunAt = time.Now().Add(delay).Unix()
|
|
task.mu.Unlock()
|
|
timer := time.NewTimer(delay)
|
|
<-timer.C
|
|
task.mu.Lock()
|
|
on := task.schedulerOn
|
|
task.mu.Unlock()
|
|
if !on {
|
|
return
|
|
}
|
|
po.runTask(task)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (po *PluginOperations) runTask(task *taskRuntime) {
|
|
task.mu.Lock()
|
|
if task.running {
|
|
task.skipped++
|
|
task.mu.Unlock()
|
|
return
|
|
}
|
|
task.running = true
|
|
timeout := task.task.Timeout
|
|
if timeout <= 0 {
|
|
timeout = DefaultHandlerTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
task.cancel = cancel
|
|
task.mu.Unlock()
|
|
|
|
start := time.Now()
|
|
err := task.task.Run(ctx)
|
|
cancel()
|
|
|
|
task.mu.Lock()
|
|
task.running = false
|
|
task.cancel = nil
|
|
task.lastRunAt = start.Unix()
|
|
task.lastDurationMS = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
task.consecutiveFailures++
|
|
task.lastError = redactSensitive(err.Error())
|
|
} else {
|
|
task.consecutiveFailures = 0
|
|
task.lastError = ""
|
|
}
|
|
task.mu.Unlock()
|
|
}
|
|
|
|
func (po *PluginOperations) TriggerTask(taskID, confirmToken string) (BackgroundTaskSummary, error) {
|
|
po.mu.Lock()
|
|
task := po.tasks[taskID]
|
|
po.mu.Unlock()
|
|
if task == nil {
|
|
return BackgroundTaskSummary{}, fmt.Errorf("background task %q not found", taskID)
|
|
}
|
|
task.mu.Lock()
|
|
expected := task.confirmToken
|
|
task.mu.Unlock()
|
|
if expected == "" || confirmToken != expected {
|
|
return BackgroundTaskSummary{}, errors.New("confirm_token is required")
|
|
}
|
|
go po.runTask(task)
|
|
return task.summary(), nil
|
|
}
|
|
|
|
func (po *PluginOperations) Snapshot(ctx context.Context, pluginID string, handlers []DispatchHandlerSummary, builds []BuildRecord, gc []GCCandidate) OperationsSnapshot {
|
|
_ = ctx
|
|
po.mu.Lock()
|
|
events := make([]EventSummary, 0, len(po.eventSummaries))
|
|
for _, event := range po.eventSummaries {
|
|
events = append(events, *event)
|
|
}
|
|
metrics := make([]CustomMetricSummary, 0, len(po.metricSummaries))
|
|
for _, metric := range po.metricSummaries {
|
|
metrics = append(metrics, *metric)
|
|
}
|
|
externals := make([]ExternalDependencySummary, 0, len(po.externals))
|
|
for name, ext := range po.externals {
|
|
externals = append(externals, ext.summary(po.pluginID, name))
|
|
}
|
|
tasks := make([]BackgroundTaskSummary, 0, len(po.tasks))
|
|
for _, task := range po.tasks {
|
|
tasks = append(tasks, task.summary())
|
|
}
|
|
po.mu.Unlock()
|
|
|
|
recentEvents, _ := po.parent.repo.RecentEvents(ctx, pluginID, DefaultEventRecentLimit)
|
|
if len(recentEvents) > 0 {
|
|
events = mergeEventSummaries(events, recentEvents)
|
|
}
|
|
logs, _ := po.parent.repo.RecentLogs(ctx, pluginID, DefaultLogRecentLimit)
|
|
traces, _ := po.parent.repo.RecentTraces(ctx, pluginID, 200)
|
|
data, _ := po.parent.repo.ListPluginData(ctx, pluginID)
|
|
files, _ := po.parent.repo.ListPluginFiles(ctx, pluginID)
|
|
diagnostics, _ := po.parent.repo.ListDiagnostics(ctx, pluginID, 20)
|
|
|
|
buildMetrics := make([]BuildMetricSummary, 0, len(builds))
|
|
for _, build := range builds {
|
|
buildMetrics = append(buildMetrics, BuildMetricSummary{
|
|
PluginID: build.PluginID,
|
|
BuildID: build.ID,
|
|
Status: build.Status,
|
|
DurationMS: build.DurationMS,
|
|
Failed: build.Status == BuildStatusFailed,
|
|
ErrorRedacted: redactSensitive(build.Error),
|
|
CreatedAt: build.CreatedAt,
|
|
})
|
|
}
|
|
sort.Slice(events, func(i, j int) bool { return events[i].LastSeenAt > events[j].LastSeenAt })
|
|
sort.Slice(metrics, func(i, j int) bool { return metrics[i].LastSeenAt > metrics[j].LastSeenAt })
|
|
sort.Slice(externals, func(i, j int) bool { return externals[i].Name < externals[j].Name })
|
|
return OperationsSnapshot{
|
|
PluginID: pluginID,
|
|
UpdatedAt: time.Now().Unix(),
|
|
Handlers: handlers,
|
|
Builds: buildMetrics,
|
|
Events: events,
|
|
CustomMetrics: metrics,
|
|
Logs: logs,
|
|
Traces: traces,
|
|
BackgroundTasks: tasks,
|
|
PluginData: data,
|
|
PluginFiles: files,
|
|
ExternalDependencies: externals,
|
|
GC: gc,
|
|
EventQueue: EventQueueSummary{
|
|
Limit: DefaultEventQueueLimit,
|
|
Queued: len(po.parent.eventQueue),
|
|
Dropped: po.parent.dropped.Load(),
|
|
DeadLetters: po.parent.deadLetter.Load(),
|
|
SubscriberQueued: po.parent.subscriberQueued.Load(),
|
|
SubscriberDropped: po.parent.subscriberDropped.Load(),
|
|
SubscriberDeadLetters: po.parent.subscriberDeadLetter.Load(),
|
|
},
|
|
Diagnostics: diagnostics,
|
|
}
|
|
}
|
|
|
|
func (o *Operations) Snapshot(ctx context.Context, pluginID string, handlers []DispatchHandlerSummary, builds []BuildRecord, gc []GCCandidate) OperationsSnapshot {
|
|
o.mu.RLock()
|
|
po := o.plugins[pluginID]
|
|
o.mu.RUnlock()
|
|
if po == nil {
|
|
po = &PluginOperations{parent: o, pluginID: pluginID}
|
|
}
|
|
return po.Snapshot(ctx, pluginID, handlers, builds, gc)
|
|
}
|
|
|
|
func (o *Operations) TriggerTask(pluginID, taskID, confirmToken string) (BackgroundTaskSummary, error) {
|
|
o.mu.RLock()
|
|
po := o.plugins[pluginID]
|
|
o.mu.RUnlock()
|
|
if po == nil {
|
|
return BackgroundTaskSummary{}, ErrPluginNotFound
|
|
}
|
|
return po.TriggerTask(taskID, confirmToken)
|
|
}
|
|
|
|
func (o *Operations) DiagnosticPackage(ctx context.Context, plugin PluginRecord, manifest Manifest, handlers []DispatchHandlerSummary, builds []BuildRecord, gc []GCCandidate) ([]byte, DiagnosticPackageSummary, error) {
|
|
snapshot := o.Snapshot(ctx, plugin.ID, handlers, builds, gc)
|
|
operations, _ := o.repo.ListOperations(ctx, plugin.ID, 50)
|
|
body := map[string]any{
|
|
"created_at": time.Now().Unix(),
|
|
"plugin": plugin,
|
|
"manifest": manifest,
|
|
"operations": snapshot,
|
|
"recent_operations": operations,
|
|
"redaction_policy": []string{
|
|
"secret", "token", "password", "session response", "full packet payload",
|
|
},
|
|
}
|
|
data, err := json.MarshalIndent(body, "", " ")
|
|
if err != nil {
|
|
return nil, DiagnosticPackageSummary{}, err
|
|
}
|
|
data = []byte(redactSensitive(string(data)))
|
|
dir := filepath.Join(o.runtimeRoot(), "diagnostics", plugin.ID)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, DiagnosticPackageSummary{}, err
|
|
}
|
|
file := filepath.Join(dir, fmt.Sprintf("%d.json", time.Now().UnixNano()))
|
|
if err := os.WriteFile(file, data, 0600); err != nil {
|
|
return nil, DiagnosticPackageSummary{}, err
|
|
}
|
|
summary, err := o.repo.SaveDiagnostic(ctx, plugin.ID, file, int64(len(data)), []string{"plugin", "manifest", "operations", "recent_operations"})
|
|
if err != nil {
|
|
return nil, DiagnosticPackageSummary{}, err
|
|
}
|
|
return data, summary, nil
|
|
}
|
|
|
|
func (o *Operations) runtimeRoot() string {
|
|
if o.root == "" {
|
|
return os.TempDir()
|
|
}
|
|
return filepath.Join(o.root, "_runtime")
|
|
}
|
|
|
|
func (o *Operations) GCCandidates(ctx context.Context, pluginID string) ([]GCCandidate, error) {
|
|
now := time.Now().Unix()
|
|
var candidates []GCCandidate
|
|
data, err := o.repo.ListPluginData(ctx, pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, record := range data {
|
|
expired := record.ExpiresAt > 0 && record.ExpiresAt <= now
|
|
reason := "plugin_data retained"
|
|
if expired {
|
|
reason = "plugin_data retention expired"
|
|
}
|
|
candidates = append(candidates, GCCandidate{
|
|
Kind: "plugin_data",
|
|
ID: record.Key,
|
|
PluginID: record.PluginID,
|
|
Protected: !expired,
|
|
Reason: reason,
|
|
SizeBytes: record.SizeBytes,
|
|
CreatedAt: record.UpdatedAt,
|
|
Referenced: !expired,
|
|
})
|
|
}
|
|
files, err := o.repo.ListPluginFiles(ctx, pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seenFiles := make(map[string]bool)
|
|
for _, record := range files {
|
|
expired := record.ExpiresAt > 0 && record.ExpiresAt <= now
|
|
filePath := filepath.Join(o.runtimeRoot(), record.PluginID, record.Namespace, filepath.FromSlash(record.Path))
|
|
seenFiles[filePath] = true
|
|
reason := "plugin file retained"
|
|
if expired {
|
|
reason = "plugin file retention expired"
|
|
}
|
|
candidates = append(candidates, GCCandidate{
|
|
Kind: "plugin_file",
|
|
ID: record.Namespace + "/" + record.Path,
|
|
PluginID: record.PluginID,
|
|
Path: filePath,
|
|
Protected: !expired,
|
|
Reason: reason,
|
|
SizeBytes: record.SizeBytes,
|
|
CreatedAt: record.UpdatedAt,
|
|
Referenced: !expired,
|
|
})
|
|
}
|
|
runtimeRoot := o.runtimeRoot()
|
|
_ = filepath.WalkDir(runtimeRoot, func(filePath string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !strings.Contains(filePath, string(filepath.Separator)+"_runtime"+string(filepath.Separator)) {
|
|
return nil
|
|
}
|
|
if seenFiles[filePath] {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(runtimeRoot, filePath)
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
id := rel
|
|
pid := ""
|
|
if len(parts) > 0 {
|
|
pid = parts[0]
|
|
}
|
|
if pluginID != "" && pid != pluginID {
|
|
return nil
|
|
}
|
|
candidates = append(candidates, GCCandidate{
|
|
Kind: "plugin_file_orphan",
|
|
ID: id,
|
|
PluginID: pid,
|
|
Path: filePath,
|
|
Protected: false,
|
|
Reason: "orphaned runtime file",
|
|
SizeBytes: info.Size(),
|
|
CreatedAt: info.ModTime().Unix(),
|
|
})
|
|
return nil
|
|
})
|
|
logs, err := o.repo.RecentLogs(ctx, pluginID, DefaultLogRecentLimit+1)
|
|
if err == nil && len(logs) > DefaultLogRecentLimit {
|
|
for _, item := range logs[DefaultLogRecentLimit:] {
|
|
candidates = append(candidates, GCCandidate{
|
|
Kind: "plugin_log",
|
|
ID: fmt.Sprintf("%s/%d", item.PluginID, item.CreatedAt),
|
|
PluginID: item.PluginID,
|
|
Protected: false,
|
|
Reason: "log summary exceeds recent retention",
|
|
SizeBytes: int64(len(item.Message)),
|
|
CreatedAt: item.CreatedAt,
|
|
})
|
|
}
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func (o *Operations) RunGC(ctx context.Context, actor, pluginID string, dryRun bool) ([]GCCandidate, error) {
|
|
candidates, err := o.GCCandidates(ctx, pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if dryRun {
|
|
_ = o.repo.RecordOperation(ctx, pluginID, "", "plugin_operations_gc", "dry_run", actor, "plugin operations gc dry-run completed", map[string]any{"candidates": len(candidates)})
|
|
return candidates, nil
|
|
}
|
|
var removed []GCCandidate
|
|
for _, candidate := range candidates {
|
|
if candidate.Protected {
|
|
continue
|
|
}
|
|
switch candidate.Kind {
|
|
case "plugin_data":
|
|
_ = o.repo.DeletePluginData(ctx, candidate.PluginID, candidate.ID)
|
|
removed = append(removed, candidate)
|
|
case "plugin_file":
|
|
namespace, name, _ := strings.Cut(candidate.ID, "/")
|
|
_ = os.Remove(candidate.Path)
|
|
_ = o.repo.DeletePluginFile(ctx, candidate.PluginID, namespace, name)
|
|
removed = append(removed, candidate)
|
|
case "plugin_file_orphan":
|
|
_ = os.Remove(candidate.Path)
|
|
removed = append(removed, candidate)
|
|
}
|
|
}
|
|
_ = o.repo.RecordOperation(ctx, pluginID, "", "plugin_operations_gc", "succeeded", actor, "plugin operations gc completed", map[string]any{"removed": len(removed)})
|
|
return removed, nil
|
|
}
|
|
|
|
func (po *PluginOperations) validateEvent(name string, fields map[string]string) (map[string]string, string, error) {
|
|
if !metricNamePattern.MatchString(name) {
|
|
return nil, "invalid_event_name", fmt.Errorf("invalid event name %q", name)
|
|
}
|
|
po.mu.Lock()
|
|
defer po.mu.Unlock()
|
|
allowed, declared := po.eventSchemas[name]
|
|
if !declared {
|
|
return nil, "undeclared_event", fmt.Errorf("event %q is not declared by manifest", name)
|
|
}
|
|
clean, err := sanitizeLabels(fields, allowed)
|
|
if err != nil {
|
|
return clean, "invalid_fields", err
|
|
}
|
|
values := po.eventValues[name]
|
|
if values == nil {
|
|
values = make(map[string]map[string]struct{})
|
|
po.eventValues[name] = values
|
|
}
|
|
for field, value := range clean {
|
|
set := values[field]
|
|
if set == nil {
|
|
set = make(map[string]struct{})
|
|
values[field] = set
|
|
}
|
|
set[value] = struct{}{}
|
|
if len(set) > 64 {
|
|
return clean, "high_cardinality_field", fmt.Errorf("event %q field %q exceeded low-cardinality limit", name, field)
|
|
}
|
|
}
|
|
return clean, "", nil
|
|
}
|
|
|
|
func (po *PluginOperations) validateMetric(name string, labels map[string]string) (map[string]string, string, error) {
|
|
if !metricNamePattern.MatchString(name) {
|
|
return nil, "", fmt.Errorf("invalid metric name %q", name)
|
|
}
|
|
po.mu.Lock()
|
|
defer po.mu.Unlock()
|
|
spec, declared := po.metricSchemas[name]
|
|
if !declared {
|
|
return nil, "", fmt.Errorf("custom metric %q is not declared by manifest", name)
|
|
}
|
|
allowed := make(map[string]bool, len(spec.Labels))
|
|
for _, label := range spec.Labels {
|
|
allowed[label] = true
|
|
}
|
|
clean, err := sanitizeLabels(labels, allowed)
|
|
if err != nil {
|
|
return clean, spec.Type, err
|
|
}
|
|
return clean, spec.Type, nil
|
|
}
|
|
|
|
type pluginLogger struct {
|
|
ops *PluginOperations
|
|
}
|
|
|
|
func (l pluginLogger) Debug(ctx context.Context, message string, fields map[string]string) {
|
|
l.write(ctx, "debug", message, fields)
|
|
}
|
|
|
|
func (l pluginLogger) Info(ctx context.Context, message string, fields map[string]string) {
|
|
l.write(ctx, "info", message, fields)
|
|
}
|
|
|
|
func (l pluginLogger) Warn(ctx context.Context, message string, fields map[string]string) {
|
|
l.write(ctx, "warn", message, fields)
|
|
}
|
|
|
|
func (l pluginLogger) Error(ctx context.Context, message string, fields map[string]string) {
|
|
l.write(ctx, "error", message, fields)
|
|
}
|
|
|
|
func (l pluginLogger) write(ctx context.Context, level, message string, fields map[string]string) {
|
|
if l.ops == nil {
|
|
return
|
|
}
|
|
trace := traceFromContext(ctx)
|
|
clean, _ := sanitizeLabels(fields, nil)
|
|
item := LogSummary{
|
|
PluginID: l.ops.pluginID,
|
|
Level: level,
|
|
Message: redactSensitive(limitString(message, 512)),
|
|
Fields: clean,
|
|
TraceID: trace.TraceID,
|
|
ConnectionID: trace.ConnectionID,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
_ = l.ops.parent.repo.SaveLog(context.Background(), item)
|
|
log.Info().
|
|
Str("plugin", l.ops.pluginID).
|
|
Str("level", level).
|
|
Str("trace_id", trace.TraceID).
|
|
Str("connection_id", trace.ConnectionID).
|
|
Msg(item.Message)
|
|
}
|
|
|
|
type pluginDataStore struct {
|
|
ops *PluginOperations
|
|
}
|
|
|
|
func (s pluginDataStore) Put(ctx context.Context, record api.DataRecord) error {
|
|
if s.ops == nil {
|
|
return errors.New("plugin data store is unavailable")
|
|
}
|
|
key, err := cleanStoreKey(record.Key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(record.Value) > DefaultPluginDataKeyLimit {
|
|
return fmt.Errorf("plugin_data key %q size %d exceeds limit %d", key, len(record.Value), DefaultPluginDataKeyLimit)
|
|
}
|
|
current, _ := s.ops.parent.repo.PluginDataUsage(ctx, s.ops.pluginID)
|
|
old, _, _ := s.ops.parent.repo.GetPluginData(ctx, s.ops.pluginID, key)
|
|
nextUsage := current - old.SizeBytes + int64(len(record.Value))
|
|
if nextUsage > s.ops.dataQuota {
|
|
return fmt.Errorf("plugin_data quota exceeded: %d > %d", nextUsage, s.ops.dataQuota)
|
|
}
|
|
expiresAt := retentionDeadline(record.Retention)
|
|
if record.DataClass == "" {
|
|
record.DataClass = s.ops.dataSpec.DataClass
|
|
}
|
|
if record.SchemaVersion == 0 {
|
|
record.SchemaVersion = s.ops.dataSpec.SchemaVersion
|
|
}
|
|
return s.ops.parent.repo.PutPluginData(ctx, PluginDataSummary{
|
|
PluginID: s.ops.pluginID,
|
|
Key: key,
|
|
SchemaVersion: record.SchemaVersion,
|
|
DataClass: redactSensitive(record.DataClass),
|
|
Exportable: record.Exportable || s.ops.dataSpec.Exportable,
|
|
SizeBytes: int64(len(record.Value)),
|
|
ExpiresAt: expiresAt,
|
|
}, append([]byte(nil), record.Value...))
|
|
}
|
|
|
|
func (s pluginDataStore) Get(ctx context.Context, key string) (api.DataRecord, error) {
|
|
if s.ops == nil {
|
|
return api.DataRecord{}, errors.New("plugin data store is unavailable")
|
|
}
|
|
clean, err := cleanStoreKey(key)
|
|
if err != nil {
|
|
return api.DataRecord{}, err
|
|
}
|
|
record, value, err := s.ops.parent.repo.GetPluginData(ctx, s.ops.pluginID, clean)
|
|
if err != nil {
|
|
return api.DataRecord{}, err
|
|
}
|
|
return api.DataRecord{
|
|
Key: record.Key,
|
|
Value: append([]byte(nil), value...),
|
|
SchemaVersion: record.SchemaVersion,
|
|
DataClass: record.DataClass,
|
|
Exportable: record.Exportable,
|
|
}, nil
|
|
}
|
|
|
|
func (s pluginDataStore) Delete(ctx context.Context, key string) error {
|
|
if s.ops == nil {
|
|
return errors.New("plugin data store is unavailable")
|
|
}
|
|
clean, err := cleanStoreKey(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.ops.parent.repo.DeletePluginData(ctx, s.ops.pluginID, clean)
|
|
}
|
|
|
|
type pluginFileStore struct {
|
|
ops *PluginOperations
|
|
}
|
|
|
|
func (s pluginFileStore) ResourcePath(name string) (string, error) {
|
|
if s.ops == nil {
|
|
return "", errors.New("plugin file store is unavailable")
|
|
}
|
|
clean, err := cleanStorePath(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
artifactDir := filepath.Join(s.ops.parent.root, s.ops.pluginID, s.ops.artifactID)
|
|
resource := filepath.Join(artifactDir, "resources", filepath.FromSlash(clean))
|
|
if !isSubpath(filepath.Join(artifactDir, "resources"), resource) {
|
|
return "", errors.New("unsafe resource path")
|
|
}
|
|
return resource, nil
|
|
}
|
|
|
|
func (s pluginFileStore) Write(ctx context.Context, namespace, name string, data []byte, dataClass string, retention time.Duration) error {
|
|
if s.ops == nil {
|
|
return errors.New("plugin file store is unavailable")
|
|
}
|
|
namespace, spec, err := s.namespaceSpec(namespace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if spec.Readonly {
|
|
return fmt.Errorf("file namespace %q is readonly", namespace)
|
|
}
|
|
clean, err := cleanStorePath(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
quota := spec.QuotaBytes
|
|
if quota <= 0 {
|
|
quota = s.ops.fileQuota
|
|
}
|
|
usage, _ := s.ops.parent.repo.PluginFileUsage(ctx, s.ops.pluginID)
|
|
if usage+int64(len(data)) > quota {
|
|
return fmt.Errorf("plugin file quota exceeded: %d > %d", usage+int64(len(data)), quota)
|
|
}
|
|
root := s.runtimeRoot(namespace)
|
|
target := filepath.Join(root, filepath.FromSlash(clean))
|
|
if !isSubpath(root, target) {
|
|
return errors.New("unsafe file path")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(target, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
if dataClass == "" {
|
|
dataClass = spec.DataClass
|
|
}
|
|
return s.ops.parent.repo.UpsertPluginFile(ctx, PluginFileSummary{
|
|
PluginID: s.ops.pluginID,
|
|
Namespace: namespace,
|
|
Path: clean,
|
|
DataClass: redactSensitive(dataClass),
|
|
SizeBytes: int64(len(data)),
|
|
ExpiresAt: retentionDeadline(retention),
|
|
Readonly: spec.Readonly,
|
|
}, target)
|
|
}
|
|
|
|
func (s pluginFileStore) Read(ctx context.Context, namespace, name string, maxBytes int64) ([]byte, error) {
|
|
_ = ctx
|
|
if s.ops == nil {
|
|
return nil, errors.New("plugin file store is unavailable")
|
|
}
|
|
namespace, _, err := s.namespaceSpec(namespace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clean, err := cleanStorePath(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
root := s.runtimeRoot(namespace)
|
|
target := filepath.Join(root, filepath.FromSlash(clean))
|
|
if !isSubpath(root, target) {
|
|
return nil, errors.New("unsafe file path")
|
|
}
|
|
if maxBytes <= 0 {
|
|
maxBytes = DefaultPluginDataKeyLimit
|
|
}
|
|
file, err := os.Open(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
var buf bytes.Buffer
|
|
if _, err := io.CopyN(&buf, file, maxBytes+1); err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, err
|
|
}
|
|
if int64(buf.Len()) > maxBytes {
|
|
return nil, fmt.Errorf("file %q exceeds max read size %d", clean, maxBytes)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func (s pluginFileStore) Delete(ctx context.Context, namespace, name string) error {
|
|
if s.ops == nil {
|
|
return errors.New("plugin file store is unavailable")
|
|
}
|
|
namespace, spec, err := s.namespaceSpec(namespace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if spec.Readonly {
|
|
return fmt.Errorf("file namespace %q is readonly", namespace)
|
|
}
|
|
clean, err := cleanStorePath(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root := s.runtimeRoot(namespace)
|
|
target := filepath.Join(root, filepath.FromSlash(clean))
|
|
if !isSubpath(root, target) {
|
|
return errors.New("unsafe file path")
|
|
}
|
|
_ = os.Remove(target)
|
|
return s.ops.parent.repo.DeletePluginFile(ctx, s.ops.pluginID, namespace, clean)
|
|
}
|
|
|
|
func (s pluginFileStore) namespaceSpec(namespace string) (string, FileStoreSpec, error) {
|
|
namespace = strings.TrimSpace(namespace)
|
|
if namespace == "" {
|
|
namespace = "data"
|
|
}
|
|
if !metricNamePattern.MatchString(namespace) {
|
|
return "", FileStoreSpec{}, fmt.Errorf("invalid file namespace %q", namespace)
|
|
}
|
|
spec := s.ops.fileSpecs[namespace]
|
|
if spec.Namespace == "" {
|
|
return "", FileStoreSpec{}, fmt.Errorf("file namespace %q is not declared", namespace)
|
|
}
|
|
return namespace, spec, nil
|
|
}
|
|
|
|
func (s pluginFileStore) runtimeRoot(namespace string) string {
|
|
root := s.ops.parent.root
|
|
if root == "" {
|
|
root = os.TempDir()
|
|
}
|
|
return filepath.Join(root, "_runtime", s.ops.pluginID, namespace)
|
|
}
|
|
|
|
type pluginExternalClient struct {
|
|
ops *PluginOperations
|
|
name string
|
|
runtime *externalRuntime
|
|
}
|
|
|
|
func (c pluginExternalClient) DoHTTP(ctx context.Context, req api.ExternalRequest) (api.ExternalResponse, error) {
|
|
if c.ops == nil || c.runtime == nil {
|
|
return api.ExternalResponse{}, errors.New("external client is unavailable")
|
|
}
|
|
spec, err := c.declaredSpec()
|
|
if err != nil {
|
|
return api.ExternalResponse{}, err
|
|
}
|
|
if req.URL == "" {
|
|
req.URL = spec.Endpoint
|
|
}
|
|
if req.Method == "" {
|
|
req.Method = http.MethodGet
|
|
}
|
|
if err := c.beforeRequest(); err != nil {
|
|
return api.ExternalResponse{}, err
|
|
}
|
|
start := time.Now()
|
|
defer c.runtime.inflight.Add(-1)
|
|
|
|
timeout := req.Timeout
|
|
if timeout <= 0 {
|
|
timeout = parseDurationDefault(spec.Timeout, DefaultHandlerTimeout)
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = DefaultHandlerTimeout
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, req.Body)
|
|
if err != nil {
|
|
c.finish(start, "request_error", err)
|
|
return api.ExternalResponse{}, err
|
|
}
|
|
httpReq.Header = req.Header.Clone()
|
|
if spec.Traceparent {
|
|
trace := traceFromContext(ctx)
|
|
if trace.TraceID != "" {
|
|
httpReq.Header.Set("traceparent", "00-"+limitHex(trace.TraceID, 32)+"-0000000000000000-01")
|
|
}
|
|
}
|
|
|
|
attempts := spec.Retry + 1
|
|
if attempts <= 0 {
|
|
attempts = 1
|
|
}
|
|
var lastErr error
|
|
var resp *http.Response
|
|
for i := 0; i < attempts; i++ {
|
|
resp, lastErr = http.DefaultClient.Do(httpReq)
|
|
if lastErr == nil && resp.StatusCode < 500 {
|
|
break
|
|
}
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
}
|
|
if lastErr != nil {
|
|
c.finish(start, "error", lastErr)
|
|
return api.ExternalResponse{}, lastErr
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := readLimited(resp.Body, 1024*1024)
|
|
if err != nil {
|
|
c.finish(start, "read_error", err)
|
|
return api.ExternalResponse{}, err
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
err := fmt.Errorf("external dependency %s returned status %d", c.name, resp.StatusCode)
|
|
c.finish(start, fmt.Sprintf("http_%d", resp.StatusCode), err)
|
|
return api.ExternalResponse{}, err
|
|
}
|
|
c.finish(start, fmt.Sprintf("http_%d", resp.StatusCode), nil)
|
|
c.recordTrace(ctx, start, "http", fmt.Sprintf("%d", resp.StatusCode))
|
|
return api.ExternalResponse{StatusCode: resp.StatusCode, Header: resp.Header.Clone(), Body: body}, nil
|
|
}
|
|
|
|
func (c pluginExternalClient) DialTCP(ctx context.Context, address string, timeout time.Duration) (net.Conn, error) {
|
|
if c.ops == nil || c.runtime == nil {
|
|
return nil, errors.New("external client is unavailable")
|
|
}
|
|
spec, err := c.declaredSpec()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if address == "" {
|
|
address = strings.TrimPrefix(spec.Endpoint, "tcp://")
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = parseDurationDefault(spec.Timeout, DefaultExternalTimeout)
|
|
}
|
|
if err := c.beforeRequest(); err != nil {
|
|
return nil, err
|
|
}
|
|
start := time.Now()
|
|
defer c.runtime.inflight.Add(-1)
|
|
var dialer net.Dialer
|
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
|
if err != nil {
|
|
c.finish(start, "dial_error", err)
|
|
return nil, err
|
|
}
|
|
c.finish(start, "ok", nil)
|
|
c.recordTrace(ctx, start, "tcp", "ok")
|
|
return conn, nil
|
|
}
|
|
|
|
func (c pluginExternalClient) HealthCheck(ctx context.Context) error {
|
|
spec, err := c.declaredSpec()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.HasPrefix(spec.Endpoint, "tcp://") {
|
|
conn, err := c.DialTCP(ctx, strings.TrimPrefix(spec.Endpoint, "tcp://"), parseDurationDefault(spec.Timeout, DefaultExternalTimeout))
|
|
if conn != nil {
|
|
_ = conn.Close()
|
|
}
|
|
return err
|
|
}
|
|
resp, err := c.DoHTTP(ctx, api.ExternalRequest{Method: http.MethodHead, URL: spec.Endpoint})
|
|
if err == nil && resp.StatusCode >= 400 {
|
|
err = fmt.Errorf("health check status %d", resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
c.runtime.mu.Lock()
|
|
c.runtime.lastStatus = "health_failed"
|
|
c.runtime.recentError = redactSensitive(err.Error())
|
|
c.runtime.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c pluginExternalClient) declaredSpec() (ExternalSpec, error) {
|
|
c.ops.mu.Lock()
|
|
defer c.ops.mu.Unlock()
|
|
spec := c.ops.externalSpecs[c.name]
|
|
if spec.Name == "" {
|
|
return ExternalSpec{}, fmt.Errorf("external dependency %q is not declared by manifest", c.name)
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
func (c pluginExternalClient) beforeRequest() error {
|
|
c.runtime.mu.Lock()
|
|
defer c.runtime.mu.Unlock()
|
|
if time.Now().Before(c.runtime.circuitUntil) {
|
|
return fmt.Errorf("external dependency %s circuit is open", c.name)
|
|
}
|
|
c.runtime.requests.Add(1)
|
|
c.runtime.inflight.Add(1)
|
|
return nil
|
|
}
|
|
|
|
func (c pluginExternalClient) finish(start time.Time, status string, err error) {
|
|
duration := time.Since(start)
|
|
c.runtime.durationCount.Add(1)
|
|
c.runtime.durationSumMS.Add(uint64(duration.Milliseconds()))
|
|
c.runtime.mu.Lock()
|
|
defer c.runtime.mu.Unlock()
|
|
c.runtime.lastStatus = status
|
|
c.runtime.lastSeenAt = time.Now().Unix()
|
|
if err != nil {
|
|
c.runtime.errors.Add(1)
|
|
failures := c.runtime.consecutiveFailures.Add(1)
|
|
c.runtime.recentError = redactSensitive(err.Error())
|
|
if failures >= 3 {
|
|
c.runtime.circuitUntil = time.Now().Add(30 * time.Second)
|
|
}
|
|
return
|
|
}
|
|
c.runtime.consecutiveFailures.Store(0)
|
|
c.runtime.recentError = ""
|
|
c.runtime.circuitUntil = time.Time{}
|
|
}
|
|
|
|
func (c pluginExternalClient) recordTrace(ctx context.Context, start time.Time, kind, status string) {
|
|
trace := traceFromContext(ctx)
|
|
_ = c.ops.parent.repo.SaveTrace(context.Background(), TraceSummary{
|
|
PluginID: c.ops.pluginID,
|
|
TraceID: trace.TraceID,
|
|
ConnectionID: trace.ConnectionID,
|
|
HandlerID: trace.HandlerID,
|
|
Operation: "external." + kind + "." + c.name,
|
|
Status: status,
|
|
DurationMS: time.Since(start).Milliseconds(),
|
|
}, map[string]string{
|
|
"endpoint": redactEndpoint(c.runtime.spec.Endpoint),
|
|
"purpose": redactSensitive(c.runtime.spec.Purpose),
|
|
})
|
|
}
|
|
|
|
func (rt *externalRuntime) summary(pluginID, name string) ExternalDependencySummary {
|
|
rt.mu.Lock()
|
|
defer rt.mu.Unlock()
|
|
state := circuitClosed
|
|
if time.Now().Before(rt.circuitUntil) {
|
|
state = circuitOpen
|
|
}
|
|
return ExternalDependencySummary{
|
|
PluginID: pluginID,
|
|
Name: name,
|
|
Endpoint: redactEndpoint(rt.spec.Endpoint),
|
|
Purpose: redactSensitive(rt.spec.Purpose),
|
|
Required: rt.spec.Required,
|
|
FailPolicy: rt.spec.FailPolicy,
|
|
DataClasses: append([]string(nil), rt.spec.DataClasses...),
|
|
Requests: rt.requests.Load(),
|
|
Errors: rt.errors.Load(),
|
|
Inflight: rt.inflight.Load(),
|
|
DurationCount: rt.durationCount.Load(),
|
|
DurationSumMS: rt.durationSumMS.Load(),
|
|
CircuitState: state,
|
|
ConsecutiveFailures: rt.consecutiveFailures.Load(),
|
|
RecentError: rt.recentError,
|
|
LastStatus: rt.lastStatus,
|
|
LastSeenAt: rt.lastSeenAt,
|
|
}
|
|
}
|
|
|
|
func (rt *taskRuntime) summary() BackgroundTaskSummary {
|
|
rt.mu.Lock()
|
|
defer rt.mu.Unlock()
|
|
mode := rt.spec.Mode
|
|
if mode == "" {
|
|
if rt.task.Manual {
|
|
mode = "manual"
|
|
} else {
|
|
mode = "interval"
|
|
}
|
|
}
|
|
return BackgroundTaskSummary{
|
|
PluginID: rt.pluginID,
|
|
TaskID: rt.task.ID,
|
|
Name: rt.task.Name,
|
|
Mode: mode,
|
|
IntervalMS: rt.task.Interval.Milliseconds(),
|
|
RunOnStart: rt.task.RunOnStart,
|
|
TimeoutMS: rt.task.Timeout.Milliseconds(),
|
|
Manual: rt.task.Manual,
|
|
Running: rt.running,
|
|
LastRunAt: rt.lastRunAt,
|
|
NextRunAt: rt.nextRunAt,
|
|
LastDurationMS: rt.lastDurationMS,
|
|
LastError: rt.lastError,
|
|
Skipped: rt.skipped,
|
|
ConsecutiveFailures: rt.consecutiveFailures,
|
|
}
|
|
}
|
|
|
|
func WithTraceContext(ctx context.Context, pluginID, traceID, connectionID, handlerID string) context.Context {
|
|
return context.WithValue(ctx, traceContextKey{}, traceContext{
|
|
PluginID: pluginID,
|
|
TraceID: traceID,
|
|
ConnectionID: connectionID,
|
|
HandlerID: handlerID,
|
|
})
|
|
}
|
|
|
|
func traceFromContext(ctx context.Context) traceContext {
|
|
if ctx == nil {
|
|
return traceContext{}
|
|
}
|
|
trace, _ := ctx.Value(traceContextKey{}).(traceContext)
|
|
return trace
|
|
}
|
|
|
|
func sanitizeLabels(fields map[string]string, allowed map[string]bool) (map[string]string, error) {
|
|
if len(fields) == 0 {
|
|
return map[string]string{}, nil
|
|
}
|
|
if len(fields) > 12 {
|
|
return nil, errors.New("too many fields")
|
|
}
|
|
clean := make(map[string]string, len(fields))
|
|
for key, value := range fields {
|
|
if !metricNamePattern.MatchString(key) {
|
|
return clean, fmt.Errorf("invalid field %q", key)
|
|
}
|
|
if len(allowed) > 0 && !allowed[key] {
|
|
return clean, fmt.Errorf("field %q is not declared", key)
|
|
}
|
|
if isSensitiveName(key) {
|
|
return clean, fmt.Errorf("field %q is sensitive", key)
|
|
}
|
|
if len(value) > DefaultLabelValueMaxBytes {
|
|
return clean, fmt.Errorf("field %q value exceeds low-cardinality size limit", key)
|
|
}
|
|
value = redactSensitive(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
clean[key] = value
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
func cleanStoreKey(key string) (string, error) {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" || !storeKeyPattern.MatchString(key) {
|
|
return "", fmt.Errorf("invalid plugin_data key %q", key)
|
|
}
|
|
if strings.Contains(key, "..") || strings.HasPrefix(key, "/") || strings.Contains(key, `\`) {
|
|
return "", fmt.Errorf("unsafe plugin_data key %q", key)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func cleanStorePath(name string) (string, error) {
|
|
if name == "" || strings.Contains(name, `\`) || strings.HasPrefix(name, "/") {
|
|
return "", fmt.Errorf("unsafe file path %q", name)
|
|
}
|
|
clean := path.Clean(name)
|
|
if clean == "." || clean != name || strings.HasPrefix(clean, "../") || clean == ".." || path.IsAbs(clean) {
|
|
return "", fmt.Errorf("unsafe file path %q", name)
|
|
}
|
|
return clean, nil
|
|
}
|
|
|
|
func isSubpath(root, target string) bool {
|
|
root = filepath.Clean(root)
|
|
target = filepath.Clean(target)
|
|
rel, err := filepath.Rel(root, target)
|
|
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
|
}
|
|
|
|
func retentionDeadline(retention time.Duration) int64 {
|
|
if retention <= 0 {
|
|
return 0
|
|
}
|
|
return time.Now().Add(retention).Unix()
|
|
}
|
|
|
|
func parseDurationDefault(value string, fallback time.Duration) time.Duration {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func deterministicJitter(jitter time.Duration, key string) time.Duration {
|
|
if jitter <= 0 || key == "" {
|
|
return 0
|
|
}
|
|
var sum int64
|
|
for _, ch := range key {
|
|
sum += int64(ch)
|
|
}
|
|
return time.Duration(sum % int64(jitter))
|
|
}
|
|
|
|
func limitString(value string, max int) string {
|
|
if max <= 0 || len(value) <= max {
|
|
return value
|
|
}
|
|
return value[:max]
|
|
}
|
|
|
|
func redactSensitive(value string) string {
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
lower := strings.ToLower(value)
|
|
for _, marker := range []string{"secret", "token", "password", "session", "credential", "authorization", "packet"} {
|
|
if strings.Contains(lower, marker) {
|
|
return "[REDACTED]"
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func redactEndpoint(endpoint string) string {
|
|
if endpoint == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(endpoint, "@") {
|
|
return "[REDACTED]"
|
|
}
|
|
return limitString(endpoint, 256)
|
|
}
|
|
|
|
func randomToken() string {
|
|
var data [16]byte
|
|
if _, err := rand.Read(data[:]); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(data[:])
|
|
}
|
|
|
|
func limitHex(value string, max int) string {
|
|
value = strings.ToLower(value)
|
|
var out strings.Builder
|
|
for _, ch := range value {
|
|
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
|
|
out.WriteRune(ch)
|
|
}
|
|
}
|
|
for out.Len() < max {
|
|
out.WriteByte('0')
|
|
}
|
|
return out.String()[:max]
|
|
}
|
|
|
|
func readLimited(reader io.Reader, max int64) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if _, err := io.CopyN(&buf, reader, max+1); err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, err
|
|
}
|
|
if int64(buf.Len()) > max {
|
|
return nil, fmt.Errorf("response exceeds limit %d", max)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func mergeEventSummaries(current, recent []EventSummary) []EventSummary {
|
|
byKey := make(map[string]EventSummary)
|
|
for _, event := range current {
|
|
byKey[event.PluginID+"\x00"+event.Name] = event
|
|
}
|
|
for _, event := range recent {
|
|
key := event.PluginID + "\x00" + event.Name
|
|
existing := byKey[key]
|
|
existing.PluginID = event.PluginID
|
|
existing.Name = event.Name
|
|
existing.Count += event.Count
|
|
existing.Dropped += event.Dropped
|
|
if event.LastSeenAt > existing.LastSeenAt {
|
|
existing.LastSeenAt = event.LastSeenAt
|
|
existing.Fields = event.Fields
|
|
}
|
|
byKey[key] = existing
|
|
}
|
|
out := make([]EventSummary, 0, len(byKey))
|
|
for _, event := range byKey {
|
|
out = append(out, event)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type noopOperationsLogger struct{}
|
|
|
|
func (noopOperationsLogger) Debug(context.Context, string, map[string]string) {}
|
|
func (noopOperationsLogger) Info(context.Context, string, map[string]string) {}
|
|
func (noopOperationsLogger) Warn(context.Context, string, map[string]string) {}
|
|
func (noopOperationsLogger) Error(context.Context, string, map[string]string) {}
|
|
|
|
type noopOperationsDataStore struct{}
|
|
|
|
func (noopOperationsDataStore) Put(context.Context, api.DataRecord) error { return nil }
|
|
func (noopOperationsDataStore) Get(context.Context, string) (api.DataRecord, error) {
|
|
return api.DataRecord{}, errors.New("plugin data store is unavailable")
|
|
}
|
|
func (noopOperationsDataStore) Delete(context.Context, string) error { return nil }
|
|
|
|
type noopOperationsFileStore struct{}
|
|
|
|
func (noopOperationsFileStore) ResourcePath(string) (string, error) {
|
|
return "", errors.New("plugin file store is unavailable")
|
|
}
|
|
func (noopOperationsFileStore) Write(context.Context, string, string, []byte, string, time.Duration) error {
|
|
return nil
|
|
}
|
|
func (noopOperationsFileStore) Read(context.Context, string, string, int64) ([]byte, error) {
|
|
return nil, errors.New("plugin file store is unavailable")
|
|
}
|
|
func (noopOperationsFileStore) Delete(context.Context, string, string) error { return nil }
|
|
|
|
type noopOperationsExternalClient struct{}
|
|
|
|
func (noopOperationsExternalClient) DoHTTP(context.Context, api.ExternalRequest) (api.ExternalResponse, error) {
|
|
return api.ExternalResponse{}, errors.New("external client is unavailable")
|
|
}
|
|
func (noopOperationsExternalClient) DialTCP(context.Context, string, time.Duration) (net.Conn, error) {
|
|
return nil, errors.New("external client is unavailable")
|
|
}
|
|
func (noopOperationsExternalClient) HealthCheck(context.Context) error { return nil }
|