diff --git a/go.mod b/go.mod index 92bdae198..85ba71aad 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,11 @@ module github.com/smartcontractkit/capabilities go 1.27.1 require ( - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 google.golang.org/protobuf v1.36.11 ) require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/stretchr/objx v0.5.3 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect ) diff --git a/go.sum b/go.sum index 75853af65..6be55f4fa 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,10 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -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/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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/libs/capability/AGENTS.md b/libs/capability/AGENTS.md new file mode 100644 index 000000000..f3bcd6df1 --- /dev/null +++ b/libs/capability/AGENTS.md @@ -0,0 +1,286 @@ +# `capability` — standalone capability binary, spike + +## What this is + +An in-progress replacement for `libs/standalone`'s bootstrapper. The goal is that a capability +binary's `main` is one line: + +```go +func main() { + capability.Run(ctx, trigger.NewCron, capability.NewConfigProvider(trigger.DefaultConfig), + capability.WithOtelViews(trigger.MetricViews()...)) +} +``` + +Dependencies register themselves, and their config, from their packages' `init`: +`capability.Provide[T](provider)` and `capability.ProvideConfig[T](namespace, default, opts...)`, +or their `ForRun`/`ForEmbed` forms to register for one command only (the plain forms call both). A +`Constructor` is `func(do.Injector) (Capability, error)`: it resolves what it needs, with +`do.Invoke[T](i)`, from the injector of the instance it is built for. The capability's own config is +a `ConfigProvider[T]` - its default and any `cli.RegisterOption[T]`s (a profile, say) - which +`RunErr` registers with `ProvideConfig` under the binary's name (`--cron.`). A capability +with no config passes `NewConfigProvider(struct{}{})`. `Option` (`WithOtelViews`) is not generic. + +The reference implementation to compare against is `libs/standalone/bootstrapper.go` (process +lifecycle) and `libs/standalone/capability` (capability hosting). Neither has been changed except +for one extraction (see *Relationship to `libs/standalone`*). + +`cron/main.go` is meant to be the first user; it still runs on `loopserver` today. The old +bootstrapper remains for everything else. + +## Current state + +`RunErr(ctx, lggr, ctor, config, opts...)` builds a cobra root (named after the executable) and +hangs `run`, `embed` and `docs` subcommands off it, binding the configs registered for each +command on it. The constructor builds one capability per +instance. `embed` runs the same sequence as `run`, `--instances` times over the process-wide +services (profiler, telemetry, CRE settings). +`docs` generates the doc comments flag help is read from (see *Config*). + +`start(ctx, lggr, name, opts, r, binder, total, ctor)` (in `runner.go`) is the whole sequence, the +same for both commands: `run` passes its registrations and 1, `embed` its own and `--instances`. +`start` never asks which: + +``` +-- once per process -- +profiler (nil when no pyroscope server configured) +telemetry building it installs the process-global beholder client +settings CRE settings, pushed by the host over gRPC when --settings.url is set +-- startInstance, for each ID in 0..total-1, on a fresh injector built from r -- +logger instance.N when Total > 1 +registry local registry, plus the node's (remote listener transport) when --capabilities.proxy-url is set +provide Instance{id, n}, logger, registry, limits factory, clock, nil org resolver, binder +startCapability ctor(instance) → Start → reg.Add (local, then announced to the node's) +health checker reports on this instance's services +web server on http.port + ID: /metrics, /debug/pprof, /healthz, /readyz +-- end -- +→ return stop: MultiCloser(svcs), concurrent — close order is not controlled +``` + +The commands do the waiting: `run` serves the go-plugin host if one launched it, else waits for +ctx; `embed` only waits (a host supervises one plugin, and an embed run is not one). + +`run` keeps a plain `[]services.Service`: each piece is **started as it is built** (`startX` +wrappers over `newX`) and appended, and a deferred `services.MultiCloser` at the top closes +whatever was appended — so a failure at any step unwinds everything before it. There is no +aggregate root service. + +Config is any struct registered with `ProvideConfig` (`provide.go`), which wraps it into a function +that binds it on a command with the `cli.Binder`, and a provider that reads what the flags decoded +into it. This package's own sections (`observability`'s, `capabilities`, `grpc`, `settings`, and +embed's `--instances`) are registered by `registerSettings` - as a dependency registers its own, +but from `RunErr` rather than `init`. It therefore runs after every dependency's `init`, so a +dependency registering one of those types is a duplicate and fails the run rather than overriding +it. They bind with `chainlink-common/x/config/cli` (flags, `CRE_`/`CL_` env vars, repeatable +`--config` TOML files). `start` resolves its own settings (`resolveSettings`). Every setting is +optional; `--http.port` defaults to 8080. + +The decode fills the registered struct in place, and the provider reads it on resolution, so a +config must not be resolved before the flags decode - which nothing does, since `init` only +registers. Registrations are process-wide, so `RunErr` runs once per process; tests swap in fresh +ones with `resetRegistrations`. + +Flag help text is the field's **doc comment**, not a tag: `cli` reads it from a generated +`DocComments()` method. Every binary gets a `docs` subcommand (`docs.go`) that runs +`commentparsing.Run` over every section bound on it and writes `doccomments_gen.go` beside each +struct in the module enclosing the working directory, so a capability documents its config with +`//go:generate go run . docs` instead of a `gen/main.go` of its own. Structs from other modules are +read from their compiled `DocComments`, which is why this package generates its own via +`internal/docgen` (`go generate` here). `TestEveryFlagIsDocumented` catches a stale file. The +`Tool` header is per binary name, because `commentparsing.Run` deletes files its tool wrote but +did not produce this time. + +Dependency injection is `samber/do`, keyed **by exact type**. There are no containers to derive: +each command has a list of registrations (`provide.go`), and every instance gets a fresh injector +built from its command's list, onto which the run provides its own values: + +``` +run registrations Provide, ProvideForRun, ProvideConfig, ProvideConfigForRun (init / RunErr) +└── instance 0 fresh injector + Instance{0, 1}, logger, registry, limits factory, clock, binder; ctor +embed registrations Provide, ProvideForEmbed, ProvideConfig, ProvideConfigForEmbed +├── instance 0 fresh injector + Instance{0, n}, ...; ctor +└── instance n-1 fresh injector + Instance{n-1, n}, ... +``` + +**Embed-specific settings**, following `rtinianov_configWithOwner`'s `libs/standalone`: embed +registers its own form of a run setting under the same namespace, with the `ForEmbed` functions, +while run registers run's with the `ForRun` ones. Today that is `capabilities` → `embeddedCapabilityConfig` (`embed.go`): no +`proxy-url` (no node to dial, so only what this binary hosts resolves, nothing is announced) and +`capability-don-id` defaulting to 1; embed provides `capabilityConfig` built from it. Each instance has its own logger (`instance.N` when there are several), registry, +health checker and web server on `http.port + ID`, as that branch's `portFor(index)`; an +embed run never serves a go-plugin host. Not carried over: per-instance prometheus labels (no +per-instance registerer yet), and the debug UI being forced on (removed). `docs` walks both commands' registrations, since each +holds configs the other does not; `TestEveryConfigIsDocumented` guards that. + +The run provides its values onto each instance's injector, so a dependency registered in `init` +resolves against that instance's, and each instance builds its own dependencies. A type a binary +registers that the run also provides is refused (`provideValue`): the run's values are not +replaceable. `Instance` (`instance.go`) is provided to every instance: `{0, 1}` under run. The +`*cli.Binder` is provided too. +The registry is provided as `registry.CapabilitiesRegistry`, which `core.CapabilitiesRegistry` is +an alias of; the clock as `clockwork.Clock` (real), so a test can shadow it. + +## The list + +Numbered against `libs/standalone/bootstrapper.go` unless noted. + +### Done +1. Block until interrupted — `signal.NotifyContext` in `RunErr`, `<-ctx.Done()` in `run` +2. `plugin.Serve` under a go-plugin host — `underPluginHost()` + empty LOOP +3. `logger.Sync()` on shutdown — deferred first so it unwinds last +4. Logger named after the binary +6. `WithOtelViews` — `Option` on `Run`/`RunErr` +7. Start the capability — an entry in the run's service list +8. Health checker registration — registers its *siblings*, not the parent +16. Serve each capability to the node — `registry.go`, on chainlink-common's `registry` and + `registry/remote` packages: the remote client serves what `Add` is handed over its own + listener transport (bound on `--grpc.advertise-host`), so this package opens no gRPC servers + of its own. +17. Register and announce to the node's registry, remove on shutdown — `registryService.Add` and + `close`. `Add` registers locally, then with the node's, rolling the local back if the node + rejects it, so the two never disagree. `close` removes everything added, in reverse, with a + fresh context rather than the engine's: `Close` closes the `StopChan` the engine's derives + from before running the hook, so the deregistration would be cancelled before it left the + process. + +### Skipped +5. `commonConfig` — an empty struct upstream; not needed yet + +### Open — bootstrapper +9. Expose the run to constructors: the mux, metrics registerer, beholder client. Each instance + has its own mux, but constructors still have no way to reach it. +10. Resolve dependencies before calling the constructor — `bootstrap_gen.go` `Run1`…`Run10` +11. Close resolved dependencies in reverse — `registerCloser` (`:150`) +12. Register each dependency's config on the command that resolves it — + `setupCommands`/`collectTargets`/`configSet` (`:374-479`). Each config instance must be bound + exactly once. (`cli` now enforces the no-clash half: a key shared with an ancestor command fails + the whole tree.) + +### Open — embed +13. ~~`embed` `--instances`~~ — done; see the container tree above. +14. `ForEmbedding` — per-instance dependency forms +15. Per-instance identity: prometheus `instance` label (`:342-348`). Logger `instance.N` and + `portFor` (`http.port + ID`) are done. + Each instance has its own registry and health checker, so instances may share service names + and capability IDs; telling them apart elsewhere (schemas, DON identities) is the + dependency's job, from its `Instance`. + +### Done — capability hosting +18. Settings pushed by the host — `settings.go`. With `--settings.url` set, the settings service + subscribes to the host's CRE settings stream, applies every update, and resubscribes with + backoff whenever the stream cannot be made or ends; a payload that cannot be applied leaves + the previous settings in force. Unset, every limit stays at its compiled-in default. +19. ~~Debug UI~~ — removed along with `libs/standalone/protohelpers/ui`, and its + `--capabilities.http-debug` flag with it. + +## Decisions, and the constraints behind them + +These are the non-obvious ones. Several were re-litigated more than once before the constraint was +found, so they are worth reading before changing the order of anything in `run`. + +**Telemetry is installed at build time, not in `Start`.** A capability creates its OTEL instruments +while it is being *constructed* (`cron/trigger/metrics.go:47` calls `beholder.GetMeter()` inside +`NewMetrics`), and an instrument resolves its meter once. Installing later leaves every capability +metric bound to the noop meter for the life of the process, silently. Same reason the limits factory +is built after telemetry. + +**The health checker cannot be inside the thing it reports on.** `Register` seeds state by calling +`reporter.Ready()` immediately and only re-reads on a **15s** tick (`services/health.go:63`). +Registering a still-starting aggregate would leave `/readyz` wrong for up to 15 seconds. It +registers its siblings instead — `run` hands it a snapshot of the slice built so far, which is +exactly what is already running when it starts. + +**Capabilities resolve individual dependencies; there is no bag.** Resolving a struct of everything +would be a dependency on everything a run has, and adding a field would silently widen what every +capability appears to need. + +**Matching is by exact type, not assignability.** `do` keys on `do.NameOf[T]`, so a narrower +interface only resolves if the run registers it under that type. Any narrowing a capability wants +has to be added in `startInstance`. + +**Local first, then the node's.** Every lookup tries the capabilities this binary hosts before +the node's registry: a capability hosted here is a value this process already has, so resolving +it locally hands back the implementation rather than a gRPC client looping back into this same +process. + +**The capability is started before it is announced.** `startCapability` runs the constructor, +starts the capability, and only then `reg.Add` serves and announces it — traffic the announcement +invites lands on something already running. Announcing at build time, rather than in a service's +`Start` of its own, means a failed announce fails the run before it is nominally up, and `/readyz` +implies announced. + +**`grpc.NewClient` does not dial, and does not validate.** The node starts this process and the two +race, so an eager dial would be a race we lose intermittently. It also accepts `""`, `"!://x"` and +unknown schemes without error — so a typo in `--capabilities.proxy-url` surfaces as a failed lookup +much later, not at startup. + +**Services start eagerly, in build order, and close concurrently** (`services/multi.go` — +`MultiCloser`). `run` starts each service as it builds it (`startX` wrappers) and appends it to +the slice; the deferred `MultiCloser` at the top of `run` closes whatever was appended, which is +what makes a failure at any step unwind everything before it. Close order is *not* controlled. + +**`StopOnce` refuses to run a `Close` hook on a service that never started** +(`services/state.go:111`, `ErrCannotStopUnstarted`). This is why anything that changes process state +at build time cannot rely on `Close` alone to undo it. + +## Known gaps and defects + +- **~~Telemetry global leaks on a pre-start failure~~ — resolved.** Every service is started as it + is built and appended to `svcs`, and the deferred `MultiCloser` at the top of `run` closes + whatever was appended, so a failure at any later step — the constructor, `newSettings`, + `reg.Add` — unwinds telemetry's global swap along with everything else. +- **~~A failure after `reg.Add` leaks the announcement~~ — resolved**, same mechanism: the + registry is started (so its `Close` runs) and on the slice before `startCapability` is called. +- **The injectors are never shut down.** Anything a provider builds that + implements `do.Shutdowner` is not shut down when the run ends. Wiring `Shutdown` in needs an order + relative to the `MultiCloser` (after it, so the capability is closed before its dependencies); + not done. +- **Close ordering.** The registry's own close is internally ordered (Remove each added, then + close the connection), so the deregistration reliably reaches the node. What remains: the + registry and the capability are still closed concurrently (`MultiCloser`), so an in-flight RPC + can call into a capability that is already closing. The bootstrapper closed dependencies + strictly after services (`registerCloser`). Unresolved. +- **The empty LOOP is a local copy** (`plugin.go`). The chainlink-common this pins no longer ships + `loop.EmptyLoop`; the handshake cookie has to keep matching the node's, which launches this + binary with it. +- **`CL_PROMETHEUS_PORT` is ignored.** Under a go-plugin host the node assigns a metrics port via + that env var, but this binds `CRE_HTTP_PORT`/`CL_HTTP_PORT`. Pre-existing — the bootstrapper has + the same naming — but now quieter, since the 8080 default means the binary starts anyway. +- **`loop.TracingConfig.OnDialError` is still nil in `libs/standalone/telemetry.go`.** Fixed here + (`telemetry.go`, `beholderConfig`); still live in the shipping bootstrapper, where + `--tracing.enabled` plus an unreachable collector is a nil dereference on a background goroutine + that kills the process. + +## Relationship to `libs/standalone` + +This package is a deliberate **copy** of the observability config and helpers, not a move. +`libs/standalone` imports this package for `NewLogger`, so this package cannot import it back +without a cycle. The duplicate is meant to resolve by *deletion* — when this is what starts a +binary, the ones in `standalone` go. + +The only change made to `libs/standalone` is that `newLogger` was moved out of `telemetry.go` into +this package's `logger.go`, and `bootstrapper.go` now calls `capability.NewLogger()`. + +## Running the tests + +``` +cd libs +go test ./capability/ -short # ~1s +go test ./capability/ -short -race +go test ./capability/ # ~21s +``` + +**Use `-short` by default.** One test — +`TestRunInstallsTelemetryBeforeBuildingTheCapability` — builds a real beholder client, and closing +one flushes to a collector that is not there: about 20s of export timeouts. It is gated on +`testing.Short()`. It is also the only test that would catch the silent-noop-metrics regression +described above, so do not delete it. + +Tests bind real TCP ports (`freePort`) and mutate `os.Args` and the beholder global, so they are not +parallel-safe. `execute()` in `run_test.go` drives the real entry point and stops the run by +cancelling its context once the HTTP server answers. + +## Style + +Comments explain *why*, not what — particularly the ordering constraints above, which are otherwise +invisible and have been reintroduced by accident more than once. Match the surrounding density. diff --git a/libs/capability/capability.go b/libs/capability/capability.go index ab7cf27a4..20d5f0f46 100644 --- a/libs/capability/capability.go +++ b/libs/capability/capability.go @@ -1,65 +1,21 @@ package capability import ( - "reflect" - - "google.golang.org/protobuf/reflect/protoreflect" - "github.com/smartcontractkit/chainlink-common/pkg/capabilities" - "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" - "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" - "github.com/smartcontractkit/chainlink-common/pkg/types/core" ) type Capability interface { services.Service capabilities.ExecutableAndTriggerCapability - - // Service is the proto service this capability's server was generated from. - Service() protoreflect.ServiceDescriptor } -// Config marks the struct a constructor asks for as the capability's own configuration -// -// type Config struct { -// capability.Config -// FastestScheduleIntervalSeconds int `usage:"fastest cron schedule a workflow may register"` -// } -// -// The run binds the struct's fields as flags on the root command, namespaced by the binary's -// name - cron's are --cron.fastest-schedule-interval-seconds, CRE_CRON_FASTEST_SCHEDULE_INTERVAL_SECONDS, -// or the same key in the config file - and hands the constructor the decoded value. A constructor -// declares at most one. -type Config struct{} - // capabilityConfig is what a capability binary needs from the node it runs beside. type capabilityConfig struct { - ProxyURL string `usage:"gRPC target of the node's capability registry proxy (e.g. localhost:9000), used to resolve capabilities this binary does not host. Unset resolves only the capabilities this binary hosts"` - CapabilityDonID uint32 `usage:"on-chain DON ID of the capability DON this process was spawned for"` - - // Serves the Debug UI - HTTPDebug bool `usage:"serve the capability debug UI on the shared HTTP server, under /debug/capabilities"` -} - -type Dependencies struct { - Logger logger.Logger - CapabilityRegistry core.CapabilitiesRegistry - LimitsFactory limits.Factory -} - -func (d Dependencies) list() []any { - return []any{d.Logger, d.CapabilityRegistry, d.LimitsFactory} -} - -func (d Dependencies) resolve(want reflect.Type) (reflect.Value, bool) { - for _, v := range d.list() { - if v == nil { - continue - } - if got := reflect.TypeOf(v); got.AssignableTo(want) { - return reflect.ValueOf(v), true - } - } - return reflect.Value{}, false + // ProxyURL is the gRPC target of the node's capability registry proxy (e.g. localhost:9000), + // used to resolve capabilities this binary does not host. Unset resolves only the capabilities + // this binary hosts. + ProxyURL string + // CapabilityDonID is the on-chain DON ID of the capability DON this process was spawned for. + CapabilityDonID uint32 } diff --git a/libs/capability/config.go b/libs/capability/config.go index f013dea7e..485ddaa5c 100644 --- a/libs/capability/config.go +++ b/libs/capability/config.go @@ -1,10 +1,8 @@ package capability -import ( - "fmt" +//go:generate go run ./internal/docgen docs - "github.com/spf13/cobra" -) +import "github.com/samber/do/v2" const capabilitiesNamespace = "capabilities" @@ -14,41 +12,31 @@ const settingsNamespace = "settings" const defaultHost = "localhost" +// grpcConfig is where the gRPC listener the registry serves this process's capabilities on binds. type grpcConfig struct { - AdvertiseHost string `usage:"host the gRPC servers this process opens bind to and are advertised at; empty binds every interface"` + // AdvertiseHost is the host the gRPC servers this process opens bind to and are advertised at. + // Empty binds every interface. + AdvertiseHost string } -// the standard configuration for a capability; does not include the config that a capability may request via its constructor. -type config struct { - observability observability - capabilities capabilityConfig - grpc grpcConfig - settings settingsConfig -} - -func defaultConfig() *config { - return &config{ - observability: *defaultObservability(), - grpc: grpcConfig{AdvertiseHost: defaultHost}, - } -} - -// namespaced pairs every config with the namespace it is registered under, in the order the flags -// are registered. -func (c *config) namespaced() []section { - return append(c.observability.namespaced(), - section{capabilitiesNamespace, &c.capabilities}, - section{grpcNamespace, &c.grpc}, - section{settingsNamespace, &c.settings}, - ) -} - -// bind binds every config to root, each under the namespace that owns it. -func (c *config) bind(root *cobra.Command) error { - for _, s := range c.namespaced() { - if err := bindFlags(root, s.namespace, s.target); err != nil { - return fmt.Errorf("failed to register the %s settings: %w", s.namespace, err) - } - } - return nil +// registerSettings registers the run's own settings, as any dependency registers its config, for +// RunErr to bind and start to resolve. capabilities differs between the commands: embed's has no +// proxy to dial and defaults the DON, and capabilityConfig is built from it. +func registerSettings() { + ProvideConfig(telemetryNamespace, TelemetryConfig{}) + ProvideConfig(tracingNamespace, TracingConfig{SamplingRatio: 1}) + ProvideConfig(chipIngressNamespace, ChipIngressConfig{}) + ProvideConfig(pyroscopeNamespace, PyroscopeConfig{}) + ProvideConfig(httpNamespace, HTTPConfig{Port: defaultHTTPPort}) + ProvideConfig(grpcNamespace, grpcConfig{AdvertiseHost: defaultHost}) + ProvideConfig(settingsNamespace, settingsConfig{}) + + ProvideConfigForRun(capabilitiesNamespace, capabilityConfig{}) + + ProvideConfigForEmbed("", embedConfig{Instances: 1}) + ProvideConfigForEmbed(capabilitiesNamespace, embeddedCapabilityConfig{CapabilityDonID: defaultEmbeddedDonID}) + ProvideForEmbed(func(i do.Injector) (capabilityConfig, error) { + cfg, err := do.Invoke[embeddedCapabilityConfig](i) + return capabilityConfig{CapabilityDonID: cfg.CapabilityDonID}, err + }) } diff --git a/libs/capability/doccomments_gen.go b/libs/capability/doccomments_gen.go new file mode 100644 index 000000000..869f3ec40 --- /dev/null +++ b/libs/capability/doccomments_gen.go @@ -0,0 +1,78 @@ +// Code generated by docgen docs, DO NOT EDIT. + +package capability + +import "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + +func (ChipIngressConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Endpoint": {Comment: "Endpoint is the chip ingress gRPC endpoint. The emitter is disabled when unset."}, + "InsecureConnection": {Comment: "InsecureConnection connects to chip ingress over an insecure connection."}, + } +} + +func (HTTPConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Port": {Comment: "Port serves /metrics, /debug/pprof, /healthz, /readyz and any routes a service registers."}, + } +} + +func (PyroscopeConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "AuthToken": {Comment: "AuthToken is the pyroscope auth token."}, + "Environment": {Comment: "Environment is the environment tag attached to profiles."}, + "ServerAddress": {Comment: "ServerAddress is the pyroscope server address. Profiling is disabled when unset."}, + } +} + +func (TelemetryConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Attributes": {Comment: "Attributes are extra telemetry resource attributes, as key=value pairs."}, + "AuthHeaders": {Comment: "AuthHeaders are telemetry auth headers, as key=value pairs."}, + "AuthHeadersTTL": {Comment: "AuthHeadersTTL is how long generated telemetry auth headers are valid for."}, + "AuthPubKeyHex": {Comment: "AuthPubKeyHex is the public key the telemetry auth headers are derived from."}, + "CACertFile": {Comment: "CACertFile is the CA certificate file used to verify the telemetry endpoint."}, + "Endpoint": {Comment: "Endpoint is the OTLP gRPC endpoint telemetry is exported to. Telemetry is disabled when unset."}, + "InsecureConnection": {Comment: "InsecureConnection exports telemetry over an insecure connection."}, + "PrometheusBridgeEnabled": {Comment: "PrometheusBridgeEnabled feeds metrics registered on the prometheus registry into the telemetry\npipeline."}, + } +} + +func (TracingConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Enabled": {Comment: "Enabled exports traces to the telemetry endpoint."}, + "SamplingRatio": {Comment: "SamplingRatio is the fraction of traces sampled, from 0 to 1."}, + "TLSCertFile": {Comment: "TLSCertFile is the TLS certificate file used by the trace exporter."}, + } +} + +func (capabilityConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "CapabilityDonID": {Comment: "CapabilityDonID is the on-chain DON ID of the capability DON this process was spawned for."}, + "ProxyURL": {Comment: "ProxyURL is the gRPC target of the node's capability registry proxy (e.g. localhost:9000),\nused to resolve capabilities this binary does not host. Unset resolves only the capabilities\nthis binary hosts."}, + } +} + +func (embedConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Instances": {Comment: "Instances is how many instances of the capability this process hosts."}, + } +} + +func (embeddedCapabilityConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "CapabilityDonID": {Comment: "CapabilityDonID is the on-chain DON ID reported for the capabilities this process hosts."}, + } +} + +func (grpcConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "AdvertiseHost": {Comment: "AdvertiseHost is the host the gRPC servers this process opens bind to and are advertised at.\nEmpty binds every interface."}, + } +} + +func (settingsConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "URL": {Comment: "URL is the gRPC target of the host's CRE settings service (e.g. localhost:9001), which pushes\nsettings updates to this process. Unset leaves every limit at its compiled-in default."}, + } +} diff --git a/libs/capability/docs.go b/libs/capability/docs.go new file mode 100644 index 000000000..828654063 --- /dev/null +++ b/libs/capability/docs.go @@ -0,0 +1,52 @@ +package capability + +import ( + "maps" + "slices" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" +) + +// docsCommand generates the DocComments every config bound on the binary needs for its flags' help +// text, so a capability's own config is documented by running its binary rather than by a +// generator of its own: +// +// //go:generate go run . docs +// +// It writes a doccomments_gen.go beside each config struct - registered with ProvideConfig, by this +// package or any dependency - declared in the module enclosing the working directory. A struct from another module - this package's sections, when a capability +// binary runs it - is read from the DocComments that module already compiled in, so this package +// generates its own too (see internal/docgen). +// +// The binder decodes before any command runs, docs included, so a config with a required field +// has to be satisfied to generate its docs. +// +// It documents what both commands register: each registers configs the other does not - +// capabilities differs between them, say. +func docsCommand() *cobra.Command { + return &cobra.Command{ + Use: "docs", + Short: "Generate the doc comments the config's help text is read from", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + var roots []any + for _, r := range []*registrations{runRegistrations, embedRegistrations} { + for _, namespace := range slices.Sorted(maps.Keys(r.configs)) { + roots = append(roots, r.configs[namespace].value) + } + } + + return commentparsing.Run(commentparsing.RunArgs{ + Roots: roots, + Markup: tomlmarkup.New(), + // Per binary: a run removes files its Tool wrote and did not produce this time, so + // two binaries in one module sharing a name would delete each other's. + Tool: cmd.Root().Name() + " docs", + LocalPrefix: "github.com/smartcontractkit", + }) + }, + } +} diff --git a/libs/capability/embed.go b/libs/capability/embed.go new file mode 100644 index 000000000..316588744 --- /dev/null +++ b/libs/capability/embed.go @@ -0,0 +1,20 @@ +package capability + +// embedConfig is the embed command's own config, registered on that command alone. +type embedConfig struct { + // Instances is how many instances of the capability this process hosts. + Instances int `validate:"min=1"` +} + +// defaultEmbeddedDonID is the DON an embedded run reports. An embed run is one DON in one process, +// so there is only ever the one, and requiring it to be configured would mean typing the same +// number into every embedded run. +const defaultEmbeddedDonID = 1 + +// embeddedCapabilityConfig is capabilityConfig as an embed run registers it, replacing run's under +// the same namespace. It keeps the DON ID and drops the proxy URL: there is no node behind an +// embedded run to dial, so only what this binary hosts resolves. +type embeddedCapabilityConfig struct { + // CapabilityDonID is the on-chain DON ID reported for the capabilities this process hosts. + CapabilityDonID uint32 +} diff --git a/libs/capability/flags.go b/libs/capability/flags.go deleted file mode 100644 index 80cda8ee0..000000000 --- a/libs/capability/flags.go +++ /dev/null @@ -1,182 +0,0 @@ -package capability - -import ( - "encoding" - "errors" - "fmt" - "os" - "reflect" - "strings" - "time" - - "github.com/go-viper/mapstructure/v2" - "github.com/iancoleman/strcase" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" - - commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" -) - -var envPrefixes = []string{"CRE", "CL"} - -var ( - durationType = reflect.TypeFor[time.Duration]() - configDurationType = reflect.TypeFor[commonconfig.Duration]() - textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() -) - -type boundField struct { - name string - key string - envs []string - flag *pflag.Flag -} - -// set reports whether the user supplied a value. Unset fields keep whatever target held when it -// was bound, rather than a default round-tripped through its text form - a SecretString's text -// form is redacted. -func (f boundField) set(v *viper.Viper) bool { - if f.flag.Changed || v.InConfig(f.key) { - return true - } - for _, env := range f.envs { - if _, ok := os.LookupEnv(env); ok { - return true - } - } - return false -} - -// bindFlags binds the exported fields of the struct target points at as persistent flags on cmd, -// named namespace.kebab-case-field, and fills them in before cmd runs from, in order of -// precedence, the flag, the CRE_/CL_ env var, or the config file. -func bindFlags(cmd *cobra.Command, namespace string, target any) error { - rv := reflect.ValueOf(target) - if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Struct { - return fmt.Errorf("can only bind flags to a pointer to a struct, got %T", target) - } - elem := rv.Elem() - - v := viper.New() - var fields []boundField - for i := range elem.NumField() { - sf := elem.Type().Field(i) - if !sf.IsExported() || sf.Anonymous { - continue - } - - key := namespace + "." + strcase.ToKebab(sf.Name) - flag, err := addFlag(cmd.PersistentFlags(), key, sf.Tag.Get("usage"), elem.Field(i)) - if err != nil { - return err - } - if err := v.BindPFlag(key, flag); err != nil { - return err - } - - suffix := strings.ToUpper(strings.NewReplacer(".", "_", "-", "_").Replace(key)) - envs := make([]string, len(envPrefixes)) - for j, p := range envPrefixes { - envs[j] = p + "_" + suffix - } - if err := v.BindEnv(append([]string{key}, envs...)...); err != nil { - return err - } - - fields = append(fields, boundField{name: sf.Name, key: key, envs: envs, flag: flag}) - } - - prev := cmd.PersistentPreRunE - cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error { - if err := readConfigFile(c, v); err != nil { - return err - } - values := map[string]any{} - for _, f := range fields { - if f.set(v) { - values[f.name] = v.Get(f.key) - } - } - if err := decode(values, target); err != nil { - return fmt.Errorf("invalid %s settings: %w", namespace, err) - } - if prev != nil { - return prev(c, args) - } - return nil - } - return nil -} - -func addFlag(fs *pflag.FlagSet, name, usage string, field reflect.Value) (*pflag.Flag, error) { - t := field.Type() - switch { - case t == configDurationType: - fs.Duration(name, field.Interface().(commonconfig.Duration).Duration(), usage) - case t == durationType: - fs.Duration(name, time.Duration(field.Int()), usage) - case reflect.PointerTo(t).Implements(textUnmarshalerType): - var def string - if !field.IsZero() { - def = fmt.Sprint(field.Interface()) - } - fs.String(name, def, usage) - case t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.String: - fs.StringSlice(name, field.Interface().([]string), usage) - case t.Kind() == reflect.String: - fs.String(name, field.String(), usage) - case t.Kind() == reflect.Bool: - fs.Bool(name, field.Bool(), usage) - case t.Kind() >= reflect.Int && t.Kind() <= reflect.Int64: - fs.Int64(name, field.Int(), usage) - case t.Kind() >= reflect.Uint && t.Kind() <= reflect.Uint64: - fs.Uint64(name, field.Uint(), usage) - case t.Kind() == reflect.Float32 || t.Kind() == reflect.Float64: - fs.Float64(name, field.Float(), usage) - default: - return nil, fmt.Errorf("cannot bind %s: unsupported type %s", name, t) - } - return fs.Lookup(name), nil -} - -// readConfigFile reads --config, or ./config.toml if it exists when --config is unset. -func readConfigFile(cmd *cobra.Command, v *viper.Viper) error { - path, _ := cmd.Flags().GetString("config") - if path != "" { - v.SetConfigFile(path) - if err := v.ReadInConfig(); err != nil { - return fmt.Errorf("failed to read config file %q: %w", path, err) - } - return nil - } - - v.AddConfigPath(".") - v.SetConfigName("config") - v.SetConfigType("toml") - if err := v.ReadInConfig(); err != nil { - if errors.As(err, &viper.ConfigFileNotFoundError{}) { - return nil - } - return fmt.Errorf("failed to parse config file: %w", err) - } - return nil -} - -// decode is weakly typed because env vars, and the flags viper does not parse itself (durations, -// unsigned ints), arrive as strings. -func decode(values map[string]any, target any) error { - d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ - Result: target, - WeaklyTypedInput: true, - DecodeHook: mapstructure.ComposeDecodeHookFunc( - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - mapstructure.TextUnmarshallerHookFunc(), - ), - }) - if err != nil { - return err - } - return d.Decode(values) -} diff --git a/libs/capability/instance.go b/libs/capability/instance.go new file mode 100644 index 000000000..b3b7d3777 --- /dev/null +++ b/libs/capability/instance.go @@ -0,0 +1,12 @@ +package capability + +// Instance is which instance of the capability this is, and how many the process runs, provided to +// each instance's container. A run is instance 0 of 1; an embed run is --instances of them. A +// capability and its dependencies use it to decide how to run and to tell instances apart - +// service names, capability IDs, schemas. +type Instance struct { + // ID is this instance's index, from 0. + ID int + // Total is how many instances the process runs. + Total int +} diff --git a/libs/capability/internal/docgen/main.go b/libs/capability/internal/docgen/main.go new file mode 100644 index 000000000..315f216ab --- /dev/null +++ b/libs/capability/internal/docgen/main.go @@ -0,0 +1,19 @@ +// Command docgen is a capability binary run only for its docs command: it generates the +// DocComments for this package's own config sections, which every capability binary reads its +// flags' help text from. +package main + +import ( + "context" + "errors" + + "github.com/samber/do/v2" + + "github.com/smartcontractkit/capabilities/libs/capability" +) + +func main() { + capability.Run(context.Background(), func(do.Injector) (capability.Capability, error) { + return nil, errors.New("docgen hosts nothing: run its docs command") + }, capability.NewConfigProvider(struct{}{})) +} diff --git a/libs/capability/observability.go b/libs/capability/observability.go index 26f704117..e81f2165c 100644 --- a/libs/capability/observability.go +++ b/libs/capability/observability.go @@ -10,37 +10,12 @@ const ( httpNamespace = "http" ) -// observability is every process-wide observability config, registered together and consumed once -// the command runs and they have been decoded. +// observability is what the beholder client is built from: its settings, resolved by run, and +// the views Run was given. type observability struct { telemetry TelemetryConfig tracing TracingConfig chipIngress ChipIngressConfig - pyroscope PyroscopeConfig - http HTTPConfig otelViews []metric.View // supplied through capability.Run } - -func defaultObservability() *observability { - return &observability{ - tracing: TracingConfig{SamplingRatio: 1}, - http: HTTPConfig{Port: defaultHTTPPort}, - } -} - -type section struct { - namespace string - target any -} - -// namespaced pairs each config with its namespace, in the order the flags are registered. -func (o *observability) namespaced() []section { - return []section{ - {telemetryNamespace, &o.telemetry}, - {tracingNamespace, &o.tracing}, - {chipIngressNamespace, &o.chipIngress}, - {pyroscopeNamespace, &o.pyroscope}, - {httpNamespace, &o.http}, - } -} diff --git a/libs/capability/plugin.go b/libs/capability/plugin.go new file mode 100644 index 000000000..ac24c401f --- /dev/null +++ b/libs/capability/plugin.go @@ -0,0 +1,30 @@ +package capability + +import ( + "context" + + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" +) + +// The empty LOOP: the server side of a go-plugin plugin that exposes no services, so a node can +// launch this binary and supervise its liveness through go-plugin while its real work is served +// elsewhere. The handshake has to match the node's, which still ships it as loop.EmptyLoop. +const emptyPluginName = "empty" + +func emptyHandshakeConfig() plugin.HandshakeConfig { + return plugin.HandshakeConfig{ + MagicCookieKey: "CL_PLUGIN_EMPTY_MAGIC_COOKIE", + MagicCookieValue: "b9a1e7c4-3f2d-4a6b-8c1e-5d9f0a2b3c4d", + } +} + +type emptyLoop struct { + plugin.NetRPCUnsupportedPlugin +} + +func (*emptyLoop) GRPCServer(*plugin.GRPCBroker, *grpc.Server) error { return nil } + +func (*emptyLoop) GRPCClient(context.Context, *plugin.GRPCBroker, *grpc.ClientConn) (any, error) { + return struct{}{}, nil +} diff --git a/libs/capability/provide.go b/libs/capability/provide.go new file mode 100644 index 000000000..6c8163694 --- /dev/null +++ b/libs/capability/provide.go @@ -0,0 +1,151 @@ +package capability + +import ( + "fmt" + "maps" + "slices" + + "github.com/samber/do/v2" + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-common/x/config/cli" +) + +// registrations is what a binary's packages register, from init, for one command: the providers +// each instance's injector is built from, and the configs bound as the command's flags. +// +// Nothing is resolved here. Each instance gets an injector of its own, built from these, so a +// provider depending on what the run provides - the logger, the registry - resolves against that +// instance's. +type registrations struct { + providers []func(do.Injector) + // names are the services provided, so a second registration of one is refused rather than + // panicking in do when an instance's injector is built. + names map[string]bool + // configs are keyed by namespace, which is how they are bound and documented. + configs map[string]boundConfig +} + +// boundConfig is one config: the struct its flags decode into, and how to bind it. +type boundConfig struct { + value any + bind func(*cli.Binder, *cobra.Command) error +} + +func newRegistrations() *registrations { + return ®istrations{names: map[string]bool{}, configs: map[string]boundConfig{}} +} + +// runRegistrations and embedRegistrations are what the run and embed commands build each +// instance's injector from. +var runRegistrations = newRegistrations() +var embedRegistrations = newRegistrations() + +// Provide registers provider for both commands. See ProvideForRun. +// +// func init() { capability.Provide(NewDB) } +func Provide[T any](provider do.Provider[T]) { + ProvideForRun(provider) + ProvideForEmbed(provider) +} + +// ProvideForRun registers provider for the run command: every run instance's injector provides T +// from it. Providing a T twice for one command panics - it is a programming error, and init is +// where it would be made. +func ProvideForRun[T any](provider do.Provider[T]) { must(provide(runRegistrations, provider)) } + +// ProvideForEmbed is ProvideForRun for the embed command, so an embedded instance can be provided +// a T of its own. +func ProvideForEmbed[T any](provider do.Provider[T]) { must(provide(embedRegistrations, provider)) } + +// ProvideConfig registers a config for both commands. See ProvideConfigForRun. +// +// func init() { capability.ProvideConfig("database", DefaultConfig) } +func ProvideConfig[T any](namespace string, defaultConfig T, opts ...cli.RegisterOption[T]) { + ProvideConfigForRun(namespace, defaultConfig, opts...) + ProvideConfigForEmbed(namespace, defaultConfig, opts...) +} + +// ProvideConfigForRun declares a T, defaulting to defaultConfig, as config of the run command: its +// fields are bound as flags under namespace (--database.url, CRE_DATABASE_URL, or the key in a +// database section of the config file), and every run instance's injector provides the decoded T. +// opts are handed to the binder - a profile, say. Registering a namespace or a T twice for one +// command panics. +func ProvideConfigForRun[T any](namespace string, defaultConfig T, opts ...cli.RegisterOption[T]) { + must(provideConfig(runRegistrations, namespace, defaultConfig, opts...)) +} + +// ProvideConfigForEmbed is ProvideConfigForRun for the embed command. +func ProvideConfigForEmbed[T any](namespace string, defaultConfig T, opts ...cli.RegisterOption[T]) { + must(provideConfig(embedRegistrations, namespace, defaultConfig, opts...)) +} + +func must(err error) { + if err != nil { + panic(err) + } +} + +func provide[T any](r *registrations, provider do.Provider[T]) error { + name := do.NameOf[T]() + if r.names[name] { + return fmt.Errorf("capability: %s is already provided", name) + } + r.names[name] = true + r.providers = append(r.providers, func(i do.Injector) { do.Provide(i, provider) }) + return nil +} + +// provideConfig wraps the config into a function that binds it on a command, and a provider that +// reads what the flags decoded into it. +func provideConfig[T any](r *registrations, namespace string, defaultConfig T, opts ...cli.RegisterOption[T]) error { + if _, ok := r.configs[namespace]; ok { + return fmt.Errorf("capability: config namespace %q is already registered", namespace) + } + + // The struct the flags decode into, filled in place when the command runs - after this, and + // before anything resolves it. + cfg := new(T) + *cfg = defaultConfig + if err := provide(r, func(do.Injector) (T, error) { return *cfg, nil }); err != nil { + return err + } + + r.configs[namespace] = boundConfig{ + value: cfg, + bind: func(b *cli.Binder, cmd *cobra.Command) error { + return b.RegisterInNamespace(cmd, namespace, cfg, opts...) + }, + } + return nil +} + +// bind binds every config r holds on cmd, in namespace order so a binary's flags do not depend on +// map iteration. +func (r *registrations) bind(b *cli.Binder, cmd *cobra.Command) error { + for _, namespace := range slices.Sorted(maps.Keys(r.configs)) { + if err := r.configs[namespace].bind(b, cmd); err != nil { + return fmt.Errorf("failed to bind config %q: %w", namespace, err) + } + } + return nil +} + +// injector builds a fresh injector from r, for one instance. +func (r *registrations) injector() *do.RootScope { + i := do.New() + for _, p := range r.providers { + p(i) + } + return i +} + +// provideValue provides v on an instance's injector, refusing a type the binary already +// registered: what the run provides is not replaceable. +func provideValue[T any](r *registrations, i do.Injector, v T) error { + if name := do.NameOf[T](); r.names[name] { + return fmt.Errorf("%s is provided by the run, and also registered by this binary", name) + } + do.ProvideValue(i, v) + return nil +} diff --git a/libs/capability/provide_test.go b/libs/capability/provide_test.go new file mode 100644 index 000000000..282dbae67 --- /dev/null +++ b/libs/capability/provide_test.go @@ -0,0 +1,84 @@ +package capability + +import ( + "testing" + + "github.com/samber/do/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/x/config/cli" +) + +// TestProvideRegistersForBothCommands covers Provide being ProvideForRun and ProvideForEmbed. +func TestProvideRegistersForBothCommands(t *testing.T) { + resetRegistrations(t) + + Provide(func(do.Injector) (*dependency, error) { return &dependency{}, nil }) + + for _, r := range []*registrations{runRegistrations, embedRegistrations} { + _, err := do.Invoke[*dependency](r.injector()) + assert.NoError(t, err) + } +} + +// TestProvideForOneCommandOnly covers the other two: each reaches its own command alone. +func TestProvideForOneCommandOnly(t *testing.T) { + resetRegistrations(t) + + ProvideForRun(func(do.Injector) (*dependency, error) { return &dependency{}, nil }) + + _, err := do.Invoke[*dependency](runRegistrations.injector()) + require.NoError(t, err) + _, err = do.Invoke[*dependency](embedRegistrations.injector()) + require.Error(t, err) +} + +// TestProvideTwicePanics covers a type provided twice for one command: a programming error, made +// in init, so it panics there rather than surfacing later. +func TestProvideTwicePanics(t *testing.T) { + resetRegistrations(t) + + ProvideForRun(func(do.Injector) (*dependency, error) { return &dependency{}, nil }) + assert.Panics(t, func() { + ProvideForRun(func(do.Injector) (*dependency, error) { return &dependency{}, nil }) + }) + // Another command is another registration. + assert.NotPanics(t, func() { + ProvideForEmbed(func(do.Injector) (*dependency, error) { return &dependency{}, nil }) + }) +} + +// TestProvideConfigTwicePanics covers a namespace registered twice for one command. +func TestProvideConfigTwicePanics(t *testing.T) { + resetRegistrations(t) + + ProvideConfigForRun("a", testConfig{}) + assert.Panics(t, func() { ProvideConfigForRun("a", HTTPConfig{}) }) +} + +// TestProvideConfigProvidesTheDefault covers the decoded value before anything decodes it: the +// default, as the injector resolves it. +func TestProvideConfigProvidesTheDefault(t *testing.T) { + resetRegistrations(t) + + ProvideConfigForRun("a", testConfig{Shout: "default"}) + + got, err := do.Invoke[testConfig](runRegistrations.injector()) + require.NoError(t, err) + assert.Equal(t, "default", got.Shout) +} + +// TestRunProvidesTheBinder covers the binder in each instance's injector, so a dependency can bind +// further config of its own. +func TestRunProvidesTheBinder(t *testing.T) { + var got *cli.Binder + + require.NoError(t, execute(t, logger.Test(t), provides(func(i do.Injector) (f *fake, err error) { + got, err = do.Invoke[*cli.Binder](i) + return newFake(), err + }), nil, "run")) + + assert.NotNil(t, got) +} diff --git a/libs/capability/pyroscope.go b/libs/capability/pyroscope.go index e4f63074f..b1ff98229 100644 --- a/libs/capability/pyroscope.go +++ b/libs/capability/pyroscope.go @@ -14,9 +14,12 @@ import ( // PyroscopeConfig configures continuous profiling. An empty ServerAddress leaves profiling off. type PyroscopeConfig struct { - ServerAddress string `usage:"pyroscope server address; profiling is disabled when unset"` - AuthToken commonconfig.SecretString `usage:"pyroscope auth token" flagdocs:"noexample"` - Environment string `usage:"environment tag attached to profiles"` + // ServerAddress is the pyroscope server address. Profiling is disabled when unset. + ServerAddress string + // AuthToken is the pyroscope auth token. + AuthToken commonconfig.SecretString + // Environment is the environment tag attached to profiles. + Environment string } // newProfiler returns continuous profiling as a service, or nil when no pyroscope server is diff --git a/libs/capability/run.go b/libs/capability/run.go index 8fed0efa4..132bbc198 100644 --- a/libs/capability/run.go +++ b/libs/capability/run.go @@ -8,26 +8,57 @@ import ( "os" "os/signal" "path/filepath" - "reflect" "syscall" + "github.com/hashicorp/go-plugin" + "github.com/samber/do/v2" "github.com/spf13/cobra" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/x/config/cli" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" ) -// Run builds and runs the capability constructor makes, and does not return until ctx is cancelled or the -// process is signalled. +// Constructor builds the capability from the injector of the instance it is built for, which +// provides everything registered with Provide and ProvideConfig - for embed, ProvideForEmbed and +// ProvideConfigForEmbed - along with what the run provides: the logger, the registry, the limits +// factory, the config binder, and the capability's own config, as the T its ConfigProvider +// declared. +type Constructor func(do.Injector) (Capability, error) + +// ConfigProvider is the capability's own config: its default, which flags, env vars and config +// files decode over, and how to register it - a profile, say. Run registers it with ProvideConfig +// under the binary's name. +type ConfigProvider[T any] struct { + defaultConfig T + opts []cli.RegisterOption[T] +} + +// NewConfigProvider declares a T, defaulting to defaultConfig, as the capability's own config. +// Run binds its fields as flags on the root command, namespaced by the binary's name - cron's are +// --cron.fastest-schedule-interval-seconds, CRE_CRON_FASTEST_SCHEDULE_INTERVAL_SECONDS, or the +// same key in a cron section of the config file - and provides the decoded T to the capability's +// container. A capability with no config passes NewConfigProvider(struct{}{}). +func NewConfigProvider[T any](defaultConfig T, opts ...cli.RegisterOption[T]) ConfigProvider[T] { + return ConfigProvider[T]{defaultConfig: defaultConfig, opts: opts} +} + +// Run builds and runs the capability ctor builds, once per instance, and does not return until ctx +// is cancelled or the process is signalled. // -// func main() { capability.Run(context.Background(), trigger.NewCron) } -func Run(ctx context.Context, constructor any, opts ...Option) { +// func main() { +// capability.Run(ctx, trigger.NewCron, capability.NewConfigProvider(trigger.DefaultConfig), +// capability.WithOtelViews(trigger.MetricViews()...)) +// } +func Run[T any](ctx context.Context, ctor Constructor, config ConfigProvider[T], opts ...Option) { lggr, err := NewLogger() if err != nil { + // lggr is nil when building it failed. log.Fatal(err) } - if err := RunErr(ctx, lggr, constructor, opts...); err != nil { + if err = RunErr(ctx, lggr, ctor, config, opts...); err != nil { lggr.Fatal(err) } } @@ -36,15 +67,12 @@ func Run(ctx context.Context, constructor any, opts ...Option) { // // A run blocks until it is told to stop, which is either ctx being cancelled or this process being // signalled - see below. -func RunErr(ctx context.Context, lggr logger.Logger, constructor any, opts ...Option) error { +func RunErr[T any](ctx context.Context, lggr logger.Logger, ctor Constructor, config ConfigProvider[T], opts ...Option) error { if lggr == nil { return errors.New("must provide a logger") } - - // We instantiate this up-front so we can bubble up an error message about an invalid `constructor` early. - c, err := newConstructor(constructor) - if err != nil { - return err + if ctor == nil { + return errors.New("must provide a capability constructor") } // TODO: Run should accept some kind of Info struct @@ -52,171 +80,115 @@ func RunErr(ctx context.Context, lggr logger.Logger, constructor any, opts ...Op Use: filepath.Base(os.Args[0]), Short: "A CRE capability", } - root.PersistentFlags().String("config", "", "Path to config file") lggr = logger.Named(lggr, root.Name()) - cfg := defaultConfig() - for _, opt := range opts { - opt(cfg) - } - if err := cfg.bind(root); err != nil { + b, err := cli.New(cli.Options{Markup: tomlmarkup.New(), Prefixes: []string{"CRE", "CL"}}) + if err != nil { return err } - // Bind the capability's config if the constructor function declares a config struct. - // This will be namespaced using the name of the root command. - // eg. --cron.fastest-schedule-interval-seconds - if err := c.bindCapabilityConfig(root); err != nil { + // The run's own settings, then the capability's own config under the binary's name - after + // every dependency's init, so a dependency registering one of these first is a duplicate. + if err = registerAll(root.Name(), config); err != nil { return err } - root.AddCommand(&cobra.Command{ + // serve starts total instances from r and blocks until ctx is done. Only run hands the process + // to a go-plugin host: a host supervises one plugin, and an embed run is not one. + serve := func(cmd *cobra.Command, r *registrations, total int, pluginHost bool) error { + defer func() { _ = lggr.Sync() }() + + stop, err := start(cmd.Context(), lggr, cmd.Root().Name(), options(opts), r, b, total, ctor) + if err != nil { + return err + } + defer stop() + + if pluginHost && underPluginHost() { + lggr.Info("Serving the empty LOOP: this process is supervised by a go-plugin host") + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: emptyHandshakeConfig(), + Plugins: map[string]plugin.Plugin{emptyPluginName: &emptyLoop{}}, + GRPCServer: plugin.DefaultGRPCServer, + }) + return nil + } + + <-cmd.Context().Done() + lggr.Info("Shutting down") + return nil + } + + runCmd := &cobra.Command{ Use: "run", Short: "Run the capability", RunE: func(cmd *cobra.Command, _ []string) error { - // TODO: pass in constructors here for our runnable/embeddable dependencies? - return run(cmd.Context(), lggr, cmd.Root().Name(), cfg, c) + return serve(cmd, runRegistrations, 1, true) }, - }) + } - // TODO: embed command - root.AddCommand(&cobra.Command{ + embedCmd := &cobra.Command{ Use: "embed", - Short: "Embed the capability", + Short: "Run several instances of the capability in this process", RunE: func(cmd *cobra.Command, _ []string) error { - // TODO: pass in constructors here for our runnable/embeddable dependencies? - // Something else here - return run(cmd.Context(), lggr, cmd.Root().Name(), cfg, c) + cfg := embedRegistrations.configs[""].value.(*embedConfig) + return serve(cmd, embedRegistrations, cfg.Instances, false) }, - }) - - // Wire up SIGTERM + SIGINT to the context - ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - defer stop() - - return root.ExecuteContext(ctx) -} - -type constructorFn struct { - fn reflect.Value - typ reflect.Type - - // configIn is the index of the parameter that is the capability's own config, or -1 when the - // constructor declares none. - configIn int - // config is the instance of that parameter the flags decode fills. Kept here rather than - // passed to run because the decode hook fills it in place when the command runs: call has to - // read that same memory, not a copy made when it was bound. Valid only when configIn >= 0. - config reflect.Value -} - -var capabilityType = reflect.TypeFor[Capability]() - -// newConstructor validates that constructor has the right shape, i.e.: -// - it's a function -// - and the function returns a capability and optionally an error -func newConstructor(ctor any) (constructorFn, error) { - t := reflect.TypeOf(ctor) - if t == nil || t.Kind() != reflect.Func { - return constructorFn{}, fmt.Errorf("a capability constructor must be a function, got %T", ctor) } - switch t.NumOut() { - case 1: - case 2: - if t.Out(1) != reflect.TypeFor[error]() { - return constructorFn{}, fmt.Errorf( - "a capability constructor returning two values must return an error second, got %s", t) - } - default: - return constructorFn{}, fmt.Errorf( - "a capability constructor must return the capability, and optionally an error, got %s", t) - } + root.AddCommand(runCmd, embedCmd, docsCommand()) - if out := t.Out(0); !out.Implements(capabilityType) { - return constructorFn{}, fmt.Errorf("%s does not implement capability.Capability", out) + // Each command binds what was registered for it. They are siblings, so a config registered for + // both is bound on both without clashing. + if err = runRegistrations.bind(b, runCmd); err != nil { + return err } - - configIn := -1 - for i := range t.NumIn() { - if !isConfig(t.In(i)) { - continue - } - if configIn >= 0 { - return constructorFn{}, fmt.Errorf("a capability constructor takes its config once, got %s", t) - } - configIn = i + if err = embedRegistrations.bind(b, embedCmd); err != nil { + return err } - return constructorFn{fn: reflect.ValueOf(ctor), typ: t, configIn: configIn}, nil -} -var isConfigType = reflect.TypeFor[Config]() + // Wire up SIGTERM + SIGINT to the context + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() -// isConfig reports whether t declares itself a capability's config by embedding Config. The -// embedding has to be direct: the marker declares the struct it is embedded in, not the structs -// that embed that one. -func isConfig(t reflect.Type) bool { - if t.Kind() != reflect.Struct { - return false - } - for i := range t.NumField() { - if f := t.Field(i); f.Anonymous && f.Type == isConfigType { - return true - } - } - return false + return root.ExecuteContext(ctx) } -// bindCapabilityConfig creates the config the constructor declares, if any, and binds its fields as flags -// on root, under root's name. -func (c *constructorFn) bindCapabilityConfig(root *cobra.Command) error { - if c.configIn < 0 { - return nil - } - - v := reflect.New(c.typ.In(c.configIn)) - if err := bindFlags(root, root.Name(), v.Interface()); err != nil { - return fmt.Errorf("failed to register the capability's config: %w", err) - } - c.config = v.Elem() +// registerAll registers the run's own settings and the capability's config, recovering the panic a +// duplicate registration raises into an error - here it is the operator's binary, not init, that +// would be crashing. +func registerAll[T any](name string, config ConfigProvider[T]) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("failed to register the run's settings: %v", r) + } + }() + registerSettings() + ProvideConfig(name, config.defaultConfig, config.opts...) return nil } -// call builds the capability by calling the contstructor. -// The constructor's parameters are scanned, and type matched against the dependency set. -// The config parameter is treated exceptionally and hydrated from the configuration that was passed in. -func (c constructorFn) call(deps Dependencies) (Capability, error) { - args := make([]reflect.Value, c.typ.NumIn()) - for i := range args { - if i == c.configIn { - args[i] = c.config - continue - } +// runOptions is what a binary passes Run that is not a setting an operator can change. +type runOptions struct { + otelViews []sdkmetric.View +} - want := c.typ.In(i) +type Option func(*runOptions) - v, ok := deps.resolve(want) - if !ok { - return nil, fmt.Errorf("the capability constructor asks for a %s, and nothing in this run "+ - "provides one: %s", want, c.typ) - } - args[i] = v - } - - out := c.fn.Call(args) - if len(out) == 2 && !out[1].IsNil() { - return nil, fmt.Errorf("failed to build the capability: %w", out[1].Interface().(error)) +func options(opts []Option) runOptions { + var o runOptions + for _, opt := range opts { + opt(&o) } - return out[0].Interface().(Capability), nil + return o } -type Option func(*config) - // WithOtelViews sets otel metric views - histogram bucket boundaries, typically - on the beholder // client this process reports through. // -// capability.Run(ctx, trigger.NewCron, capability.WithOtelViews(trigger.MetricViews()...)) +// capability.Run(ctx, trigger.NewCron, capability.NewConfigProvider(trigger.DefaultConfig), +// capability.WithOtelViews(trigger.MetricViews()...)) func WithOtelViews(views ...sdkmetric.View) Option { - return func(c *config) { c.observability.otelViews = append(c.observability.otelViews, views...) } + return func(o *runOptions) { o.otelViews = append(o.otelViews, views...) } } diff --git a/libs/capability/run_test.go b/libs/capability/run_test.go index ea97349d7..fcd1b1d34 100644 --- a/libs/capability/run_test.go +++ b/libs/capability/run_test.go @@ -9,17 +9,22 @@ import ( "os" "slices" "strconv" + "sync" "testing" "time" + "github.com/jonboulle/clockwork" + "github.com/samber/do/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/reflect/protoreflect" "github.com/smartcontractkit/chainlink-common/pkg/capabilities" capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/registry" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/core" ) @@ -61,11 +66,13 @@ const fakeID = "fake@1.0.0" // newFake builds one with a real service behind it, since the health checker asks the capability // its name and whether it is ready. -func newFake() *fake { +func newFake() *fake { return newFakeNamed("FakeCapability") } + +func newFakeNamed(name string) *fake { f := &fake{} f.info, _ = capabilities.NewCapabilityInfo(fakeID, capabilities.CapabilityTypeCombined, "a fake") f.runnable.Service, _ = services.Config{ - Name: "FakeCapability", + Name: name, Start: func(context.Context) error { f.started = true; return nil }, Close: func() error { f.closed = true; return nil }, }.NewServiceEngine(logger.Nop()) @@ -80,13 +87,19 @@ func newFake() *fake { // A run blocks until its context is cancelled, so this waits until the binary is serving and then // cancels - which is a test standing in for the signal an operator would send. A run that fails on // the way up never gets there and reports that instead. -func execute(t *testing.T, lggr logger.Logger, ctor any, args ...string) error { +func execute(t *testing.T, lggr logger.Logger, a app, opts []Option, args ...string) error { t.Helper() + resetRegistrations(t) + if a.setup != nil { + a.setup() + } + // A free port rather than the default, since tests share a machine and run alongside each // other - two of them on 8080 would collide. It is also how this tells that the binary is up. port := 0 - if slices.Contains(args, "run") && !slices.Contains(args, "--http.port") { + serves := slices.Contains(args, "run") || slices.Contains(args, "embed") + if serves && !slices.Contains(args, "--http.port") { port = freePort(t) args = append(args, "--http.port", strconv.Itoa(port)) @@ -102,10 +115,10 @@ func execute(t *testing.T, lggr logger.Logger, ctor any, args ...string) error { defer cancel() done := make(chan error, 1) - go func() { done <- RunErr(ctx, lggr, ctor) }() + go func() { done <- a.runErr(ctx, lggr, opts...) }() if port == 0 { - // Nothing that serves: a rejected constructor, or a bare invocation that prints help. + // Nothing that serves: a rejected registration, or a bare invocation that prints help. return <-done } @@ -154,53 +167,132 @@ func freePort(t *testing.T) int { return port } -func TestRunErrRejects(t *testing.T) { - lggr := logger.Test(t) +// app is what a binary brings to a run: what its packages register from init, the constructor +// it hands Run, and its own config. +type app struct { + setup func() + ctor Constructor + // runErr is RunErr over ctor and the app's ConfigProvider, kept as a closure because the + // provider's type parameter is the app's own. Nil runs with no config of its own. + run func(ctx context.Context, lggr logger.Logger, ctor Constructor, opts ...Option) error +} - t.Run("no logger", func(t *testing.T) { - require.ErrorContains(t, RunErr(context.Background(), nil, newFake), "must provide a logger") - }) +// runErr is RunErr as the app's binary calls it. +func (a app) runErr(ctx context.Context, lggr logger.Logger, opts ...Option) error { + if a.run != nil { + return a.run(ctx, lggr, a.ctor, opts...) + } + return RunErr(ctx, lggr, a.ctor, NewConfigProvider(struct{}{}), opts...) +} - t.Run("something that is not a function", func(t *testing.T) { - require.ErrorContains(t, RunErr(context.Background(), lggr, "cron"), "must be a function, got string") - }) +// withConfig is a, with cfg as its own config. +func withConfig[T any](a app, cfg ConfigProvider[T]) app { + a.run = func(ctx context.Context, lggr logger.Logger, ctor Constructor, opts ...Option) error { + return RunErr(ctx, lggr, ctor, cfg, opts...) + } + return a +} - t.Run("nothing at all", func(t *testing.T) { - require.ErrorContains(t, RunErr(context.Background(), lggr, nil), "must be a function") - }) +// resetRegistrations gives one test fresh, empty registrations, and puts the process's back +// afterwards: a run registers its settings, so two tests sharing them would collide. RunErr +// registers the run's settings itself. +func resetRegistrations(t *testing.T) { + t.Helper() - t.Run("a function returning something that cannot be hosted", func(t *testing.T) { - require.ErrorContains(t, RunErr(context.Background(), lggr, func() string { return "" }), - "string does not implement capability.Capability") - }) + run, embed := runRegistrations, embedRegistrations + t.Cleanup(func() { runRegistrations, embedRegistrations = run, embed }) + + runRegistrations, embedRegistrations = newRegistrations(), newRegistrations() +} - t.Run("a function returning nothing", func(t *testing.T) { - require.ErrorContains(t, RunErr(context.Background(), lggr, func() {}), "must return the capability") +// resetWithSettings is resetRegistrations with the run's settings registered, as RunErr would, for +// a test that does not go through RunErr. +func resetWithSettings(t *testing.T) { + t.Helper() + + resetRegistrations(t) + registerSettings() +} + +// runConfig is the config registered for run under namespace, as the *T its flags decode into, so +// a test can set it as an operator would. +func runConfig[T any](t *testing.T, namespace string) *T { + t.Helper() + + cfg, ok := runRegistrations.configs[namespace].value.(*T) + require.True(t, ok, "no %T registered under %q", cfg, namespace) + return cfg +} + +// provides is an app of one capability, built by build from its instance's container. +func provides(build func(do.Injector) (*fake, error)) app { + return app{ctor: func(c do.Injector) (Capability, error) { + f, err := build(c) + if err != nil { + return nil, err + } + return f, nil + }} +} + +// providesFake is provides for a capability that asks for nothing. +func providesFake(build func() *fake) app { + return provides(func(do.Injector) (*fake, error) { return build(), nil }) +} + +// withSetup is a, with setup standing in for its packages' init. +func (a app) withSetup(setup func()) app { + a.setup = setup + return a +} + +func TestRunErrRejects(t *testing.T) { + lggr := logger.Test(t) + + t.Run("no logger", func(t *testing.T) { + require.ErrorContains(t, RunErr(context.Background(), nil, providesFake(newFake).ctor, NewConfigProvider(struct{}{})), + "must provide a logger") }) - t.Run("a function whose second result is not an error", func(t *testing.T) { - require.ErrorContains(t, RunErr(context.Background(), lggr, func() (*fake, string) { return nil, "" }), - "must return an error second") + t.Run("no constructor", func(t *testing.T) { + require.ErrorContains(t, RunErr(context.Background(), lggr, nil, NewConfigProvider(struct{}{})), + "must provide a capability constructor") }) } func TestRunBuildsTheCapability(t *testing.T) { built := 0 - require.NoError(t, execute(t, logger.Test(t), func() *fake { built++; return newFake() }, "run")) - assert.Equal(t, 1, built, "the constructor should have been called exactly once") + require.NoError(t, execute(t, logger.Test(t), providesFake(func() *fake { built++; return newFake() }), nil, "run")) + assert.Equal(t, 1, built, "the capability should have been built exactly once") } func TestRunReportsAFailedConstructor(t *testing.T) { - err := execute(t, logger.Test(t), func() (*fake, error) { return nil, errors.New("no schedule") }, "run") - require.ErrorContains(t, err, "failed to build the capability: no schedule") + err := execute(t, logger.Test(t), provides(func(do.Injector) (*fake, error) { + return nil, errors.New("no schedule") + }), nil, "run") + require.ErrorContains(t, err, "failed to build the capability") + require.ErrorContains(t, err, "no schedule") } -// TestRunNamesWhatItCannotProvide covers a parameter nothing answers: it is reported before -// anything starts, and names the type that went unmatched rather than the position it was in. +// TestRunReportsANoCapability covers a constructor that returns neither a capability nor an error. +func TestRunReportsANoCapability(t *testing.T) { + err := execute(t, logger.Test(t), app{ctor: func(do.Injector) (Capability, error) { + return nil, nil + }}, nil, "run") + require.ErrorContains(t, err, "the constructor returned none") +} + +// TestRunNamesWhatItCannotProvide covers a dependency nothing answers: it is reported before +// anything starts, and names the type that went unmatched. func TestRunNamesWhatItCannotProvide(t *testing.T) { - err := execute(t, logger.Test(t), func(string) *fake { return newFake() }, "run") - require.ErrorContains(t, err, "asks for a string, and nothing in this run provides one") + err := execute(t, logger.Test(t), provides(func(r do.Injector) (*fake, error) { + if _, err := do.Invoke[string](r); err != nil { + return nil, err + } + return newFake(), nil + }), nil, "run") + require.ErrorContains(t, err, "string") } // TestRunPassesTheLoggerToTheConstructor covers the first dependency: this process's logger, so a @@ -208,159 +300,399 @@ func TestRunNamesWhatItCannotProvide(t *testing.T) { func TestRunPassesTheLoggerToTheConstructor(t *testing.T) { var got logger.Logger - require.NoError(t, execute(t, logger.Test(t), func(l logger.Logger) *fake { - got = l - return newFake() - }, "run")) + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (f *fake, err error) { + got, err = do.Invoke[logger.Logger](r) + return newFake(), err + }), nil, "run")) - assert.NotNil(t, got, "the constructor should have been handed the run's logger") + assert.NotNil(t, got, "the capability should have been handed the run's logger") } // TestRunPassesTheRegistryToTheConstructor covers the registry a capability resolves other // capabilities through. -// -// By type rather than by position, so a constructor asks for what it needs and takes nothing it -// does not - which is what lets the one below coexist with the no-argument constructors elsewhere -// in these tests. func TestRunPassesTheRegistryToTheConstructor(t *testing.T) { + var got registry.CapabilitiesRegistry + + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (f *fake, err error) { + got, err = do.Invoke[registry.CapabilitiesRegistry](r) + return newFake(), err + }), nil, "run")) + + assert.NotNil(t, got, "the capability should have been handed the run's registry") +} + +// TestRunPassesTheRegistryAsTheCoreInterface covers core.CapabilitiesRegistry, the name most +// capabilities know the registry by: an alias of registry.CapabilitiesRegistry, so it resolves. +func TestRunPassesTheRegistryAsTheCoreInterface(t *testing.T) { var got core.CapabilitiesRegistry - require.NoError(t, execute(t, logger.Test(t), func(r core.CapabilitiesRegistry) *fake { - got = r - return newFake() - }, "run")) + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (f *fake, err error) { + got, err = do.Invoke[core.CapabilitiesRegistry](r) + return newFake(), err + }), nil, "run")) - assert.NotNil(t, got, "the constructor should have been handed the run's registry") + assert.NotNil(t, got) } -// TestRunPassesTheLimitsFactoryToTheConstructor covers the second dependency: what a capability -// bounds a workflow's requests with. -// -// A struct rather than an interface, which the type matching handles the same way - what a -// constructor asks for is a type, not a kind of type. +// TestRunPassesTheLimitsFactoryToTheConstructor covers what a capability bounds a workflow's +// requests with. func TestRunPassesTheLimitsFactoryToTheConstructor(t *testing.T) { var got limits.Factory - require.NoError(t, execute(t, logger.Test(t), func(f limits.Factory) *fake { - got = f - return newFake() - }, "run")) + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (f *fake, err error) { + got, err = do.Invoke[limits.Factory](r) + return newFake(), err + }), nil, "run")) assert.NotNil(t, got.Settings, "the factory should be built over this run's settings") assert.NotNil(t, got.Meter) } -// TestRunPassesEveryDependencyItHas covers a constructor asking for more than one, in an order of -// its own: matching is by type, so the order it lists them in is its business. -func TestRunPassesEveryDependencyItHas(t *testing.T) { - var ( - gotLimits limits.Factory - gotRegistry core.CapabilitiesRegistry - ) +// TestRunPassesANilOrgResolver pins the placeholder: resolvable, so a constructor asking for one +// builds, and nil, since nothing builds one yet. +func TestRunPassesANilOrgResolver(t *testing.T) { + var got orgresolver.OrgResolver + resolved := false + + require.NoError(t, execute(t, logger.Test(t), provides(func(c do.Injector) (f *fake, err error) { + got, err = do.Invoke[orgresolver.OrgResolver](c) + resolved = err == nil + return newFake(), err + }), nil, "run")) + + assert.True(t, resolved) + assert.Nil(t, got) +} - require.NoError(t, execute(t, logger.Test(t), func(f limits.Factory, r core.CapabilitiesRegistry) *fake { - gotLimits, gotRegistry = f, r - return newFake() - }, "run")) +// TestRunPassesTheClock covers the run's clock, provided as the interface so a test can shadow it. +func TestRunPassesTheClock(t *testing.T) { + var got clockwork.Clock - assert.NotNil(t, gotLimits.Settings) - assert.NotNil(t, gotRegistry) + require.NoError(t, execute(t, logger.Test(t), provides(func(c do.Injector) (f *fake, err error) { + got, err = do.Invoke[clockwork.Clock](c) + return newFake(), err + }), nil, "run")) + + assert.NotNil(t, got) } -// testConfig is a capability's own config, declared by embedding Config: the one parameter an -// operator supplies rather than the run resolves. +// dependency is something a package registers with Provide from its init, built over what the run +// provides. +type dependency struct{ lggr logger.Logger } + +// TestRunResolvesWhatInitRegistered covers the point of registering from init: a dependency +// registered before the run, depending on what only the run provides, is built against it. +func TestRunResolvesWhatInitRegistered(t *testing.T) { + var got *dependency + + a := provides(func(c do.Injector) (f *fake, err error) { + got, err = do.Invoke[*dependency](c) + return newFake(), err + }).withSetup(func() { + Provide(func(i do.Injector) (*dependency, error) { + lggr, err := do.Invoke[logger.Logger](i) + return &dependency{lggr: lggr}, err + }) + }) + + require.NoError(t, execute(t, logger.Test(t), a, nil, "run")) + require.NotNil(t, got) + assert.NotNil(t, got.lggr, "the dependency should be built over the run's logger") +} + +// TestProvideForRunAndForEmbed covers what an embedded instance does differently: each command +// resolves what was registered for it. +func TestProvideForRunAndForEmbed(t *testing.T) { + setup := func() { + ProvideForRun(func(do.Injector) (*dependency, error) { return &dependency{}, nil }) + ProvideForEmbed(func(do.Injector) (*dependency, error) { return &dependency{lggr: logger.Nop()}, nil }) + } + var got *dependency + a := provides(func(c do.Injector) (f *fake, err error) { + got, err = do.Invoke[*dependency](c) + return newFake(), err + }).withSetup(setup) + + require.NoError(t, execute(t, logger.Test(t), a, nil, "run")) + assert.Nil(t, got.lggr, "run resolves what was provided for run") + + require.NoError(t, execute(t, logger.Test(t), a, nil, "embed")) + assert.NotNil(t, got.lggr, "embed resolves what was provided for embed") +} + +// testConfig is the capability's own config, handed to Run through its ConfigProvider: what an +// operator supplies rather than the run builds. type testConfig struct { - Config - Shout string `usage:"what the fake says"` + // Shout is what the fake says. + Shout string +} + +// readsConfig is a capability with testConfig as its own config, defaulting to def, that records +// what it was handed. +func readsConfig(got *testConfig, def testConfig) app { + return withConfig(provides(func(c do.Injector) (f *fake, err error) { + *got, err = do.Invoke[testConfig](c) + return newFake(), err + }), NewConfigProvider(def)) } -// TestRunHandsTheConstructorItsConfig covers the parameter that is not a dependency: the -// capability's own settings, bound under the binary's name and handed over decoded. The binary is -// named cron - execute's argv[0] - so its flag is --cron.shout. +// TestRunHandsTheConstructorItsConfig covers a registered config, bound under its namespace and +// resolved decoded. func TestRunHandsTheConstructorItsConfig(t *testing.T) { var got testConfig - require.NoError(t, execute(t, logger.Test(t), func(cfg testConfig) *fake { - got = cfg - return newFake() - }, "run", "--cron.shout", "hello")) + require.NoError(t, execute(t, logger.Test(t), readsConfig(&got, testConfig{}), nil, "run", "--cron.shout", "hello")) assert.Equal(t, "hello", got.Shout) } +// TestRunDefaultsTheCapabilityConfig covers NewConfigProvider's default: what the capability is +// handed when nothing sets it. +func TestRunDefaultsTheCapabilityConfig(t *testing.T) { + var got testConfig + + require.NoError(t, execute(t, logger.Test(t), readsConfig(&got, testConfig{Shout: "default"}), nil, "run")) + + assert.Equal(t, "default", got.Shout) +} + // TestRunReadsTheCapabilityConfigFromTheEnvironment is the same binding from the other direction: -// CRE_, then the binary's name, as every other section's settings are. +// CRE_, then the namespace, as every other section's settings are. func TestRunReadsTheCapabilityConfigFromTheEnvironment(t *testing.T) { t.Setenv("CRE_CRON_SHOUT", "from the environment") var got testConfig - require.NoError(t, execute(t, logger.Test(t), func(cfg testConfig) *fake { - got = cfg - return newFake() - }, "run")) + require.NoError(t, execute(t, logger.Test(t), readsConfig(&got, testConfig{}), nil, "run")) assert.Equal(t, "from the environment", got.Shout) } -// TestRunHandsTheConstructorItsConfigAndItsDependencies covers the two kinds of parameter side by -// side: the config from the flags, the dependencies from the run, and neither answered by the -// other. -func TestRunHandsTheConstructorItsConfigAndItsDependencies(t *testing.T) { - var ( - gotCfg testConfig - gotLimits limits.Factory - ) +// TestEmbedBindsRunsConfig covers a config registered with ProvideConfig reaching embed too. +func TestEmbedBindsRunsConfig(t *testing.T) { + var got testConfig - require.NoError(t, execute(t, logger.Test(t), func(cfg testConfig, f limits.Factory) *fake { - gotCfg, gotLimits = cfg, f - return newFake() - }, "run", "--cron.shout", "hello")) + require.NoError(t, execute(t, logger.Test(t), readsConfig(&got, testConfig{}), nil, "embed", "--cron.shout", "embedded")) - assert.Equal(t, "hello", gotCfg.Shout) - assert.NotNil(t, gotLimits.Settings) + assert.Equal(t, "embedded", got.Shout) } -// TestRunRejectsTwoConfigs pins the one-config rule: the struct is bound under the binary's name, -// so two of them would share one namespace and which setting belonged to which would be anyone's -// guess. -func TestRunRejectsTwoConfigs(t *testing.T) { - err := execute(t, logger.Test(t), func(a, b testConfig) *fake { return newFake() }, "run") - require.ErrorContains(t, err, "takes its config once") -} - -// TestRunRejectsTheDependenciesStruct pins the rule the other way round: a constructor declares the -// things it uses, not the bag they came in. -// -// Taking the struct would be a dependency on everything a run has, and adding a field to it would -// silently widen what every capability appeared to need. -func TestRunRejectsTheDependenciesStruct(t *testing.T) { - err := execute(t, logger.Test(t), func(Dependencies) *fake { return newFake() }, "run") +// TestEmbedOnlyConfigIsNotOnRun covers ProvideConfigForEmbed: bound on embed alone. +func TestEmbedOnlyConfigIsNotOnRun(t *testing.T) { + a := providesFake(newFake).withSetup(func() { + ProvideConfigForEmbed("embedonly", testConfig{}) + }) - require.ErrorContains(t, err, "asks for a capability.Dependencies, and nothing in this run provides one") + err := execute(t, logger.Test(t), a, nil, "run", "--embedonly.shout", "x") + require.ErrorContains(t, err, "unknown flag: --embedonly.shout") } -// TestRunPassesTheRegistryAsANarrowerInterface covers matching by assignability: a capability that -// only needs to resolve capabilities can say so, without naming the wider type the run holds. -func TestRunPassesTheRegistryAsANarrowerInterface(t *testing.T) { - var got core.CapabilitiesRegistry - - require.NoError(t, execute(t, logger.Test(t), func(r core.CapabilitiesRegistry) *fake { - got = r - return newFake() - }, "run")) +// TestRunRejectsADependencyRegisteringARunSetting pins the order: RunErr registers the run's own +// settings after every init, so a dependency that registered one of those types first is a +// duplicate, not an override. +func TestRunRejectsADependencyRegisteringARunSetting(t *testing.T) { + a := providesFake(newFake).withSetup(func() { + ProvideConfig("mine", HTTPConfig{Port: 1}) + }) - assert.NotNil(t, got) + err := execute(t, logger.Test(t), a, nil, "run", "--http.port", "1") + require.ErrorContains(t, err, "failed to register the run's settings") } // TestRunHasARunCommand is the shape of the binary rather than what it does: a root that runs // nothing itself, and a way to start it hanging off it. func TestRunHasARunCommand(t *testing.T) { // A bare invocation prints help and does nothing, rather than starting the capability. - require.NoError(t, execute(t, logger.Test(t), func() *fake { + require.NoError(t, execute(t, logger.Test(t), app{ctor: func(do.Injector) (Capability, error) { t.Error("the root command should not build the capability") - return newFake() - })) + return newFake(), nil + }}, nil)) +} + +// newFakeFor is a fake whose ID and service name name its instance: the run's one registry refuses +// an ID twice, and its health checker a name, so embedded instances have to differ in both. +func newFakeFor(e Instance) *fake { + f := newFakeNamed(fmt.Sprintf("FakeCapability%d", e.ID)) + f.info.ID = fmt.Sprintf("fake-%d@1.0.0", e.ID) + return f +} + +// TestEmbedHostsEveryInstance covers --instances: one capability per instance, each built in a +// container of its own that tells it which instance it is and how many there are. +func TestEmbedHostsEveryInstance(t *testing.T) { + var ( + mu sync.Mutex + got []Instance + ) + + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (*fake, error) { + e, err := do.Invoke[Instance](r) + if err != nil { + return nil, err + } + mu.Lock() + got = append(got, e) + mu.Unlock() + return newFakeFor(e), nil + }), nil, "embed", "--instances", "3")) + + assert.ElementsMatch(t, []Instance{ + {ID: 0, Total: 3}, + {ID: 1, Total: 3}, + {ID: 2, Total: 3}, + }, got) +} + +// TestEmbedDefaultsToOneInstance pins the default, so embed with no flags is a run of one that +// still says it is embedded. +func TestEmbedDefaultsToOneInstance(t *testing.T) { + var got Instance + + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (f *fake, err error) { + got, err = do.Invoke[Instance](r) + return newFake(), err + }), nil, "embed")) + + assert.Equal(t, Instance{ID: 0, Total: 1}, got) +} + +// TestEmbedInstancesHaveTheirOwnResources covers what an instance is: each runs its own copy of +// the per-instance resources - here the registry - rather than sharing one. +func TestEmbedInstancesHaveTheirOwnResources(t *testing.T) { + var ( + mu sync.Mutex + got []registry.CapabilitiesRegistry + ) + + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (*fake, error) { + e, err := do.Invoke[Instance](r) + if err != nil { + return nil, err + } + reg, err := do.Invoke[registry.CapabilitiesRegistry](r) + if err != nil { + return nil, err + } + mu.Lock() + got = append(got, reg) + mu.Unlock() + return newFakeFor(e), nil + }), nil, "embed", "--instances", "2")) + + require.Len(t, got, 2) + assert.NotSame(t, got[0], got[1]) +} + +// TestEmbedHasNoProxy covers embed's capabilities settings: an embedded run has no node behind +// it, so it has no --capabilities.proxy-url to dial one with. +func TestEmbedHasNoProxy(t *testing.T) { + err := execute(t, logger.Test(t), providesFake(newFake), nil, "embed", "--capabilities.proxy-url", "x:1") + require.ErrorContains(t, err, "unknown flag: --capabilities.proxy-url") +} + +// TestEmbedDoesNotAnnounce is the same from the run's side: with no proxy, nothing reaches a +// node's registry even though one is listening. +func TestEmbedDoesNotAnnounce(t *testing.T) { + node := newSpyRegistry(t) + _ = serveNodeRegistry(t, node) + + require.NoError(t, execute(t, logger.Test(t), providesFake(newFake), nil, "embed")) + + assert.Empty(t, node.addedIDs()) +} + +// TestEmbedDefaultsTheDonID pins embed's default DON: one DON in one process, so 1 rather than +// the unset 0 a run starts from. +func TestEmbedDefaultsTheDonID(t *testing.T) { + root, _, _ := settingsRoot(t) + embedCmd, _, err := root.Find([]string{"embed"}) + require.NoError(t, err) + + don := embedCmd.PersistentFlags().Lookup("capabilities.capability-don-id") + require.NotNil(t, don) + assert.Equal(t, "1", don.DefValue) +} + +// TestEmbedNamesEachInstancesLogger covers instance.N: several instances log apart, through the +// logger each resolves from its own container. +func TestEmbedNamesEachInstancesLogger(t *testing.T) { + var ( + mu sync.Mutex + names = map[string]bool{} + ) + + require.NoError(t, execute(t, logger.Test(t), provides(func(c do.Injector) (*fake, error) { + e, err := do.Invoke[Instance](c) + if err != nil { + return nil, err + } + lggr, err := do.Invoke[logger.Logger](c) + if err != nil { + return nil, err + } + mu.Lock() + names[lggr.Name()] = true + mu.Unlock() + return newFakeFor(e), nil + }), nil, "embed", "--instances", "2")) + + assert.Equal(t, map[string]bool{"cron.instance.0": true, "cron.instance.1": true}, names) +} + +// TestEmbedServesEachInstanceOnItsOwnPort covers http.port + ID: each instance has its own +// web server, and so its own /healthz. +func TestEmbedServesEachInstanceOnItsOwnPort(t *testing.T) { + resetRegistrations(t) + base := freePort(t) + // The next port has to be free too; take two and use the lower. + next := freePort(t) + if next != base+1 { + t.Skipf("ports %d and %d are not consecutive", base, next) + } + + previous := os.Args + t.Cleanup(func() { os.Args = previous }) + os.Args = []string{"cron", "embed", "--instances", "2", "--http.port", strconv.Itoa(base)} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- RunErr(ctx, logger.Test(t), provides(func(r do.Injector) (*fake, error) { + e, err := do.Invoke[Instance](r) + return newFakeFor(e), err + }).ctor, NewConfigProvider(struct{}{})) + }() + + require.Eventually(t, func() bool { return serving(base) && serving(base+1) }, 30*time.Second, + 5*time.Millisecond, "both instances should be serving") + cancel() + require.NoError(t, <-done) +} + +// TestEmbedRejectsNoInstances covers the floor: an embed hosting nothing is a mistake, not a run. +func TestEmbedRejectsNoInstances(t *testing.T) { + err := execute(t, logger.Test(t), providesFake(newFake), nil, "embed", "--instances", "0", "--http.port", "1") + require.ErrorContains(t, err, "Instances") +} + +// TestRunIsOneInstance covers the run command's Instance: instance 0 of 1, so a capability may +// always ask which instance it is. +func TestRunIsOneInstance(t *testing.T) { + var got Instance + + require.NoError(t, execute(t, logger.Test(t), provides(func(r do.Injector) (f *fake, err error) { + got, err = do.Invoke[Instance](r) + return newFake(), err + }), nil, "run")) + + assert.Equal(t, Instance{ID: 0, Total: 1}, got) +} + +// TestRunHasNoInstancesFlag pins --instances to embed: it is registered on that command alone. +func TestRunHasNoInstancesFlag(t *testing.T) { + err := execute(t, logger.Test(t), providesFake(newFake), nil, "run", "--instances", "2") + require.ErrorContains(t, err, "unknown flag: --instances") } // TestRunServesAndAnnouncesTheCapability is the whole path through the real entry point: a run @@ -372,9 +704,9 @@ func TestRunServesAndAnnouncesTheCapability(t *testing.T) { node := newSpyRegistry(t) proxyURL := serveNodeRegistry(t, node) - require.NoError(t, execute(t, logger.Test(t), func() *fake { return newFake() }, + require.NoError(t, execute(t, logger.Test(t), providesFake(newFake), nil, "run", "--capabilities.proxy-url", proxyURL)) - assert.Equal(t, []string{fakeID}, node.addedIDs(), "the run should have announced the capability to the node's registry") - assert.Equal(t, []string{fakeID}, node.removedIDs(), "and deregistered it on the way out") + assert.Contains(t, node.addedIDs(), fakeID, "the run should have announced the capability to the node's registry") + assert.Contains(t, node.removedIDs(), fakeID, "and deregistered it on the way out") } diff --git a/libs/capability/runner.go b/libs/capability/runner.go index e3a3001e2..98dc682ff 100644 --- a/libs/capability/runner.go +++ b/libs/capability/runner.go @@ -2,33 +2,53 @@ package capability import ( "context" + "errors" "fmt" "net/http" "os" + "strconv" - "github.com/hashicorp/go-plugin" + "github.com/jonboulle/clockwork" + "github.com/samber/do/v2" "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/registry" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/loop" "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/services/orgresolver" + "github.com/smartcontractkit/chainlink-common/x/config/cli" ) -func run(ctx context.Context, lggr logger.Logger, name string, cfg *config, newCapability constructorFn) error { - defer func() { _ = lggr.Sync() }() - +// start brings up what the process shares - profiler, telemetry, the CRE settings the host pushes - +// and then total instances over it, each with an injector of its own built from r, and returns how +// to stop them all. +// +// A failure part-way stops whatever had started. +func start(ctx context.Context, lggr logger.Logger, name string, opts runOptions, r *registrations, b *cli.Binder, total int, ctor Constructor) (_ func(), err error) { var svcs []services.Service - defer func() { + stop := func() { if err := services.MultiCloser(svcs).Close(); err != nil { logger.Sugared(lggr).Errorw("failed to stop the services of this run", "err", err) } + } + defer func() { + if err != nil { + stop() + } }() - mux := http.NewServeMux() + // The run's own settings are configs, read through what the flags decoded into, so an injector + // built only to read them builds nothing else. + cfg, err := resolveSettings(r.injector()) + if err != nil { + return nil, err + } + cfg.observability.otelViews = opts.otelViews - profiler, err := startProfiler(ctx, lggr, name, cfg.observability.pyroscope) + profiler, err := startProfiler(ctx, lggr, name, cfg.pyroscope) if err != nil { - return fmt.Errorf("failed to start profiler: %w", err) + return nil, fmt.Errorf("failed to start profiler: %w", err) } if profiler != nil { svcs = append(svcs, profiler) @@ -36,53 +56,114 @@ func run(ctx context.Context, lggr logger.Logger, name string, cfg *config, newC telemetry, err := startTelemetry(ctx, lggr, &cfg.observability) if err != nil { - return fmt.Errorf("failed to build telemetry: %w", err) + return nil, fmt.Errorf("failed to build telemetry: %w", err) } svcs = append(svcs, telemetry) + settings, err := startSettings(ctx, lggr, cfg.settings) + if err != nil { + return nil, fmt.Errorf("failed to start settings: %w", err) + } + svcs = append(svcs, settings) + + for id := range total { + started, err := startInstance(ctx, lggr, r, b, Instance{ID: id, Total: total}, cfg, settings.values, ctor) + svcs = append(svcs, started...) + if err != nil { + return nil, fmt.Errorf("instance %d: %w", id, err) + } + } + + return stop, nil +} + +// startInstance brings up one instance, on an injector of its own built from r: its Instance, the +// run's values as this instance sees them, the capability, and the health checker and web server +// reporting on them. It returns what it started, even on failure, so the caller stops it. +// +// Several instances are told apart: each logs as instance.N, and serves on http.port + N. +func startInstance(ctx context.Context, lggr logger.Logger, r *registrations, b *cli.Binder, instance Instance, cfg runSettings, settings *loop.AtomicSettings, ctor Constructor) (svcs []services.Service, err error) { + if instance.Total > 1 { + lggr = logger.Named(lggr, "instance."+strconv.Itoa(instance.ID)) + } + reg, err := startRegistry(ctx, lggr, cfg.capabilities, cfg.grpc) if err != nil { - return fmt.Errorf("failed to start registry: %w", err) + return svcs, fmt.Errorf("failed to start registry: %w", err) } svcs = append(svcs, reg) - settings, err := startSettings(ctx, lggr, cfg.settings) - if err != nil { - return fmt.Errorf("failed to start settings: %w", err) + i := r.injector() + if err := errors.Join( + provideValue(r, i, instance), + provideValue(r, i, lggr), + // core.CapabilitiesRegistry is an alias of registry.CapabilitiesRegistry, so this is both. + provideValue[registry.CapabilitiesRegistry](r, i, reg), + provideValue(r, i, newLimitsFactory(lggr, settings)), + provideValue[clockwork.Clock](r, i, clockwork.NewRealClock()), + // Nil until a run can build one; every consumer already tolerates a missing resolver. + provideValue[orgresolver.OrgResolver](r, i, nil), + provideValue(r, i, b), + ); err != nil { + return svcs, err } - svcs = append(svcs, settings) - capability, err := startCapability(ctx, lggr, cfg, newCapability, reg, settings.values, mux) + capability, err := startCapability(ctx, i, ctor, reg) if err != nil { - return err + return svcs, err } svcs = append(svcs, capability) health, err := startHealthChecker(ctx, lggr, beholder.GetClient(), servicesToHealthReporters(svcs)) if err != nil { - return fmt.Errorf("failed to start health checker: %w", err) + return svcs, fmt.Errorf("failed to start health checker: %w", err) } svcs = append(svcs, health) - ws, err := startWebServer(ctx, lggr, cfg.observability.http, mux, health.checker) + mux := http.NewServeMux() + + httpCfg := cfg.http + httpCfg.Port += uint16(instance.ID) + ws, err := startWebServer(ctx, lggr, httpCfg, mux, health.checker) if err != nil { - return fmt.Errorf("failed to start web server: %w", err) + return svcs, fmt.Errorf("failed to start web server: %w", err) } svcs = append(svcs, ws) - if underPluginHost() { - lggr.Info("Serving the empty LOOP: this process is supervised by a go-plugin host") - plugin.Serve(&plugin.ServeConfig{ - HandshakeConfig: emptyHandshakeConfig(), - Plugins: map[string]plugin.Plugin{emptyPluginName: &emptyLoop{}}, - GRPCServer: plugin.DefaultGRPCServer, - }) - return nil + return svcs, nil +} + +// runSettings is what start resolves of its own settings. +type runSettings struct { + observability observability + pyroscope PyroscopeConfig + http HTTPConfig + capabilities capabilityConfig + grpc grpcConfig + settings settingsConfig +} + +// resolveSettings resolves the run's settings from i. +func resolveSettings(i do.Injector) (cfg runSettings, err error) { + if err := errors.Join( + resolve(i, &cfg.observability.telemetry), + resolve(i, &cfg.observability.tracing), + resolve(i, &cfg.observability.chipIngress), + resolve(i, &cfg.pyroscope), + resolve(i, &cfg.http), + resolve(i, &cfg.grpc), + resolve(i, &cfg.capabilities), + resolve(i, &cfg.settings), + ); err != nil { + return runSettings{}, fmt.Errorf("failed to resolve the run's settings: %w", err) } + return cfg, nil +} - <-ctx.Done() - lggr.Info("Shutting down") - return nil +// resolve fills target with the T i resolves. +func resolve[T any](i do.Injector, target *T) (err error) { + *target, err = do.Invoke[T](i) + return err } func servicesToHealthReporters(svcs []services.Service) []services.HealthReporter { @@ -103,23 +184,22 @@ func underPluginHost() bool { return os.Getenv(h.MagicCookieKey) == h.MagicCookieValue } -// startCapability builds the capability from its constructor and makes it reachable: started, -// served and announced by the registry, and mounted via the debug UI if debug mode is turned on. -func startCapability(ctx context.Context, lggr logger.Logger, cfg *config, ctor constructorFn, reg *registryService, settings *loop.AtomicSettings, mux *http.ServeMux) (Capability, error) { - c, err := ctor.call(Dependencies{ - Logger: lggr, - CapabilityRegistry: reg, - LimitsFactory: newLimitsFactory(lggr, settings), - }) +// startCapability builds a capability with ctor from the instance's injector and makes it +// reachable: started, served and announced by the registry. +func startCapability(ctx context.Context, i do.Injector, ctor Constructor, reg *registryService) (Capability, error) { + c, err := ctor(i) if err != nil { - return nil, fmt.Errorf("failed to instantiate capability: %w", err) + return nil, fmt.Errorf("failed to build the capability: %w", err) + } + if c == nil { + return nil, errors.New("failed to build the capability: the constructor returned none") } - if err := c.Start(ctx); err != nil { + if err = c.Start(ctx); err != nil { return nil, fmt.Errorf("failed to start capability: %w", err) } - if err := reg.Add(ctx, c); err != nil { + if err = reg.Add(ctx, c); err != nil { // Not yet on the caller's list, so the deferred close would never reach it. _ = c.Close() return nil, err diff --git a/libs/capability/runner_test.go b/libs/capability/runner_test.go index c58d5e07f..9be48fa0f 100644 --- a/libs/capability/runner_test.go +++ b/libs/capability/runner_test.go @@ -9,29 +9,48 @@ import ( "testing" "time" + "github.com/samber/do/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/beholder" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/x/config/cli" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" ) -// runArgs is the arguments of one run over settings a test can start: a real port, and everything -// else off. -func runArgs(t *testing.T, ctor any) (*config, constructorFn, uint16) { +// runArgs sets the run's settings up for one run a test can start - a real port, and everything else +// off - and returns the constructor and the port. +func runArgs(t *testing.T, a app) (Constructor, uint16) { t.Helper() - - c, err := newConstructor(ctor) - require.NoError(t, err) + resetWithSettings(t) port := uint16(freePort(t)) - cfg := defaultConfig() - cfg.observability.http.Port = port + runConfig[HTTPConfig](t, httpNamespace).Port = port // A stub the registrar's announcement can land on: registering the capability dials the // proxy, so it has to be a real listener rather than merely a lazy one. - cfg.capabilities.ProxyURL = serveNodeRegistry(t, newSpyRegistry(t)) + runConfig[capabilityConfig](t, capabilitiesNamespace).ProxyURL = serveNodeRegistry(t, newSpyRegistry(t)) + + return a.ctor, port +} + +// runOne is what the run command does, minus the plugin host: start one instance, wait for ctx, +// stop. +// +// The containers are reset by runArgs, on the test's goroutine, since this runs on another. +func runOne(ctx context.Context, t *testing.T, ctor Constructor) error { + b, err := cli.New(cli.Options{Markup: tomlmarkup.New()}) + if err != nil { + return err + } + stop, err := start(ctx, logger.Test(t), "cron", runOptions{}, runRegistrations, b, 1, ctor) + if err != nil { + return err + } + defer stop() - return cfg, c, port + <-ctx.Done() + return nil } // runToCompletion starts a run and stops it once it is serving, returning what run returned. @@ -39,14 +58,14 @@ func runArgs(t *testing.T, ctor any) (*config, constructorFn, uint16) { // A run blocks until its context is cancelled, so a test that wants one to finish has to be the // thing that ends it - standing in for the signal an operator would send. A run that fails on the // way up never gets as far as serving and reports that instead. -func runToCompletion(t *testing.T, cfg *config, c constructorFn, port uint16) error { +func runToCompletion(t *testing.T, c Constructor, port uint16) error { t.Helper() ctx, cancel := context.WithCancel(context.Background()) defer cancel() done := make(chan error, 1) - go func() { done <- run(ctx, logger.Test(t), "cron", cfg, c) }() + go func() { done <- runOne(ctx, t, c) }() deadline := time.After(30 * time.Second) for { @@ -73,9 +92,9 @@ func runToCompletion(t *testing.T, cfg *config, c constructorFn, port uint16) er // one that gave the port back, which nothing else in the run reports. func TestRunRunsAndUnwinds(t *testing.T) { built := 0 - cfg, c, port := runArgs(t, func() *fake { built++; return newFake() }) + c, port := runArgs(t, providesFake(func() *fake { built++; return newFake() })) - require.NoError(t, runToCompletion(t, cfg, c, port)) + require.NoError(t, runToCompletion(t, c, port)) assert.Equal(t, 1, built) assertPortFree(t, port) @@ -85,9 +104,9 @@ func TestRunRunsAndUnwinds(t *testing.T) { // is running - which is what makes a health check mean anything. func TestRunStartsTheCapability(t *testing.T) { f := newFake() - cfg, c, port := runArgs(t, func() *fake { return f }) + c, port := runArgs(t, providesFake(func() *fake { return f })) - require.NoError(t, runToCompletion(t, cfg, c, port)) + require.NoError(t, runToCompletion(t, c, port)) assert.True(t, f.started, "the run should have started the capability") assert.True(t, f.closed, "and closed it again on the way out") @@ -110,19 +129,18 @@ func TestRunInstallsTelemetryBeforeBuildingTheCapability(t *testing.T) { t.Skip("builds a real beholder client; ~20s of export timeouts on close") } - cfg, _, port := runArgs(t, newFake) + _, port := runArgs(t, providesFake(newFake)) // Nothing is listening there, which is fine: the client is built and installed without dialling. - cfg.observability.telemetry.Endpoint = fmt.Sprintf("localhost:%d", freePort(t)) + runConfig[TelemetryConfig](t, telemetryNamespace).Endpoint = fmt.Sprintf("localhost:%d", freePort(t)) var duringBuild *beholder.Client - c, err := newConstructor(func() *fake { + c := providesFake(func() *fake { duringBuild = beholder.GetClient() return newFake() - }) - require.NoError(t, err) + }).ctor before := beholder.GetClient() - require.NoError(t, runToCompletion(t, cfg, c, port)) + require.NoError(t, runToCompletion(t, c, port)) require.NotNil(t, duringBuild, "the constructor should have run") assert.NotSame(t, before, duringBuild, "the capability was built before telemetry was installed") @@ -135,14 +153,14 @@ func TestRunInstallsTelemetryBeforeBuildingTheCapability(t *testing.T) { // The beholder assertion below holds trivially here and is not evidence about telemetry. These // settings configure no endpoint, so newTelemetry returns the noop service and installs nothing - // there is nothing to put back. A run with telemetry configured would fail this if it asserted -// anything, which is the gap documented on the telemetry block in run: the global is swapped when +// anything, which is the gap documented on the telemetry block in start: the global is swapped when // telemetry is built, and the undo only works once the root has started it. func TestRunUnwindsWhatStartedBeforeAFailure(t *testing.T) { before := beholder.GetClient() - cfg, c, port := runArgs(t, func() (*fake, error) { return nil, errors.New("no schedule") }) - require.Empty(t, cfg.observability.telemetry.Endpoint, "these settings leave telemetry off") + c, port := runArgs(t, provides(func(do.Injector) (*fake, error) { return nil, errors.New("no schedule") })) + require.Empty(t, runConfig[TelemetryConfig](t, telemetryNamespace).Endpoint, "these settings leave telemetry off") - require.ErrorContains(t, runToCompletion(t, cfg, c, port), "no schedule") + require.ErrorContains(t, runToCompletion(t, c, port), "no schedule") assert.Same(t, before, beholder.GetClient(), "nothing was installed, so nothing changed") // The web server never got as far as starting, so the port was never taken. @@ -154,14 +172,14 @@ func TestRunUnwindsWhatStartedBeforeAFailure(t *testing.T) { // // It is also what proves run gets as far as the web server on the configured port at all. func TestRunReportsWhereItFailed(t *testing.T) { - cfg, c, port := runArgs(t, newFake) + c, port := runArgs(t, providesFake(newFake)) // Something else already has the port the web server is configured for. taken, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) require.NoError(t, err) t.Cleanup(func() { _ = taken.Close() }) - require.ErrorContains(t, runToCompletion(t, cfg, c, port), + require.ErrorContains(t, runToCompletion(t, c, port), fmt.Sprintf("failed to listen on port %d", port)) } @@ -171,11 +189,11 @@ func TestRunReportsWhereItFailed(t *testing.T) { // It is also the only test that looks at a run from outside while it is up: everything is still // serving when the assertions below are made. func TestRunStaysUpUntilItIsToldToStop(t *testing.T) { - cfg, c, port := runArgs(t, newFake) + c, port := runArgs(t, providesFake(newFake)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) - go func() { done <- run(ctx, logger.Test(t), "cron", cfg, c) }() + go func() { done <- runOne(ctx, t, c) }() require.Eventually(t, func() bool { return serving(int(port)) }, 30*time.Second, 5*time.Millisecond, "the run should be serving while it is up") diff --git a/libs/capability/settings.go b/libs/capability/settings.go index 4d2fefc07..0c77ff1d7 100644 --- a/libs/capability/settings.go +++ b/libs/capability/settings.go @@ -21,8 +21,11 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/types/core" ) +// settingsConfig is where this process's CRE settings come from. type settingsConfig struct { - URL string `usage:"gRPC target of the host's CRE settings service (e.g. localhost:9001), which pushes settings updates to this process. Unset leaves every limit at its compiled-in default"` + // URL is the gRPC target of the host's CRE settings service (e.g. localhost:9001), which pushes + // settings updates to this process. Unset leaves every limit at its compiled-in default. + URL string } // settingsService holds this process's CRE settings and, when the host serves them, keeps them diff --git a/libs/capability/settings_test.go b/libs/capability/settings_test.go index 0c5b32bdf..19f09d490 100644 --- a/libs/capability/settings_test.go +++ b/libs/capability/settings_test.go @@ -15,6 +15,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/loop" "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" "github.com/smartcontractkit/chainlink-common/pkg/types/core" ) @@ -42,7 +43,7 @@ func TestFollowHostSettingsAppliesUpdates(t *testing.T) { require.NoError(t, err) host := newFakeBroadcaster() - go followHostSettings(t.Context(), logger.Test(t), host, s) + follow(t, host, s) host.send(t, core.SettingsUpdate{Settings: "[global]\nTriggerRegistrationStatusUpdateTimeout = \"15s\"\n", Hash: "a"}) @@ -60,7 +61,7 @@ func TestFollowHostSettingsKeepsThePreviousSettingsOnFailure(t *testing.T) { require.NoError(t, err) host := newFakeBroadcaster() - go followHostSettings(t.Context(), logger.Test(t), host, s) + follow(t, host, s) host.send(t, core.SettingsUpdate{Settings: "[global]\nTriggerRegistrationStatusUpdateTimeout = \"15s\"\n", Hash: "a"}) host.send(t, core.SettingsUpdate{Settings: "not a settings payload", Hash: "b"}) @@ -79,7 +80,7 @@ func TestFollowHostSettingsResubscribes(t *testing.T) { host := newFakeBroadcaster() host.failFirst.Store(true) - go followHostSettings(t.Context(), logger.Test(t), host, s) + follow(t, host, s) host.send(t, core.SettingsUpdate{Settings: "[global]\n", Hash: "a"}) host.closeStream() @@ -88,6 +89,23 @@ func TestFollowHostSettingsResubscribes(t *testing.T) { assert.GreaterOrEqual(t, host.subscriptions.Load(), int32(3)) } +// follow runs followHostSettings for the test, and waits for it to return before the test ends: +// it logs through the test's logger, which must not be written to once the test is done. +func follow(t *testing.T, host core.SettingsBroadcaster, s *loop.AtomicSettings) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + followHostSettings(ctx, logger.Test(t), host, s) + }() + t.Cleanup(func() { + cancel() + <-done + }) +} + // fakeBroadcaster hands out one stream per subscription, and sends on whichever is current. type fakeBroadcaster struct { failFirst atomic.Bool @@ -100,14 +118,39 @@ func newFakeBroadcaster() *fakeBroadcaster { return &fakeBroadcaster{streams: make(chan chan core.SettingsUpdate, 1)} } -func (f *fakeBroadcaster) Subscribe(context.Context) (<-chan core.SettingsUpdate, error) { +// Subscribe hands out a stream that, like a real one, ends when the subscription's ctx does. +func (f *fakeBroadcaster) Subscribe(ctx context.Context) (<-chan core.SettingsUpdate, error) { f.subscriptions.Add(1) if f.failFirst.CompareAndSwap(true, false) { return nil, errors.New("host unavailable") } ch := make(chan core.SettingsUpdate) - f.streams <- ch - return ch, nil + select { + case f.streams <- ch: + case <-ctx.Done(): + return nil, ctx.Err() + } + + out := make(chan core.SettingsUpdate) + go func() { + defer close(out) + for { + select { + case u, ok := <-ch: + if !ok { + return + } + select { + case out <- u: + case <-ctx.Done(): + return + } + case <-ctx.Done(): + return + } + } + }() + return out, nil } func (f *fakeBroadcaster) send(t *testing.T, u core.SettingsUpdate) { diff --git a/libs/capability/telemetry.go b/libs/capability/telemetry.go index f78d70548..6dec94465 100644 --- a/libs/capability/telemetry.go +++ b/libs/capability/telemetry.go @@ -17,45 +17,60 @@ import ( ) const ( - // Legacy env prefixes for the two map-valued telemetry settings. pkg/config/flags has no map - // support, so those are []string of key=value pairs here; a host sets one env var per entry - // instead, and envPairs still picks those up. See TelemetryConfig.Attributes. + // Legacy env prefixes for the two map-valued telemetry settings, which are []string of + // key=value pairs here; a host sets one env var per entry instead, and envPairs still picks + // those up. See TelemetryConfig.Attributes. envTelemetryAttributePrefix = "CL_TELEMETRY_ATTRIBUTE_" envTelemetryAuthHeaderPrefix = "CL_TELEMETRY_AUTH_HEADER_" ) // TelemetryConfig is the beholder client's configuration: an empty Endpoint leaves telemetry off, and the global noop client in place, so // instruments created by services record nothing. +// +// Attributes and AuthHeaders are key=value pairs ("env=staging") rather than maps, which the old +// flags package could not bind; x/config/cli can, so they could become maps now. Entries from the legacy CL_TELEMETRY_ATTRIBUTE_ and +// CL_TELEMETRY_AUTH_HEADER_ env vars are merged in on top of these, so a plugin host setting +// one env var per entry keeps working. Field comments are the flags' help text. type TelemetryConfig struct { - Endpoint string `usage:"OTLP gRPC endpoint telemetry is exported to; telemetry is disabled when unset"` - InsecureConnection bool `usage:"export telemetry over an insecure connection"` - CACertFile string `usage:"CA certificate file used to verify the telemetry endpoint"` - - // Attributes and AuthHeaders are key=value pairs ("env=staging") rather than maps, since - // flags cannot bind a map field. Entries from the legacy CL_TELEMETRY_ATTRIBUTE_ and - // CL_TELEMETRY_AUTH_HEADER_ env vars are merged in on top of these, so a plugin host - // setting one env var per entry keeps working. - Attributes []string `usage:"extra telemetry resource attributes, as key=value pairs" example:"['env=staging']"` - AuthHeaders []string `usage:"telemetry auth headers, as key=value pairs" flagdocs:"noexample"` - - AuthPubKeyHex string `usage:"public key the telemetry auth headers are derived from"` - AuthHeadersTTL commonconfig.Duration `usage:"how long generated telemetry auth headers are valid for"` - PrometheusBridgeEnabled bool `usage:"feed metrics registered on the prometheus registry into the telemetry pipeline"` + // Endpoint is the OTLP gRPC endpoint telemetry is exported to. Telemetry is disabled when unset. + Endpoint string + // InsecureConnection exports telemetry over an insecure connection. + InsecureConnection bool + // CACertFile is the CA certificate file used to verify the telemetry endpoint. + CACertFile string + + // Attributes are extra telemetry resource attributes, as key=value pairs. + Attributes []string + // AuthHeaders are telemetry auth headers, as key=value pairs. + AuthHeaders []string + + // AuthPubKeyHex is the public key the telemetry auth headers are derived from. + AuthPubKeyHex string + // AuthHeadersTTL is how long generated telemetry auth headers are valid for. + AuthHeadersTTL commonconfig.Duration + // PrometheusBridgeEnabled feeds metrics registered on the prometheus registry into the telemetry + // pipeline. + PrometheusBridgeEnabled bool } // TracingConfig is the OTLP tracing configuration. Traces go to the telemetry endpoint, so Enabled // does nothing unless TelemetryConfig.Endpoint is set too. type TracingConfig struct { - Enabled bool `usage:"export traces to the telemetry endpoint"` - SamplingRatio float64 `usage:"fraction of traces sampled, from 0 to 1"` - TLSCertFile string `usage:"TLS certificate file used by the trace exporter"` + // Enabled exports traces to the telemetry endpoint. + Enabled bool + // SamplingRatio is the fraction of traces sampled, from 0 to 1. + SamplingRatio float64 + // TLSCertFile is the TLS certificate file used by the trace exporter. + TLSCertFile string } // ChipIngressConfig points the beholder client's chip ingress emitter at an endpoint. Emitting is // enabled by setting one. type ChipIngressConfig struct { - Endpoint string `usage:"chip ingress gRPC endpoint; the emitter is disabled when unset"` - InsecureConnection bool `usage:"connect to chip ingress over an insecure connection"` + // Endpoint is the chip ingress gRPC endpoint. The emitter is disabled when unset. + Endpoint string + // InsecureConnection connects to chip ingress over an insecure connection. + InsecureConnection bool } // newTelemetry builds the beholder client and the service that owns it. diff --git a/libs/capability/telemetry_test.go b/libs/capability/telemetry_test.go index 164cab1b1..af2e7ad36 100644 --- a/libs/capability/telemetry_test.go +++ b/libs/capability/telemetry_test.go @@ -3,10 +3,13 @@ package capability import ( "context" "fmt" + "reflect" + "strings" "testing" "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" sdkmetric "go.opentelemetry.io/otel/sdk/metric" @@ -15,41 +18,51 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/beholder" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/x/config/cli" + "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" ) -// settingsRoot builds the command tree the way RunErr does, through the same registration, and -// returns what the flags are bound to. +// settingsRoot builds the command tree the way RunErr does, through the same registration and +// binding, and returns its run command and how to read the settings back once it has run. // // The registration is the production one, so what these tests read is what a binary gets. -func settingsRoot(t *testing.T) (*cobra.Command, *config) { +func settingsRoot(t *testing.T) (root, runCmd *cobra.Command, settings func() runSettings) { t.Helper() - root := &cobra.Command{Use: "cron"} - root.PersistentFlags().String("config", "", "Path to config file") + resetWithSettings(t) - // The production defaults, so what these read is what a binary gets rather than a zero value. - cfg := defaultConfig() - require.NoError(t, cfg.bind(root)) - return root, cfg + root = &cobra.Command{Use: "cron", SilenceUsage: true, SilenceErrors: true} + runCmd = &cobra.Command{Use: "run", RunE: func(*cobra.Command, []string) error { return nil }} + embedCmd := &cobra.Command{Use: "embed", RunE: func(*cobra.Command, []string) error { return nil }} + root.AddCommand(runCmd, embedCmd) + + b, err := cli.New(cli.Options{Markup: tomlmarkup.New(), Prefixes: []string{"CRE", "CL"}}) + require.NoError(t, err) + require.NoError(t, runRegistrations.bind(b, runCmd)) + require.NoError(t, embedRegistrations.bind(b, embedCmd)) + + return root, runCmd, func() runSettings { + cfg, err := resolveSettings(runRegistrations.injector()) + require.NoError(t, err) + return cfg + } } -// decoded runs args through the command tree and returns the settings as they were decoded. -func decoded(t *testing.T, args ...string) config { +// decoded runs args through the run command and returns the settings as they were decoded. +func decoded(t *testing.T, args ...string) runSettings { t.Helper() - root, cfg := settingsRoot(t) - root.AddCommand(&cobra.Command{Use: "run", RunE: func(*cobra.Command, []string) error { return nil }}) - - args = append([]string{"run"}, args...) - root.SetArgs(args) + root, _, settings := settingsRoot(t) + root.SetArgs(append([]string{"run"}, args...)) require.NoError(t, root.Execute()) - return *cfg + return settings() } // TestTelemetryFlags pins the surface the beholder client's settings have: a flag per field, named // after the service that owns it. A binary gets these without asking, because every binary has them. func TestObservabilityFlags(t *testing.T) { - root, _ := settingsRoot(t) + _, runCmd, _ := settingsRoot(t) for _, name := range []string{ "telemetry.endpoint", @@ -77,7 +90,7 @@ func TestObservabilityFlags(t *testing.T) { "capabilities.proxy-url", "capabilities.capability-don-id", } { - assert.NotNil(t, root.PersistentFlags().Lookup(name), "missing flag --%s", name) + assert.NotNil(t, runCmd.PersistentFlags().Lookup(name), "missing flag --%s", name) } } @@ -96,11 +109,7 @@ func TestCapabilitiesDecodeFromFlags(t *testing.T) { // // --http.port is the one thing a run still cannot do without, which is what the args below are. func TestCapabilitiesAreOptional(t *testing.T) { - root, cfg := settingsRoot(t) - root.AddCommand(&cobra.Command{Use: "run", RunE: func(*cobra.Command, []string) error { return nil }}) - root.SetArgs([]string{"run", "--http.port", "1"}) - - require.NoError(t, root.Execute()) + cfg := decoded(t, "--http.port", "1") assert.Empty(t, cfg.capabilities.ProxyURL) assert.Zero(t, cfg.capabilities.CapabilityDonID) } @@ -108,13 +117,13 @@ func TestCapabilitiesAreOptional(t *testing.T) { // TestObservabilityDefaults pins the settings that are not their zero value, since those are the // ones an operator gets without asking. func TestObservabilityDefaults(t *testing.T) { - root, _ := settingsRoot(t) + _, runCmd, _ := settingsRoot(t) - sampling := root.PersistentFlags().Lookup("tracing.sampling-ratio") + sampling := runCmd.PersistentFlags().Lookup("tracing.sampling-ratio") require.NotNil(t, sampling) assert.Equal(t, "1", sampling.DefValue, "sampling every trace is the default") - port := root.PersistentFlags().Lookup("http.port") + port := runCmd.PersistentFlags().Lookup("http.port") require.NotNil(t, port) assert.Equal(t, "8080", port.DefValue) } @@ -125,13 +134,8 @@ func TestObservabilityDefaults(t *testing.T) { // It is the whole surface in one assertion - every setting this binary has is now either defaulted // or genuinely optional - so a flag gaining `validate:"required"` fails here. func TestBinaryStartsWithNoSettingsAtAll(t *testing.T) { - root, cfg := settingsRoot(t) - root.AddCommand(&cobra.Command{Use: "run", RunE: func(*cobra.Command, []string) error { return nil }}) - root.SetArgs([]string{"run"}) - root.SilenceUsage, root.SilenceErrors = true, true - - require.NoError(t, root.Execute()) - assert.Equal(t, uint16(defaultHTTPPort), cfg.observability.http.Port) + cfg := decoded(t) + assert.Equal(t, uint16(defaultHTTPPort), cfg.http.Port) } // TestTelemetryDecodesFromFlags is the point of registering them: what an operator types reaches the @@ -165,8 +169,8 @@ func TestObservabilityDecodesFromFlags(t *testing.T) { assert.Equal(t, "chip:9000", cfg.observability.chipIngress.Endpoint) assert.True(t, cfg.observability.chipIngress.InsecureConnection) - assert.Equal(t, "pyro:4040", cfg.observability.pyroscope.ServerAddress) - assert.Equal(t, "staging", cfg.observability.pyroscope.Environment) + assert.Equal(t, "pyro:4040", cfg.pyroscope.ServerAddress) + assert.Equal(t, "staging", cfg.pyroscope.Environment) } // TestChipIngressFoldsIntoTheBeholderClient covers the shape of these three: chip ingress and @@ -275,11 +279,9 @@ func TestWithOtelViewsReachesTheClient(t *testing.T) { sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: []float64{1, 2, 3}}}, ) - cfg := defaultConfig() - cfg.observability.telemetry.Endpoint = "otel:4317" - WithOtelViews(view)(cfg) + obs := observability{telemetry: TelemetryConfig{Endpoint: "otel:4317"}, otelViews: options([]Option{WithOtelViews(view)}).otelViews} - bcfg, err := beholderConfig(logger.Test(t), &cfg.observability) + bcfg, err := beholderConfig(logger.Test(t), &obs) require.NoError(t, err) assert.Len(t, bcfg.MetricViews, 1, "the view should be on the config the client is built from") } @@ -287,8 +289,7 @@ func TestWithOtelViewsReachesTheClient(t *testing.T) { // TestWithOtelViewsDefaultsToNone covers a binary that passes none: the client keeps whatever // beholder's own defaults are rather than being handed an empty list to mean something. func TestWithOtelViewsDefaultsToNone(t *testing.T) { - obs := defaultObservability() - obs.telemetry.Endpoint = "otel:4317" + obs := &observability{telemetry: TelemetryConfig{Endpoint: "otel:4317"}} cfg, err := beholderConfig(logger.Test(t), obs) require.NoError(t, err) @@ -302,7 +303,7 @@ func TestWithOtelViewsDefaultsToNone(t *testing.T) { // config fails before a client is ever created - and an unregistered flag would have failed // earlier, with cobra's "unknown flag" instead. func TestRunUsesTheDecodedTelemetrySettings(t *testing.T) { - err := execute(t, logger.Test(t), newFake, "run", + err := execute(t, logger.Test(t), providesFake(newFake), nil, "run", "--telemetry.endpoint", "otel:4317", "--telemetry.attributes", "nope") require.ErrorContains(t, err, `invalid telemetry.attributes entry "nope": expected key=value`) @@ -385,7 +386,7 @@ func TestInstallGloballyNests(t *testing.T) { func TestRunLeavesTelemetryAsItFoundIt(t *testing.T) { before := beholder.GetClient() - require.NoError(t, execute(t, logger.Test(t), newFake, "run")) + require.NoError(t, execute(t, logger.Test(t), providesFake(newFake), nil, "run")) assert.Same(t, before, beholder.GetClient()) } @@ -399,3 +400,30 @@ func detachedLogger() (logger.Logger, *observer.ObservedLogs) { core, logs := observer.New(zapcore.ErrorLevel) return logger.NewWithCores(core), logs } + +// TestEveryConfigIsDocumented guards what docs generates: every config either container +// registers has DocComments, including those embed replaces and so are not among its Configs. +func TestEveryConfigIsDocumented(t *testing.T) { + resetWithSettings(t) + for _, r := range []*registrations{runRegistrations, embedRegistrations} { + for namespace, cfg := range r.configs { + _, err := commentparsing.Lookup(reflect.TypeOf(cfg.value)) + assert.NoError(t, err, "config %q; run go generate", namespace) + } + } +} + +// TestEveryFlagIsDocumented guards doccomments_gen.go: a field added without regenerating binds +// with only its env vars as help. Fix with `go generate` in this package. +func TestEveryFlagIsDocumented(t *testing.T) { + root, _, _ := settingsRoot(t) + + for _, cmd := range root.Commands() { + cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + if f.Name == "config" { + return + } + assert.False(t, strings.HasPrefix(f.Usage, "[env"), "--%s has no help text; run go generate", f.Name) + }) + } +} diff --git a/libs/capability/webserver.go b/libs/capability/webserver.go index 32261e198..946343451 100644 --- a/libs/capability/webserver.go +++ b/libs/capability/webserver.go @@ -26,7 +26,8 @@ const defaultHTTPPort = 8080 // routes a service registers on the mux while it is being built - it is not only prometheus's, so // it is named for the transport it serves rather than for one of its handlers. type HTTPConfig struct { - Port uint16 `usage:"port serving /metrics, /debug/pprof, /healthz, /readyz and any routes a service registers"` + // Port serves /metrics, /debug/pprof, /healthz, /readyz and any routes a service registers. + Port uint16 } // newWebServer returns the shared HTTP server as a service. diff --git a/libs/go.mod b/libs/go.mod index fafbbbd58..ff83d8d82 100644 --- a/libs/go.mod +++ b/libs/go.mod @@ -4,27 +4,27 @@ go 1.27.1 require ( github.com/cenkalti/backoff/v5 v5.0.3 - github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/grafana/pyroscope-go v1.2.8 github.com/hashicorp/go-plugin v1.8.0 - github.com/iancoleman/strcase v0.3.0 + github.com/jonboulle/clockwork v0.5.0 github.com/prometheus/client_golang v1.23.2 + github.com/samber/do/v2 v2.1.0 github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chainlink-common v0.11.2-0.20261005132539-b7d63d5fd549 + github.com/smartcontractkit/chainlink-common/x/config v0.0.0-20261013123758-011ac3c7dbe1 github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 - github.com/spf13/cobra v1.7.0 - github.com/spf13/pflag v1.0.5 - github.com/spf13/viper v1.17.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 go.uber.org/zap v1.28.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 ) @@ -39,7 +39,6 @@ require ( github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.1 // indirect github.com/cloudevents/sdk-go/v2 v2.16.1 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect @@ -48,6 +47,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect @@ -55,8 +55,8 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -66,30 +66,26 @@ require ( github.com/jhump/protoreflect v1.18.1 // indirect github.com/jhump/protoreflect/v2 v2.0.0-beta.2 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.10.9 // indirect - github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.21 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/run v1.2.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/pkg/errors v0.9.1 // 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/sagikazarmark/locafero v0.11.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/samber/go-type-to-string v1.8.0 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/scylladb/go-reflectx v1.0.1 // indirect github.com/smartcontractkit/chain-selectors v1.0.104 // indirect @@ -99,13 +95,9 @@ require ( github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260709145319-7782fb89eb16 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e // indirect github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.5.1 // indirect github.com/stellar/go-stellar-sdk v0.4.0 // indirect github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf // indirect github.com/stretchr/objx v0.5.3 // indirect - github.com/subosito/gotenv v1.6.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -131,12 +123,13 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/libs/go.sum b/libs/go.sum index f7f0b65ec..f55dd4ca0 100644 --- a/libs/go.sum +++ b/libs/go.sum @@ -25,11 +25,9 @@ github.com/cloudevents/sdk-go/binding/format/protobuf/v2 v2.16.1/go.mod h1:6Q+F2 github.com/cloudevents/sdk-go/v2 v2.16.1 h1:G91iUdqvl88BZ1GYYr9vScTj5zzXSyEuqbfE63gbu9Q= github.com/cloudevents/sdk-go/v2 v2.16.1/go.mod h1:v/kVOaWjNfbvc6tkhhlkhvLapj8Aa8kvXiH5GiOHCKI= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= @@ -37,10 +35,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= -github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -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/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -114,8 +110,6 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= @@ -159,8 +153,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0= @@ -176,8 +168,6 @@ github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -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/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= @@ -197,8 +187,8 @@ github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -216,10 +206,10 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/samber/do/v2 v2.1.0 h1:lqCHn05XvY3VqwxvZDQPSkH+jIGWSVHUrSVLEbPOopo= +github.com/samber/do/v2 v2.1.0/go.mod h1:wJBoiaZcUZyGuraOhfz15b517ZMogGs+U03DvnqvT6Q= +github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= +github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/scylladb/go-reflectx v1.0.1 h1:b917wZM7189pZdlND9PbIJ6NQxfDPfBvUaQ7cjj1iZQ= @@ -232,6 +222,8 @@ github.com/smartcontractkit/chainlink-common v0.11.2-0.20261005132539-b7d63d5fd5 github.com/smartcontractkit/chainlink-common v0.11.2-0.20261005132539-b7d63d5fd549/go.mod h1:/i+QJhDWFoBoGMOO7H466Jo7dLj9YrRsBbd5V/fu128= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20261005132539-b7d63d5fd549 h1:J4vYRJxkBO8Yf//17wScKjYWmS45VZaFWx0zt+4ETEU= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20261005132539-b7d63d5fd549/go.mod h1:Scyk7O80fde5rJF3Ty3fEE3xiS6j84eWbu8/2gl3pIY= +github.com/smartcontractkit/chainlink-common/x/config v0.0.0-20261013123758-011ac3c7dbe1 h1:QuzQZNkkcft1z/ArjrN9WndcVC2KiD3bYg6/ihd8zq4= +github.com/smartcontractkit/chainlink-common/x/config v0.0.0-20261013123758-011ac3c7dbe1/go.mod h1:APT7KmsW2UCEPGhwSd3ZNnPYiLLw+0UFBTVDOj44ZEk= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648 h1:WEUMkKQPAgcNMRgES6CBWrRUiII+HKEWQjulKQBSuMA= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260804191526-b7a850ae7648/go.mod h1:/i8hjTPFdVWHiY+QjeSiVS2Z3GB3WAZznGgXHstC02E= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= @@ -246,18 +238,11 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 h1:pEcgcjTGA83MzpqbTbyIg9AJrOs62s77SooDdJGIg9w= github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342/go.mod h1:5JPtsRwjugpyfsdEALC4RopfvohqK/G+3DHaR8uv+Bc= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= -github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= -github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stellar/go-stellar-sdk v0.4.0 h1:WBLeJk7EllU7WhwrTH1L5Mu2EhZor/xy5sVi/82JzNQ= github.com/stellar/go-stellar-sdk v0.4.0/go.mod h1:tLKAQPxa2I5UvGMabBbUXcY3fmgYnfDudrMeK7CDX4w= github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf h1:GY1RVbX3Hg7poPXEf6yojjP0hyypvgUgZmCqQU9D0xg= @@ -276,8 +261,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= @@ -347,6 +330,7 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -439,8 +423,8 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20210401141331-865547bb08e2/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= @@ -464,8 +448,6 @@ google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j 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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=