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 11 commits into
env overrides for config templating#482Conversation
|
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. |
279d5fa to
f8bd4ff
Compare
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
Decoding the body into generic Go values and re-encoding it destroys two
properties a config template depends on: implicit scalar types are
re-resolved on the way out (`2024-01-02` coming back as a timestamp,
`0123456` as octal), and the caller's quoting around a `${VAR}` is
dropped, which is what stops an interpolated value containing YAML
metacharacters from restructuring the document. Editing the node tree
leaves every untouched scalar byte-for-byte as it was written.
A body that fails to parse is now returned unchanged rather than
erroring, since the documented `${VAR: default}` form is not valid YAML
until substitution has run. Such a body has no env field to find, so the
existing downstream path reports the error as it did before.
Also drops a dead `err = nil` assignment in the chilled branch, which the
following statement overwrites regardless.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
The config document travels as a string under "template", so it is never
parsed or re-marshalled before substitution: a body is a template, and a
round trip through go-yaml's emitter can re-resolve implicit scalar types
and drop the quoting that stops an interpolated value from restructuring
the document. It also means the envelope parses whatever the template
contains, including the ${VAR: default} form that is not valid YAML until
substitution has run.
A body that does not match the envelope shape exactly is returned
unchanged, so requests predating this feature are unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
Also drops extractEnvOverrides, which is now unreferenced: overrides no longer travel as a sibling of the config fields, so the body is never parsed or re-marshalled before substitution. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
One case resolves a relative output path, which wrote a fallback.jsonl into internal/stream/manager on every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
The /resources/{type}/{id} description gains the empty-string caveat the
/streams/{id} description already carries: env_vars.go treats an empty
value as unset, so a variable overridden to "" falls back to the default
in the ${VAR:default} form. Both endpoints behave identically here, so
the caveat belongs on both.
The bulk POST /streams description now says the envelope is not accepted
there. Bulk is out of scope for env substitution, but its two neighbours
now advertise the envelope, so a caller will reasonably try it -- and the
body is read as a set of stream configs named "env" and "template"
rather than rejected.
Also drops two test comments left over from the removed env-sibling
design: one for TestTypeAPIStreamEnvOverrides that had drifted onto
streamTemplate, and a duplicate on TestResourceAPIEnvOverrides still
describing the deleted extractEnvOverrides seam.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
PATCH is a documented verb of /streams/{id}, but patchConfig never called
decodeConfigBody. An envelope-shaped patch body was gabs-merged verbatim,
and since a patch is neither linted nor parsed strictly it returned 200
while persisting stray env/template keys into the stored config.
Decode the body the same way POST and PUT do, so the envelope's template
is applied as the patch document and a body that is not an envelope still
reaches the merge byte-for-byte unchanged.
A patch performs no environment variable substitution, so overrides could
only ever be a no-op here. Reject a non-empty env with a 400 rather than
accept a request whose whole point is silently dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr
72c01e6 to
d44af54
Compare
|
Commits
Message wording itself is fine throughout — lowercase, imperative, and accurate to the contents. Review The core design holds up. Applying overrides by wrapping One issue, already disclosed in the PR description's "Known gaps" and repeated here so it is tracked against the code:
Sub-threshold, not blocking: benthos/internal/stream/manager/api_test.go Lines 616 to 630 in d44af54 names inside the assert.Eventually condition and asserts on it after the call returns, with the condition satisfied by len(names) > 0. Comparing inside the condition, or using assert.EventuallyWithT, keeps the assertion and the polled state on the same goroutine.
|
Hi @josephwoodward, thanks for triggering the CI this morning! I ran the prompt for the AI review locally this morning, and it brought up some issues that led me to rethink my approach. I updated the PR with a revised approach this afternoon, ran a few of my review agents, and wrapped things up with a review agent with the CI prompt. Let me know what you think of this revised approach as well as if you have any thoughts on the one area left open! |
Closes #481
Summary
Adds an optional envelope request form 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" }, "template": "input:\n kafka:\n addresses: [ \"${BROKER}\" ]\n topics: [ \"${TOPIC}\" ]\noutput:\n stdout: {}\n" }Covered endpoints:
POST/PUT/PATCH /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. Its endpoint description now says so explicitly, since its two neighbours advertise the envelope.No new templating syntax is introduced. This only changes where the existing
envLookupFuncseam gets its answers from.The request shape differs from the sketch in #481
The issue proposed
envas a sibling of the config fields:{ "env": { ... }, "input": { ... }, "output": { ... } }That shape forces the server to parse the config in order to find and strip
envbefore linting — and then re-serialise what is left. A config body is a template, not a document, and a round trip through go-yaml's emitter is free to:2024-01-02comes back as a timestamp,0123456as octal.${VAR}— which is precisely what stops an interpolated value containing YAML metacharacters from restructuring the document.Worse, the sibling shape requires the body to parse at all before substitution has run, which is not guaranteed: the documented
${VAR: default}form puts a:inside an otherwise plain scalar.Carrying the config as a string under
templateremoves the parse entirely. Reading a string node hands back exactly the bytes the caller wrote, and the envelope itself parses whatever the template contains — as a JSON string or a YAML block scalar,${VAR: default}is just text.Implementation
internal/stream/manager/api.go—decodeConfigBodySplits a request body into overrides and the config document to act on. The envelope form is a mapping whose only keys are
envandtemplate, wheretemplateholds the config as a string. Anything that does not match that shape exactly — a repeated key, any third key, a missing or non-stringtemplate— falls back to treating the body as the config itself, returned unchanged (the same backing slice; a test pins this withassert.Same). No request that worked before this existed can behave differently.Past the point where the body is unambiguously an envelope, a malformed
envis reported as a 400 rather than silently reinterpreted. Value typing is validated from the YAML node tag, so a quoted"5"is accepted and a bare5is rejected — a decode intomap[string]stringcould not tell those apart.internal/config/reader.go—OptAddEnvLookupOverridesA 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. An earlier two-pass approach broke both of those.Everything downstream (lint,
ParsedConfigFromAny,stream.FromParsed,chilled/ErrMissingEnvVarshandling) is untouched.internal/stream/manager/api.go—patchConfigPATCHis a documented verb of/streams/{id}, so it decodes the body the same wayPOSTandPUTdo and applies the envelope'stemplateas the patch document. Without this the envelope's own keys merged into the stored config as ordinary fields — a patch is neither linted nor parsed strictly, so the request returned a 200 while persisting strayenv/templatekeys visible on the nextGET.A patch performs no environment variable substitution (it never has, and this PR does not change that), so overrides could only ever be a no-op there. A non-empty
envis rejected with a 400 rather than accepting a request whose whole point is silently dropped:PATCH /streams/{id}body{"input": { ... }}{"template": "<config>"}{"env": {}, "template": "<config>"}{"env": {"FOO": "x"}, "template": "<config>"}field env: not supported by PATCH{"env": {"FOO": 3}, "template": "<config>"}value of 'FOO' must be a stringAcceptance criteria
envmap resolves${VAR}placeholders in the configenvvalue takes precedence over a same-named OS environment variableErrMissingEnvVars/chilledbehaviorenvvalue is rejected with a 400, not coerced/streams/{id}and/resources/{type}/{id}Tests
Added in
internal/stream/manager/api_test.go,internal/stream/manager/api_internal_test.goandinternal/config/env_vars_test.go:TestDecodeConfigBody— 24 cases covering envelope detection and passthrough: raw JSON and YAML configs, a body that only parses after substitution, empty and bare-scalar bodies,templateholding a mapping or a number,templatealongside a config field,envwith notemplate, duplicated keys, block scalars, and the full set of non-stringenvvalue rejectionsTestEnvLookupOverrides— 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-envelope regression checkTestTypeAPIStreamEnvOverridesPreserveDocument/...PreserveScalars— a template's quoting and implicit scalar types survive the request path unchangedTestTypeAPIPatchEnvelope— the envelope'stemplateis applied as the patch and its keys do not leak into the stored config, null and emptyenvare accepted, a populated or non-stringenvis a 400 that leaves the stream untouched, and a raw patch body still applies as beforeVerification
make test— full suite passesmake lint— 0 issuesmake fmt— no changesThe endpoint description strings for
/streams/{id},/resources/{type}/{id}and/streamswere updated; no generated docs are affected.Known gaps
One behaviour found in review that is not addressed here, called out so it is a deliberate choice rather than an oversight:
envbut a missing or non-stringtemplatefalls through to passthrough, silently discarding the overrides. Under?chilled=truethis yields a 200 and a running stream built from the envelope itself. Sinceenvis not a valid top-level field on either endpoint, its presence is unambiguous envelope intent and arguably should be a 400.Happy to fold that into this PR if reviewers would rather not land the feature without it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Lw4vixL5ecJAvuKAEhCdGr