Fail loudly on set-but-unparseable env config values (closes #80) #92
Reference in New Issue
Block a user
Delete Branch "issue-80-config-fail-loud"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #80. Single commit on top of
main@4f5ecb1.Why
The config env helpers silently substituted the documented default
whenever a variable was set but could not be parsed. A typo in an
operator-supplied value therefore produced a running daemon with
configuration nobody asked for, instead of a startup failure:
PORT=eightyquietly listened on 8080, andDEBUG=ture(oryes, oron) quietly disabled debug logging becauseenvBooltreated everynon-
true/1value as false.The policy this PR encodes: a set-but-invalid environment value
aborts startup; defaults apply only to keys that are unset or
empty. There is no third behaviour.
This is an intentional behaviour change. An existing deployment with a
malformed config value will now fail to start rather than run with a
silently substituted default.
What changed
internal/config/config.go:envPositiveIntadded verbatim from PR #87. Same name, samesignature
envPositiveInt(key string, defaultValue int) (int, error),same package-level
ErrNonPositiveValue, same error strings(
invalid integer for %s: %q: %wand%w: %s must be at least 1, got %q). See the note on #87 below.envIntremoved entirely. Its only caller wasPORT, which isnow parsed by a new
envPortwrapper:envPositiveIntcoversunset/unparseable/<1, and
envPortadds the TCP upper bound with anew package-level
ErrInvalidPort(maxPort = 65535). Nosilent-fallback integer variant survives in the package.
envBoolnow returns(bool, error)and parses withstrconv.ParseBoolrather than a hand-rolled spelling table. Unset orempty keeps the default; anything else that fails to parse is a
wrapped error naming the key and the value. Callers are
DEBUGandMAINTENANCE_MODE. This deliberately narrows the accepted set:yes,on, and mixed-case oddities thatstrings.EqualFoldused toswallow are now startup errors.
loadFromEnv, which returnsthe first error it hits;
config.Newreturns it so fx aborts startup.Every error names the offending key and value. The
WEBHOOKER_ENVIRONMENTblock was extracted intoresolveEnvironmentto keep
Newinside thefunlenbudget — also copied verbatim from#87, for the same rebase reason.
envStringandenvDurationwere auditedExplicitly, so the reviewer does not have to re-derive it:
envStringis a bareos.Getenvpassthrough. It parses nothing, sothere is no set-but-unparseable case to fail on. Unchanged.
envDurationwas already made fail-loud in #78: unset returns thedefault, a set-but-unparseable value returns
invalid duration for %s: %q: %w. Unchanged.Repo-wide
os.Getenvaudit (spec step 5)git ls-filespiped through a grep foros.Getenvandos.LookupEnvacross every tracked file in the repo returns exactly one file:
internal/config/config.go. (Untracked sibling worktrees under.claude/show up in a naive recursive grep; they are copies of thissame file, not additional call sites.)
There are no environment-variable parse sites outside
internal/config, so there was nothing else to fix under this issue.cmd/webhookerand everyinternal/*package take their configurationfrom the injected
*config.Config.Relationship to PR #87 (unmerged, merge-ready)
PR #87 (
issue-64-receiver-rate-limit) introduces an identicalenvPositiveIntandErrNonPositiveValue, plus the sameresolveEnvironmentextraction. That duplication is deliberate and wasspecified in the issue: whichever of the two lands first, rebasing the
other is a delete-one-copy operation on those definitions rather
than a semantic merge. The copies here are byte-for-byte identical to
#87's, comments included, so a rebase should produce no behavioural
question at all. The remaining conflict surface is the usual
TODO.mdand README churn.
Tests
internal/config/env_test.go(new), with the unexported helpers reachedthrough a small
export_test.goshim so each helper gets its own tablewithout widening the package API:
TestEnvBool— unset (both defaults), empty,true/1/False/0, and rejection ofyes,on,ture.TestEnvPositiveInt— unset, empty,42, unparseable,0,-5(the last two asserted with
errors.Is(err, ErrNonPositiveValue)).TestEnvPort— unset,9000,65535, unparseable,0, and65536asserted with
errors.Is(err, ErrInvalidPort).TestNewRejectsBadEnvValues—config.Newthrough fx: a badPORT(unparseable and out-of-range), a bad
DEBUG, and a badMAINTENANCE_MODEeach abort with an error naming the key and thevalue, while valid values are applied.
TestNewUsesDefaultsWhenUnset— the legitimate unset case stillyields port 8080 and both booleans false.
All env manipulation uses
t.Setenv(withos.Unsetenvfor thedeliberately-absent cases), so nothing leaks between tests.
Docs
README.md: new "Invalid values abort startup" subsection under theconfiguration table stating that defaults apply only to unset
variables, that a set-but-unparseable value aborts startup, that
PORTmust be 1–65535, and listing the exact boolean spellingsstrconv.ParseBoolaccepts.MAINTENANCE_MODEadded to the tablesince this PR changes how it parses.
TODO.md: updated in the same commit, with the stale Next Step (theretention reaper, delivered in #63) rotated into Completed Steps.
Verification
make fmtthenmake check— tests andfmt-checkgreen.script/cibuild(Docker, golangci-lint v2.12.2 as pinned in theDockerfile) — exit 0, so lint is green under the pinnedtoolchain that CI uses.
make linton the host reports onegosecG704 ininternal/delivery/client_ssrf_test.go. That ispre-existing host/CI version skew — the host golangci-lint is v2.10.1
and reports it on pristine
origin/maintoo; the pinned v2.12.2 doesnot. Nothing to do with this change.
.golangci.ymluntouched (sha256 unchanged,021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb);the pinned golangci-lint v2.12.2 Dockerfile digest is unchanged.
Summary of what was built and how it was verified
One commit,
985464d, onissue-80-config-fail-loudoffmain@4f5ecb1. 5 files, +597/-49.Behaviour delivered: no helper in
internal/configfalls back to adefault for a value the operator actually set.
envIntis gone;PORTgoes throughenvPort(envPositiveIntforunset/unparseable/<1, plus
ErrInvalidPortabove 65535);envBoolreturns an error and uses
strconv.ParseBool.config.Newpropagatesthe first error through
loadFromEnv, so fx aborts startup and everymessage names the offending key and value. An unset variable still
gets its documented default — covered by an explicit test.
Audit results, so the reviewer need not redo them:
envStringparses nothing (bare
os.Getenv) andenvDurationwas alreadyfail-loud from #78; both intentionally unchanged. A grep for
os.Getenv/os.LookupEnvover every tracked file returns onlyinternal/config/config.go— there are no parse sites elsewhere in therepo, so nothing outside
internal/configneeded fixing.Verification (repo entrypoints only, no raw
goorgolangci-lint):make fmt, thenmake check— 172 tests pass,fmt-checkclean.script/cibuild— exit 0. This is the authoritative lint gate: itbuilds through the
Dockerfile, whose lint stage is pinned togolangci-lint v2.12.2 by digest, the same toolchain CI uses. Two
earlier iterations were red under it (
funlen,paralleltest,then 6
goconst) and were fixed; the run on985464d's tree isgreen.
gosecG704 ininternal/delivery/client_ssrf_test.go, which the hostgolangci-lint v2.10.1 reports on pristine
origin/mainas well andthe pinned v2.12.2 does not report at all. Out of scope here, and
not introduced by this change.
.golangci.ymluntouched; sha256 still021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.The pinned lint image digest in
Dockerfileis unchanged.Rebase note:
envPositiveInt,ErrNonPositiveValue, andresolveEnvironmentare byte-for-byte copies of the definitions onthe unmerged, merge-ready #87. Whichever lands first, the other's
rebase is a delete-one-copy edit on those definitions plus the usual
TODO.md/README.mdchurn.Review of PR #92 @
985464dVerdict: PASS. No blocking findings. Eight non-blocking nits are
listed below; none of them need to be fixed before merge.
Verified by execution
script/cibuildon the PR head tree — exit 0. Runs the pinnedgolangci-lint:v2.12.2@sha256:5cceeef0…lint stage,make fmt-check,make test, andmake build.check / check (push)on985464d— success in 6m3s(run 98). It was still queued at the start of this review and has
since completed green.
make checkon the host — every test passes. It exits non-zero onlyon
internal/delivery/client_ssrf_test.go:78G704(gosec). Thatfile is not in this diff, and this is the documented host
golangci-lint v2.10.1 / pinned v2.12.2 skew. Not attributable to this
change.
make fmt— no drift;git status --porcelainclean afterwards.make checkmodifies no tracked files (REPO_POLICIES line 233).repo files touched): reintroduced silent defaulting in three places —
envBoolreturningdefaultValue, nilonParseBoolfailure, thei < 1guard removed fromenvPositiveInt, and theport > maxPortguard removed fromenvPort. Result: 11 subtestfailures across
TestEnvBool,TestEnvPositiveInt,TestEnvPort,and
TestNewRejectsBadEnvValues. The tests are not vacuous; everyreintroduction of the defect is caught.
envPositiveIntandresolveEnvironmentfrom bothorigin/issue-64-receiver-rate-limitand this head and diffed them — byte-for-byte identical, comments
included.
ErrNonPositiveValuedeclaration and doc comment likewiseidentical. Error strings match the spec exactly
(
invalid integer for %s: %q: %wand%w: %s must be at least 1, got %q). No divergence found.envPortbounds: 1 and 65535 accepted, 0 / -5 rejected viaErrNonPositiveValue, 65536 rejected viaErrInvalidPort, allconfirmed by passing subtests. No off-by-one. Overflowing inputs
(
99999999999999999999) and whitespace-padded inputs fail throughstrconv.Atoirather than defaulting.git merge-tree --write-tree origin/main HEAD— merges clean againstmain@4f5ecb1, which is also the PR base. Not stale.sha256sum .golangci.yml=021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb,matching the required value;
git diff origin/main..HEADis empty for.golangci.yml,Dockerfile,go.mod, andgo.sum, so thev2.12.2 digest pin is intact.
Verified by reading
internal/configwas read,not just the changed ones.
resolveEnvironment(unset -> dev,unrecognised ->
ErrInvalidEnvironment),envBool,envPositiveInt,envPort, andenvDurationall return an error on a set-but-invalidvalue.
envStringis a bareos.Getenvand parses nothing. There isno remaining code path in the package that substitutes a default for
a value the operator set.
envIntis gone from the package entirely.PORT="",DEBUG=""etc. take the default, andREADME.mdsays so explicitly("unset (or set to an empty string)"). This matches the spec's
"unset (or empty)" wording. Correct call — an empty value in a
compose/systemd environment is conventionally an absent value.
loadFromEnv(config.go:221-261)performs five fallible reads and each is followed by
if err != nil { return nil, err }. Nothing is logged-and-continued,nothing is assigned to
_.New(config.go:266-306) returns theerror before any logging or defaulting, so fx aborts. Both
envBoolcallers (
DEBUG,MAINTENANCE_MODE) propagate.envBoolnarrowing usesstrconv.ParseBoolper the librarypolicy, and the README lists the accepted spellings accurately
(
1 t T TRUE true True 0 f F FALSE false False) and states thatyes,on, andoffare now rejected. The mixed-case narrowing(
TrUe, previously accepted bystrings.EqualFold) is stated asintentional in the commit message, the PR body, and the README. No
file tracked in this repo (no compose file, unit file, or
.env)sets a now-rejected spelling, so nothing in-tree breaks.
os.Getenvaudit independently reproduced.git grepforos.Getenv,os.LookupEnv, andsyscall.Getenvover tracked filesreturns exactly five hits, all in
internal/config/config.go. ThePR body's claim is accurate; there are no parse sites elsewhere.
remit. The
loadFromEnvextraction is behaviour-preserving: the samevalues are read, the struct is populated identically, and
log/paramsare assigned inNewafterwards. The only observabledifference is which error wins when two variables are simultaneously
bad (
PORTnow precedesRETENTION_SWEEP_INTERVAL), which is notmeaningful.
export_test.gois test-only and does not widen theshipped API.
Fail loudly on set-but-unparseable env config values (closes #80)ends with the required
(closes #80). Author and committer areclawbot. NoCo-Authored-By, no session trailers, and acase-insensitive grep for Claude/Anthropic over the whole tree
returns nothing. No 4-byte UTF-8 anywhere in the diff.
TODO.mdisupdated in the same commit. No non-inclusive terminology introduced.
Test-file idiom (
t.Setenv, thet.Parallelexplanatory comment,require/assertsplit, table-per-helper) matches the existinginternal/config/config_test.goexactly.Non-blocking nits
internal/config/env_test.go:112, 189, 270, 399— the "unset" casescall
os.Unsetenvwith not.Cleanuprestore, so the unset stateleaks for the rest of the test binary.
TestNewUsesDefaultsWhenUnsetpermanently unsets the real
PORT,DEBUG, andMAINTENANCE_MODE.Harmless today (
config_test.gosorts beforeenv_test.go, and thisis the last test in the file) and it is exactly the pre-existing
idiom in
config_test.go:59, 160, 231, so consistency argues forleaving it. If it is ever cleaned up, do both files together with a
save/restore helper registered through
t.Cleanup.internal/config/env_test.go:314-388—TestNewRejectsBadEnvValuessets only the one key under test and does not neutralise ambient
PORT/DEBUG/MAINTENANCE_MODE/DATA_DIR/RETENTION_SWEEP_INTERVAL. A developer with any of those exported intheir shell can get a spurious failure. Again matches the existing
pattern. Acceptable would be unsetting the full set at the top of
each subtest before applying
tt.key.internal/config/env_test.go:277-280— theTestEnvPorterrorbranch asserts the message contains the key but, unlike its two
sibling tables, omits
assert.Contains(t, err.Error(), tt.value).Both
envPorterror paths do include the value(
got "0"andgot 65536), so the assertion would hold; dropping itmakes this table weaker than the others for no reason.
internal/config/config.go:47-49—ErrInvalidPortreads as "anyinvalid port" but covers only the above-range case;
PORT=0yieldsErrNonPositiveValue, soerrors.Is(err, ErrInvalidPort)is falsefor a value most operators would call an invalid port. The doc
comment does say "set above the valid port range", and the issue spec
asked for this name specifically, so this is fine as shipped —
ErrPortOutOfRangewould just be less surprising.internal/config/config.go:158-168—envPortrenders the offendingvalue with
%dwhileenvPositiveInttwo functions above renders itwith
%q. Cosmetic inconsistency between adjacent error strings.envPositiveInt's form is locked by the verbatim-copy requirement,so only
envPortcould move.internal/config/config.go:94-98— theenvBoolgodoc says "ReturnsdefaultValue if not set" without mentioning that an empty string is
also treated as unset, which is the behaviour the README documents
and the tests assert.
envPositiveInthas the same gap but itswording is frozen by the #87 copy requirement;
envBool's is not.README.md:104— the new prose citesRETENTION_SWEEP_INTERVAL=1 houras a fail-loud example, butRETENTION_SWEEP_INTERVALis not a row in the configuration tabledirectly above it. That omission predates this PR (#78 added the
variable without a table row), but this change makes it visible.
Adding the row would be a one-line follow-up.
internal/config/export_test.go— the conventional Go shim isvar EnvBool = envBoolrather than a wrapper namedEnvBoolForTest; theForTestsuffix is redundant in a file thatonly exists during test builds. Purely stylistic, and the current
form is perfectly readable.
Note for the #87 rebase (not a defect in this PR)
issue-64-receiver-rate-limitstill carries the old silent-fallbackenvIntalongside its copy ofenvPositiveInt. If #92 lands first, the#87 rebase must drop both its duplicate
envPositiveInt/ErrNonPositiveValue/resolveEnvironmentdefinitions and itsenvInt; a mechanical conflict resolution that keepsenvIntwouldsilently reintroduce exactly the defect #80 fixes. Worth flagging on #87
so whoever rebases it is looking for that.
Manager note
Independent review verdict: PASS, no blocking findings. The reviewer did not author this change.
What raises my confidence here beyond a read-only review:
envBool, deleting thei < 1guard inenvPositiveInt, and deleting theport > maxPortguard inenvPortproduced 11 subtest failures. The tests genuinely pin the behaviour rather than passing incidentally — which is the main risk with a change of this shape, where it is easy to write tests that assertassert.Errorand prove nothing.envPositiveInt,resolveEnvironment, andErrNonPositiveValuewere extracted from bothorigin/issue-64-receiver-rate-limitand this branch and diffed. Byte-for-byte identical, comments included. That is exactly what the spec asked for and it means the rebase after #87 lands is a delete-one-copy operation.os.Getenvaudit claim was independently reproduced rather than taken on trust: five hits, all ininternal/config/config.go.envPortbounds were checked for off-by-one at both ends: 1 and 65535 accepted, 0 and -5 rejected, 65536 rejected.Also confirmed:
script/cibuildexit 0, Gitea CI green on985464d(6m3s — the reviewer polled it to completion rather than assuming), merges clean againstmain@4f5ecb1,.golangci.ymlsha256 unchanged,Dockerfile/go.mod/go.sumzero diff.The eight non-blocking nits are tracked as #94 rather than round-tripping this PR — they are test-hygiene and documentation items, none of which affect the correctness of the fix.
Labeled
merge-readyand assigned to @sneak.Merge-ordering hazard, please read before merging
This PR and #87 both define
envPositiveInt,ErrNonPositiveValue, andresolveEnvironment, and #87 also still carries the old silent-fallbackenvIntthat this PR deletes.If #92 lands first, the #87 rebase must delete both its duplicated helpers and its
envInt. A mechanical conflict resolution that keepsenvIntwould silently reinstate the exact defect #80 exists to fix, and nothing would fail —envIntwould simply sit there unused until someone wired a new variable through it. I have posted the same warning on #87.PR #91 (#90) is also merge-ready and also touches
TODO.md, so whichever of #91/#92 lands second needs a trivialTODO.mdrebase.Verification re-check: the green is real
A fleet-wide warning came in that
script/cibuildcan report a green it did not earn — it is a plaindocker build .with no cache control, and the Dockerfile doesCOPY . .thenRUN make check, so on an unchanged tree Docker serves the check layer from cache and the build exits 0 without running anything. Observed elsewhere as a SUCCESS in 0.262 seconds with every layerCACHED.This PR's verification cites
script/cibuildexit 0 as the authoritative lint evidence, and in this repo that claim carries real weight: the host golangci-lint (v2.10.1) disagrees with the pinned v2.12.2, so a cached layer would leave the pinned-linter result entirely unproven.Re-checked against Gitea CI, which builds each commit on a runner independently of any local Docker cache:
985464d—check / check (push): success in 6m3s (run 98)A cached build finishes in under a second. Six minutes is a genuine execution inside the pinned v2.12.2 image. The claim stands. No re-label, no pull-back.
All five currently merge-ready PRs were re-checked the same way and all have genuine multi-minute CI runs: #87 2m37s, #91 3m6s, #92 6m3s, #96 2m43s, #100 3m3s.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.