streams API: support per-request env overrides for config templating - #482
streams API: support per-request env overrides for config templating#482g-hurst wants to merge 4 commits into
env overrides for config templating#482Conversation
POST/PUT /streams/{id} and POST /resources/{type}/{id} now accept an
optional top-level "env" object of string values in the request body.
These values fill in ${VAR}-style template placeholders elsewhere in
the document, taking precedence over a same-named OS environment
variable, before the config is stripped, linted and parsed.
This reuses the existing config.OptUseEnvLookupFunc seam rather than
introducing new templating syntax: the "env" field is extracted and
removed from the raw document, then feeds a lookup func that checks
the request-supplied overrides before falling back to os.LookupEnv.
env values must be strings; a non-string value is a 400 request error
rather than being silently coerced.
Bulk POST /streams is intentionally left untouched (out of scope: it
has no env-var substitution at all today, so there is no existing seam
to reuse there).
Also ignore .claude-context/ (local planning notes, not part of the
repo).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwgEYHEkYQqNb3P4i17c5S
The per-request `env` overrides were applied by running ReplaceEnvVariables
twice: once with a lookup func that resolved only the overrides and returned
every other name as its own "${name}" placeholder text, then again through a
plain OS-backed reader. That leaked interpolation state between the passes and
broke two behaviours whenever an `env` field was present:
- `${FOO:default}` no longer honoured its default. The first pass returned the
reconstructed "${FOO}" string, which is non-empty, so the `value == ""` check
that selects the default never fired and the placeholder reached the parser
as literal text.
- `${{FOO}}` escapes leaked real OS values. ReplaceEnvVariables unescapes
`${{FOO}}` to `${FOO}` at the end of every pass, so the second pass saw a
live placeholder and interpolated text the caller had explicitly escaped.
Replace the two-pass approach with a new config.OptAddEnvLookupOverrides option
that wraps the reader's existing envLookupFunc instead of the config document.
Overrides shadow same-named OS env vars, everything else falls through to the
default OS lookup, and all interpolation syntax keeps working because only one
pass ever runs. This also drops the "os" import from the stream manager, which
now just hands over a map.
While here, apply two code review findings on the original commit: build the
reader once via an opts slice rather than constructing and discarding one, and
reset err after consuming ErrMissingEnvVars.BestAttempt in HandleStreamCRUD so
it matches HandleResourceCRUD.
Tests: a table for the new option in the config package covering precedence,
fall-through, defaults, escapes and still-missing vars, plus stream/resource
API cases for configs that mix override-supplied and OS-supplied variables,
including regression cases for the two bugs above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwgEYHEkYQqNb3P4i17c5S
|
Hi @josephwoodward — no rush at all on this one, just flagging it since the workflows here need a maintainer to kick them off for first-time contributors, and I don't think they've been approved to run yet (only the CLA check has reported so far). Whenever you have a spare moment, would you mind approving a CI run? Happy to fix anything that falls out. For context on the motivation: I'm a happy Connect user, and this came out of actually running into the limitation rather than from reading the API surface. In a streams-mode deployment the process env is shared by every stream in it, so there's no way to post the same config template twice with different parameters — you either pre-render the template yourself before posting or split things across separate processes. Making the templating per-request removes that, and since Connect builds on Benthos the ergonomic win lands downstream in Connect, which is the outcome I'm really after here. If you get a chance to look at the approach itself later on, I'd really value your feedback. I've just added a short "Divergences from the sketch in #481" section to the description covering the two places this departs from what I originally proposed in the issue, including one where I'd happily take the smaller-surface option if you prefer it. Thanks for all the review work you do on this repo — much appreciated. |
Closes #481
Summary
Adds an optional top-level
envfield to the streams-mode HTTP API's config payloads, so a caller can supply per-request environment variable overrides instead of pre-rendering${FOO}-style templates themselves or relying on real OS environment variables.{ "env": { "TOPIC": "orders", "BROKER": "localhost:9092" }, "input": { "kafka": { "addresses": ["${BROKER}"], "topics": ["${TOPIC}"] } }, "output": { "stdout": {} } }Covered endpoints:
POST/PUT /streams/{id}(HandleStreamCRUD)POST /resources/{type}/{id}(HandleResourceCRUD)Bulk
POST /streamsis out of scope, as described in the issue — it has no env substitution today, so adding overrides there means building that support for the first time rather than reusing it.No new templating syntax is introduced. This only changes where the existing
envLookupFuncseam gets its answers from.Implementation
internal/config/reader.go—OptAddEnvLookupOverridesA new reader option that composes with the reader's existing lookup func rather than replacing it: overrides are checked first, and any name absent from them falls through to whatever the reader already had (by default
os.LookupEnv). An override therefore shadows a same-named OS env var, while every other variable continues to resolve from the OS as usual.Wrapping the lookup func — rather than pre-substituting the document — is what keeps the rest of the interpolation contract intact.
${FOO:default}still falls back to its default whenFOOis neither overridden nor set, and a${{FOO}}escape is still left alone, becauseReplaceEnvVariablesremains the single thing doing the parsing.internal/stream/manager/api.go—extractEnvOverridesdocs.FieldSpecs.LintYAMLraises an error-levelLintUnknownfor any unrecognised top-level field, and both handlers treat a non-empty lint list as a hard 400, soenvhas to be removed from the document before linting and parsing.The stripping is done by editing the
yaml.Nodetree in place instead of decoding to generic Go values and re-encoding. A config body is a template, and a decode/re-encode round trip destroys two properties it depends on:2024-01-02comes back as a timestamp,0123456as octal.${VAR}— which is precisely what stops an interpolated value containing YAML metacharacters from altering the document's structure.Node-level editing leaves every untouched scalar byte-for-byte as written.
Two further details:
${VAR: default}form puts a:inside an otherwise plain scalar. The existing downstream path reports the error as it does today."5"is accepted and a bare5is rejected with a 400 — a decode intomap[string]stringcould not tell those apart.Everything downstream (lint,
ParsedConfigFromAny,stream.FromParsed,chilled/ErrMissingEnvVarshandling) is untouched.Divergences from the sketch in #481
Two deliberate departures from the implementation sketch in the issue:
Stripping
envby editing theyaml.Nodetree rather than agabsround trip. The sketch proposed followingpatchConfig's decode →gabs.Wrap→ mutate → re-encode pattern. That pattern is safe wherepatchConfiguses it, because it runs against a config that has already been through substitution — this stripping has to run against the raw template before substitution, where a round trip re-resolves implicit scalars and drops the caller's quoting around a${VAR}, as above.A new
OptAddEnvLookupOverridesrather than a local precedence helper passed to the existingOptUseEnvLookupFunc. The sketch put the "check overrides, fall back toos.LookupEnv" helper inmanager. This puts the composition in the reader instead, so it falls through to whatever lookup func the reader already holds rather than hardcodingos.LookupEnv. To be clear about what that does and doesn't buy: neither call site exercises the difference today, since both build a fresh reader whose lookup func is the default — it's a forward-looking choice rather than a fix, and what it protects is the case where the streams manager gains a secret lookup func the wayinternal/cli/common/reader.goalready has one, at which point a hardcoded fallback would silently drop secrets. If you'd rather not grow the option set inconfigfor a single consumer, I'm happy to move this back to a local helper inmanager— it's a contained change and the tests keep their shape.Acceptance criteria
envmap resolves${VAR}placeholders in the rest of the documentenvvalue takes precedence over a same-named OS environment variableenvbehaves exactly as today — no behavior change for existing callersErrMissingEnvVars/chilledbehaviorenvvalue is rejected with a 400, not coerced/streams/{id}and/resources/{type}/{id}Tests
Added in
internal/stream/manager/api_test.goandinternal/config/env_vars_test.go:TestEnvLookupOverrides— override precedence, fallthrough to the wrapped lookup, defaults preserved,${{...}}escapes untouchedTestTypeAPIStreamEnvOverrides/TestResourceAPIEnvOverrides— override applied, precedence over a real OS env var, fallback to OS env when no override is given, mixed resolution from both sources, missing-var behavior unchanged, non-string rejected with 400, and a no-envregression checkTestTypeAPIStreamEnvOverridesPreserveDocument/...PreserveScalars— the stripping round trip does not mutate quoting or re-resolve implicit scalar typesVerification
make test— full suite passesmake lint— 0 issuesmake fmt— no changesThe endpoint description strings for
/streams/{id}and/resources/{type}/{id}were updated to document the new field; no generated docs are affected.