Skip to content

Add config package for shared base configuration - #181

Merged
reyortiz3 merged 7 commits into
mainfrom
add-base-config-package
Jul 23, 2026
Merged

Add config package for shared base configuration#181
reyortiz3 merged 7 commits into
mainfrom
add-base-config-package

Conversation

@reyortiz3

@reyortiz3 reyortiz3 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New config package: an embeddable BaseConfig (service name, log level, freeform environment) plus a strict, generic YAML Load[T any] helper.
  • Consuming services embed BaseConfig inline (yaml:",inline") and add their own fields/validation on top — base fields don't grow to cover every service's schema.
  • Environment is a freeform string, deliberately unvalidated and without a fixed set of values — deployments name their environments differently, so a closed enum would force everyone into one org's naming.
  • Prompted by Airlock's planned migration into stacklok-enterprise-platform, which currently hand-rolls its own strict-YAML config loader (internal/config) and could consume this instead.
  • Also includes an unrelated dependency bump: google.golang.org/grpc v1.82.0 → v1.82.1, fixing a High-severity CVE (GHSA-hrxh-6v49-42gf) that was blocking the grype-scan CI gate.

Test plan

  • task lint — clean
  • task test (full repo) — all packages pass, including new config tests
  • task license-check — SPDX headers present
  • task grype-scan — clean after the grpc bump
  • Test coverage includes: base validation table tests, log-level parsing, an example service config that embeds+extends BaseConfig, strict unknown-field rejection, missing-file handling, and freeform Environment round-tripping through YAML

🤖 Generated with Claude Code

Services in the ToolHive ecosystem each hand-roll their own YAML
config loader. Give them a common BaseConfig (service name, log
level) plus a strict generic Load() helper to embed and extend, so
only truly universal fields live here and each service still owns
its own schema and validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Give BaseConfig a freeform Environment field so services don't each
invent their own ad hoc field for this. Left unvalidated and without
a fixed set of values on purpose: deployments name their environments
differently, and a closed enum would force everyone into one org's
naming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@reyortiz3
reyortiz3 force-pushed the add-base-config-package branch from 97403bb to 4c47d8d Compare July 22, 2026 20:01
reyortiz3 and others added 2 commits July 22, 2026 18:23
Fixes GHSA-hrxh-6v49-42gf (High), which the grype-scan CI gate
blocks on. Unrelated to the config package changes in this PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@rdimitrov rdimitrov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

What it does. Adds an Alpha config package with two pieces: an embeddable BaseConfig (ServiceName, LogLevel, freeform Environment) that consumers embed yaml:",inline" and extend, plus a generic Load[T any](path, *T) that strict-decodes YAML (KnownFields(true)). Also bumps grpc v1.82.0→v1.82.1 for a High-severity CVE that was blocking the grype-scan gate.

Overall: 👍 land it. The correctness surface is clean, the CVE bump unblocks CI, and Alpha gives room to iterate. Comments below are mostly about scope and API fit for the wider ecosystem.

Correctness

I verified the package's central claim by hand: strict decoding does reject unknown fields both at the top level (alongside the inline base fields) and nested, on yaml.v3 v3.0.1, so the guarantee holds. gopkg.in/yaml.v3 is already a direct dep — no go.mod churn needed. Nothing rose to a must-fix bug. Minor notes:

  • Empty config file → cryptic error. yaml.Decoder.Decode on a zero-byte file returns io.EOF, so Load returns decode config file X: EOF rather than a clean empty-then-validation-fails path. Low severity (an empty file fails Validate() anyway), but consider special-casing io.EOF.
  • Validate()'s if c == nil guard is effectively dead in the documented inline-embed usage — you can't get a nil pointer to an embedded value field. Harmless, but it implies a calling pattern the package doesn't actually support.
  • Test gap (not a bug): TestLoad_UnknownFieldRejected only exercises a nested unknown field. Since the headline feature is strict decoding of the inline top-level fields, a case with an unknown key at the document root would lock in the behavior I verified manually.

Does the idea make sense for the ecosystem?

Directionally yes — the duplication is real. toolhive already hand-rolls this exact pattern in pkg/vmcp/config/yaml_loader.go (yaml.NewDecoder(...) + KnownFields(true)), and Airlock's internal/config does the same. So the strict-YAML loader has ≥2 independent reinventions today; consolidating that is genuinely valuable.

But the value is lopsided, and a few things are worth deciding before this graduates past Alpha or gets treated as the ecosystem config layer:

  1. Load[T] is the reusable piece; BaseConfig is thin. It's three fields, one (Environment) explicitly unvalidated with no helpers — closer to documentation than behavior. Its value only materializes if consumers converge on it, and they haven't yet: toolhive-registry-server already exposes serviceName nested under telemetry:, not at the top level, so adopting BaseConfig there means restructuring their schema. "Universal top-level fields" is more aspirational than observed. I'd resist growing BaseConfig's field set until ≥2 services embed it unchanged.

  2. Load may be too narrow for the biggest consumer. It's path-only (os.Open inside). toolhive's real loader reads bytes and then does substantial post-processing: env-var expansion (via the existing env.Reader), auth-strategy resolution, composite-tool handling. Two cheap generalizations would widen adoption a lot:

    • Offer a Decode(r io.Reader, *T) variant — embedded FS, remote config, in-memory tests, and byte-slice consumers all need this; path-only forces a temp file.
    • Decide where env-var interpolation (${VAR}) lives, since every server-style consumer wants it and will otherwise keep hand-rolling around Load.
  3. The string → slog.Level parser is arguably in the wrong package. SlogLevel() bridges into the existing logging package (which consumes slog.Leveler). Its natural home is logging, where the level type and defaults already live — otherwise config and logging can drift on what e.g. "warn" means.

  4. Reuse breadth is currently speculative. One concrete near-term consumer (Airlock) plus one demonstrable duplication (toolhive) justifies the loader. It doesn't yet justify framing BaseConfig as ecosystem-wide shared schema — a mild YAGNI flag. Alpha is the right hedge.

Suggested path before graduation: (a) add an io.Reader/Decode variant so byte-slice consumers like toolhive can adopt it; (b) move level-string parsing into logging; (c) prove BaseConfig by migrating a second service beyond Airlock (ideally converging registry-server's telemetry.serviceName) rather than expanding its fields speculatively.

Process nit: the grpc CVE bump is unrelated to the config package and bundled here only because it blocked the same CI gate — reasonable pragmatically, just flagging that the PR does two things.

🤖 Generated with Claude Code

rdimitrov
rdimitrov previously approved these changes Jul 23, 2026
Addresses PR #181 review: Load was path-only (os.Open inside), forcing
byte-slice/embedded-FS/test callers through a temp file. Decode takes an
io.Reader directly; Load is now a thin wrapper over it.

Strict decoding stays the default, but AllowUnknownFields lets a future
consumer opt out (e.g. a rolling deploy reading a newer config) without
losing the default typo-catching behavior.

Env-var interpolation is intentionally deferred — noted in doc.go rather
than designed speculatively; better decided against the actual toolhive
loader this package is meant to absorb.
New tests added "gw-1" occurrences past goconst's threshold.
Addresses PR #181 review: config shouldn't reference slog at all.
BaseConfig.Validate now checks LogLevel against a plain string set with
no logging-package dependency. The string->slog.Level parser moves to
logging.ParseLevel, its natural home alongside the level type and
defaults it already owns.
@reyortiz3
reyortiz3 merged commit 6596685 into main Jul 23, 2026
5 checks passed
@reyortiz3
reyortiz3 deleted the add-base-config-package branch July 23, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants