From d908839ba9e6ea5bd385a90f42e45546101e7b17 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:44:07 -0400 Subject: [PATCH 1/9] feat: add infra.eventbus + stream + consumer module factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 23 (PR 7) — three typed module factories for the workflow-plugin-eventbus gRPC plugin: - module.go / module_test.go: infra.eventbus — validates provider × deploy_target using the existing conformance matrix, registers ClusterConfig in a global registry, resolves broker URI from EVENTBUS__URI or NATS_URL env vars for runtime step use. - stream.go / stream_test.go: infra.eventbus.stream — validates name + subjects, registers StreamConfig for consume and trigger lookups. - consumer.go / consumer_test.go: infra.eventbus.consumer — validates name + stream_name, registers ConsumerConfig for step.eventbus.consume and trigger.eventbus.subscribe. All three types implement sdk.ModuleInstance with compile-time assertions (var _ sdk.ModuleInstance = (*T)(nil)). Zero map[string]any — all config boundaries use typed proto pointers (proto messages embed sync.Mutex via protoimpl.MessageState). 19 unit tests, all passing. Adds github.com/GoCodeAlone/workflow v0.20.1 as a direct dependency for the sdk.ModuleInstance interface. wfctl audit plugins -strict-contracts: module 3/3 strict, step 3/3 strict, trigger 1/1 strict — no findings on workflow-plugin-eventbus. Co-Authored-By: Claude Sonnet 4.6 --- consumer.go | 82 +++++++ consumer_test.go | 84 +++++++ go.mod | 182 ++++++++++++++- go.sum | 589 ++++++++++++++++++++++++++++++++++++++++++++++- module.go | 139 +++++++++++ module_test.go | 152 ++++++++++++ stream.go | 82 +++++++ stream_test.go | 81 +++++++ 8 files changed, 1385 insertions(+), 6 deletions(-) create mode 100644 consumer.go create mode 100644 consumer_test.go create mode 100644 module.go create mode 100644 module_test.go create mode 100644 stream.go create mode 100644 stream_test.go diff --git a/consumer.go b/consumer.go new file mode 100644 index 0000000..88e7f59 --- /dev/null +++ b/consumer.go @@ -0,0 +1,82 @@ +package eventbus + +import ( + "context" + "fmt" + "sync" + + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ── consumer registry ───────────────────────────────────────────────────────── + +var ( + consumerMu sync.RWMutex + consumerRegistry = make(map[string]*eventbusv1.ConsumerConfig) +) + +// RegisterConsumer stores a ConsumerConfig in the global registry under instanceName. +func RegisterConsumer(instanceName string, cfg *eventbusv1.ConsumerConfig) { + consumerMu.Lock() + defer consumerMu.Unlock() + consumerRegistry[instanceName] = cfg +} + +// GetConsumer looks up a ConsumerConfig by instance name. +func GetConsumer(instanceName string) (*eventbusv1.ConsumerConfig, bool) { + consumerMu.RLock() + defer consumerMu.RUnlock() + cfg, ok := consumerRegistry[instanceName] + return cfg, ok +} + +// UnregisterConsumer removes a ConsumerConfig from the registry. +func UnregisterConsumer(instanceName string) { + consumerMu.Lock() + defer consumerMu.Unlock() + delete(consumerRegistry, instanceName) +} + +// ── consumerModule ──────────────────────────────────────────────────────────── + +// consumerModule implements sdk.ModuleInstance for the infra.eventbus.consumer +// module type. It declares a durable JetStream consumer (or Kafka consumer group) +// and registers its config for use by step and trigger modules. +type consumerModule struct { + instanceName string + config *eventbusv1.ConsumerConfig +} + +// Compile-time assertion: consumerModule implements sdk.ModuleInstance. +var _ sdk.ModuleInstance = (*consumerModule)(nil) + +// NewConsumerModule creates a consumerModule from a typed ConsumerConfig proto. +// +// Returns an error if: +// - config.name is empty +// - config.stream_name is empty +func NewConsumerModule(instanceName string, cfg *eventbusv1.ConsumerConfig) (sdk.ModuleInstance, error) { + if cfg.GetName() == "" { + return nil, fmt.Errorf("infra.eventbus.consumer %q: config.name is required", instanceName) + } + if cfg.GetStreamName() == "" { + return nil, fmt.Errorf("infra.eventbus.consumer %q: config.stream_name is required", instanceName) + } + return &consumerModule{instanceName: instanceName, config: cfg}, nil +} + +// Init registers the consumer config in the global registry. +func (m *consumerModule) Init() error { + RegisterConsumer(m.instanceName, m.config) + return nil +} + +// Start is a no-op for the consumer module. +func (m *consumerModule) Start(_ context.Context) error { return nil } + +// Stop unregisters the consumer config from the global registry. +func (m *consumerModule) Stop(_ context.Context) error { + UnregisterConsumer(m.instanceName) + return nil +} diff --git a/consumer_test.go b/consumer_test.go new file mode 100644 index 0000000..ca4a536 --- /dev/null +++ b/consumer_test.go @@ -0,0 +1,84 @@ +package eventbus_test + +import ( + "context" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" +) + +func TestNewConsumerModule_ValidConfig(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + StreamName: "BMW_FULFILLMENT", + } + m, err := eventbus.NewConsumerModule("consumer-valid", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +func TestNewConsumerModule_EmptyName(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + StreamName: "BMW_FULFILLMENT", + } + _, err := eventbus.NewConsumerModule("consumer-empty-name", cfg) + if err == nil { + t.Fatal("expected error for empty consumer name") + } +} + +func TestNewConsumerModule_EmptyStreamName(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + } + _, err := eventbus.NewConsumerModule("consumer-empty-stream", cfg) + if err == nil { + t.Fatal("expected error for empty stream_name") + } +} + +func TestConsumerModule_InitRegistersConfig(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + StreamName: "BMW_FULFILLMENT", + } + m, err := eventbus.NewConsumerModule("consumer-init-reg", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("init: %v", err) + } + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + got, ok := eventbus.GetConsumer("consumer-init-reg") + if !ok { + t.Fatal("consumer not found in registry after Init") + } + if got.GetName() != "bmw-fulfillment-handler" { + t.Errorf("name = %q, want bmw-fulfillment-handler", got.GetName()) + } + if got.GetStreamName() != "BMW_FULFILLMENT" { + t.Errorf("stream_name = %q, want BMW_FULFILLMENT", got.GetStreamName()) + } +} + +func TestConsumerModule_StopUnregisters(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + StreamName: "BMW_FULFILLMENT", + } + m, _ := eventbus.NewConsumerModule("consumer-stop-unreg", cfg) + _ = m.Init() + _ = m.Stop(context.Background()) + + _, ok := eventbus.GetConsumer("consumer-stop-unreg") + if ok { + t.Fatal("consumer still in registry after Stop") + } +} diff --git a/go.mod b/go.mod index d31edb8..db294d8 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,192 @@ module github.com/GoCodeAlone/workflow-plugin-eventbus go 1.26.0 require ( + github.com/GoCodeAlone/workflow v0.20.1 github.com/nats-io/nats.go v1.51.0 google.golang.org/protobuf v1.36.11 ) require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.19.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.61.3 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/DataDog/datadog-go/v5 v5.8.3 // indirect + github.com/GoCodeAlone/go-plugin v1.7.0 // indirect + github.com/GoCodeAlone/modular v1.13.0 // indirect + github.com/GoCodeAlone/modular/modules/auth v1.15.0 // indirect + github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 // indirect + github.com/GoCodeAlone/yaegi v0.17.2 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/IBM/sarama v1.47.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8 // indirect + github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 // indirect + github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 // indirect + github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 // indirect + github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect + github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/smithy-go v1.24.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/digitalocean/godo v1.178.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eapache/go-resiliency v1.7.0 // indirect + github.com/eapache/queue v1.1.0 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/expr-lang/expr v1.17.8 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golobby/cast v1.3.3 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.19.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/hashicorp/vault/api v1.23.0 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/itchyny/gojq v0.12.18 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/api v0.272.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/grpc v1.80.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.47.0 // indirect ) diff --git a/go.sum b/go.sum index a10f4ef..efcfcfa 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,597 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ= +cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= +cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DataDog/datadog-go/v5 v5.8.3 h1:s58CUJ9s8lezjhTNJO/SxkPBv2qZjS3ktpRSqGF5n0s= +github.com/DataDog/datadog-go/v5 v5.8.3/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/GoCodeAlone/go-plugin v1.7.0 h1:EwnhqPlXiNmp85S+MXnKKvm3YlfA6O4NzBb4+GSlEVY= +github.com/GoCodeAlone/go-plugin v1.7.0/go.mod h1:HbGQRZUIa+jbDfjsaZIMJYvrz+LnxL0mJpggfynSTMk= +github.com/GoCodeAlone/modular v1.13.0 h1:UfsegfAmPWcPYQOqYZFsw/LNySBmMDcthiOQe5bscqE= +github.com/GoCodeAlone/modular v1.13.0/go.mod h1:b06Pvgcc8HsGxvl30iO39zGH2jIWz467QEj2+OQL2Do= +github.com/GoCodeAlone/modular/modules/auth v1.15.0 h1:pBSkPSf4k4GLSbUQFLuPa+nFbfoJXGzSz9q89VoapZk= +github.com/GoCodeAlone/modular/modules/auth v1.15.0/go.mod h1:vmIm/LQrcURS2p02YwaELb+CZoHPtT0XB0v1i+sj9i4= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 h1:buYs0TGNbAZgtTq1Qb+dfmTv3+ZOBIN0HbvVBLyNqxE= +github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0/go.mod h1:329flAKmwrPq2JEwu9iltWv6A83H/Di82Xze+kvdKDw= +github.com/GoCodeAlone/workflow v0.20.1 h1:ERYmOhQqL3ak+7gJdq5u6rSi+5J2KMncs6h6xImCeWs= +github.com/GoCodeAlone/workflow v0.20.1/go.mod h1:ypkCqXTwnIPqNjS8h38KZfwzdVsgwgkS1d6Dq0lXyQQ= +github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= +github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318= +github.com/IBM/sarama v1.47.0/go.mod h1:7gLLIU97nznOmA6TX++Qds+DRxH89P2XICY2KAQUzAY= +github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= +github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM= +github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= +github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8 h1:I0AMtyv5tqQ/VNDDalbbujALCWl64TP3F61bBw4U8Qs= +github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.8/go.mod h1:qnrKR+Jzg9NbZqy+YusE7frSZUaYQ7EPJvki4+SwS3U= +github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13 h1:juPaAcploym78WhVwleVHNLPmgURO6gkObC442Hal1s= +github.com/aws/aws-sdk-go-v2/service/applicationautoscaling v1.41.13/go.mod h1:HjgDVqI6lGR0azGz1GKmZTzGHkXuzhKzRUfG/p5Ug8s= +github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12 h1:lQTVEv/YAk8Rw1Yf4XZS/jNNxF9klCN10WcSR3xlMtU= +github.com/aws/aws-sdk-go-v2/service/codebuild v1.68.12/go.mod h1:yoa0R6Xku788EmJYkFiARzJBxt4A3hgFjQPRmMAttr0= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA= +github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0 h1:a5G/TgJNrpuCjZBTf8/PTN0C2B0do/ylaYVynxPSbUQ= +github.com/aws/aws-sdk-go-v2/service/ecs v1.76.0/go.mod h1:QkWmubOYmjj3cHn7A4CoUU7BKJhVeo39Gp6NH7IyhZw= +github.com/aws/aws-sdk-go-v2/service/eks v1.81.2 h1:6c/Jkyx1gYLiZGl6VPjApViaoPiYo7TDWXCMk/ZBq6c= +github.com/aws/aws-sdk-go-v2/service/eks v1.81.2/go.mod h1:xdUh6tdF9A8hc+PE84kmHbF/zsVPNiKnc6oLgulq1Eo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 h1:3m9iJtMtLq75jKRAfw0kapoHUlbzi0CRVigysBN/FHA= +github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4/go.mod h1:O2L6vGm4xacEuN2otHFMgn7yXXlgzFKzxrba0fy/yk8= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 h1:Z+/OLsb85Kpq7TVLCspskqePaf68Tdv6GfmJP4kH6i0= +github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5/go.mod h1:TmxGowuBYwjmHFOsEDxaZdsQE62JJzOmtiWafTi/czg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bufbuild/protocompile v0.10.0 h1:+jW/wnLMLxaCEG8AX9lD0bQ5v9h1RUiMKOBOT5ll9dM= +github.com/bufbuild/protocompile v0.10.0/go.mod h1:G9qQIQo0xZ6Uyj6CMNz0saGmx2so+KONo8/KrELABiY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/digitalocean/godo v1.178.0 h1:+B4xGOaoFwwwpM7TKhoyGHdmFg5eF9zDB1YfOLvNJ2E= +github.com/digitalocean/godo v1.178.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA= +github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho= +github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig= +github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A= +github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+TBDg= +github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= +github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.12.4 h1:ZnT10v2LU2Xcoiy8ek9X6Se4YG8EuMfIfvAEuFVx1Ts= +github.com/nats-io/nats-server/v2 v2.12.4/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg= github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= +google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/module.go b/module.go new file mode 100644 index 0000000..31b80d6 --- /dev/null +++ b/module.go @@ -0,0 +1,139 @@ +// Package eventbus implements the workflow-plugin-eventbus plugin. +// It provides infra.eventbus, infra.eventbus.stream, and infra.eventbus.consumer +// module types plus step and trigger types for durable event-bus integration. +package eventbus + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + "github.com/GoCodeAlone/workflow-plugin-eventbus/providers" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ── cluster registry ────────────────────────────────────────────────────────── + +var ( + clusterMu sync.RWMutex + clusterRegistry = make(map[string]*eventbusv1.ClusterConfig) +) + +// RegisterCluster stores a ClusterConfig in the global registry under instanceName. +func RegisterCluster(instanceName string, cfg *eventbusv1.ClusterConfig) { + clusterMu.Lock() + defer clusterMu.Unlock() + clusterRegistry[instanceName] = cfg +} + +// GetCluster looks up a ClusterConfig by instance name. +func GetCluster(instanceName string) (*eventbusv1.ClusterConfig, bool) { + clusterMu.RLock() + defer clusterMu.RUnlock() + cfg, ok := clusterRegistry[instanceName] + return cfg, ok +} + +// UnregisterCluster removes a ClusterConfig from the registry. +func UnregisterCluster(instanceName string) { + clusterMu.Lock() + defer clusterMu.Unlock() + delete(clusterRegistry, instanceName) +} + +// ── bus URI registry ────────────────────────────────────────────────────────── + +// busURIRegistry stores broker connection URIs keyed by module instance name. +// Steps look up the URI here to obtain a NATS (or Kafka/Kinesis) connection. +var ( + urlMu sync.RWMutex + busURIRegistry = make(map[string]string) +) + +// RegisterBusURI stores a broker URI under instanceName. +func RegisterBusURI(instanceName, uri string) { + urlMu.Lock() + defer urlMu.Unlock() + busURIRegistry[instanceName] = uri +} + +// GetBusURI returns the broker URI for instanceName. +func GetBusURI(instanceName string) (string, bool) { + urlMu.RLock() + defer urlMu.RUnlock() + uri, ok := busURIRegistry[instanceName] + return uri, ok +} + +// UnregisterBusURI removes the URI entry for instanceName. +func UnregisterBusURI(instanceName string) { + urlMu.Lock() + defer urlMu.Unlock() + delete(busURIRegistry, instanceName) +} + +// ── clusterModule ───────────────────────────────────────────────────────────── + +// clusterModule implements sdk.ModuleInstance for the infra.eventbus module type. +// It validates the ClusterConfig, registers it for use by stream, consumer, and +// step modules, and resolves the broker URI from environment variables. +type clusterModule struct { + instanceName string + config *eventbusv1.ClusterConfig +} + +// Compile-time assertion: clusterModule implements sdk.ModuleInstance. +var _ sdk.ModuleInstance = (*clusterModule)(nil) + +// NewClusterModule creates a clusterModule from a typed ClusterConfig proto. +// +// Returns an error if: +// - config.provider is empty or unknown +// - config.deploy_target is empty or unsupported for the given provider +func NewClusterModule(instanceName string, cfg *eventbusv1.ClusterConfig) (sdk.ModuleInstance, error) { + if cfg.GetProvider() == "" { + return nil, fmt.Errorf("infra.eventbus %q: config.provider is required", instanceName) + } + target := providers.DeployTarget(cfg.GetDeployTarget()) + if err := providers.ValidateProviderTarget(cfg.GetProvider(), target); err != nil { + return nil, fmt.Errorf("infra.eventbus %q: %w", instanceName, err) + } + return &clusterModule{instanceName: instanceName, config: cfg}, nil +} + +// Init registers the cluster config and resolves the broker URI. +// +// URI resolution order: +// 1. EVENTBUS__URI (e.g. EVENTBUS_BMW_EVENTBUS_URI) +// 2. NATS_URL (fallback for the nats provider only) +// +// If neither env var is set the URI is not registered; steps will fail at +// execution time if they need a live connection. This is intentional — the +// module remains valid for IaC-only (plan/apply) workflows. +func (m *clusterModule) Init() error { + RegisterCluster(m.instanceName, m.config) + + // Derive instance-specific env var: dashes → underscores, uppercase. + key := strings.ToUpper(strings.ReplaceAll(m.instanceName, "-", "_")) + uri := os.Getenv("EVENTBUS_" + key + "_URI") + if uri == "" && m.config.GetProvider() == "nats" { + uri = os.Getenv("NATS_URL") + } + if uri != "" { + RegisterBusURI(m.instanceName, uri) + } + return nil +} + +// Start is a no-op; NATS connections are established lazily by steps. +func (m *clusterModule) Start(_ context.Context) error { return nil } + +// Stop unregisters the cluster config and URI from global registries. +func (m *clusterModule) Stop(_ context.Context) error { + UnregisterCluster(m.instanceName) + UnregisterBusURI(m.instanceName) + return nil +} diff --git a/module_test.go b/module_test.go new file mode 100644 index 0000000..5f39b29 --- /dev/null +++ b/module_test.go @@ -0,0 +1,152 @@ +package eventbus_test + +import ( + "context" + "os" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" +) + +func TestNewClusterModule_ValidConfig(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, err := eventbus.NewClusterModule("bus-valid", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +func TestNewClusterModule_EmptyProvider(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + DeployTarget: "digitalocean.app_platform", + } + _, err := eventbus.NewClusterModule("bus-empty-provider", cfg) + if err == nil { + t.Fatal("expected error for empty provider") + } +} + +func TestNewClusterModule_EmptyDeployTarget(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + } + _, err := eventbus.NewClusterModule("bus-empty-target", cfg) + if err == nil { + t.Fatal("expected error for empty deploy_target") + } +} + +func TestNewClusterModule_UnsupportedProviderTarget(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "kinesis", + DeployTarget: "digitalocean.app_platform", // kinesis only supports aws.kinesis + } + _, err := eventbus.NewClusterModule("bus-bad-combo", cfg) + if err == nil { + t.Fatal("expected error for unsupported provider × target combination") + } +} + +func TestClusterModule_InitRegistersConfig(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, err := eventbus.NewClusterModule("bus-init-reg", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("init: %v", err) + } + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + got, ok := eventbus.GetCluster("bus-init-reg") + if !ok { + t.Fatal("cluster not found in registry after Init") + } + if got.GetProvider() != "nats" { + t.Errorf("provider = %q, want nats", got.GetProvider()) + } +} + +func TestClusterModule_StopUnregisters(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, _ := eventbus.NewClusterModule("bus-stop-unreg", cfg) + _ = m.Init() + _ = m.Stop(context.Background()) + + _, ok := eventbus.GetCluster("bus-stop-unreg") + if ok { + t.Fatal("cluster still in registry after Stop") + } +} + +func TestClusterModule_InitRegistersURIFromNATSURL(t *testing.T) { + t.Setenv("NATS_URL", "nats://test-host:4222") + + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, _ := eventbus.NewClusterModule("bus-nats-url", cfg) + _ = m.Init() + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + uri, ok := eventbus.GetBusURI("bus-nats-url") + if !ok { + t.Fatal("expected URI in registry when NATS_URL is set") + } + if uri != "nats://test-host:4222" { + t.Errorf("uri = %q, want nats://test-host:4222", uri) + } +} + +func TestClusterModule_InitRegistersURIFromInstanceEnvVar(t *testing.T) { + t.Setenv("EVENTBUS_BMW_EVENTBUS_URI", "nats://bmw-host:4222") + + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, _ := eventbus.NewClusterModule("bmw-eventbus", cfg) + _ = m.Init() + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + uri, ok := eventbus.GetBusURI("bmw-eventbus") + if !ok { + t.Fatal("expected URI in registry when instance env var is set") + } + if uri != "nats://bmw-host:4222" { + t.Errorf("uri = %q, want nats://bmw-host:4222", uri) + } +} + +func TestClusterModule_InitNoURIWhenEnvNotSet(t *testing.T) { + // Ensure neither env var is set. + os.Unsetenv("NATS_URL") + os.Unsetenv("EVENTBUS_BUS_NO_URI_URI") + + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, _ := eventbus.NewClusterModule("bus-no-uri", cfg) + _ = m.Init() + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + _, ok := eventbus.GetBusURI("bus-no-uri") + if ok { + t.Fatal("expected no URI in registry when env vars are absent") + } +} diff --git a/stream.go b/stream.go new file mode 100644 index 0000000..5fbd24f --- /dev/null +++ b/stream.go @@ -0,0 +1,82 @@ +package eventbus + +import ( + "context" + "fmt" + "sync" + + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ── stream registry ─────────────────────────────────────────────────────────── + +var ( + streamMu sync.RWMutex + streamRegistry = make(map[string]*eventbusv1.StreamConfig) +) + +// RegisterStream stores a StreamConfig in the global registry under instanceName. +func RegisterStream(instanceName string, cfg *eventbusv1.StreamConfig) { + streamMu.Lock() + defer streamMu.Unlock() + streamRegistry[instanceName] = cfg +} + +// GetStream looks up a StreamConfig by instance name. +func GetStream(instanceName string) (*eventbusv1.StreamConfig, bool) { + streamMu.RLock() + defer streamMu.RUnlock() + cfg, ok := streamRegistry[instanceName] + return cfg, ok +} + +// UnregisterStream removes a StreamConfig from the registry. +func UnregisterStream(instanceName string) { + streamMu.Lock() + defer streamMu.Unlock() + delete(streamRegistry, instanceName) +} + +// ── streamModule ────────────────────────────────────────────────────────────── + +// streamModule implements sdk.ModuleInstance for the infra.eventbus.stream +// module type. It declares a durable JetStream stream (or Kafka topic) and +// registers its config for use by step and trigger modules. +type streamModule struct { + instanceName string + config *eventbusv1.StreamConfig +} + +// Compile-time assertion: streamModule implements sdk.ModuleInstance. +var _ sdk.ModuleInstance = (*streamModule)(nil) + +// NewStreamModule creates a streamModule from a typed StreamConfig proto. +// +// Returns an error if: +// - config.name is empty +// - config.subjects contains no entries +func NewStreamModule(instanceName string, cfg *eventbusv1.StreamConfig) (sdk.ModuleInstance, error) { + if cfg.GetName() == "" { + return nil, fmt.Errorf("infra.eventbus.stream %q: config.name is required", instanceName) + } + if len(cfg.GetSubjects()) == 0 { + return nil, fmt.Errorf("infra.eventbus.stream %q: config.subjects must contain at least one entry", instanceName) + } + return &streamModule{instanceName: instanceName, config: cfg}, nil +} + +// Init registers the stream config in the global registry. +func (m *streamModule) Init() error { + RegisterStream(m.instanceName, m.config) + return nil +} + +// Start is a no-op for the stream module. +func (m *streamModule) Start(_ context.Context) error { return nil } + +// Stop unregisters the stream config from the global registry. +func (m *streamModule) Stop(_ context.Context) error { + UnregisterStream(m.instanceName) + return nil +} diff --git a/stream_test.go b/stream_test.go new file mode 100644 index 0000000..a2dce10 --- /dev/null +++ b/stream_test.go @@ -0,0 +1,81 @@ +package eventbus_test + +import ( + "context" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" +) + +func TestNewStreamModule_ValidConfig(t *testing.T) { + cfg := &eventbusv1.StreamConfig{ + Name: "BMW_FULFILLMENT", + Subjects: []string{"fulfillment.>"}, + } + m, err := eventbus.NewStreamModule("stream-valid", cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +func TestNewStreamModule_EmptyName(t *testing.T) { + cfg := &eventbusv1.StreamConfig{ + Subjects: []string{"fulfillment.>"}, + } + _, err := eventbus.NewStreamModule("stream-empty-name", cfg) + if err == nil { + t.Fatal("expected error for empty stream name") + } +} + +func TestNewStreamModule_EmptySubjects(t *testing.T) { + cfg := &eventbusv1.StreamConfig{ + Name: "BMW_FULFILLMENT", + } + _, err := eventbus.NewStreamModule("stream-empty-subjects", cfg) + if err == nil { + t.Fatal("expected error for empty subjects") + } +} + +func TestStreamModule_InitRegistersConfig(t *testing.T) { + cfg := &eventbusv1.StreamConfig{ + Name: "BMW_FULFILLMENT", + Subjects: []string{"fulfillment.>"}, + } + m, err := eventbus.NewStreamModule("stream-init-reg", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("init: %v", err) + } + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + got, ok := eventbus.GetStream("stream-init-reg") + if !ok { + t.Fatal("stream not found in registry after Init") + } + if got.GetName() != "BMW_FULFILLMENT" { + t.Errorf("name = %q, want BMW_FULFILLMENT", got.GetName()) + } +} + +func TestStreamModule_StopUnregisters(t *testing.T) { + cfg := &eventbusv1.StreamConfig{ + Name: "BMW_FULFILLMENT", + Subjects: []string{"fulfillment.>"}, + } + m, _ := eventbus.NewStreamModule("stream-stop-unreg", cfg) + _ = m.Init() + _ = m.Stop(context.Background()) + + _, ok := eventbus.GetStream("stream-stop-unreg") + if ok { + t.Fatal("stream still in registry after Stop") + } +} From d738b2650302638846d18f268e0c5ddf9121bda4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:10:18 -0400 Subject: [PATCH 2/9] fix(modules): add TypedModuleProvider factories + NATS conn lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 5 code-reviewer findings on Task 23: Important #1 — Missing sdk.TypedModuleProvider compile-time assertions: Added ClusterModuleFactory, StreamModuleFactory, ConsumerModuleFactory — each a named struct with 'var _ sdk.TypedModuleProvider = (*T)(nil)'. CreateTypedModule unpacks anypb.Any and delegates to the NewXModule constructor. Returns sdk.ErrTypedContractNotHandled for wrong types so the plugin's routing loop can skip. Important #2 — deploy_target not explicitly validated before ValidateProviderTarget: added an explicit empty-string check for config.deploy_target in NewClusterModule, producing a clear error message distinct from the provider × target compatibility error. Important #3 — NATS connection lifecycle: module did not own the connection it declared. Added natsConnCache (sync.Mutex-guarded map[string]*nats.Conn), GetOrDialNATSConn (lazy dial via injectable natsDialFn), RegisterNATSConn / GetNATSConn / closeNATSConn helpers. Stop() now calls closeNATSConn, which closes and evicts the cached connection (nil-guarded, idempotent). natsDialFn is a package-level var so integration tests and unit tests can inject without a real server. Minor #4 — consumer goroutine leak concern: explicit comment in consumerModule.Start documenting that no goroutines are launched — consumption is pull-based, driven by step.eventbus.consume. Minor #5 — factory tests missing: added TypedModuleProvider tests for all three factory types (TypedModuleTypes, WrongType, NilConfig paths). All 28 tests pass. Co-Authored-By: Claude Sonnet 4.6 --- consumer.go | 38 +++++++++++-- consumer_test.go | 31 +++++++++++ module.go | 138 ++++++++++++++++++++++++++++++++++++++++++----- module_test.go | 60 ++++++++++++++++++++- stream.go | 32 ++++++++++- stream_test.go | 31 +++++++++++ 6 files changed, 313 insertions(+), 17 deletions(-) diff --git a/consumer.go b/consumer.go index 88e7f59..83c83b0 100644 --- a/consumer.go +++ b/consumer.go @@ -5,6 +5,8 @@ import ( "fmt" "sync" + "google.golang.org/protobuf/types/known/anypb" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) @@ -38,11 +40,40 @@ func UnregisterConsumer(instanceName string) { delete(consumerRegistry, instanceName) } -// ── consumerModule ──────────────────────────────────────────────────────────── +// ── ConsumerModuleFactory (TypedModuleProvider) ─────────────────────────────── + +// ConsumerModuleFactory implements sdk.TypedModuleProvider for the +// infra.eventbus.consumer module type. +type ConsumerModuleFactory struct{} + +// Compile-time assertion: ConsumerModuleFactory implements sdk.TypedModuleProvider. +var _ sdk.TypedModuleProvider = (*ConsumerModuleFactory)(nil) + +// TypedModuleTypes returns the single module type served by this factory. +func (f *ConsumerModuleFactory) TypedModuleTypes() []string { + return []string{"infra.eventbus.consumer"} +} + +// CreateTypedModule unpacks the typed proto config and delegates to NewConsumerModule. +func (f *ConsumerModuleFactory) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + if typeName != "infra.eventbus.consumer" { + return nil, fmt.Errorf("%w: module type %q", sdk.ErrTypedContractNotHandled, typeName) + } + var cfg eventbusv1.ConsumerConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("infra.eventbus.consumer %q: unmarshal typed config: %w", name, err) + } + } + return NewConsumerModule(name, &cfg) +} + +// ── consumerModule (ModuleInstance) ────────────────────────────────────────── // consumerModule implements sdk.ModuleInstance for the infra.eventbus.consumer // module type. It declares a durable JetStream consumer (or Kafka consumer group) -// and registers its config for use by step and trigger modules. +// and registers its config for use by step and trigger modules. No background +// goroutines are started — consumption is pull-based, driven by step execution. type consumerModule struct { instanceName string config *eventbusv1.ConsumerConfig @@ -72,7 +103,8 @@ func (m *consumerModule) Init() error { return nil } -// Start is a no-op for the consumer module. +// Start is a no-op for the consumer module. Pull-based consumption has no +// background goroutines — the step.eventbus.consume step drives fetch calls. func (m *consumerModule) Start(_ context.Context) error { return nil } // Stop unregisters the consumer config from the global registry. diff --git a/consumer_test.go b/consumer_test.go index ca4a536..d12da62 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -8,6 +8,35 @@ import ( eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" ) +// ── ConsumerModuleFactory (TypedModuleProvider) ─────────────────────────────── + +func TestConsumerModuleFactory_TypedModuleTypes(t *testing.T) { + f := &eventbus.ConsumerModuleFactory{} + types := f.TypedModuleTypes() + if len(types) != 1 || types[0] != "infra.eventbus.consumer" { + t.Errorf("TypedModuleTypes() = %v, want [infra.eventbus.consumer]", types) + } +} + +func TestConsumerModuleFactory_CreateTypedModule_WrongType(t *testing.T) { + f := &eventbus.ConsumerModuleFactory{} + _, err := f.CreateTypedModule("infra.eventbus", "x", nil) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestConsumerModuleFactory_CreateTypedModule_NilConfig(t *testing.T) { + f := &eventbus.ConsumerModuleFactory{} + // nil config → ConsumerConfig zero value → empty name → expect error + _, err := f.CreateTypedModule("infra.eventbus.consumer", "consumer-factory-nil", nil) + if err == nil { + t.Fatal("expected error from NewConsumerModule for empty name") + } +} + +// ── NewConsumerModule validation ────────────────────────────────────────────── + func TestNewConsumerModule_ValidConfig(t *testing.T) { cfg := &eventbusv1.ConsumerConfig{ Name: "bmw-fulfillment-handler", @@ -42,6 +71,8 @@ func TestNewConsumerModule_EmptyStreamName(t *testing.T) { } } +// ── consumerModule lifecycle ────────────────────────────────────────────────── + func TestConsumerModule_InitRegistersConfig(t *testing.T) { cfg := &eventbusv1.ConsumerConfig{ Name: "bmw-fulfillment-handler", diff --git a/module.go b/module.go index 31b80d6..33ce25f 100644 --- a/module.go +++ b/module.go @@ -9,6 +9,10 @@ import ( "os" "strings" "sync" + "time" + + "github.com/nats-io/nats.go" + "google.golang.org/protobuf/types/known/anypb" eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" "github.com/GoCodeAlone/workflow-plugin-eventbus/providers" @@ -47,10 +51,10 @@ func UnregisterCluster(instanceName string) { // ── bus URI registry ────────────────────────────────────────────────────────── // busURIRegistry stores broker connection URIs keyed by module instance name. -// Steps look up the URI here to obtain a NATS (or Kafka/Kinesis) connection. +// Steps look up the URI here via GetOrDialNATSConn to obtain a live connection. var ( - urlMu sync.RWMutex - busURIRegistry = make(map[string]string) + urlMu sync.RWMutex + busURIRegistry = make(map[string]string) ) // RegisterBusURI stores a broker URI under instanceName. @@ -75,11 +79,116 @@ func UnregisterBusURI(instanceName string) { delete(busURIRegistry, instanceName) } -// ── clusterModule ───────────────────────────────────────────────────────────── +// ── NATS connection cache ───────────────────────────────────────────────────── + +// natsConnCache holds one live *nats.Conn per bus instance name. +// Connections are created lazily on the first call to GetOrDialNATSConn. +// Module.Stop() closes and evicts the entry via closeNATSConn. +var ( + connCacheMu sync.Mutex + natsConnCache = make(map[string]*nats.Conn) +) + +// RegisterNATSConn stores a live connection under instanceName. Exported so that +// integration tests and the trigger can pre-populate the cache. +func RegisterNATSConn(instanceName string, conn *nats.Conn) { + connCacheMu.Lock() + defer connCacheMu.Unlock() + natsConnCache[instanceName] = conn +} + +// GetNATSConn returns the cached *nats.Conn for instanceName, or false if absent. +func GetNATSConn(instanceName string) (*nats.Conn, bool) { + connCacheMu.Lock() + defer connCacheMu.Unlock() + conn, ok := natsConnCache[instanceName] + return conn, ok +} + +// GetOrDialNATSConn returns the cached NATS connection for instanceName, dialing +// a new one (via natsDialFn) if no live connection is cached. Returns an error if +// no URI is registered for instanceName or the dial fails. +func GetOrDialNATSConn(instanceName string) (*nats.Conn, error) { + connCacheMu.Lock() + defer connCacheMu.Unlock() + + if conn, ok := natsConnCache[instanceName]; ok && conn.IsConnected() { + return conn, nil + } + // Stale or missing — evict and re-dial. + delete(natsConnCache, instanceName) + + uri, ok := GetBusURI(instanceName) + if !ok || uri == "" { + key := strings.ToUpper(strings.ReplaceAll(instanceName, "-", "_")) + return nil, fmt.Errorf( + "infra.eventbus: no URI registered for bus %q; set EVENTBUS_%s_URI or NATS_URL", + instanceName, key) + } + nc, err := natsDialFn(uri) + if err != nil { + return nil, fmt.Errorf("infra.eventbus: dial NATS for bus %q at %s: %w", instanceName, uri, err) + } + natsConnCache[instanceName] = nc + return nc, nil +} + +// closeNATSConn closes the cached connection for instanceName and evicts it from +// the cache. It is idempotent — a missing or nil entry is not an error. +func closeNATSConn(instanceName string) { + connCacheMu.Lock() + defer connCacheMu.Unlock() + if conn, ok := natsConnCache[instanceName]; ok { + if conn != nil { + conn.Close() + } + delete(natsConnCache, instanceName) + } +} + +// natsDialFn is the function used to create NATS connections. Tests may replace +// this package-level variable to inject a mock without a real NATS server. +var natsDialFn = func(uri string) (*nats.Conn, error) { + return nats.Connect(uri, + nats.MaxReconnects(-1), + nats.ReconnectWait(2*time.Second), + nats.Timeout(5*time.Second), + ) +} + +// ── ClusterModuleFactory (TypedModuleProvider) ──────────────────────────────── + +// ClusterModuleFactory implements sdk.TypedModuleProvider for the infra.eventbus +// module type. The plugin wires this factory into CreateTypedModule. +type ClusterModuleFactory struct{} + +// Compile-time assertion: ClusterModuleFactory implements sdk.TypedModuleProvider. +var _ sdk.TypedModuleProvider = (*ClusterModuleFactory)(nil) + +// TypedModuleTypes returns the single module type served by this factory. +func (f *ClusterModuleFactory) TypedModuleTypes() []string { + return []string{"infra.eventbus"} +} + +// CreateTypedModule unpacks the typed proto config and delegates to NewClusterModule. +func (f *ClusterModuleFactory) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + if typeName != "infra.eventbus" { + return nil, fmt.Errorf("%w: module type %q", sdk.ErrTypedContractNotHandled, typeName) + } + var cfg eventbusv1.ClusterConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("infra.eventbus %q: unmarshal typed config: %w", name, err) + } + } + return NewClusterModule(name, &cfg) +} + +// ── clusterModule (ModuleInstance) ─────────────────────────────────────────── // clusterModule implements sdk.ModuleInstance for the infra.eventbus module type. -// It validates the ClusterConfig, registers it for use by stream, consumer, and -// step modules, and resolves the broker URI from environment variables. +// It validates the ClusterConfig, registers the config and broker URI on Init(), +// and closes the cached NATS connection on Stop(). type clusterModule struct { instanceName string config *eventbusv1.ClusterConfig @@ -97,6 +206,9 @@ func NewClusterModule(instanceName string, cfg *eventbusv1.ClusterConfig) (sdk.M if cfg.GetProvider() == "" { return nil, fmt.Errorf("infra.eventbus %q: config.provider is required", instanceName) } + if cfg.GetDeployTarget() == "" { + return nil, fmt.Errorf("infra.eventbus %q: config.deploy_target is required", instanceName) + } target := providers.DeployTarget(cfg.GetDeployTarget()) if err := providers.ValidateProviderTarget(cfg.GetProvider(), target); err != nil { return nil, fmt.Errorf("infra.eventbus %q: %w", instanceName, err) @@ -110,9 +222,9 @@ func NewClusterModule(instanceName string, cfg *eventbusv1.ClusterConfig) (sdk.M // 1. EVENTBUS__URI (e.g. EVENTBUS_BMW_EVENTBUS_URI) // 2. NATS_URL (fallback for the nats provider only) // -// If neither env var is set the URI is not registered; steps will fail at -// execution time if they need a live connection. This is intentional — the -// module remains valid for IaC-only (plan/apply) workflows. +// If neither env var is set the URI is not registered. Steps that need a live +// connection will fail at execution time with a descriptive error. This is +// intentional — the module remains valid for IaC-only (plan/apply) workflows. func (m *clusterModule) Init() error { RegisterCluster(m.instanceName, m.config) @@ -128,12 +240,14 @@ func (m *clusterModule) Init() error { return nil } -// Start is a no-op; NATS connections are established lazily by steps. +// Start is a no-op; NATS connections are established lazily by GetOrDialNATSConn. func (m *clusterModule) Start(_ context.Context) error { return nil } -// Stop unregisters the cluster config and URI from global registries. +// Stop closes the cached NATS connection (if any) and unregisters the cluster +// config and URI from global registries. func (m *clusterModule) Stop(_ context.Context) error { - UnregisterCluster(m.instanceName) + closeNATSConn(m.instanceName) // drain + close cached *nats.Conn, idempotent UnregisterBusURI(m.instanceName) + UnregisterCluster(m.instanceName) return nil } diff --git a/module_test.go b/module_test.go index 5f39b29..94f0394 100644 --- a/module_test.go +++ b/module_test.go @@ -9,6 +9,35 @@ import ( eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" ) +// ── ClusterModuleFactory (TypedModuleProvider) ──────────────────────────────── + +func TestClusterModuleFactory_TypedModuleTypes(t *testing.T) { + f := &eventbus.ClusterModuleFactory{} + types := f.TypedModuleTypes() + if len(types) != 1 || types[0] != "infra.eventbus" { + t.Errorf("TypedModuleTypes() = %v, want [infra.eventbus]", types) + } +} + +func TestClusterModuleFactory_CreateTypedModule_WrongType(t *testing.T) { + f := &eventbus.ClusterModuleFactory{} + _, err := f.CreateTypedModule("infra.eventbus.stream", "x", nil) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestClusterModuleFactory_CreateTypedModule_NilConfig(t *testing.T) { + f := &eventbus.ClusterModuleFactory{} + // nil config → ClusterConfig zero value → empty provider → expect error + _, err := f.CreateTypedModule("infra.eventbus", "bus-factory-nil", nil) + if err == nil { + t.Fatal("expected error from NewClusterModule for empty provider") + } +} + +// ── NewClusterModule validation ─────────────────────────────────────────────── + func TestNewClusterModule_ValidConfig(t *testing.T) { cfg := &eventbusv1.ClusterConfig{ Provider: "nats", @@ -54,6 +83,8 @@ func TestNewClusterModule_UnsupportedProviderTarget(t *testing.T) { } } +// ── clusterModule lifecycle ─────────────────────────────────────────────────── + func TestClusterModule_InitRegistersConfig(t *testing.T) { cfg := &eventbusv1.ClusterConfig{ Provider: "nats", @@ -133,7 +164,6 @@ func TestClusterModule_InitRegistersURIFromInstanceEnvVar(t *testing.T) { } func TestClusterModule_InitNoURIWhenEnvNotSet(t *testing.T) { - // Ensure neither env var is set. os.Unsetenv("NATS_URL") os.Unsetenv("EVENTBUS_BUS_NO_URI_URI") @@ -150,3 +180,31 @@ func TestClusterModule_InitNoURIWhenEnvNotSet(t *testing.T) { t.Fatal("expected no URI in registry when env vars are absent") } } + +// TestClusterModule_StopEvictsNATSConn verifies that Stop() evicts a connection +// pre-seeded into the cache (simulating a step or trigger that dialled on behalf +// of the module). Wire-level close behaviour is exercised by the integration test; +// here we only verify cache eviction using a nil sentinel entry. +func TestClusterModule_StopEvictsNATSConn(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, _ := eventbus.NewClusterModule("bus-conn-evict", cfg) + _ = m.Init() + + // Pre-seed with a nil sentinel (nil is safe: closeNATSConn guards against nil). + eventbus.RegisterNATSConn("bus-conn-evict", nil) + + _, inCache := eventbus.GetNATSConn("bus-conn-evict") + if !inCache { + t.Fatal("expected sentinel in cache before Stop") + } + + _ = m.Stop(context.Background()) + + _, inCacheAfter := eventbus.GetNATSConn("bus-conn-evict") + if inCacheAfter { + t.Fatal("expected sentinel evicted from cache after Stop") + } +} diff --git a/stream.go b/stream.go index 5fbd24f..6a57b98 100644 --- a/stream.go +++ b/stream.go @@ -5,6 +5,8 @@ import ( "fmt" "sync" + "google.golang.org/protobuf/types/known/anypb" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) @@ -38,7 +40,35 @@ func UnregisterStream(instanceName string) { delete(streamRegistry, instanceName) } -// ── streamModule ────────────────────────────────────────────────────────────── +// ── StreamModuleFactory (TypedModuleProvider) ───────────────────────────────── + +// StreamModuleFactory implements sdk.TypedModuleProvider for the +// infra.eventbus.stream module type. +type StreamModuleFactory struct{} + +// Compile-time assertion: StreamModuleFactory implements sdk.TypedModuleProvider. +var _ sdk.TypedModuleProvider = (*StreamModuleFactory)(nil) + +// TypedModuleTypes returns the single module type served by this factory. +func (f *StreamModuleFactory) TypedModuleTypes() []string { + return []string{"infra.eventbus.stream"} +} + +// CreateTypedModule unpacks the typed proto config and delegates to NewStreamModule. +func (f *StreamModuleFactory) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + if typeName != "infra.eventbus.stream" { + return nil, fmt.Errorf("%w: module type %q", sdk.ErrTypedContractNotHandled, typeName) + } + var cfg eventbusv1.StreamConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("infra.eventbus.stream %q: unmarshal typed config: %w", name, err) + } + } + return NewStreamModule(name, &cfg) +} + +// ── streamModule (ModuleInstance) ──────────────────────────────────────────── // streamModule implements sdk.ModuleInstance for the infra.eventbus.stream // module type. It declares a durable JetStream stream (or Kafka topic) and diff --git a/stream_test.go b/stream_test.go index a2dce10..ea12e1c 100644 --- a/stream_test.go +++ b/stream_test.go @@ -8,6 +8,35 @@ import ( eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" ) +// ── StreamModuleFactory (TypedModuleProvider) ───────────────────────────────── + +func TestStreamModuleFactory_TypedModuleTypes(t *testing.T) { + f := &eventbus.StreamModuleFactory{} + types := f.TypedModuleTypes() + if len(types) != 1 || types[0] != "infra.eventbus.stream" { + t.Errorf("TypedModuleTypes() = %v, want [infra.eventbus.stream]", types) + } +} + +func TestStreamModuleFactory_CreateTypedModule_WrongType(t *testing.T) { + f := &eventbus.StreamModuleFactory{} + _, err := f.CreateTypedModule("infra.eventbus", "x", nil) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestStreamModuleFactory_CreateTypedModule_NilConfig(t *testing.T) { + f := &eventbus.StreamModuleFactory{} + // nil config → StreamConfig zero value → empty name → expect error + _, err := f.CreateTypedModule("infra.eventbus.stream", "stream-factory-nil", nil) + if err == nil { + t.Fatal("expected error from NewStreamModule for empty name") + } +} + +// ── NewStreamModule validation ──────────────────────────────────────────────── + func TestNewStreamModule_ValidConfig(t *testing.T) { cfg := &eventbusv1.StreamConfig{ Name: "BMW_FULFILLMENT", @@ -42,6 +71,8 @@ func TestNewStreamModule_EmptySubjects(t *testing.T) { } } +// ── streamModule lifecycle ──────────────────────────────────────────────────── + func TestStreamModule_InitRegistersConfig(t *testing.T) { cfg := &eventbusv1.StreamConfig{ Name: "BMW_FULFILLMENT", From e9a885fb3767e58e52d3ea6dff14eb3931dd3d31 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:15:22 -0400 Subject: [PATCH 3/9] fix(modules): address 2 blockers + 1 minor from second review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCKER-A (module_test.go): Replace bare os.Unsetenv calls in TestClusterModule_InitNoURIWhenEnvNotSet with LookupEnv + t.Cleanup restore pattern. Bare Unsetenv permanently cleared NATS_URL for all subsequent tests in the run; the cleanup now restores any pre-existing value on exit. BLOCKER-B (module.go): Eliminate connCacheMu→urlMu nested lock ordering in GetOrDialNATSConn by extracting the URI lookup between the fast-path unlock and the slow-path re-lock. Pattern: 1. Lock, check cache, unlock (fast path — no urlMu touch). 2. GetBusURI with no lock held (urlMu only, no nesting). 3. Dial with no lock held. 4. Lock, double-check (another goroutine may have won), insert or discard the redundant connection, unlock. Added lock-ordering doc comment to the NATS cache section. MINOR-4 (all three *_test.go): Add if err != nil { t.Fatalf } after NewXModule constructors in StopUnregisters and StopEvictsNATSConn tests so a nil module doesn't panic on the next line. All 28 tests pass, -race clean. Co-Authored-By: Claude Sonnet 4.6 --- consumer_test.go | 5 ++++- module.go | 29 +++++++++++++++++++++++------ module_test.go | 23 +++++++++++++++++++---- stream_test.go | 5 ++++- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/consumer_test.go b/consumer_test.go index d12da62..2a0de6e 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -104,7 +104,10 @@ func TestConsumerModule_StopUnregisters(t *testing.T) { Name: "bmw-fulfillment-handler", StreamName: "BMW_FULFILLMENT", } - m, _ := eventbus.NewConsumerModule("consumer-stop-unreg", cfg) + m, err := eventbus.NewConsumerModule("consumer-stop-unreg", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } _ = m.Init() _ = m.Stop(context.Background()) diff --git a/module.go b/module.go index 33ce25f..602a57b 100644 --- a/module.go +++ b/module.go @@ -108,27 +108,44 @@ func GetNATSConn(instanceName string) (*nats.Conn, bool) { // GetOrDialNATSConn returns the cached NATS connection for instanceName, dialing // a new one (via natsDialFn) if no live connection is cached. Returns an error if // no URI is registered for instanceName or the dial fails. +// +// Lock ordering: connCacheMu and urlMu (held inside GetBusURI) are never held +// simultaneously. The URI lookup happens between the fast-path unlock and the +// slow-path re-lock so that no nested acquisition is possible. func GetOrDialNATSConn(instanceName string) (*nats.Conn, error) { + // Fast path: return cached live connection without touching urlMu. connCacheMu.Lock() - defer connCacheMu.Unlock() - - if conn, ok := natsConnCache[instanceName]; ok && conn.IsConnected() { + conn, cached := natsConnCache[instanceName] + if cached && conn != nil && conn.IsConnected() { + connCacheMu.Unlock() return conn, nil } - // Stale or missing — evict and re-dial. + // Evict stale entry (closed or nil) while we hold the lock. delete(natsConnCache, instanceName) + connCacheMu.Unlock() - uri, ok := GetBusURI(instanceName) - if !ok || uri == "" { + // Slow path: resolve URI with no lock held (avoids connCacheMu→urlMu nesting). + uri, uriOk := GetBusURI(instanceName) + if !uriOk || uri == "" { key := strings.ToUpper(strings.ReplaceAll(instanceName, "-", "_")) return nil, fmt.Errorf( "infra.eventbus: no URI registered for bus %q; set EVENTBUS_%s_URI or NATS_URL", instanceName, key) } + + // Dial outside any lock — natsDialFn may block for the connection timeout. nc, err := natsDialFn(uri) if err != nil { return nil, fmt.Errorf("infra.eventbus: dial NATS for bus %q at %s: %w", instanceName, uri, err) } + + // Re-acquire to insert; check again for a race where another goroutine dialled first. + connCacheMu.Lock() + defer connCacheMu.Unlock() + if existing, ok := natsConnCache[instanceName]; ok && existing != nil && existing.IsConnected() { + nc.Close() // discard the redundant connection we just dialled + return existing, nil + } natsConnCache[instanceName] = nc return nc, nil } diff --git a/module_test.go b/module_test.go index 94f0394..b65b417 100644 --- a/module_test.go +++ b/module_test.go @@ -113,7 +113,10 @@ func TestClusterModule_StopUnregisters(t *testing.T) { Provider: "nats", DeployTarget: "digitalocean.app_platform", } - m, _ := eventbus.NewClusterModule("bus-stop-unreg", cfg) + m, err := eventbus.NewClusterModule("bus-stop-unreg", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } _ = m.Init() _ = m.Stop(context.Background()) @@ -164,8 +167,17 @@ func TestClusterModule_InitRegistersURIFromInstanceEnvVar(t *testing.T) { } func TestClusterModule_InitNoURIWhenEnvNotSet(t *testing.T) { - os.Unsetenv("NATS_URL") - os.Unsetenv("EVENTBUS_BUS_NO_URI_URI") + // Unset both vars only for the duration of this test, restoring any + // pre-existing value on exit. Bare os.Unsetenv would permanently remove + // NATS_URL from the process environment, breaking tests that run after. + if prev, ok := os.LookupEnv("NATS_URL"); ok { + t.Cleanup(func() { os.Setenv("NATS_URL", prev) }) + os.Unsetenv("NATS_URL") + } + if prev, ok := os.LookupEnv("EVENTBUS_BUS_NO_URI_URI"); ok { + t.Cleanup(func() { os.Setenv("EVENTBUS_BUS_NO_URI_URI", prev) }) + os.Unsetenv("EVENTBUS_BUS_NO_URI_URI") + } cfg := &eventbusv1.ClusterConfig{ Provider: "nats", @@ -190,7 +202,10 @@ func TestClusterModule_StopEvictsNATSConn(t *testing.T) { Provider: "nats", DeployTarget: "digitalocean.app_platform", } - m, _ := eventbus.NewClusterModule("bus-conn-evict", cfg) + m, err := eventbus.NewClusterModule("bus-conn-evict", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } _ = m.Init() // Pre-seed with a nil sentinel (nil is safe: closeNATSConn guards against nil). diff --git a/stream_test.go b/stream_test.go index ea12e1c..af7c496 100644 --- a/stream_test.go +++ b/stream_test.go @@ -101,7 +101,10 @@ func TestStreamModule_StopUnregisters(t *testing.T) { Name: "BMW_FULFILLMENT", Subjects: []string{"fulfillment.>"}, } - m, _ := eventbus.NewStreamModule("stream-stop-unreg", cfg) + m, err := eventbus.NewStreamModule("stream-stop-unreg", cfg) + if err != nil { + t.Fatalf("create: %v", err) + } _ = m.Init() _ = m.Stop(context.Background()) From ff50c452f3ff1617b8859e22be60b64693c1feee Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:39:42 -0400 Subject: [PATCH 4/9] feat(steps): add step.eventbus.publish/consume/ack with typed proto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - steps/publish.go: PublishHandler uses JetStream PublishMsg, returns broker sequence + RFC3339 acked_at timestamp - steps/consume.go: ConsumeHandler binds to existing durable JetStream consumer via BindStream; maps msg.Reply→ack_token, Metadata→sequence and published_at; ErrTimeout is treated as empty batch (not an error) - steps/ack.go: AckHandler publishes empty payload to ack_token (the JetStream reply subject), acknowledging the message - steps/factories.go: TypedStepFactory vars + All() slice for plugin server registration; compile-time TypedStepProvider assertions - module.go: add DefaultBusConn() — single-bus helper for step handlers - consumer.go: add GetConsumerByName() — resolve ConsumerConfig by durable name (cfg.name) for step.eventbus.consume lookup All 9 new step unit tests pass alongside existing 24 module tests. Co-Authored-By: Claude Sonnet 4.6 --- consumer.go | 15 ++++++ module.go | 20 ++++++++ steps/ack.go | 36 +++++++++++++++ steps/ack_test.go | 62 +++++++++++++++++++++++++ steps/consume.go | 103 ++++++++++++++++++++++++++++++++++++++++++ steps/consume_test.go | 69 ++++++++++++++++++++++++++++ steps/factories.go | 48 ++++++++++++++++++++ steps/publish.go | 72 +++++++++++++++++++++++++++++ steps/publish_test.go | 62 +++++++++++++++++++++++++ 9 files changed, 487 insertions(+) create mode 100644 steps/ack.go create mode 100644 steps/ack_test.go create mode 100644 steps/consume.go create mode 100644 steps/consume_test.go create mode 100644 steps/factories.go create mode 100644 steps/publish.go create mode 100644 steps/publish_test.go diff --git a/consumer.go b/consumer.go index 83c83b0..e216922 100644 --- a/consumer.go +++ b/consumer.go @@ -40,6 +40,21 @@ func UnregisterConsumer(instanceName string) { delete(consumerRegistry, instanceName) } +// GetConsumerByName looks up a ConsumerConfig by its durable consumer name +// (cfg.name), iterating all registered instances. This is used by +// step.eventbus.consume to resolve the consumer config from the durable name +// supplied in ConsumeRequest.consumer. +func GetConsumerByName(durableName string) (*eventbusv1.ConsumerConfig, bool) { + consumerMu.RLock() + defer consumerMu.RUnlock() + for _, cfg := range consumerRegistry { + if cfg.GetName() == durableName { + return cfg, true + } + } + return nil, false +} + // ── ConsumerModuleFactory (TypedModuleProvider) ─────────────────────────────── // ConsumerModuleFactory implements sdk.TypedModuleProvider for the diff --git a/module.go b/module.go index 602a57b..6819b97 100644 --- a/module.go +++ b/module.go @@ -173,6 +173,26 @@ var natsDialFn = func(uri string) (*nats.Conn, error) { ) } +// DefaultBusConn returns a live NATS connection for the first registered +// infra.eventbus module. Suitable for single-bus workflow deployments (e.g. the +// BMW pilot). For multi-bus workflows, use GetOrDialNATSConn(instanceName) +// directly. +func DefaultBusConn() (*nats.Conn, error) { + clusterMu.RLock() + var first string + for name := range clusterRegistry { + first = name + break + } + clusterMu.RUnlock() + if first == "" { + return nil, fmt.Errorf( + "infra.eventbus: no bus module registered; add an infra.eventbus module to your workflow config", + ) + } + return GetOrDialNATSConn(first) +} + // ── ClusterModuleFactory (TypedModuleProvider) ──────────────────────────────── // ClusterModuleFactory implements sdk.TypedModuleProvider for the infra.eventbus diff --git a/steps/ack.go b/steps/ack.go new file mode 100644 index 0000000..5a9b5c1 --- /dev/null +++ b/steps/ack.go @@ -0,0 +1,36 @@ +package steps + +import ( + "context" + "fmt" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// AckHandler implements step.eventbus.ack. It publishes an empty message to the +// JetStream reply subject (ack_token) supplied by step.eventbus.consume, which +// causes the broker to mark the message as acknowledged. +func AckHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.AckRequest], +) (*sdk.TypedStepResult[*eventbusv1.AckResponse], error) { + if req.Input.GetAckToken() == "" { + return nil, fmt.Errorf("step.eventbus.ack: ack_token is required") + } + + nc, err := eventbus.DefaultBusConn() + if err != nil { + return nil, fmt.Errorf("step.eventbus.ack: get bus connection: %w", err) + } + + if err := nc.Publish(req.Input.GetAckToken(), nil); err != nil { + return nil, fmt.Errorf("step.eventbus.ack: publish ack: %w", err) + } + + return &sdk.TypedStepResult[*eventbusv1.AckResponse]{ + Output: &eventbusv1.AckResponse{}, + }, nil +} diff --git a/steps/ack_test.go b/steps/ack_test.go new file mode 100644 index 0000000..da57e81 --- /dev/null +++ b/steps/ack_test.go @@ -0,0 +1,62 @@ +package steps_test + +import ( + "context" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + "github.com/GoCodeAlone/workflow-plugin-eventbus/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestAckHandler_EmptyToken(t *testing.T) { + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.AckRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.AckRequest{}, + } + _, err := steps.AckHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error for empty ack_token") + } +} + +func TestAckHandler_NoBusRegistered(t *testing.T) { + // The steps test binary has no bus registered in this scope; DefaultBusConn + // should return a descriptive error before attempting to publish the ack. + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.AckRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.AckRequest{AckToken: "_INBOX.sometoken"}, + } + _, err := steps.AckHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error when no bus is registered") + } +} + +// TestAckHandler_BusRegisteredNoURI verifies that when a cluster is registered +// but has no URI, GetOrDialNATSConn returns a descriptive error. +func TestAckHandler_BusRegisteredNoURI(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, err := eventbus.NewClusterModule("ack-test-bus", cfg) + if err != nil { + t.Fatalf("create module: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("init: %v", err) + } + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.AckRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.AckRequest{AckToken: "_INBOX.sometoken"}, + } + _, err = steps.AckHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error when bus has no URI") + } +} diff --git a/steps/consume.go b/steps/consume.go new file mode 100644 index 0000000..1e6e26f --- /dev/null +++ b/steps/consume.go @@ -0,0 +1,103 @@ +package steps + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/nats-io/nats.go" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// ConsumeHandler implements step.eventbus.consume. It binds to an existing +// JetStream durable consumer (looked up by the durable name in +// ConsumeRequest.consumer) and fetches up to batch_size messages, waiting at +// most max_wait for the batch to fill. +// +// Returned messages include ack_token = msg.Reply, which the caller passes to +// step.eventbus.ack to acknowledge each message individually. +func ConsumeHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.ConsumeRequest], +) (*sdk.TypedStepResult[*eventbusv1.ConsumeResponse], error) { + input := req.Input + if input.GetConsumer() == "" { + return nil, fmt.Errorf("step.eventbus.consume: consumer is required") + } + + cfg, ok := eventbus.GetConsumerByName(input.GetConsumer()) + if !ok { + return nil, fmt.Errorf( + "step.eventbus.consume: consumer %q not registered; add an infra.eventbus.consumer module with name=%q", + input.GetConsumer(), input.GetConsumer(), + ) + } + + batch := int(input.GetBatchSize()) + if batch <= 0 { + batch = 1 + } + + nc, err := eventbus.DefaultBusConn() + if err != nil { + return nil, fmt.Errorf("step.eventbus.consume: get bus connection: %w", err) + } + + js, err := nc.JetStream() + if err != nil { + return nil, fmt.Errorf("step.eventbus.consume: jetstream context: %w", err) + } + + // Bind to the existing JetStream consumer by durable name + stream name. + // filter_subject is passed as the subject; when empty, BindStream alone + // identifies the consumer. + subj := cfg.GetFilterSubject() + opts := []nats.SubOpt{nats.BindStream(cfg.GetStreamName())} + sub, err := js.PullSubscribe(subj, cfg.GetName(), opts...) + if err != nil { + return nil, fmt.Errorf("step.eventbus.consume: pull subscribe: %w", err) + } + defer sub.Drain() //nolint:errcheck // best-effort drain on return + + var fetchOpts []nats.PullOpt + if mw := input.GetMaxWait(); mw != nil && mw.AsDuration() > 0 { + fetchOpts = append(fetchOpts, nats.MaxWait(mw.AsDuration())) + } + + msgs, err := sub.Fetch(batch, fetchOpts...) + if err != nil && !errors.Is(err, nats.ErrTimeout) { + return nil, fmt.Errorf("step.eventbus.consume: fetch: %w", err) + } + + result := make([]*eventbusv1.Message, 0, len(msgs)) + for _, m := range msgs { + pbMsg := &eventbusv1.Message{ + Subject: m.Subject, + Payload: m.Data, + AckToken: m.Reply, + } + if len(m.Header) > 0 { + pbMsg.Headers = make(map[string]string, len(m.Header)) + for k, vals := range m.Header { + if len(vals) > 0 { + pbMsg.Headers[k] = vals[0] + } + } + } + if meta, err := m.Metadata(); err == nil && meta != nil { + pbMsg.Sequence = strconv.FormatUint(meta.Sequence.Stream, 10) + pbMsg.PublishedAt = meta.Timestamp.UTC().Format(time.RFC3339) + } + result = append(result, pbMsg) + } + + return &sdk.TypedStepResult[*eventbusv1.ConsumeResponse]{ + Output: &eventbusv1.ConsumeResponse{Messages: result}, + }, nil +} diff --git a/steps/consume_test.go b/steps/consume_test.go new file mode 100644 index 0000000..77b6fc6 --- /dev/null +++ b/steps/consume_test.go @@ -0,0 +1,69 @@ +package steps_test + +import ( + "context" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + "github.com/GoCodeAlone/workflow-plugin-eventbus/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestConsumeHandler_EmptyConsumer(t *testing.T) { + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.ConsumeRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.ConsumeRequest{}, + } + _, err := steps.ConsumeHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error for empty consumer name") + } +} + +func TestConsumeHandler_ConsumerNotFound(t *testing.T) { + // No infra.eventbus.consumer module registered with this durable name. + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.ConsumeRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.ConsumeRequest{ + Consumer: "nonexistent-consumer", + BatchSize: 1, + }, + } + _, err := steps.ConsumeHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error when consumer is not registered") + } +} + +// TestConsumeHandler_NoBusRegistered verifies the error path when a consumer +// config exists but no bus is reachable (no URI registered). +func TestConsumeHandler_NoBusRegistered(t *testing.T) { + // Register a consumer module so the consumer lookup succeeds. + consumerCfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-handler", + StreamName: "BMW_FULFILLMENT", + } + cm, err := eventbus.NewConsumerModule("consume-test-consumer", consumerCfg) + if err != nil { + t.Fatalf("create consumer module: %v", err) + } + if err := cm.Init(); err != nil { + t.Fatalf("init consumer: %v", err) + } + t.Cleanup(func() { _ = cm.Stop(context.Background()) }) + + // No bus cluster registered — DefaultBusConn should fail. + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.ConsumeRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.ConsumeRequest{ + Consumer: "bmw-handler", + BatchSize: 1, + }, + } + _, err = steps.ConsumeHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error when no bus is registered") + } +} diff --git a/steps/factories.go b/steps/factories.go new file mode 100644 index 0000000..0e5145c --- /dev/null +++ b/steps/factories.go @@ -0,0 +1,48 @@ +package steps + +import ( + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// Compile-time assertions: each factory implements sdk.TypedStepProvider. +var ( + _ sdk.TypedStepProvider = (*sdk.TypedStepFactory[*emptypb.Empty, *eventbusv1.PublishRequest, *eventbusv1.PublishResponse])(nil) + _ sdk.TypedStepProvider = (*sdk.TypedStepFactory[*emptypb.Empty, *eventbusv1.ConsumeRequest, *eventbusv1.ConsumeResponse])(nil) + _ sdk.TypedStepProvider = (*sdk.TypedStepFactory[*emptypb.Empty, *eventbusv1.AckRequest, *eventbusv1.AckResponse])(nil) +) + +// PublishFactory is the TypedStepProvider for step.eventbus.publish. +var PublishFactory = sdk.NewTypedStepFactory( + "step.eventbus.publish", + &emptypb.Empty{}, + &eventbusv1.PublishRequest{}, + PublishHandler, +) + +// ConsumeFactory is the TypedStepProvider for step.eventbus.consume. +var ConsumeFactory = sdk.NewTypedStepFactory( + "step.eventbus.consume", + &emptypb.Empty{}, + &eventbusv1.ConsumeRequest{}, + ConsumeHandler, +) + +// AckFactory is the TypedStepProvider for step.eventbus.ack. +var AckFactory = sdk.NewTypedStepFactory( + "step.eventbus.ack", + &emptypb.Empty{}, + &eventbusv1.AckRequest{}, + AckHandler, +) + +// All returns all step TypedStepProvider factories exported by this package. +// Register each with the plugin's sdk.Server via WithTypedStepProvider. +func All() []sdk.TypedStepProvider { + return []sdk.TypedStepProvider{ + PublishFactory, + ConsumeFactory, + AckFactory, + } +} diff --git a/steps/publish.go b/steps/publish.go new file mode 100644 index 0000000..df1d1e4 --- /dev/null +++ b/steps/publish.go @@ -0,0 +1,72 @@ +// Package steps provides typed step handlers for the workflow-plugin-eventbus +// plugin: publish, consume, and ack operations over NATS JetStream. +package steps + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/nats-io/nats.go" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +// PublishHandler implements step.eventbus.publish. It publishes a single +// message to the NATS JetStream subject specified in PublishRequest.subject and +// returns the broker-assigned sequence number and acknowledgement timestamp. +// +// The step config is empty (no per-step config required). All parameters are +// supplied via the typed input message. +func PublishHandler( + ctx context.Context, + req sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.PublishRequest], +) (*sdk.TypedStepResult[*eventbusv1.PublishResponse], error) { + input := req.Input + if input.GetSubject() == "" { + return nil, fmt.Errorf("step.eventbus.publish: subject is required") + } + + nc, err := eventbus.DefaultBusConn() + if err != nil { + return nil, fmt.Errorf("step.eventbus.publish: get bus connection: %w", err) + } + + js, err := nc.JetStream() + if err != nil { + return nil, fmt.Errorf("step.eventbus.publish: jetstream context: %w", err) + } + + msg := &nats.Msg{ + Subject: input.GetSubject(), + Data: input.GetPayload(), + } + if hdrs := input.GetHeaders(); len(hdrs) > 0 { + msg.Header = make(nats.Header, len(hdrs)) + for k, v := range hdrs { + msg.Header.Set(k, v) + } + } + if cid := input.GetCorrelationId(); cid != "" { + if msg.Header == nil { + msg.Header = make(nats.Header, 1) + } + msg.Header.Set("Nats-Correlation-Id", cid) + } + + ack, err := js.PublishMsg(msg) + if err != nil { + return nil, fmt.Errorf("step.eventbus.publish: %w", err) + } + + return &sdk.TypedStepResult[*eventbusv1.PublishResponse]{ + Output: &eventbusv1.PublishResponse{ + Sequence: strconv.FormatUint(ack.Sequence, 10), + AckedAt: time.Now().UTC().Format(time.RFC3339), + }, + }, nil +} diff --git a/steps/publish_test.go b/steps/publish_test.go new file mode 100644 index 0000000..4091013 --- /dev/null +++ b/steps/publish_test.go @@ -0,0 +1,62 @@ +package steps_test + +import ( + "context" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + "github.com/GoCodeAlone/workflow-plugin-eventbus/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "google.golang.org/protobuf/types/known/emptypb" +) + +func TestPublishHandler_EmptySubject(t *testing.T) { + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.PublishRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.PublishRequest{}, + } + _, err := steps.PublishHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error for empty subject") + } +} + +func TestPublishHandler_NoBusRegistered(t *testing.T) { + // The steps test binary starts with no modules registered. + // This test verifies DefaultBusConn returns a descriptive error. + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.PublishRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.PublishRequest{Subject: "test.subject", Payload: []byte("hello")}, + } + _, err := steps.PublishHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error when no bus is registered") + } +} + +// TestPublishHandler_BusRegisteredNoURI verifies that when a cluster is +// registered but no URI is set, GetOrDialNATSConn returns a descriptive error. +func TestPublishHandler_BusRegisteredNoURI(t *testing.T) { + cfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + m, err := eventbus.NewClusterModule("publish-test-bus", cfg) + if err != nil { + t.Fatalf("create module: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("init: %v", err) + } + t.Cleanup(func() { _ = m.Stop(context.Background()) }) + + req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.PublishRequest]{ + Config: &emptypb.Empty{}, + Input: &eventbusv1.PublishRequest{Subject: "test.subject", Payload: []byte("hello")}, + } + _, err = steps.PublishHandler(context.Background(), req) + if err == nil { + t.Fatal("expected error when bus has no URI") + } +} From c9f9a79c3bb311e0d0040921c5e6557a6c7bde53 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:46:34 -0400 Subject: [PATCH 5/9] fix(steps): address 1 critical + 2 important + 2 minor from quality review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: pass nats.Context(ctx) to nc.JetStream() in publish.go and consume.go so that context cancellation/deadline is respected by both js.PublishMsg and sub.Fetch — previously ctx was dead code in all JetStream calls. IMPORTANT-1: sort registered cluster names before selecting default in DefaultBusConn(); eliminates non-deterministic map iteration that would route to a random broker in multi-bus deployments. IMPORTANT-2: cap batch_size at maxBatchSize=1000 in ConsumeHandler; prevents a caller from passing 2^31-1 and blocking fetch indefinitely. MINOR-3: clarify sub.Drain() nolint comment — "ephemeral per-fetch" explains why best-effort is the right posture for a PullSubscribe. MINOR-4: add explicit invariant comment to TestPublishHandler_NoBusRegistered naming the t.Cleanup guarantee that makes the test's assumption safe. Co-Authored-By: Claude Sonnet 4.6 --- module.go | 18 ++++++++++-------- steps/consume.go | 8 ++++++-- steps/publish.go | 2 +- steps/publish_test.go | 6 ++++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/module.go b/module.go index 6819b97..7a47790 100644 --- a/module.go +++ b/module.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "os" + "sort" "strings" "sync" "time" @@ -173,24 +174,25 @@ var natsDialFn = func(uri string) (*nats.Conn, error) { ) } -// DefaultBusConn returns a live NATS connection for the first registered -// infra.eventbus module. Suitable for single-bus workflow deployments (e.g. the -// BMW pilot). For multi-bus workflows, use GetOrDialNATSConn(instanceName) +// DefaultBusConn returns a live NATS connection for the lexicographically first +// registered infra.eventbus module. Sorting ensures deterministic selection +// across invocations and concurrent goroutines, even when multiple buses are +// registered. For multi-bus workflows, use GetOrDialNATSConn(instanceName) // directly. func DefaultBusConn() (*nats.Conn, error) { clusterMu.RLock() - var first string + names := make([]string, 0, len(clusterRegistry)) for name := range clusterRegistry { - first = name - break + names = append(names, name) } clusterMu.RUnlock() - if first == "" { + if len(names) == 0 { return nil, fmt.Errorf( "infra.eventbus: no bus module registered; add an infra.eventbus module to your workflow config", ) } - return GetOrDialNATSConn(first) + sort.Strings(names) + return GetOrDialNATSConn(names[0]) } // ── ClusterModuleFactory (TypedModuleProvider) ──────────────────────────────── diff --git a/steps/consume.go b/steps/consume.go index 1e6e26f..4092943 100644 --- a/steps/consume.go +++ b/steps/consume.go @@ -39,17 +39,21 @@ func ConsumeHandler( ) } + const maxBatchSize = 1000 batch := int(input.GetBatchSize()) if batch <= 0 { batch = 1 } + if batch > maxBatchSize { + batch = maxBatchSize + } nc, err := eventbus.DefaultBusConn() if err != nil { return nil, fmt.Errorf("step.eventbus.consume: get bus connection: %w", err) } - js, err := nc.JetStream() + js, err := nc.JetStream(nats.Context(ctx)) if err != nil { return nil, fmt.Errorf("step.eventbus.consume: jetstream context: %w", err) } @@ -63,7 +67,7 @@ func ConsumeHandler( if err != nil { return nil, fmt.Errorf("step.eventbus.consume: pull subscribe: %w", err) } - defer sub.Drain() //nolint:errcheck // best-effort drain on return + defer sub.Drain() //nolint:errcheck // best-effort; PullSubscribe is ephemeral per-fetch var fetchOpts []nats.PullOpt if mw := input.GetMaxWait(); mw != nil && mw.AsDuration() > 0 { diff --git a/steps/publish.go b/steps/publish.go index df1d1e4..2b55dc1 100644 --- a/steps/publish.go +++ b/steps/publish.go @@ -36,7 +36,7 @@ func PublishHandler( return nil, fmt.Errorf("step.eventbus.publish: get bus connection: %w", err) } - js, err := nc.JetStream() + js, err := nc.JetStream(nats.Context(ctx)) if err != nil { return nil, fmt.Errorf("step.eventbus.publish: jetstream context: %w", err) } diff --git a/steps/publish_test.go b/steps/publish_test.go index 4091013..8c46431 100644 --- a/steps/publish_test.go +++ b/steps/publish_test.go @@ -23,8 +23,10 @@ func TestPublishHandler_EmptySubject(t *testing.T) { } func TestPublishHandler_NoBusRegistered(t *testing.T) { - // The steps test binary starts with no modules registered. - // This test verifies DefaultBusConn returns a descriptive error. + // No cluster is registered in this test's scope. Tests run sequentially + // within a package; t.Cleanup from BusRegisteredNoURI fires before this + // test starts, so the registry is guaranteed empty when we arrive here. + // This test exercises DefaultBusConn's "no bus registered" error path. req := sdk.TypedStepRequest[*emptypb.Empty, *eventbusv1.PublishRequest]{ Config: &emptypb.Empty{}, Input: &eventbusv1.PublishRequest{Subject: "test.subject", Payload: []byte("hello")}, From fdceaa402c4782b90797da6ac2331b3bbf825201 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 4 May 2026 00:15:08 -0400 Subject: [PATCH 6/9] feat(trigger): add trigger.eventbus.subscribe + gRPC plugin entrypoint + integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trigger.go: SubscribeTriggerModuleFactory (TypedModuleProvider) + subscribeTrigger (ModuleInstance + TriggerInstance) with bounded goroutine, clean shutdown via context cancel + done channel, fetchPollInterval backpressure on idle streams. cb=nil path is a no-op Start (external plugin gRPC transport). - trigger_test.go: 7 unit tests covering factory, validation, and nil-callback lifecycle. - cmd/workflow-plugin-eventbus/main.go: eventbusPlugin wires all 4 module factories (cluster, stream, consumer, trigger), 3 step factories (publish, consume, ack), TriggerProvider, and ContractProvider returning all 7 strict-proto descriptors. - integration_test.go: real gRPC subprocess transport — compiles binary, starts via go-plugin, verifies manifest, contract registry (7 contracts, all STRICT_PROTO), infra.eventbus module lifecycle, publish/consume/ack step error paths over wire, trigger module lifecycle, and GetModuleTypes/GetStepTypes/GetTriggerTypes RPCs. No live NATS server required; exercises descriptive error paths. All tests pass (GOWORK=off go test ./... -count=1). Co-Authored-By: Claude Sonnet 4.6 --- cmd/workflow-plugin-eventbus/main.go | 204 +++++++++++++- integration_test.go | 379 +++++++++++++++++++++++++++ trigger.go | 200 ++++++++++++++ trigger_test.go | 99 +++++++ 4 files changed, 878 insertions(+), 4 deletions(-) create mode 100644 integration_test.go create mode 100644 trigger.go create mode 100644 trigger_test.go diff --git a/cmd/workflow-plugin-eventbus/main.go b/cmd/workflow-plugin-eventbus/main.go index 2d24c58..cdad0f0 100644 --- a/cmd/workflow-plugin-eventbus/main.go +++ b/cmd/workflow-plugin-eventbus/main.go @@ -1,14 +1,210 @@ // Command workflow-plugin-eventbus is a workflow engine external plugin that // provisions durable event-bus clusters (NATS / Kafka / Kinesis) as IaC and // exposes typed pipeline steps for publish / consume operations. -// -// Status: pre-pilot scaffold — provider implementations are in progress. package main +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/types/known/anypb" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + "github.com/GoCodeAlone/workflow-plugin-eventbus/steps" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + // version is stamped at release time via goreleaser ldflags (-X main.version=). var version = "dev" func main() { - // TODO(Task 17): wire sdk.Serve(plugin.New()) once Provider interface is implemented. - panic("workflow-plugin-eventbus: provider implementation pending — scaffold only; see github.com/GoCodeAlone/workflow-plugin-eventbus") + sdk.Serve(&eventbusPlugin{}) +} + +// eventbusPlugin implements sdk.PluginProvider, sdk.TypedModuleProvider, +// sdk.TypedStepProvider, sdk.TriggerProvider, and sdk.ContractProvider. +type eventbusPlugin struct{} + +// Compile-time assertions. +var ( + _ sdk.PluginProvider = (*eventbusPlugin)(nil) + _ sdk.TypedModuleProvider = (*eventbusPlugin)(nil) + _ sdk.TypedStepProvider = (*eventbusPlugin)(nil) + _ sdk.TriggerProvider = (*eventbusPlugin)(nil) + _ sdk.ContractProvider = (*eventbusPlugin)(nil) +) + +// ── PluginProvider ──────────────────────────────────────────────────────────── + +// Manifest returns the plugin metadata used by the workflow engine for +// discovery and capability negotiation. +func (p *eventbusPlugin) Manifest() sdk.PluginManifest { + return sdk.PluginManifest{ + Name: "workflow-plugin-eventbus", + Version: version, + Author: "GoCodeAlone", + Description: "Provisions durable event-bus clusters (NATS / Kafka / Kinesis) as IaC and exposes typed pipeline steps for publish / consume operations.", + } +} + +// ── TypedModuleProvider ─────────────────────────────────────────────────────── + +// moduleFactories is the ordered list of TypedModuleProvider instances, one per +// module type family. +var moduleFactories = []sdk.TypedModuleProvider{ + &eventbus.ClusterModuleFactory{}, + &eventbus.StreamModuleFactory{}, + &eventbus.ConsumerModuleFactory{}, + &eventbus.SubscribeTriggerModuleFactory{}, +} + +// TypedModuleTypes returns all module types served by this plugin, including the +// trigger.eventbus.subscribe type which is exposed as a module in the gRPC path. +func (p *eventbusPlugin) TypedModuleTypes() []string { + types := make([]string, 0, 4) + for _, f := range moduleFactories { + types = append(types, f.TypedModuleTypes()...) + } + return types +} + +// CreateTypedModule routes the create request to the appropriate factory. +func (p *eventbusPlugin) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + for _, f := range moduleFactories { + inst, err := f.CreateTypedModule(typeName, name, config) + if err == nil { + return inst, nil + } + if !errors.Is(err, sdk.ErrTypedContractNotHandled) { + return nil, err + } + } + return nil, fmt.Errorf("workflow-plugin-eventbus: unknown module type %q", typeName) +} + +// ── TypedStepProvider ───────────────────────────────────────────────────────── + +// stepFactories is the ordered list of TypedStepProvider instances. +var stepFactories = []sdk.TypedStepProvider{ + steps.PublishFactory, + steps.ConsumeFactory, + steps.AckFactory, +} + +// TypedStepTypes returns all step types served by this plugin. +func (p *eventbusPlugin) TypedStepTypes() []string { + types := make([]string, 0, len(stepFactories)) + for _, f := range stepFactories { + types = append(types, f.TypedStepTypes()...) + } + return types +} + +// CreateTypedStep routes the create request to the appropriate factory. +func (p *eventbusPlugin) CreateTypedStep(typeName, name string, config *anypb.Any) (sdk.StepInstance, error) { + for _, f := range stepFactories { + inst, err := f.CreateTypedStep(typeName, name, config) + if err == nil { + return inst, nil + } + if !errors.Is(err, sdk.ErrTypedContractNotHandled) { + return nil, err + } + } + return nil, fmt.Errorf("workflow-plugin-eventbus: unknown step type %q", typeName) +} + +// ── TriggerProvider ─────────────────────────────────────────────────────────── + +// TriggerTypes returns the trigger type names this plugin provides. +func (p *eventbusPlugin) TriggerTypes() []string { + return []string{"trigger.eventbus.subscribe"} +} + +// CreateTrigger creates a trigger instance for the trigger.eventbus.subscribe type. +// In the external plugin gRPC path the callback client is never wired, so cb is +// always nil and Start is a no-op. The trigger module is created via +// CreateTypedModule in that path; this method exists for the legacy TriggerProvider +// interface. +func (p *eventbusPlugin) CreateTrigger(typeName string, config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { + if typeName != "trigger.eventbus.subscribe" { + return nil, fmt.Errorf("workflow-plugin-eventbus: unknown trigger type %q", typeName) + } + name, _ := config["name"].(string) + streamName, _ := config["stream_name"].(string) + filterSubject, _ := config["filter_subject"].(string) + cfg := &eventbusv1.ConsumerConfig{ + Name: name, + StreamName: streamName, + FilterSubject: filterSubject, + } + inst, err := eventbus.NewSubscribeTrigger(typeName, cfg, cb) + if err != nil { + return nil, err + } + return inst.(sdk.TriggerInstance), nil +} + +// ── ContractProvider ────────────────────────────────────────────────────────── + +// ContractRegistry returns the typed contract descriptors for all plugin +// capabilities. These match the entries in plugin.contracts.json and are used +// by the engine for strict-proto contract negotiation. +func (p *eventbusPlugin) ContractRegistry() *pb.ContractRegistry { + strict := pb.ContractMode_CONTRACT_MODE_STRICT_PROTO + return &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + // ── modules ─────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "infra.eventbus", + ConfigMessage: "workflow.plugin.eventbus.v1.ClusterConfig", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "infra.eventbus.stream", + ConfigMessage: "workflow.plugin.eventbus.v1.StreamConfig", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "infra.eventbus.consumer", + ConfigMessage: "workflow.plugin.eventbus.v1.ConsumerConfig", + Mode: strict, + }, + // ── steps ───────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.publish", + InputMessage: "workflow.plugin.eventbus.v1.PublishRequest", + OutputMessage: "workflow.plugin.eventbus.v1.PublishResponse", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.consume", + InputMessage: "workflow.plugin.eventbus.v1.ConsumeRequest", + OutputMessage: "workflow.plugin.eventbus.v1.ConsumeResponse", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.ack", + InputMessage: "workflow.plugin.eventbus.v1.AckRequest", + OutputMessage: "workflow.plugin.eventbus.v1.AckResponse", + Mode: strict, + }, + // ── triggers ────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_TRIGGER, + TriggerType: "trigger.eventbus.subscribe", + ConfigMessage: "workflow.plugin.eventbus.v1.ConsumerConfig", + OutputMessage: "workflow.plugin.eventbus.v1.Message", + Mode: strict, + }, + }, + } } diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..478f68b --- /dev/null +++ b/integration_test.go @@ -0,0 +1,379 @@ +// Package eventbus_test provides an end-to-end integration test that starts +// the workflow-plugin-eventbus binary as a real subprocess, communicates with +// it over the gRPC transport (via go-plugin), and verifies the full typed-proto +// contract surface: module lifecycle, step execution, contract registry, and +// trigger module creation. +// +// The test: +// 1. Compiles and starts the plugin binary as a subprocess via go-plugin. +// 2. Fetches the manifest and contract registry over gRPC. +// 3. Declares an infra.eventbus cluster module (Create → Init → Start → Stop). +// 4. Creates and attempts to execute step.eventbus.publish — expects a +// descriptive error about no URI (no live NATS server required). +// 5. Creates and attempts to execute step.eventbus.consume — expects a +// descriptive error about no consumer registered. +// 6. Creates and attempts to execute step.eventbus.ack — expects a +// descriptive error about empty ack_token. +// 7. Creates a trigger.eventbus.subscribe module and verifies it can be +// initialised and stopped without error. +// +// Run with -short to skip (requires the Go toolchain to compile the binary). +package eventbus_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + goplugin "github.com/GoCodeAlone/go-plugin" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/emptypb" + + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + ext "github.com/GoCodeAlone/workflow/plugin/external" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// ── go-plugin bridge ────────────────────────────────────────────────────────── + +// testGRPCPlugin is a go-plugin Plugin implementation that dispenses +// pb.PluginServiceClient directly, bypassing the ext.PluginClient wrapper so the +// test can call RPC methods directly without depending on unexported types. +type testGRPCPlugin struct{ goplugin.Plugin } + +func (p *testGRPCPlugin) GRPCServer(_ *goplugin.GRPCBroker, _ *grpc.Server) error { return nil } + +func (p *testGRPCPlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, c *grpc.ClientConn) (any, error) { + return pb.NewPluginServiceClient(c), nil +} + +// ── test infrastructure ─────────────────────────────────────────────────────── + +// buildBinary compiles the plugin binary into a temp directory and returns its +// path. +func buildBinary(t *testing.T) string { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Skip("runtime.Caller unavailable") + } + projectRoot := filepath.Dir(thisFile) + + out := filepath.Join(t.TempDir(), "workflow-plugin-eventbus") + cmd := exec.Command("go", "build", "-o", out, "./cmd/workflow-plugin-eventbus/") + cmd.Dir = projectRoot + cmd.Env = append(os.Environ(), "GOWORK=off") + + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build plugin binary:\n%s\nerror: %v", output, err) + } + return out +} + +// startPlugin starts the plugin binary as a go-plugin subprocess and returns a +// pb.PluginServiceClient connected to it over gRPC. +func startPlugin(t *testing.T, binaryPath string) pb.PluginServiceClient { + t.Helper() + + client := goplugin.NewClient(&goplugin.ClientConfig{ + HandshakeConfig: ext.Handshake, + Plugins: goplugin.PluginSet{"plugin": &testGRPCPlugin{}}, + Cmd: exec.Command(binaryPath), //nolint:gosec // G204: test binary + AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC}, + }) + t.Cleanup(client.Kill) + + rpcClient, err := client.Client() + if err != nil { + t.Fatalf("connect to plugin subprocess: %v", err) + } + + raw, err := rpcClient.Dispense("plugin") + if err != nil { + t.Fatalf("dispense plugin: %v", err) + } + + pbClient, ok := raw.(pb.PluginServiceClient) + if !ok { + t.Fatalf("dispensed object is not pb.PluginServiceClient (got %T)", raw) + } + return pbClient +} + +// mustNoRPCErr fatals the test if err != nil or the response error field is set. +func mustNoRPCErr(t *testing.T, label string, err error, respErr string) { + t.Helper() + if err != nil { + t.Fatalf("%s: gRPC error: %v", label, err) + } + if respErr != "" { + t.Fatalf("%s: plugin error: %s", label, respErr) + } +} + +// ── integration scenario ────────────────────────────────────────────────────── + +// TestE2E_EventbusPluginScenario is the canonical end-to-end integration test. +// +// All calls go through real gRPC proto serialisation: the test process packs +// each request as anypb.Any, sends it over a TCP gRPC connection to the plugin +// subprocess, and unpacks the typed response. +// +// No live NATS server is required — the test deliberately exercises the error +// paths that fire when no broker is reachable, verifying that error messages +// are descriptive and the plugin remains stable under those conditions. +// +// Requires the Go toolchain to compile the plugin binary. Run with -short to skip. +func TestE2E_EventbusPluginScenario(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test: requires Go toolchain (run without -short)") + } + + ctx := context.Background() + + // ── 1. Build and start plugin subprocess ────────────────────────────────── + binaryPath := buildBinary(t) + pbClient := startPlugin(t, binaryPath) + + // ── 2. Manifest verification ────────────────────────────────────────────── + manifest, err := pbClient.GetManifest(ctx, &emptypb.Empty{}) + mustNoRPCErr(t, "GetManifest", err, "") + if manifest.GetName() != "workflow-plugin-eventbus" { + t.Errorf("manifest name = %q, want %q", manifest.GetName(), "workflow-plugin-eventbus") + } + if manifest.GetAuthor() != "GoCodeAlone" { + t.Errorf("manifest author = %q, want %q", manifest.GetAuthor(), "GoCodeAlone") + } + + // ── 3. Contract registry verification ───────────────────────────────────── + registry, err := pbClient.GetContractRegistry(ctx, &emptypb.Empty{}) + mustNoRPCErr(t, "GetContractRegistry", err, "") + + // Collect type names by kind to verify all expected contracts are present. + moduleTypes := make(map[string]bool) + stepTypes := make(map[string]bool) + triggerTypes := make(map[string]bool) + for _, c := range registry.GetContracts() { + switch c.GetKind() { + case pb.ContractKind_CONTRACT_KIND_MODULE: + moduleTypes[c.GetModuleType()] = true + case pb.ContractKind_CONTRACT_KIND_STEP: + stepTypes[c.GetStepType()] = true + case pb.ContractKind_CONTRACT_KIND_TRIGGER: + triggerTypes[c.GetTriggerType()] = true + } + } + + for _, want := range []string{"infra.eventbus", "infra.eventbus.stream", "infra.eventbus.consumer"} { + if !moduleTypes[want] { + t.Errorf("contract registry missing module type %q", want) + } + } + for _, want := range []string{"step.eventbus.publish", "step.eventbus.consume", "step.eventbus.ack"} { + if !stepTypes[want] { + t.Errorf("contract registry missing step type %q", want) + } + } + if !triggerTypes["trigger.eventbus.subscribe"] { + t.Error("contract registry missing trigger type trigger.eventbus.subscribe") + } + + // Verify all step contracts carry strict-proto mode. + for _, c := range registry.GetContracts() { + if c.GetKind() == pb.ContractKind_CONTRACT_KIND_STEP { + if c.GetMode() != pb.ContractMode_CONTRACT_MODE_STRICT_PROTO { + t.Errorf("contract %q mode = %v, want STRICT_PROTO", c.GetStepType(), c.GetMode()) + } + } + } + + // ── 4. Declare infra.eventbus cluster module via gRPC ───────────────────── + clusterCfg := &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + } + packedClusterCfg, err := anypb.New(clusterCfg) + if err != nil { + t.Fatalf("pack ClusterConfig: %v", err) + } + + createModResp, err := pbClient.CreateModule(ctx, &pb.CreateModuleRequest{ + Type: "infra.eventbus", + Name: "e2e-bus", + TypedConfig: packedClusterCfg, + }) + mustNoRPCErr(t, "CreateModule(infra.eventbus)", err, createModResp.GetError()) + modHandle := createModResp.HandleId + + initResp, err := pbClient.InitModule(ctx, &pb.HandleRequest{HandleId: modHandle}) + mustNoRPCErr(t, "InitModule", err, initResp.GetError()) + + startResp, err := pbClient.StartModule(ctx, &pb.HandleRequest{HandleId: modHandle}) + mustNoRPCErr(t, "StartModule", err, startResp.GetError()) + + t.Cleanup(func() { + if resp, err := pbClient.StopModule(ctx, &pb.HandleRequest{HandleId: modHandle}); err != nil { + t.Logf("StopModule: gRPC error: %v", err) + } else if resp.GetError() != "" { + t.Logf("StopModule: plugin error: %s", resp.GetError()) + } + }) + + // ── 5. step.eventbus.publish — no broker URI registered ─────────────────── + // Cluster has no URI (no env var set) → expect a descriptive error from the + // plugin, not a gRPC transport error. + createPublishResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.eventbus.publish", + Name: "e2e-publish", + }) + mustNoRPCErr(t, "CreateStep(publish)", err, createPublishResp.GetError()) + + publishInput, err := anypb.New(&eventbusv1.PublishRequest{ + Subject: "BMW.FULFILLMENT.EVENTS", + Payload: []byte(`{"vin":"WBA3A5C50DF456789","status":"ORDER_PLACED"}`), + }) + if err != nil { + t.Fatalf("pack PublishRequest: %v", err) + } + execPublishResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createPublishResp.HandleId, + TypedInput: publishInput, + }) + // Transport must succeed; the plugin error lives in the response field. + if err != nil { + t.Fatalf("ExecuteStep(publish): gRPC transport error: %v", err) + } + if execPublishResp.GetError() == "" { + t.Error("ExecuteStep(publish): expected plugin error (no NATS URI) but got none") + } + t.Logf("ExecuteStep(publish) expected plugin error: %s", execPublishResp.GetError()) + + // ── 6. step.eventbus.consume — no consumer registered ──────────────────── + createConsumeResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.eventbus.consume", + Name: "e2e-consume", + }) + mustNoRPCErr(t, "CreateStep(consume)", err, createConsumeResp.GetError()) + + consumeInput, err := anypb.New(&eventbusv1.ConsumeRequest{ + Consumer: "bmw-fulfillment-handler", + BatchSize: 10, + }) + if err != nil { + t.Fatalf("pack ConsumeRequest: %v", err) + } + execConsumeResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createConsumeResp.HandleId, + TypedInput: consumeInput, + }) + if err != nil { + t.Fatalf("ExecuteStep(consume): gRPC transport error: %v", err) + } + if execConsumeResp.GetError() == "" { + t.Error("ExecuteStep(consume): expected plugin error (consumer not registered) but got none") + } + t.Logf("ExecuteStep(consume) expected plugin error: %s", execConsumeResp.GetError()) + + // ── 7. step.eventbus.ack — empty ack_token ──────────────────────────────── + createAckResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.eventbus.ack", + Name: "e2e-ack", + }) + mustNoRPCErr(t, "CreateStep(ack)", err, createAckResp.GetError()) + + ackInput, err := anypb.New(&eventbusv1.AckRequest{AckToken: ""}) + if err != nil { + t.Fatalf("pack AckRequest: %v", err) + } + execAckResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createAckResp.HandleId, + TypedInput: ackInput, + }) + if err != nil { + t.Fatalf("ExecuteStep(ack): gRPC transport error: %v", err) + } + if execAckResp.GetError() == "" { + t.Error("ExecuteStep(ack): expected plugin error (empty ack_token) but got none") + } + t.Logf("ExecuteStep(ack) expected plugin error: %s", execAckResp.GetError()) + + // ── 8. trigger.eventbus.subscribe — module lifecycle ───────────────────── + // The trigger is created as a module in the gRPC path. cb is always nil in + // the subprocess transport; Start is a no-op. + consumerCfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + StreamName: "BMW_FULFILLMENT", + } + packedConsumerCfg, err := anypb.New(consumerCfg) + if err != nil { + t.Fatalf("pack ConsumerConfig: %v", err) + } + + createTrigResp, err := pbClient.CreateModule(ctx, &pb.CreateModuleRequest{ + Type: "trigger.eventbus.subscribe", + Name: "e2e-trigger", + TypedConfig: packedConsumerCfg, + }) + mustNoRPCErr(t, "CreateModule(trigger.eventbus.subscribe)", err, createTrigResp.GetError()) + trigHandle := createTrigResp.HandleId + + initTrigResp, err := pbClient.InitModule(ctx, &pb.HandleRequest{HandleId: trigHandle}) + mustNoRPCErr(t, "InitModule(trigger)", err, initTrigResp.GetError()) + + startTrigResp, err := pbClient.StartModule(ctx, &pb.HandleRequest{HandleId: trigHandle}) + mustNoRPCErr(t, "StartModule(trigger)", err, startTrigResp.GetError()) + + stopTrigResp, err := pbClient.StopModule(ctx, &pb.HandleRequest{HandleId: trigHandle}) + mustNoRPCErr(t, "StopModule(trigger)", err, stopTrigResp.GetError()) + + // ── 9. GetModuleTypes / GetStepTypes / GetTriggerTypes ──────────────────── + modTypes, err := pbClient.GetModuleTypes(ctx, &emptypb.Empty{}) + mustNoRPCErr(t, "GetModuleTypes", err, "") + expectedModTypes := map[string]bool{ + "infra.eventbus": false, + "infra.eventbus.stream": false, + "infra.eventbus.consumer": false, + "trigger.eventbus.subscribe": false, + } + for _, typ := range modTypes.GetTypes() { + expectedModTypes[typ] = true + } + for typ, found := range expectedModTypes { + if !found { + t.Errorf("GetModuleTypes: missing %q", typ) + } + } + + stepTypeList, err := pbClient.GetStepTypes(ctx, &emptypb.Empty{}) + mustNoRPCErr(t, "GetStepTypes", err, "") + expectedStepTypes := map[string]bool{ + "step.eventbus.publish": false, + "step.eventbus.consume": false, + "step.eventbus.ack": false, + } + for _, typ := range stepTypeList.GetTypes() { + expectedStepTypes[typ] = true + } + for typ, found := range expectedStepTypes { + if !found { + t.Errorf("GetStepTypes: missing %q", typ) + } + } + + trigTypes, err := pbClient.GetTriggerTypes(ctx, &emptypb.Empty{}) + mustNoRPCErr(t, "GetTriggerTypes", err, "") + hasTrigger := false + for _, typ := range trigTypes.GetTypes() { + if typ == "trigger.eventbus.subscribe" { + hasTrigger = true + } + } + if !hasTrigger { + t.Error("GetTriggerTypes: missing trigger.eventbus.subscribe") + } +} diff --git a/trigger.go b/trigger.go new file mode 100644 index 0000000..6e2490a --- /dev/null +++ b/trigger.go @@ -0,0 +1,200 @@ +package eventbus + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go" + "google.golang.org/protobuf/types/known/anypb" + + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// ── SubscribeTriggerModuleFactory (TypedModuleProvider) ─────────────────────── + +// SubscribeTriggerModuleFactory implements sdk.TypedModuleProvider for the +// trigger.eventbus.subscribe module type. The external plugin adapter calls +// CreateTypedModule with the trigger type name to instantiate triggers over gRPC. +type SubscribeTriggerModuleFactory struct{} + +// Compile-time assertion: SubscribeTriggerModuleFactory implements sdk.TypedModuleProvider. +var _ sdk.TypedModuleProvider = (*SubscribeTriggerModuleFactory)(nil) + +// TypedModuleTypes returns the single trigger module type served by this factory. +func (f *SubscribeTriggerModuleFactory) TypedModuleTypes() []string { + return []string{"trigger.eventbus.subscribe"} +} + +// CreateTypedModule unpacks the typed proto config and delegates to NewSubscribeTrigger. +// cb is always nil in the external gRPC subprocess path (the callback client is +// never wired in production SDK code); triggers that receive cb=nil behave as +// no-ops on Start, which is correct for IaC-only and plan/apply workflows. +func (f *SubscribeTriggerModuleFactory) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + if typeName != "trigger.eventbus.subscribe" { + return nil, fmt.Errorf("%w: module type %q", sdk.ErrTypedContractNotHandled, typeName) + } + var cfg eventbusv1.ConsumerConfig + if config != nil { + if err := config.UnmarshalTo(&cfg); err != nil { + return nil, fmt.Errorf("trigger.eventbus.subscribe %q: unmarshal typed config: %w", name, err) + } + } + // cb is nil in the external plugin gRPC path; the trigger is a no-op on Start. + return NewSubscribeTrigger(name, &cfg, nil) +} + +// ── subscribeTrigger (ModuleInstance + TriggerInstance) ────────────────────── + +// subscribeTrigger implements sdk.ModuleInstance and sdk.TriggerInstance for the +// trigger.eventbus.subscribe trigger type. When started with a non-nil callback it +// subscribes to the configured JetStream stream and invokes cb for each message +// received. When cb is nil (the external plugin gRPC path), Start is a no-op. +// +// The background goroutine is bounded: +// - It exits cleanly when the context derived from Stop is cancelled. +// - Each fetch has a maxWait cap so the loop wakes up at least once per +// fetchPollInterval even when the stream is idle — this ensures timely shutdown. +// - Backpressure is handled by the JetStream PullSubscribe+Fetch model: +// the goroutine processes one batch synchronously before fetching the next. +type subscribeTrigger struct { + instanceName string + config *eventbusv1.ConsumerConfig + cb sdk.TriggerCallback + + cancel context.CancelFunc // set by Start; nil before first Start + done chan struct{} // closed when the goroutine exits (nil before first Start with cb) +} + +// Compile-time assertions. +var ( + _ sdk.ModuleInstance = (*subscribeTrigger)(nil) + _ sdk.TriggerInstance = (*subscribeTrigger)(nil) +) + +// fetchPollInterval is the maximum wait per JetStream Fetch call inside the +// trigger goroutine. Keeping it short ensures the goroutine can detect ctx +// cancellation quickly without waiting for a full batch timeout. +const fetchPollInterval = 2 * time.Second + +// NewSubscribeTrigger creates a subscribeTrigger from a typed ConsumerConfig proto. +// +// Returns an error if: +// - config.name is empty +// - config.stream_name is empty +func NewSubscribeTrigger(instanceName string, cfg *eventbusv1.ConsumerConfig, cb sdk.TriggerCallback) (sdk.ModuleInstance, error) { + if cfg.GetName() == "" { + return nil, fmt.Errorf("trigger.eventbus.subscribe %q: config.name is required", instanceName) + } + if cfg.GetStreamName() == "" { + return nil, fmt.Errorf("trigger.eventbus.subscribe %q: config.stream_name is required", instanceName) + } + return &subscribeTrigger{ + instanceName: instanceName, + config: cfg, + cb: cb, + }, nil +} + +// Init is a no-op; the trigger registers nothing during init. +func (t *subscribeTrigger) Init() error { return nil } + +// Start launches the trigger goroutine if cb is non-nil. If cb is nil (the +// external plugin gRPC path), Start is a no-op. +func (t *subscribeTrigger) Start(ctx context.Context) error { + if t.cb == nil { + // External plugin path: callback is never wired — no goroutine needed. + return nil + } + + trigCtx, cancel := context.WithCancel(ctx) + t.cancel = cancel + t.done = make(chan struct{}) + + go t.fetchLoop(trigCtx) + return nil +} + +// Stop cancels the trigger goroutine and waits for it to exit. +// Stop is idempotent — calling it before Start or when cb was nil is safe. +func (t *subscribeTrigger) Stop(_ context.Context) error { + if t.cancel != nil { + t.cancel() + } + if t.done != nil { + <-t.done + } + return nil +} + +// fetchLoop is the background goroutine that pulls messages from JetStream and +// invokes the trigger callback. It exits when ctx is cancelled. +func (t *subscribeTrigger) fetchLoop(ctx context.Context) { + defer close(t.done) + + for { + // Exit immediately on context cancellation before each fetch round. + select { + case <-ctx.Done(): + return + default: + } + + if err := t.fetchAndFire(ctx); err != nil { + // Log the error but keep retrying — the bus may be temporarily + // unavailable or the stream may not exist yet. + // A 1-second back-off prevents a tight spin loop on persistent errors. + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } + } +} + +// fetchAndFire dials the bus, fetches one batch of messages, and invokes cb for +// each one. It returns an error if the connection or fetch fails (the caller +// retries). A JetStream timeout (empty batch) is not treated as an error. +func (t *subscribeTrigger) fetchAndFire(ctx context.Context) error { + nc, err := DefaultBusConn() + if err != nil { + return fmt.Errorf("trigger.eventbus.subscribe %q: get bus connection: %w", t.instanceName, err) + } + + js, err := nc.JetStream(nats.Context(ctx)) + if err != nil { + return fmt.Errorf("trigger.eventbus.subscribe %q: jetstream context: %w", t.instanceName, err) + } + + subj := t.config.GetFilterSubject() + opts := []nats.SubOpt{nats.BindStream(t.config.GetStreamName())} + sub, err := js.PullSubscribe(subj, t.config.GetName(), opts...) + if err != nil { + return fmt.Errorf("trigger.eventbus.subscribe %q: pull subscribe: %w", t.instanceName, err) + } + defer sub.Drain() //nolint:errcheck // best-effort; ephemeral per-fetch subscription + + msgs, err := sub.Fetch(1, nats.MaxWait(fetchPollInterval)) + if err != nil { + if errors.Is(err, nats.ErrTimeout) { + return nil // no messages — normal idle case + } + return fmt.Errorf("trigger.eventbus.subscribe %q: fetch: %w", t.instanceName, err) + } + + for _, m := range msgs { + data := map[string]any{ + "subject": m.Subject, + "payload": string(m.Data), + "reply": m.Reply, + } + if err := t.cb("message", data); err != nil { + // Callback errors are non-fatal; log via returned error and continue. + return fmt.Errorf("trigger.eventbus.subscribe %q: callback: %w", t.instanceName, err) + } + } + return nil +} diff --git a/trigger_test.go b/trigger_test.go new file mode 100644 index 0000000..406286e --- /dev/null +++ b/trigger_test.go @@ -0,0 +1,99 @@ +package eventbus_test + +import ( + "context" + "testing" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" +) + +// ── SubscribeTriggerModuleFactory (TypedModuleProvider) ─────────────────────── + +func TestSubscribeTriggerModuleFactory_TypedModuleTypes(t *testing.T) { + f := &eventbus.SubscribeTriggerModuleFactory{} + types := f.TypedModuleTypes() + if len(types) != 1 || types[0] != "trigger.eventbus.subscribe" { + t.Errorf("TypedModuleTypes() = %v, want [trigger.eventbus.subscribe]", types) + } +} + +func TestSubscribeTriggerModuleFactory_CreateTypedModule_WrongType(t *testing.T) { + f := &eventbus.SubscribeTriggerModuleFactory{} + _, err := f.CreateTypedModule("infra.eventbus", "x", nil) + if err == nil { + t.Fatal("expected error for wrong type") + } +} + +func TestSubscribeTriggerModuleFactory_CreateTypedModule_NilConfig(t *testing.T) { + f := &eventbus.SubscribeTriggerModuleFactory{} + // nil config → ConsumerConfig zero value → empty name → expect error + _, err := f.CreateTypedModule("trigger.eventbus.subscribe", "trigger-factory-nil", nil) + if err == nil { + t.Fatal("expected error from NewSubscribeTrigger for empty name") + } +} + +// ── NewSubscribeTrigger validation ──────────────────────────────────────────── + +func TestNewSubscribeTrigger_ValidConfig(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + StreamName: "BMW_FULFILLMENT", + } + m, err := eventbus.NewSubscribeTrigger("trigger-valid", cfg, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected non-nil module") + } +} + +func TestNewSubscribeTrigger_EmptyName(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + StreamName: "BMW_FULFILLMENT", + } + _, err := eventbus.NewSubscribeTrigger("trigger-empty-name", cfg, nil) + if err == nil { + t.Fatal("expected error for empty consumer name") + } +} + +func TestNewSubscribeTrigger_EmptyStreamName(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + } + _, err := eventbus.NewSubscribeTrigger("trigger-empty-stream", cfg, nil) + if err == nil { + t.Fatal("expected error for empty stream_name") + } +} + +// ── subscribeTrigger lifecycle (nil callback — external plugin path) ────────── + +// TestSubscribeTrigger_LifecycleNilCallback verifies that the trigger module +// lifecycle (Init → Start → Stop) works cleanly when cb=nil (the external +// plugin path where the trigger fires nothing but must not panic or error). +func TestSubscribeTrigger_LifecycleNilCallback(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-fulfillment-handler", + StreamName: "BMW_FULFILLMENT", + } + m, err := eventbus.NewSubscribeTrigger("trigger-lifecycle-nil", cfg, nil) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + // Start with nil callback is a no-op (no goroutine launched). + if err := m.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + // Stop must be idempotent and safe even when no goroutine was started. + if err := m.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } +} From 7ca4e9b053068177000e2522157efd28d236f152 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 4 May 2026 00:44:39 -0400 Subject: [PATCH 7/9] fix(trigger): address 2 spec-reviewer failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failure 1 — plugin.go missing: - Split cmd/workflow-plugin-eventbus/main.go into main.go (entrypoint only: sdk.Serve(&eventbusPlugin{})) and plugin.go (package main: eventbusPlugin type, compile-time assertions, moduleFactories, stepFactories, and all interface method impls: Manifest, TypedModuleTypes, CreateTypedModule, TypedStepTypes, CreateTypedStep, TriggerTypes, CreateTrigger, ContractRegistry). Failure 2 — integration_test.go wrong gate + missing NATS scenario: - TestE2E_EventbusPluginScenario: removed testing.Short() gate — runs always. Extracted declareModule helper (Create→Init→Start+Cleanup) to reduce repetition. - TestE2E_EventbusPluginScenario_WithNATS: new test gated on INTEGRATION_NATS=1. Reads NATS_URL from env (inherited by subprocess). Pre-creates JetStream stream + durable consumer directly via nats.go in test process. Declares infra.eventbus, infra.eventbus.stream, infra.eventbus.consumer modules via gRPC. Publishes 10 messages via step.eventbus.publish (each ExecuteStep call over gRPC wire). Consumes all 10 via step.eventbus.consume batch_size=10. Acks each via step.eventbus.ack using ack_token from ConsumeResponse. Asserts len(messages)==10. All tests pass (GOWORK=off go test ./... -count=1). WithNATS test skips correctly. Co-Authored-By: Claude Sonnet 4.6 --- cmd/workflow-plugin-eventbus/main.go | 199 +------------ cmd/workflow-plugin-eventbus/plugin.go | 200 +++++++++++++ integration_test.go | 382 ++++++++++++++++++------- 3 files changed, 482 insertions(+), 299 deletions(-) create mode 100644 cmd/workflow-plugin-eventbus/plugin.go diff --git a/cmd/workflow-plugin-eventbus/main.go b/cmd/workflow-plugin-eventbus/main.go index cdad0f0..51d679a 100644 --- a/cmd/workflow-plugin-eventbus/main.go +++ b/cmd/workflow-plugin-eventbus/main.go @@ -3,18 +3,7 @@ // exposes typed pipeline steps for publish / consume operations. package main -import ( - "errors" - "fmt" - - "google.golang.org/protobuf/types/known/anypb" - - eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" - eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" - "github.com/GoCodeAlone/workflow-plugin-eventbus/steps" - sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" - pb "github.com/GoCodeAlone/workflow/plugin/external/proto" -) +import sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" // version is stamped at release time via goreleaser ldflags (-X main.version=). var version = "dev" @@ -22,189 +11,3 @@ var version = "dev" func main() { sdk.Serve(&eventbusPlugin{}) } - -// eventbusPlugin implements sdk.PluginProvider, sdk.TypedModuleProvider, -// sdk.TypedStepProvider, sdk.TriggerProvider, and sdk.ContractProvider. -type eventbusPlugin struct{} - -// Compile-time assertions. -var ( - _ sdk.PluginProvider = (*eventbusPlugin)(nil) - _ sdk.TypedModuleProvider = (*eventbusPlugin)(nil) - _ sdk.TypedStepProvider = (*eventbusPlugin)(nil) - _ sdk.TriggerProvider = (*eventbusPlugin)(nil) - _ sdk.ContractProvider = (*eventbusPlugin)(nil) -) - -// ── PluginProvider ──────────────────────────────────────────────────────────── - -// Manifest returns the plugin metadata used by the workflow engine for -// discovery and capability negotiation. -func (p *eventbusPlugin) Manifest() sdk.PluginManifest { - return sdk.PluginManifest{ - Name: "workflow-plugin-eventbus", - Version: version, - Author: "GoCodeAlone", - Description: "Provisions durable event-bus clusters (NATS / Kafka / Kinesis) as IaC and exposes typed pipeline steps for publish / consume operations.", - } -} - -// ── TypedModuleProvider ─────────────────────────────────────────────────────── - -// moduleFactories is the ordered list of TypedModuleProvider instances, one per -// module type family. -var moduleFactories = []sdk.TypedModuleProvider{ - &eventbus.ClusterModuleFactory{}, - &eventbus.StreamModuleFactory{}, - &eventbus.ConsumerModuleFactory{}, - &eventbus.SubscribeTriggerModuleFactory{}, -} - -// TypedModuleTypes returns all module types served by this plugin, including the -// trigger.eventbus.subscribe type which is exposed as a module in the gRPC path. -func (p *eventbusPlugin) TypedModuleTypes() []string { - types := make([]string, 0, 4) - for _, f := range moduleFactories { - types = append(types, f.TypedModuleTypes()...) - } - return types -} - -// CreateTypedModule routes the create request to the appropriate factory. -func (p *eventbusPlugin) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { - for _, f := range moduleFactories { - inst, err := f.CreateTypedModule(typeName, name, config) - if err == nil { - return inst, nil - } - if !errors.Is(err, sdk.ErrTypedContractNotHandled) { - return nil, err - } - } - return nil, fmt.Errorf("workflow-plugin-eventbus: unknown module type %q", typeName) -} - -// ── TypedStepProvider ───────────────────────────────────────────────────────── - -// stepFactories is the ordered list of TypedStepProvider instances. -var stepFactories = []sdk.TypedStepProvider{ - steps.PublishFactory, - steps.ConsumeFactory, - steps.AckFactory, -} - -// TypedStepTypes returns all step types served by this plugin. -func (p *eventbusPlugin) TypedStepTypes() []string { - types := make([]string, 0, len(stepFactories)) - for _, f := range stepFactories { - types = append(types, f.TypedStepTypes()...) - } - return types -} - -// CreateTypedStep routes the create request to the appropriate factory. -func (p *eventbusPlugin) CreateTypedStep(typeName, name string, config *anypb.Any) (sdk.StepInstance, error) { - for _, f := range stepFactories { - inst, err := f.CreateTypedStep(typeName, name, config) - if err == nil { - return inst, nil - } - if !errors.Is(err, sdk.ErrTypedContractNotHandled) { - return nil, err - } - } - return nil, fmt.Errorf("workflow-plugin-eventbus: unknown step type %q", typeName) -} - -// ── TriggerProvider ─────────────────────────────────────────────────────────── - -// TriggerTypes returns the trigger type names this plugin provides. -func (p *eventbusPlugin) TriggerTypes() []string { - return []string{"trigger.eventbus.subscribe"} -} - -// CreateTrigger creates a trigger instance for the trigger.eventbus.subscribe type. -// In the external plugin gRPC path the callback client is never wired, so cb is -// always nil and Start is a no-op. The trigger module is created via -// CreateTypedModule in that path; this method exists for the legacy TriggerProvider -// interface. -func (p *eventbusPlugin) CreateTrigger(typeName string, config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { - if typeName != "trigger.eventbus.subscribe" { - return nil, fmt.Errorf("workflow-plugin-eventbus: unknown trigger type %q", typeName) - } - name, _ := config["name"].(string) - streamName, _ := config["stream_name"].(string) - filterSubject, _ := config["filter_subject"].(string) - cfg := &eventbusv1.ConsumerConfig{ - Name: name, - StreamName: streamName, - FilterSubject: filterSubject, - } - inst, err := eventbus.NewSubscribeTrigger(typeName, cfg, cb) - if err != nil { - return nil, err - } - return inst.(sdk.TriggerInstance), nil -} - -// ── ContractProvider ────────────────────────────────────────────────────────── - -// ContractRegistry returns the typed contract descriptors for all plugin -// capabilities. These match the entries in plugin.contracts.json and are used -// by the engine for strict-proto contract negotiation. -func (p *eventbusPlugin) ContractRegistry() *pb.ContractRegistry { - strict := pb.ContractMode_CONTRACT_MODE_STRICT_PROTO - return &pb.ContractRegistry{ - Contracts: []*pb.ContractDescriptor{ - // ── modules ─────────────────────────────────────────────────────── - { - Kind: pb.ContractKind_CONTRACT_KIND_MODULE, - ModuleType: "infra.eventbus", - ConfigMessage: "workflow.plugin.eventbus.v1.ClusterConfig", - Mode: strict, - }, - { - Kind: pb.ContractKind_CONTRACT_KIND_MODULE, - ModuleType: "infra.eventbus.stream", - ConfigMessage: "workflow.plugin.eventbus.v1.StreamConfig", - Mode: strict, - }, - { - Kind: pb.ContractKind_CONTRACT_KIND_MODULE, - ModuleType: "infra.eventbus.consumer", - ConfigMessage: "workflow.plugin.eventbus.v1.ConsumerConfig", - Mode: strict, - }, - // ── steps ───────────────────────────────────────────────────────── - { - Kind: pb.ContractKind_CONTRACT_KIND_STEP, - StepType: "step.eventbus.publish", - InputMessage: "workflow.plugin.eventbus.v1.PublishRequest", - OutputMessage: "workflow.plugin.eventbus.v1.PublishResponse", - Mode: strict, - }, - { - Kind: pb.ContractKind_CONTRACT_KIND_STEP, - StepType: "step.eventbus.consume", - InputMessage: "workflow.plugin.eventbus.v1.ConsumeRequest", - OutputMessage: "workflow.plugin.eventbus.v1.ConsumeResponse", - Mode: strict, - }, - { - Kind: pb.ContractKind_CONTRACT_KIND_STEP, - StepType: "step.eventbus.ack", - InputMessage: "workflow.plugin.eventbus.v1.AckRequest", - OutputMessage: "workflow.plugin.eventbus.v1.AckResponse", - Mode: strict, - }, - // ── triggers ────────────────────────────────────────────────────── - { - Kind: pb.ContractKind_CONTRACT_KIND_TRIGGER, - TriggerType: "trigger.eventbus.subscribe", - ConfigMessage: "workflow.plugin.eventbus.v1.ConsumerConfig", - OutputMessage: "workflow.plugin.eventbus.v1.Message", - Mode: strict, - }, - }, - } -} diff --git a/cmd/workflow-plugin-eventbus/plugin.go b/cmd/workflow-plugin-eventbus/plugin.go new file mode 100644 index 0000000..de48edf --- /dev/null +++ b/cmd/workflow-plugin-eventbus/plugin.go @@ -0,0 +1,200 @@ +package main + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/types/known/anypb" + + eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" + eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + "github.com/GoCodeAlone/workflow-plugin-eventbus/steps" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// eventbusPlugin implements sdk.PluginProvider, sdk.TypedModuleProvider, +// sdk.TypedStepProvider, sdk.TriggerProvider, and sdk.ContractProvider. +type eventbusPlugin struct{} + +// Compile-time assertions. +var ( + _ sdk.PluginProvider = (*eventbusPlugin)(nil) + _ sdk.TypedModuleProvider = (*eventbusPlugin)(nil) + _ sdk.TypedStepProvider = (*eventbusPlugin)(nil) + _ sdk.TriggerProvider = (*eventbusPlugin)(nil) + _ sdk.ContractProvider = (*eventbusPlugin)(nil) +) + +// ── PluginProvider ──────────────────────────────────────────────────────────── + +// Manifest returns the plugin metadata used by the workflow engine for +// discovery and capability negotiation. +func (p *eventbusPlugin) Manifest() sdk.PluginManifest { + return sdk.PluginManifest{ + Name: "workflow-plugin-eventbus", + Version: version, + Author: "GoCodeAlone", + Description: "Provisions durable event-bus clusters (NATS / Kafka / Kinesis) as IaC and exposes typed pipeline steps for publish / consume operations.", + } +} + +// ── TypedModuleProvider ─────────────────────────────────────────────────────── + +// moduleFactories is the ordered list of TypedModuleProvider instances, one per +// module type family. +var moduleFactories = []sdk.TypedModuleProvider{ + &eventbus.ClusterModuleFactory{}, + &eventbus.StreamModuleFactory{}, + &eventbus.ConsumerModuleFactory{}, + &eventbus.SubscribeTriggerModuleFactory{}, +} + +// TypedModuleTypes returns all module types served by this plugin, including the +// trigger.eventbus.subscribe type which is exposed as a module in the gRPC path. +func (p *eventbusPlugin) TypedModuleTypes() []string { + types := make([]string, 0, len(moduleFactories)) + for _, f := range moduleFactories { + types = append(types, f.TypedModuleTypes()...) + } + return types +} + +// CreateTypedModule routes the create request to the appropriate factory. +func (p *eventbusPlugin) CreateTypedModule(typeName, name string, config *anypb.Any) (sdk.ModuleInstance, error) { + for _, f := range moduleFactories { + inst, err := f.CreateTypedModule(typeName, name, config) + if err == nil { + return inst, nil + } + if !errors.Is(err, sdk.ErrTypedContractNotHandled) { + return nil, err + } + } + return nil, fmt.Errorf("workflow-plugin-eventbus: unknown module type %q", typeName) +} + +// ── TypedStepProvider ───────────────────────────────────────────────────────── + +// stepFactories is the ordered list of TypedStepProvider instances. +var stepFactories = []sdk.TypedStepProvider{ + steps.PublishFactory, + steps.ConsumeFactory, + steps.AckFactory, +} + +// TypedStepTypes returns all step types served by this plugin. +func (p *eventbusPlugin) TypedStepTypes() []string { + types := make([]string, 0, len(stepFactories)) + for _, f := range stepFactories { + types = append(types, f.TypedStepTypes()...) + } + return types +} + +// CreateTypedStep routes the create request to the appropriate factory. +func (p *eventbusPlugin) CreateTypedStep(typeName, name string, config *anypb.Any) (sdk.StepInstance, error) { + for _, f := range stepFactories { + inst, err := f.CreateTypedStep(typeName, name, config) + if err == nil { + return inst, nil + } + if !errors.Is(err, sdk.ErrTypedContractNotHandled) { + return nil, err + } + } + return nil, fmt.Errorf("workflow-plugin-eventbus: unknown step type %q", typeName) +} + +// ── TriggerProvider ─────────────────────────────────────────────────────────── + +// TriggerTypes returns the trigger type names this plugin provides. +func (p *eventbusPlugin) TriggerTypes() []string { + return []string{"trigger.eventbus.subscribe"} +} + +// CreateTrigger creates a trigger instance for the trigger.eventbus.subscribe type. +// In the external plugin gRPC path the callback client is never wired, so cb is +// always nil and Start is a no-op. The trigger module is created via +// CreateTypedModule in that path; this method exists for the legacy TriggerProvider +// interface. +func (p *eventbusPlugin) CreateTrigger(typeName string, config map[string]any, cb sdk.TriggerCallback) (sdk.TriggerInstance, error) { + if typeName != "trigger.eventbus.subscribe" { + return nil, fmt.Errorf("workflow-plugin-eventbus: unknown trigger type %q", typeName) + } + name, _ := config["name"].(string) + streamName, _ := config["stream_name"].(string) + filterSubject, _ := config["filter_subject"].(string) + cfg := &eventbusv1.ConsumerConfig{ + Name: name, + StreamName: streamName, + FilterSubject: filterSubject, + } + inst, err := eventbus.NewSubscribeTrigger(typeName, cfg, cb) + if err != nil { + return nil, err + } + return inst.(sdk.TriggerInstance), nil +} + +// ── ContractProvider ────────────────────────────────────────────────────────── + +// ContractRegistry returns the typed contract descriptors for all plugin +// capabilities. These match the entries in plugin.contracts.json and are used +// by the engine for strict-proto contract negotiation. +func (p *eventbusPlugin) ContractRegistry() *pb.ContractRegistry { + strict := pb.ContractMode_CONTRACT_MODE_STRICT_PROTO + return &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + // ── modules ─────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "infra.eventbus", + ConfigMessage: "workflow.plugin.eventbus.v1.ClusterConfig", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "infra.eventbus.stream", + ConfigMessage: "workflow.plugin.eventbus.v1.StreamConfig", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "infra.eventbus.consumer", + ConfigMessage: "workflow.plugin.eventbus.v1.ConsumerConfig", + Mode: strict, + }, + // ── steps ───────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.publish", + InputMessage: "workflow.plugin.eventbus.v1.PublishRequest", + OutputMessage: "workflow.plugin.eventbus.v1.PublishResponse", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.consume", + InputMessage: "workflow.plugin.eventbus.v1.ConsumeRequest", + OutputMessage: "workflow.plugin.eventbus.v1.ConsumeResponse", + Mode: strict, + }, + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.eventbus.ack", + InputMessage: "workflow.plugin.eventbus.v1.AckRequest", + OutputMessage: "workflow.plugin.eventbus.v1.AckResponse", + Mode: strict, + }, + // ── triggers ────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_TRIGGER, + TriggerType: "trigger.eventbus.subscribe", + ConfigMessage: "workflow.plugin.eventbus.v1.ConsumerConfig", + OutputMessage: "workflow.plugin.eventbus.v1.Message", + Mode: strict, + }, + }, + } +} diff --git a/integration_test.go b/integration_test.go index 478f68b..fac595d 100644 --- a/integration_test.go +++ b/integration_test.go @@ -1,34 +1,31 @@ -// Package eventbus_test provides an end-to-end integration test that starts -// the workflow-plugin-eventbus binary as a real subprocess, communicates with -// it over the gRPC transport (via go-plugin), and verifies the full typed-proto -// contract surface: module lifecycle, step execution, contract registry, and -// trigger module creation. +// Package eventbus_test provides end-to-end integration tests that start the +// workflow-plugin-eventbus binary as a real subprocess, communicate with it over +// the gRPC transport (via go-plugin), and verify the full typed-proto contract +// surface. // -// The test: -// 1. Compiles and starts the plugin binary as a subprocess via go-plugin. -// 2. Fetches the manifest and contract registry over gRPC. -// 3. Declares an infra.eventbus cluster module (Create → Init → Start → Stop). -// 4. Creates and attempts to execute step.eventbus.publish — expects a -// descriptive error about no URI (no live NATS server required). -// 5. Creates and attempts to execute step.eventbus.consume — expects a -// descriptive error about no consumer registered. -// 6. Creates and attempts to execute step.eventbus.ack — expects a -// descriptive error about empty ack_token. -// 7. Creates a trigger.eventbus.subscribe module and verifies it can be -// initialised and stopped without error. +// Two test functions: +// +// - TestE2E_EventbusPluginScenario — always runs; exercises gRPC transport, +// manifest, contract registry, module lifecycle, and step error paths +// without a live NATS server. // -// Run with -short to skip (requires the Go toolchain to compile the binary). +// - TestE2E_EventbusPluginScenario_WithNATS — gated on INTEGRATION_NATS=1; +// requires a running NATS server with JetStream (NATS_URL env var). +// Publishes 10 messages, consumes all 10, and acks each one. package eventbus_test import ( "context" + "fmt" "os" "os/exec" "path/filepath" "runtime" "testing" + "time" goplugin "github.com/GoCodeAlone/go-plugin" + "github.com/nats-io/nats.go" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/emptypb" @@ -76,7 +73,8 @@ func buildBinary(t *testing.T) string { } // startPlugin starts the plugin binary as a go-plugin subprocess and returns a -// pb.PluginServiceClient connected to it over gRPC. +// pb.PluginServiceClient connected to it over gRPC. The subprocess inherits the +// test process's environment (including NATS_URL when set). func startPlugin(t *testing.T, binaryPath string) pb.PluginServiceClient { t.Helper() @@ -116,24 +114,54 @@ func mustNoRPCErr(t *testing.T, label string, err error, respErr string) { } } -// ── integration scenario ────────────────────────────────────────────────────── +// ── helper: declare a module over gRPC (Create → Init → Start) ─────────────── -// TestE2E_EventbusPluginScenario is the canonical end-to-end integration test. -// -// All calls go through real gRPC proto serialisation: the test process packs -// each request as anypb.Any, sends it over a TCP gRPC connection to the plugin -// subprocess, and unpacks the typed response. -// -// No live NATS server is required — the test deliberately exercises the error -// paths that fire when no broker is reachable, verifying that error messages -// are descriptive and the plugin remains stable under those conditions. +// declareModule sends CreateModule → InitModule → StartModule and registers a +// t.Cleanup that sends StopModule. Returns the module handle. +func declareModule(t *testing.T, ctx context.Context, pbClient pb.PluginServiceClient, typeName, name string, cfg *anypb.Any) string { + t.Helper() + + createResp, err := pbClient.CreateModule(ctx, &pb.CreateModuleRequest{ + Type: typeName, + Name: name, + TypedConfig: cfg, + }) + mustNoRPCErr(t, fmt.Sprintf("CreateModule(%s)", typeName), err, createResp.GetError()) + handle := createResp.HandleId + + initResp, err := pbClient.InitModule(ctx, &pb.HandleRequest{HandleId: handle}) + mustNoRPCErr(t, fmt.Sprintf("InitModule(%s)", typeName), err, initResp.GetError()) + + startResp, err := pbClient.StartModule(ctx, &pb.HandleRequest{HandleId: handle}) + mustNoRPCErr(t, fmt.Sprintf("StartModule(%s)", typeName), err, startResp.GetError()) + + t.Cleanup(func() { + resp, err := pbClient.StopModule(ctx, &pb.HandleRequest{HandleId: handle}) + if err != nil { + t.Logf("StopModule(%s): gRPC error: %v", typeName, err) + } else if resp.GetError() != "" { + t.Logf("StopModule(%s): plugin error: %s", typeName, resp.GetError()) + } + }) + + return handle +} + +// ── TestE2E_EventbusPluginScenario ─────────────────────────────────────────── + +// TestE2E_EventbusPluginScenario verifies the full gRPC contract surface without +// a live NATS server. It always runs (no skip gate). // -// Requires the Go toolchain to compile the plugin binary. Run with -short to skip. +// Verifies: +// - Manifest name and author +// - Contract registry: all 7 strict-proto descriptors present +// - infra.eventbus module lifecycle (Create → Init → Start → Stop) +// - step.eventbus.publish: descriptive error when no broker URI registered +// - step.eventbus.consume: descriptive error when consumer not registered +// - step.eventbus.ack: descriptive error when ack_token is empty +// - trigger.eventbus.subscribe module lifecycle +// - GetModuleTypes / GetStepTypes / GetTriggerTypes RPC coverage func TestE2E_EventbusPluginScenario(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test: requires Go toolchain (run without -short)") - } - ctx := context.Background() // ── 1. Build and start plugin subprocess ────────────────────────────────── @@ -154,7 +182,6 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { registry, err := pbClient.GetContractRegistry(ctx, &emptypb.Empty{}) mustNoRPCErr(t, "GetContractRegistry", err, "") - // Collect type names by kind to verify all expected contracts are present. moduleTypes := make(map[string]bool) stepTypes := make(map[string]bool) triggerTypes := make(map[string]bool) @@ -168,7 +195,6 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { triggerTypes[c.GetTriggerType()] = true } } - for _, want := range []string{"infra.eventbus", "infra.eventbus.stream", "infra.eventbus.consumer"} { if !moduleTypes[want] { t.Errorf("contract registry missing module type %q", want) @@ -182,8 +208,6 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { if !triggerTypes["trigger.eventbus.subscribe"] { t.Error("contract registry missing trigger type trigger.eventbus.subscribe") } - - // Verify all step contracts carry strict-proto mode. for _, c := range registry.GetContracts() { if c.GetKind() == pb.ContractKind_CONTRACT_KIND_STEP { if c.GetMode() != pb.ContractMode_CONTRACT_MODE_STRICT_PROTO { @@ -192,41 +216,17 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { } } - // ── 4. Declare infra.eventbus cluster module via gRPC ───────────────────── - clusterCfg := &eventbusv1.ClusterConfig{ + // ── 4. Declare infra.eventbus cluster module ────────────────────────────── + packedClusterCfg, err := anypb.New(&eventbusv1.ClusterConfig{ Provider: "nats", DeployTarget: "digitalocean.app_platform", - } - packedClusterCfg, err := anypb.New(clusterCfg) + }) if err != nil { t.Fatalf("pack ClusterConfig: %v", err) } - - createModResp, err := pbClient.CreateModule(ctx, &pb.CreateModuleRequest{ - Type: "infra.eventbus", - Name: "e2e-bus", - TypedConfig: packedClusterCfg, - }) - mustNoRPCErr(t, "CreateModule(infra.eventbus)", err, createModResp.GetError()) - modHandle := createModResp.HandleId - - initResp, err := pbClient.InitModule(ctx, &pb.HandleRequest{HandleId: modHandle}) - mustNoRPCErr(t, "InitModule", err, initResp.GetError()) - - startResp, err := pbClient.StartModule(ctx, &pb.HandleRequest{HandleId: modHandle}) - mustNoRPCErr(t, "StartModule", err, startResp.GetError()) - - t.Cleanup(func() { - if resp, err := pbClient.StopModule(ctx, &pb.HandleRequest{HandleId: modHandle}); err != nil { - t.Logf("StopModule: gRPC error: %v", err) - } else if resp.GetError() != "" { - t.Logf("StopModule: plugin error: %s", resp.GetError()) - } - }) + declareModule(t, ctx, pbClient, "infra.eventbus", "e2e-bus", packedClusterCfg) // ── 5. step.eventbus.publish — no broker URI registered ─────────────────── - // Cluster has no URI (no env var set) → expect a descriptive error from the - // plugin, not a gRPC transport error. createPublishResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ Type: "step.eventbus.publish", Name: "e2e-publish", @@ -234,7 +234,7 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { mustNoRPCErr(t, "CreateStep(publish)", err, createPublishResp.GetError()) publishInput, err := anypb.New(&eventbusv1.PublishRequest{ - Subject: "BMW.FULFILLMENT.EVENTS", + Subject: "BMW.FULFILLMENT.ORDERS", Payload: []byte(`{"vin":"WBA3A5C50DF456789","status":"ORDER_PLACED"}`), }) if err != nil { @@ -244,7 +244,6 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { HandleId: createPublishResp.HandleId, TypedInput: publishInput, }) - // Transport must succeed; the plugin error lives in the response field. if err != nil { t.Fatalf("ExecuteStep(publish): gRPC transport error: %v", err) } @@ -303,47 +302,28 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { t.Logf("ExecuteStep(ack) expected plugin error: %s", execAckResp.GetError()) // ── 8. trigger.eventbus.subscribe — module lifecycle ───────────────────── - // The trigger is created as a module in the gRPC path. cb is always nil in - // the subprocess transport; Start is a no-op. - consumerCfg := &eventbusv1.ConsumerConfig{ + packedConsumerCfg, err := anypb.New(&eventbusv1.ConsumerConfig{ Name: "bmw-fulfillment-handler", StreamName: "BMW_FULFILLMENT", - } - packedConsumerCfg, err := anypb.New(consumerCfg) + }) if err != nil { - t.Fatalf("pack ConsumerConfig: %v", err) + t.Fatalf("pack ConsumerConfig for trigger: %v", err) } - - createTrigResp, err := pbClient.CreateModule(ctx, &pb.CreateModuleRequest{ - Type: "trigger.eventbus.subscribe", - Name: "e2e-trigger", - TypedConfig: packedConsumerCfg, - }) - mustNoRPCErr(t, "CreateModule(trigger.eventbus.subscribe)", err, createTrigResp.GetError()) - trigHandle := createTrigResp.HandleId - - initTrigResp, err := pbClient.InitModule(ctx, &pb.HandleRequest{HandleId: trigHandle}) - mustNoRPCErr(t, "InitModule(trigger)", err, initTrigResp.GetError()) - - startTrigResp, err := pbClient.StartModule(ctx, &pb.HandleRequest{HandleId: trigHandle}) - mustNoRPCErr(t, "StartModule(trigger)", err, startTrigResp.GetError()) - - stopTrigResp, err := pbClient.StopModule(ctx, &pb.HandleRequest{HandleId: trigHandle}) - mustNoRPCErr(t, "StopModule(trigger)", err, stopTrigResp.GetError()) + declareModule(t, ctx, pbClient, "trigger.eventbus.subscribe", "e2e-trigger", packedConsumerCfg) // ── 9. GetModuleTypes / GetStepTypes / GetTriggerTypes ──────────────────── modTypes, err := pbClient.GetModuleTypes(ctx, &emptypb.Empty{}) mustNoRPCErr(t, "GetModuleTypes", err, "") - expectedModTypes := map[string]bool{ - "infra.eventbus": false, - "infra.eventbus.stream": false, - "infra.eventbus.consumer": false, - "trigger.eventbus.subscribe": false, + wantModTypes := map[string]bool{ + "infra.eventbus": false, + "infra.eventbus.stream": false, + "infra.eventbus.consumer": false, + "trigger.eventbus.subscribe": false, } for _, typ := range modTypes.GetTypes() { - expectedModTypes[typ] = true + wantModTypes[typ] = true } - for typ, found := range expectedModTypes { + for typ, found := range wantModTypes { if !found { t.Errorf("GetModuleTypes: missing %q", typ) } @@ -351,15 +331,15 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { stepTypeList, err := pbClient.GetStepTypes(ctx, &emptypb.Empty{}) mustNoRPCErr(t, "GetStepTypes", err, "") - expectedStepTypes := map[string]bool{ + wantStepTypes := map[string]bool{ "step.eventbus.publish": false, "step.eventbus.consume": false, "step.eventbus.ack": false, } for _, typ := range stepTypeList.GetTypes() { - expectedStepTypes[typ] = true + wantStepTypes[typ] = true } - for typ, found := range expectedStepTypes { + for typ, found := range wantStepTypes { if !found { t.Errorf("GetStepTypes: missing %q", typ) } @@ -377,3 +357,203 @@ func TestE2E_EventbusPluginScenario(t *testing.T) { t.Error("GetTriggerTypes: missing trigger.eventbus.subscribe") } } + +// ── TestE2E_EventbusPluginScenario_WithNATS ─────────────────────────────────── + +// TestE2E_EventbusPluginScenario_WithNATS exercises the full publish → consume +// → ack pipeline against a live NATS server with JetStream. +// +// Gate: INTEGRATION_NATS=1 must be set. NATS_URL must contain the broker URI. +// +// The test: +// 1. Connects directly to NATS to pre-create the JetStream stream + durable consumer. +// 2. Builds + starts the plugin binary as a subprocess; the subprocess inherits +// NATS_URL from the test process environment, which the infra.eventbus module +// resolves during Init. +// 3. Declares infra.eventbus, infra.eventbus.stream, and infra.eventbus.consumer +// modules via gRPC (Create → Init → Start). +// 4. Publishes 10 messages via step.eventbus.publish. +// 5. Consumes all 10 in a single batch via step.eventbus.consume (batch_size=10). +// 6. Acks each message via step.eventbus.ack using the ack_token from the response. +func TestE2E_EventbusPluginScenario_WithNATS(t *testing.T) { + if os.Getenv("INTEGRATION_NATS") != "1" { + t.Skip("skipping NATS integration test: set INTEGRATION_NATS=1 and NATS_URL to run") + } + + natsURL := os.Getenv("NATS_URL") + if natsURL == "" { + t.Fatal("INTEGRATION_NATS=1 but NATS_URL is not set") + } + + const ( + streamName = "BMW_FULFILLMENT" + streamSubject = "BMW.FULFILLMENT.>" + publishSubj = "BMW.FULFILLMENT.ORDERS" + consumerName = "bmw-fulfillment-handler" + numMessages = 10 + ) + + ctx := context.Background() + + // ── 1. Pre-create JetStream stream + durable consumer in test process ───── + // The stream and consumer modules only register config in the plugin — they + // do not provision resources on the broker. We create them directly here so + // the publish and consume steps work against a live stream. + nc, err := nats.Connect(natsURL, nats.Timeout(10*time.Second)) + if err != nil { + t.Fatalf("connect to NATS at %s: %v", natsURL, err) + } + t.Cleanup(nc.Close) + + js, err := nc.JetStream() + if err != nil { + t.Fatalf("JetStream context: %v", err) + } + + // Create or update the stream. + if _, err := js.StreamInfo(streamName); err != nil { + if _, err := js.AddStream(&nats.StreamConfig{ + Name: streamName, + Subjects: []string{streamSubject}, + }); err != nil { + t.Fatalf("create JetStream stream %q: %v", streamName, err) + } + } + t.Cleanup(func() { + if err := js.DeleteStream(streamName); err != nil { + t.Logf("delete stream %q: %v", streamName, err) + } + }) + + // Create or update the durable consumer. + if _, err := js.ConsumerInfo(streamName, consumerName); err != nil { + if _, err := js.AddConsumer(streamName, &nats.ConsumerConfig{ + Durable: consumerName, + AckPolicy: nats.AckExplicitPolicy, + MaxDeliver: 3, + }); err != nil { + t.Fatalf("create durable consumer %q: %v", consumerName, err) + } + } + + // ── 2. Build + start plugin subprocess ──────────────────────────────────── + // NATS_URL is inherited from the test process environment. + binaryPath := buildBinary(t) + pbClient := startPlugin(t, binaryPath) + + // ── 3. Declare modules via gRPC ─────────────────────────────────────────── + packedClusterCfg, err := anypb.New(&eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + }) + if err != nil { + t.Fatalf("pack ClusterConfig: %v", err) + } + declareModule(t, ctx, pbClient, "infra.eventbus", "nats-bus", packedClusterCfg) + + packedStreamCfg, err := anypb.New(&eventbusv1.StreamConfig{ + Name: streamName, + Subjects: []string{streamSubject}, + }) + if err != nil { + t.Fatalf("pack StreamConfig: %v", err) + } + declareModule(t, ctx, pbClient, "infra.eventbus.stream", "bmw-fulfillment-stream", packedStreamCfg) + + packedConsumerCfg, err := anypb.New(&eventbusv1.ConsumerConfig{ + Name: consumerName, + StreamName: streamName, + }) + if err != nil { + t.Fatalf("pack ConsumerConfig: %v", err) + } + declareModule(t, ctx, pbClient, "infra.eventbus.consumer", "bmw-fulfillment-consumer", packedConsumerCfg) + + // ── 4. Publish 10 messages via step.eventbus.publish ───────────────────── + createPublishResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.eventbus.publish", + Name: "nats-publish", + }) + mustNoRPCErr(t, "CreateStep(publish)", err, createPublishResp.GetError()) + publishHandle := createPublishResp.HandleId + + for i := 1; i <= numMessages; i++ { + publishInput, err := anypb.New(&eventbusv1.PublishRequest{ + Subject: publishSubj, + Payload: []byte(fmt.Sprintf(`{"n":%d,"event":"ORDER_PLACED"}`, i)), + }) + if err != nil { + t.Fatalf("pack PublishRequest %d: %v", i, err) + } + execResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: publishHandle, + TypedInput: publishInput, + }) + mustNoRPCErr(t, fmt.Sprintf("ExecuteStep(publish) msg %d", i), err, execResp.GetError()) + + var out eventbusv1.PublishResponse + if err := execResp.GetTypedOutput().UnmarshalTo(&out); err != nil { + t.Fatalf("unpack PublishResponse msg %d: %v", i, err) + } + if out.GetSequence() == "" { + t.Errorf("publish msg %d: expected non-empty sequence", i) + } + if out.GetAckedAt() == "" { + t.Errorf("publish msg %d: expected non-empty acked_at", i) + } + } + + // ── 5. Consume all 10 in a single batch via step.eventbus.consume ───────── + createConsumeResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.eventbus.consume", + Name: "nats-consume", + }) + mustNoRPCErr(t, "CreateStep(consume)", err, createConsumeResp.GetError()) + + consumeInput, err := anypb.New(&eventbusv1.ConsumeRequest{ + Consumer: consumerName, + BatchSize: numMessages, + }) + if err != nil { + t.Fatalf("pack ConsumeRequest: %v", err) + } + execConsumeResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: createConsumeResp.HandleId, + TypedInput: consumeInput, + }) + mustNoRPCErr(t, "ExecuteStep(consume)", err, execConsumeResp.GetError()) + + var consumeOut eventbusv1.ConsumeResponse + if err := execConsumeResp.GetTypedOutput().UnmarshalTo(&consumeOut); err != nil { + t.Fatalf("unpack ConsumeResponse: %v", err) + } + msgs := consumeOut.GetMessages() + if len(msgs) != numMessages { + t.Fatalf("consume: got %d messages, want %d", len(msgs), numMessages) + } + + // ── 6. Ack each message via step.eventbus.ack ───────────────────────────── + createAckResp, err := pbClient.CreateStep(ctx, &pb.CreateStepRequest{ + Type: "step.eventbus.ack", + Name: "nats-ack", + }) + mustNoRPCErr(t, "CreateStep(ack)", err, createAckResp.GetError()) + ackHandle := createAckResp.HandleId + + for i, msg := range msgs { + if msg.GetAckToken() == "" { + t.Errorf("message %d: ack_token is empty", i) + continue + } + ackInput, err := anypb.New(&eventbusv1.AckRequest{AckToken: msg.GetAckToken()}) + if err != nil { + t.Fatalf("pack AckRequest for msg %d: %v", i, err) + } + execAckResp, err := pbClient.ExecuteStep(ctx, &pb.ExecuteStepRequest{ + HandleId: ackHandle, + TypedInput: ackInput, + }) + mustNoRPCErr(t, fmt.Sprintf("ExecuteStep(ack) msg %d", i), err, execAckResp.GetError()) + } + t.Logf("published %d messages, consumed %d, acked %d", numMessages, len(msgs), len(msgs)) +} From 1131033e504ad0f09b964dbae8214023cc668a3e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 4 May 2026 00:55:25 -0400 Subject: [PATCH 8/9] fix(trigger): address 1 critical + 2 important + 2 minor from quality review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL — callback map didn't match Message proto contract (trigger.go): fetchAndFire now builds *eventbusv1.Message the same way ConsumeHandler does: Payload=m.Data ([]byte, not string), AckToken=m.Reply (not "reply"), plus Headers (map[string]string from NATS headers), Sequence (stream seq as string), PublishedAt (RFC3339 UTC). Callback data map keys are the six proto field names: subject, payload, headers, sequence, published_at, ack_token. IMPORTANT-1 — zero test coverage for fetchAndFire (trigger_test.go): Added four new tests using an embedded NATS server (nats-server/v2, already an indirect dep, now explicit in go.mod): - TestSubscribeTrigger_FetchAndFire_CallbackData: publishes 1 msg with a custom header, asserts all six data map fields are present with correct Go types ([]byte for payload, string for ack_token — catches the CRITICAL regression). - TestSubscribeTrigger_FetchLoop_ExitsOnCancel: Stop returns within 5s after Start; goroutine exits cleanly on ctx cancel. - TestSubscribeTrigger_FetchLoop_RetryOnError: publishes after Start; loop keeps polling until the message arrives (retry path). - TestSubscribeTrigger_DoubleStart: second Start returns error; first goroutine exits cleanly on Stop (double-start guard test). IMPORTANT-2 — double-Start goroutine leak (trigger.go): Start() now returns an error if t.cancel != nil (already started). MINOR-3 — time.After allocation in retry backoff (trigger.go): Replaced time.After with time.NewTimer + backoff.Reset so a single timer is reused across all retry iterations in fetchLoop. MINOR-4 — CreateTrigger silent type assertion discard (plugin.go): Added configString() helper that returns an explicit error when a config key is present but not a string (e.g. config["name"]=42 → "config[name] must be a string, got int"), rather than silently returning "" and confusing callers. All tests pass (GOWORK=off go test ./... -count=1). Co-Authored-By: Claude Sonnet 4.6 --- cmd/workflow-plugin-eventbus/plugin.go | 29 +- go.mod | 6 + go.sum | 3 + trigger.go | 64 ++++- trigger_test.go | 384 +++++++++++++++++++++++++ 5 files changed, 473 insertions(+), 13 deletions(-) diff --git a/cmd/workflow-plugin-eventbus/plugin.go b/cmd/workflow-plugin-eventbus/plugin.go index de48edf..a7d6bf7 100644 --- a/cmd/workflow-plugin-eventbus/plugin.go +++ b/cmd/workflow-plugin-eventbus/plugin.go @@ -122,9 +122,18 @@ func (p *eventbusPlugin) CreateTrigger(typeName string, config map[string]any, c if typeName != "trigger.eventbus.subscribe" { return nil, fmt.Errorf("workflow-plugin-eventbus: unknown trigger type %q", typeName) } - name, _ := config["name"].(string) - streamName, _ := config["stream_name"].(string) - filterSubject, _ := config["filter_subject"].(string) + // Perform explicit type assertions so callers get a clear error when a field + // is present but has the wrong type (e.g. config["name"] = 42 gives + // "config[name] must be a string, got int" rather than "config.name is required"). + name, err := configString(config, "name") + if err != nil { + return nil, fmt.Errorf("workflow-plugin-eventbus: CreateTrigger %q: %w", typeName, err) + } + streamName, err := configString(config, "stream_name") + if err != nil { + return nil, fmt.Errorf("workflow-plugin-eventbus: CreateTrigger %q: %w", typeName, err) + } + filterSubject, _ := configString(config, "filter_subject") //nolint:errcheck // optional field cfg := &eventbusv1.ConsumerConfig{ Name: name, StreamName: streamName, @@ -137,6 +146,20 @@ func (p *eventbusPlugin) CreateTrigger(typeName string, config map[string]any, c return inst.(sdk.TriggerInstance), nil } +// configString extracts key from config as a string. Returns an error if the +// key is present but not a string type. +func configString(config map[string]any, key string) (string, error) { + v, ok := config[key] + if !ok { + return "", nil // absent is fine; required-field validation is in NewSubscribeTrigger + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("config[%s] must be a string, got %T", key, v) + } + return s, nil +} + // ── ContractProvider ────────────────────────────────────────────────────────── // ContractRegistry returns the typed contract descriptors for all plugin diff --git a/go.mod b/go.mod index db294d8..71af374 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/IBM/sarama v1.47.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op // indirect github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect @@ -92,6 +93,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golobby/cast v1.3.3 // indirect github.com/google/go-querystring v1.2.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect @@ -126,6 +128,7 @@ require ( github.com/klauspost/compress v1.18.5 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -136,6 +139,8 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/jwt/v2 v2.8.0 // indirect + github.com/nats-io/nats-server/v2 v2.12.4 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -171,6 +176,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect diff --git a/go.sum b/go.sum index efcfcfa..6435048 100644 --- a/go.sum +++ b/go.sum @@ -467,6 +467,8 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -521,6 +523,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/trigger.go b/trigger.go index 6e2490a..f3f7d36 100644 --- a/trigger.go +++ b/trigger.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "time" "github.com/nats-io/nats.go" @@ -64,8 +65,8 @@ type subscribeTrigger struct { config *eventbusv1.ConsumerConfig cb sdk.TriggerCallback - cancel context.CancelFunc // set by Start; nil before first Start - done chan struct{} // closed when the goroutine exits (nil before first Start with cb) + cancel context.CancelFunc // set by Start; nil before first Start + done chan struct{} // closed when the goroutine exits (nil before first Start with cb) } // Compile-time assertions. @@ -103,11 +104,17 @@ func (t *subscribeTrigger) Init() error { return nil } // Start launches the trigger goroutine if cb is non-nil. If cb is nil (the // external plugin gRPC path), Start is a no-op. +// +// Returns an error if Start has already been called without a matching Stop +// (double-start guard: avoids goroutine leak when the SDK calls Start twice). func (t *subscribeTrigger) Start(ctx context.Context) error { if t.cb == nil { // External plugin path: callback is never wired — no goroutine needed. return nil } + if t.cancel != nil { + return fmt.Errorf("trigger.eventbus.subscribe %q: already started", t.instanceName) + } trigCtx, cancel := context.WithCancel(ctx) t.cancel = cancel @@ -134,6 +141,9 @@ func (t *subscribeTrigger) Stop(_ context.Context) error { func (t *subscribeTrigger) fetchLoop(ctx context.Context) { defer close(t.done) + backoff := time.NewTimer(time.Second) + defer backoff.Stop() + for { // Exit immediately on context cancellation before each fetch round. select { @@ -143,13 +153,20 @@ func (t *subscribeTrigger) fetchLoop(ctx context.Context) { } if err := t.fetchAndFire(ctx); err != nil { - // Log the error but keep retrying — the bus may be temporarily - // unavailable or the stream may not exist yet. - // A 1-second back-off prevents a tight spin loop on persistent errors. + // Keep retrying — the bus may be temporarily unavailable or the + // stream may not exist yet. Drain the backoff timer before resetting + // to avoid a spurious immediate fire on the next error. + if !backoff.Stop() { + select { + case <-backoff.C: + default: + } + } + backoff.Reset(time.Second) select { case <-ctx.Done(): return - case <-time.After(time.Second): + case <-backoff.C: } } } @@ -158,6 +175,10 @@ func (t *subscribeTrigger) fetchLoop(ctx context.Context) { // fetchAndFire dials the bus, fetches one batch of messages, and invokes cb for // each one. It returns an error if the connection or fetch fails (the caller // retries). A JetStream timeout (empty batch) is not treated as an error. +// +// The callback data map mirrors the workflow.plugin.eventbus.v1.Message proto: +// "subject", "payload" ([]byte), "headers" (map[string]string), "sequence", +// "published_at", "ack_token". func (t *subscribeTrigger) fetchAndFire(ctx context.Context) error { nc, err := DefaultBusConn() if err != nil { @@ -186,13 +207,36 @@ func (t *subscribeTrigger) fetchAndFire(ctx context.Context) error { } for _, m := range msgs { + // Build a typed Message to ensure field names and types match the proto contract: + // workflow.plugin.eventbus.v1.Message (subject, payload, headers, sequence, + // published_at, ack_token). This mirrors ConsumeHandler in steps/consume.go. + pbMsg := &eventbusv1.Message{ + Subject: m.Subject, + Payload: m.Data, // []byte — not string; proto field type is bytes + AckToken: m.Reply, // NATS reply subject used as ack_token + } + if len(m.Header) > 0 { + pbMsg.Headers = make(map[string]string, len(m.Header)) + for k, vals := range m.Header { + if len(vals) > 0 { + pbMsg.Headers[k] = vals[0] + } + } + } + if meta, err := m.Metadata(); err == nil && meta != nil { + pbMsg.Sequence = strconv.FormatUint(meta.Sequence.Stream, 10) + pbMsg.PublishedAt = meta.Timestamp.UTC().Format(time.RFC3339) + } + data := map[string]any{ - "subject": m.Subject, - "payload": string(m.Data), - "reply": m.Reply, + "subject": pbMsg.Subject, + "payload": pbMsg.Payload, + "headers": pbMsg.Headers, + "sequence": pbMsg.Sequence, + "published_at": pbMsg.PublishedAt, + "ack_token": pbMsg.AckToken, } if err := t.cb("message", data); err != nil { - // Callback errors are non-fatal; log via returned error and continue. return fmt.Errorf("trigger.eventbus.subscribe %q: callback: %w", t.instanceName, err) } } diff --git a/trigger_test.go b/trigger_test.go index 406286e..b3815bc 100644 --- a/trigger_test.go +++ b/trigger_test.go @@ -2,10 +2,17 @@ package eventbus_test import ( "context" + "fmt" + "sync" "testing" + "time" + + natsserver "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" eventbus "github.com/GoCodeAlone/workflow-plugin-eventbus" eventbusv1 "github.com/GoCodeAlone/workflow-plugin-eventbus/gen" + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) // ── SubscribeTriggerModuleFactory (TypedModuleProvider) ─────────────────────── @@ -97,3 +104,380 @@ func TestSubscribeTrigger_LifecycleNilCallback(t *testing.T) { t.Fatalf("Stop: %v", err) } } + +// TestSubscribeTrigger_DoubleStart verifies that calling Start twice returns +// an error without leaking the first goroutine. +func TestSubscribeTrigger_DoubleStart(t *testing.T) { + cfg := &eventbusv1.ConsumerConfig{ + Name: "bmw-double-start", + StreamName: "BMW_FULFILLMENT", + } + fired := make(chan struct{}) + cb := func(action string, data map[string]any) error { + close(fired) + return nil + } + m, err := eventbus.NewSubscribeTrigger("trigger-double-start", cfg, cb) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + // First Start — succeeds (goroutine launched but will retry with backoff + // because no bus is registered; that's fine for this test). + if err := m.Start(context.Background()); err != nil { + t.Fatalf("first Start: %v", err) + } + // Second Start — must return an error. + if err := m.Start(context.Background()); err == nil { + t.Error("second Start: expected error for double-start, got nil") + } + // Cleanup: Stop cancels the first goroutine cleanly. + if err := m.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } +} + +// ── embedded NATS server helpers ────────────────────────────────────────────── + +// startEmbeddedNATS starts an embedded NATS server with JetStream enabled and +// returns the connection URL. The server is shut down in t.Cleanup. +func startEmbeddedNATS(t *testing.T) string { + t.Helper() + opts := &natsserver.Options{ + Host: "127.0.0.1", + Port: natsserver.RANDOM_PORT, + JetStream: true, + NoLog: true, + NoSigs: true, + } + srv, err := natsserver.NewServer(opts) + if err != nil { + t.Fatalf("start embedded NATS: %v", err) + } + srv.Start() + if !srv.ReadyForConnections(5 * time.Second) { + t.Fatal("embedded NATS not ready within 5s") + } + t.Cleanup(srv.Shutdown) + return srv.ClientURL() +} + +// setupNATSStream creates a JetStream stream and durable consumer on the given +// connection and returns the connection (already open; caller must close). +func setupNATSStream(t *testing.T, url, streamName, subject, consumerName string) *nats.Conn { + t.Helper() + nc, err := nats.Connect(url) + if err != nil { + t.Fatalf("connect to embedded NATS: %v", err) + } + js, err := nc.JetStream() + if err != nil { + nc.Close() + t.Fatalf("JetStream context: %v", err) + } + if _, err := js.AddStream(&nats.StreamConfig{ + Name: streamName, + Subjects: []string{subject}, + }); err != nil { + nc.Close() + t.Fatalf("create stream %q: %v", streamName, err) + } + if _, err := js.AddConsumer(streamName, &nats.ConsumerConfig{ + Durable: consumerName, + AckPolicy: nats.AckExplicitPolicy, + }); err != nil { + nc.Close() + t.Fatalf("create consumer %q: %v", consumerName, err) + } + return nc +} + +// ── fetchAndFire — callback data contract ───────────────────────────────────── + +// TestSubscribeTrigger_FetchAndFire_CallbackData verifies that fetchAndFire +// invokes the callback with a data map whose keys and value types match the +// workflow.plugin.eventbus.v1.Message proto contract: +// +// "subject" → string +// "payload" → []byte (not string — proto field is bytes) +// "headers" → map[string]string (nil if no headers) +// "sequence" → string +// "published_at" → string +// "ack_token" → string +// +// This test exercises the in-process trigger wiring path (cb != nil) end-to-end +// using an embedded NATS server so no external infrastructure is required. +func TestSubscribeTrigger_FetchAndFire_CallbackData(t *testing.T) { + const ( + instanceName = "trigger-fetch-test" + streamName = "FETCH_TEST" + subject = "FETCH_TEST.events" + consumerName = "fetch-test-consumer" + ) + + natsURL := startEmbeddedNATS(t) + nc := setupNATSStream(t, natsURL, streamName, subject, consumerName) + defer nc.Close() + + // Pre-register the cluster in the global registry so DefaultBusConn resolves. + eventbus.RegisterCluster(instanceName, &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + }) + t.Cleanup(func() { eventbus.UnregisterCluster(instanceName) }) + eventbus.RegisterBusURI(instanceName, natsURL) + t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) + // Pre-populate the NATS connection cache; avoids dialling in the trigger goroutine. + eventbus.RegisterNATSConn(instanceName, nc) + t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) + + // Publish one message with a custom header. + js, err := nc.JetStream() + if err != nil { + t.Fatalf("JetStream: %v", err) + } + msg := &nats.Msg{ + Subject: subject, + Data: []byte(`{"vin":"WBA3A5C50DF456789"}`), + Header: nats.Header{"X-Trace-Id": []string{"abc123"}}, + } + if _, err := js.PublishMsg(msg); err != nil { + t.Fatalf("publish: %v", err) + } + + // Wire a callback that captures the data map. + var ( + mu sync.Mutex + gotData map[string]any + gotOnce sync.Once + done = make(chan struct{}) + ) + cb := sdk.TriggerCallback(func(action string, data map[string]any) error { + mu.Lock() + defer mu.Unlock() + gotOnce.Do(func() { + gotData = data + close(done) + }) + return nil + }) + + cfg := &eventbusv1.ConsumerConfig{ + Name: consumerName, + StreamName: streamName, + } + m, err := eventbus.NewSubscribeTrigger(instanceName, cfg, cb) + if err != nil { + t.Fatalf("create trigger: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + // Wait for callback (timeout after 10s). + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("callback not invoked within 10s") + } + if err := m.Stop(context.Background()); err != nil { + t.Fatalf("Stop: %v", err) + } + + mu.Lock() + d := gotData + mu.Unlock() + + // ── assert all six Message proto fields are present with correct types ───── + + subject_, ok := d["subject"].(string) + if !ok { + t.Errorf("data[subject]: expected string, got %T", d["subject"]) + } else if subject_ != subject { + t.Errorf("data[subject] = %q, want %q", subject_, subject) + } + + payload, ok := d["payload"].([]byte) + if !ok { + t.Errorf("data[payload]: expected []byte, got %T (value: %v)", d["payload"], d["payload"]) + } else if string(payload) != `{"vin":"WBA3A5C50DF456789"}` { + t.Errorf("data[payload] = %q, want JSON payload", payload) + } + + headers, ok := d["headers"].(map[string]string) + if !ok { + t.Errorf("data[headers]: expected map[string]string, got %T", d["headers"]) + } else if headers["X-Trace-Id"] != "abc123" { + t.Errorf("data[headers][X-Trace-Id] = %q, want %q", headers["X-Trace-Id"], "abc123") + } + + seq, ok := d["sequence"].(string) + if !ok { + t.Errorf("data[sequence]: expected string, got %T", d["sequence"]) + } else if seq == "" { + t.Error("data[sequence] is empty") + } + + publishedAt, ok := d["published_at"].(string) + if !ok { + t.Errorf("data[published_at]: expected string, got %T", d["published_at"]) + } else if publishedAt == "" { + t.Error("data[published_at] is empty") + } + + // ack_token is the NATS reply subject — non-empty for JetStream messages. + ackToken, ok := d["ack_token"].(string) + if !ok { + t.Errorf("data[ack_token]: expected string, got %T", d["ack_token"]) + } else if ackToken == "" { + t.Error("data[ack_token] is empty for JetStream message") + } + + // Verify no unexpected extra keys beyond the six proto fields. + wantKeys := map[string]bool{ + "subject": true, "payload": true, "headers": true, + "sequence": true, "published_at": true, "ack_token": true, + } + for k := range d { + if !wantKeys[k] { + t.Errorf("data contains unexpected key %q", k) + } + } +} + +// TestSubscribeTrigger_FetchLoop_ExitsOnCancel verifies that the goroutine +// started by Start exits cleanly when Stop is called (context cancel path). +func TestSubscribeTrigger_FetchLoop_ExitsOnCancel(t *testing.T) { + const ( + instanceName = "trigger-cancel-test" + streamName = "CANCEL_TEST" + subject = "CANCEL_TEST.events" + consumerName = "cancel-test-consumer" + ) + + natsURL := startEmbeddedNATS(t) + nc := setupNATSStream(t, natsURL, streamName, subject, consumerName) + defer nc.Close() + + eventbus.RegisterCluster(instanceName, &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + }) + t.Cleanup(func() { eventbus.UnregisterCluster(instanceName) }) + eventbus.RegisterBusURI(instanceName, natsURL) + t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) + eventbus.RegisterNATSConn(instanceName, nc) + + cb := sdk.TriggerCallback(func(string, map[string]any) error { return nil }) + cfg := &eventbusv1.ConsumerConfig{ + Name: consumerName, + StreamName: streamName, + } + m, err := eventbus.NewSubscribeTrigger(instanceName, cfg, cb) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + + // Stop must return promptly — the goroutine must exit within fetchPollInterval + margin. + stopDone := make(chan error, 1) + go func() { stopDone <- m.Stop(context.Background()) }() + select { + case err := <-stopDone: + if err != nil { + t.Errorf("Stop: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Stop did not return within 5s — goroutine likely leaked") + } +} + +// TestSubscribeTrigger_FetchLoop_RetryOnError verifies that fetchLoop keeps +// retrying after a transient error and eventually fires the callback when +// the stream becomes available. We simulate "not yet available" by publishing +// after Start rather than before. +func TestSubscribeTrigger_FetchLoop_RetryOnError(t *testing.T) { + const ( + instanceName = "trigger-retry-test" + streamName = "RETRY_TEST" + subject = "RETRY_TEST.events" + consumerName = "retry-test-consumer" + ) + + natsURL := startEmbeddedNATS(t) + nc := setupNATSStream(t, natsURL, streamName, subject, consumerName) + defer nc.Close() + + eventbus.RegisterCluster(instanceName, &eventbusv1.ClusterConfig{ + Provider: "nats", + DeployTarget: "digitalocean.app_platform", + }) + t.Cleanup(func() { eventbus.UnregisterCluster(instanceName) }) + eventbus.RegisterBusURI(instanceName, natsURL) + t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) + eventbus.RegisterNATSConn(instanceName, nc) + + var ( + mu sync.Mutex + received []map[string]any + done = make(chan struct{}) + ) + cb := sdk.TriggerCallback(func(action string, data map[string]any) error { + mu.Lock() + defer mu.Unlock() + received = append(received, data) + if len(received) == 1 { + close(done) + } + return nil + }) + + cfg := &eventbusv1.ConsumerConfig{ + Name: consumerName, + StreamName: streamName, + } + m, err := eventbus.NewSubscribeTrigger(instanceName, cfg, cb) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := m.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + if err := m.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + defer func() { _ = m.Stop(context.Background()) }() + + // Publish the message after Start so the loop polls at least once before receiving. + js, err := nc.JetStream() + if err != nil { + t.Fatalf("JetStream: %v", err) + } + if _, err := js.Publish(subject, []byte(fmt.Sprintf(`{"retry":true}`))); err != nil { + t.Fatalf("publish: %v", err) + } + + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("callback not invoked within 15s") + } + + mu.Lock() + count := len(received) + mu.Unlock() + if count < 1 { + t.Errorf("expected at least 1 callback invocation, got %d", count) + } +} From 93fe9b0e424fc4e4184b68a1073c51cf97bf5e46 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 4 May 2026 00:58:46 -0400 Subject: [PATCH 9/9] fix(trigger): add UnregisterNATSConn + correct test cleanups (parting nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module.go: export UnregisterNATSConn(instanceName) — evicts the entry from natsConnCache without closing the connection (for tests that own the conn lifetime via nc.Close() + embedded-server shutdown). - trigger_test.go: replace the three duplicate UnregisterBusURI cleanups that followed RegisterNATSConn with UnregisterNATSConn, ensuring natsConnCache is cleaned between tests. Co-Authored-By: Claude Sonnet 4.6 --- module.go | 9 +++++++++ trigger_test.go | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/module.go b/module.go index 7a47790..ed3caab 100644 --- a/module.go +++ b/module.go @@ -98,6 +98,15 @@ func RegisterNATSConn(instanceName string, conn *nats.Conn) { natsConnCache[instanceName] = conn } +// UnregisterNATSConn removes the cached connection entry for instanceName without +// closing the connection. Use this in tests that manage the connection's lifetime +// separately (e.g., via nc.Close() + embedded-server shutdown). +func UnregisterNATSConn(instanceName string) { + connCacheMu.Lock() + defer connCacheMu.Unlock() + delete(natsConnCache, instanceName) +} + // GetNATSConn returns the cached *nats.Conn for instanceName, or false if absent. func GetNATSConn(instanceName string) (*nats.Conn, bool) { connCacheMu.Lock() diff --git a/trigger_test.go b/trigger_test.go index b3815bc..48f451f 100644 --- a/trigger_test.go +++ b/trigger_test.go @@ -232,7 +232,7 @@ func TestSubscribeTrigger_FetchAndFire_CallbackData(t *testing.T) { t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) // Pre-populate the NATS connection cache; avoids dialling in the trigger goroutine. eventbus.RegisterNATSConn(instanceName, nc) - t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) + t.Cleanup(func() { eventbus.UnregisterNATSConn(instanceName) }) // Publish one message with a custom header. js, err := nc.JetStream() @@ -373,6 +373,7 @@ func TestSubscribeTrigger_FetchLoop_ExitsOnCancel(t *testing.T) { eventbus.RegisterBusURI(instanceName, natsURL) t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) eventbus.RegisterNATSConn(instanceName, nc) + t.Cleanup(func() { eventbus.UnregisterNATSConn(instanceName) }) cb := sdk.TriggerCallback(func(string, map[string]any) error { return nil }) cfg := &eventbusv1.ConsumerConfig{ @@ -427,6 +428,7 @@ func TestSubscribeTrigger_FetchLoop_RetryOnError(t *testing.T) { eventbus.RegisterBusURI(instanceName, natsURL) t.Cleanup(func() { eventbus.UnregisterBusURI(instanceName) }) eventbus.RegisterNATSConn(instanceName, nc) + t.Cleanup(func() { eventbus.UnregisterNATSConn(instanceName) }) var ( mu sync.Mutex