Skip to content

Introduce RuntimeConfig wrapper for vMCP ConfigMap surface#5238

Draft
ChrisJBurns wants to merge 4 commits intomainfrom
cburns/runtime-config-wrapper
Draft

Introduce RuntimeConfig wrapper for vMCP ConfigMap surface#5238
ChrisJBurns wants to merge 4 commits intomainfrom
cburns/runtime-config-wrapper

Conversation

@ChrisJBurns
Copy link
Copy Markdown
Collaborator

Summary

VirtualMCPServerSpec.Config is typed as pkg/vmcp/config.Config in v1beta1, so controller-gen walks every field reachable from Config into the public CRD schema. That blocks adding operator-resolved sidecar fields (per-backend secret-identifier maps, resolved CA bundle paths, future BackendHeaderForward for MCPServerEntry references, etc.) without churning the CRD and triggering the v1beta1 stability gate.

This PR introduces pkg/vmcp/config.RuntimeConfig: a wrapper that embeds Config inline and is the designated home for operator-resolved fields. Today the wrapper adds nothing — marshalled YAML is byte-identical, parsed YAML is identical, and task operator-manifests produces zero CRD diff. Future PRs add sidecar fields onto RuntimeConfig without touching the public Config or v1beta1.

This is a foundational refactor for #4996 / #5013 (forward MCPServerEntry.headerForward to vMCP outbound requests). The current #5013 implementation adds 96 lines of CRD YAML and 86 lines of crd-api.md because HeaderForward was placed on StaticBackendConfig (a Config-reachable type). Once this PR lands, #5013 can be reworked to put the field on RuntimeConfig instead — net CRD diff drops to zero.

Wiring
  • Loader: YAMLLoader.Load now returns *RuntimeConfig. Callers that only need user-facing Config fields read them through the embed (rc.Name, rc.Group, etc.); callers that consume sidecars read them off the wrapper directly. Strict KnownFields(true) validation is preserved.
  • Operator write: cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go wraps *Config in RuntimeConfig{} before YAML marshal. Single write path, single read path.
  • CLI boundary: loadAndValidateConfig in pkg/vmcp/cli/serve.go and validate.go unwrap to *Config to keep the existing serve pipeline tight. A comment marks where to thread the wrapper through when a sidecar consumer arrives.
Tests pinning the seam
  • pkg/vmcp/config/runtime_config_test.go:
    • TestRuntimeConfig_MarshalsIdenticallyToConfig — byte-identity vs Config today.
    • TestRuntimeConfig_Load_RoundTrip — Load through the operator's write shape.
    • TestRuntimeConfig_DisjointTopLevelTags — reflect-based check that catches a future field on RuntimeConfig sharing a JSON or YAML key with any Config field. encoding/json (anonymous-field promotion) and yaml.v3 (,inline) handle key collisions differently, so a collision would silently produce divergent serialization. This is forward-looking — today the wrapper has no extras and the test is trivially green.
  • cmd/thv-operator/pkg/spectoconfig/runtime_config_drift_test.go:
    • Asserts RuntimeConfig is a strict superset of Config.
    • Any extras must appear in runtimeOnlyLeafJustifications with a written rationale (today empty).
    • Catches stale entries and contradicting classifications.
    • Lives operator-side because the drift harness in cmd/thv-operator/internal/testutil/ is operator-internal.
Acceptance gate

git diff main -- deploy/charts/operator-crds/ docs/operator/crd-api.md is empty after running both:

task operator-manifests
task operator-generate

The wrapper is invisible to controller-gen because RuntimeConfig is not field-referenced from any v1beta1 type. The doc on RuntimeConfig calls out the only way to break that invariant (retyping VirtualMCPServerSpec.Config from config.Config to config.RuntimeConfig — don't).

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change
  • Refactoring
  • Documentation
  • Other

Test plan

  • task build passes (verified via go build ./...)
  • task test passes for all touched packages (pkg/vmcp/..., cmd/thv-operator/pkg/spectoconfig/...)
  • New unit tests:
    • TestRuntimeConfig_MarshalsIdenticallyToConfig
    • TestRuntimeConfig_Load_RoundTrip
    • TestRuntimeConfig_DisjointTopLevelTags
    • TestRuntimeConfigSeam (operator-side drift test)
  • task operator-manifests and task operator-generate produce zero diff under deploy/charts/operator-crds/ and docs/operator/crd-api.md

Special notes for reviewers

  • Pure refactor, no behaviour change. The wrapper adds zero serialized keys today; YAML output and parse semantics are identical to Config directly. The point is to establish the seam now so the next operator-resolved field doesn't have to fight v1beta1 stability.
  • Load() signature change. YAMLLoader.Load() now returns *RuntimeConfig instead of *Config. Most existing callers are field-access only (works transparently through embed). Two callers needed &cfg.Config for Validator.Validate(*Config). The CLI's loadAndValidateConfig unwraps to *Config at its return boundary to keep the serve pipeline tight; a comment documents the migration path when a sidecar consumer is added.
  • Drift test placement. Test lives in cmd/thv-operator/pkg/spectoconfig/runtime_config_drift_test.go because the drift harness lives in cmd/thv-operator/internal/testutil/ and is operator-internal. The TYPES it tests live in pkg/vmcp/config/. This layering is acceptable; moving the harness up to a shared location is out of scope for this PR.

Implementation plan

Approved plan (AI-assisted)

Three agents reviewed the design before commit:

  • toolhive-expert verified caller safety: 3 production + 8 test callers of Load() covered, single ConfigMap write path, no checksum fixtures pinned to current YAML.
  • kubernetes-go-expert verified the four key claims (CRD invisibility, deepcopy-skip, ,inline semantics, strict-decode behaviour). One refinement landed in the type doc — the only way to leak RuntimeConfig fields into the CRD is retyping VirtualMCPServerSpec.Config, now explicitly called out.
  • go-architect caught two real issues that were addressed before commit: (1) collapsed Load/LoadRuntime into one Load() returning *RuntimeConfig (interface pollution; lossy view would have become a latent bug); (2) added the disjoint-tag reflect test to catch the top forward-looking hazard.

🤖 Generated with Claude Code

VirtualMCPServerSpec.Config is typed as pkg/vmcp/config.Config in
v1beta1, so controller-gen walks every field reachable from Config into
the public CRD schema. That blocks adding operator-resolved sidecar
fields (per-backend secret-identifier maps, resolved CA bundle paths,
etc.) without churning the CRD.

Introduce RuntimeConfig: a wrapper that embeds Config inline and is the
designated place for operator-resolved fields. Today RuntimeConfig adds
nothing — marshalled YAML is byte-identical, parsed YAML is identical,
and `task operator-manifests` produces zero CRD diff. Future PRs add
sidecar fields here without touching the public Config or v1beta1.

Wiring:
  - YAMLLoader.Load now returns *RuntimeConfig. Callers that only need
    user-facing Config fields read them through the embed (rc.Name,
    rc.Group, etc.); callers that consume sidecars read them off the
    wrapper directly.
  - The operator wraps *Config in RuntimeConfig{} before marshal to the
    vMCP ConfigMap (single write path, single read path).
  - The CLI boundary (loadAndValidateConfig in pkg/vmcp/cli/serve.go and
    validate.go) unwraps to *Config to keep the existing pipeline tight;
    a comment marks where to thread the wrapper through when a sidecar
    consumer arrives.

Tests pinning the seam:
  - pkg/vmcp/config/runtime_config_test.go: byte-identity vs Config,
    round-trip through Load, and a disjoint-tag check that catches a
    future field on RuntimeConfig sharing a JSON or YAML key with any
    Config field (encoding/json and yaml.v3 handle key collisions
    differently).
  - cmd/thv-operator/pkg/spectoconfig/runtime_config_drift_test.go:
    asserts RuntimeConfig is a strict superset of Config and that any
    extras must appear in runtimeOnlyLeafJustifications with a written
    rationale. Lives operator-side because the drift harness in
    cmd/thv-operator/internal/testutil is operator-internal.

Acceptance gate verified: task operator-manifests and task
operator-generate produce zero diff under deploy/charts/operator-crds/
and docs/operator/crd-api.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label May 9, 2026
CI's gci/gofmt step wanted an extra space in the column-aligned method
declaration on fakeEnv. No semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels May 9, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.96%. Comparing base (9211a36) to head (471ae22).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5238      +/-   ##
==========================================
+ Coverage   67.91%   67.96%   +0.05%     
==========================================
  Files         610      612       +2     
  Lines       62522    62724     +202     
==========================================
+ Hits        42464    42633     +169     
- Misses      16879    16900      +21     
- Partials     3179     3191      +12     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

CI's crdref-gen step (task crdref-gen) globs pkg/vmcp/config/*.go and
iterates every type for documentation rendering. The shared template at
docs/operator/templates/markdown/gv_details.tpl emits two newlines per
loop iteration regardless of whether the inner type renders content,
which means a non-+gendoc type leaks two blank lines into
docs/operator/crd-api.md. Every existing type in pkg/vmcp/config has
+gendoc, so the latent template bug never surfaced — until RuntimeConfig
arrived without one (intentionally; we don't want it documented as CRD
surface).

Two principled fixes were considered. Patching the gv_details.tpl loop
to suppress per-iteration whitespace eats the section separators that
the existing rendered docs depend on, so it would require a deeper
template rework outside this PR's scope. Instead, move RuntimeConfig
into the pkg/vmcp/config/runtime subpackage. The Taskfile sources at
cmd/thv-operator/Taskfile.yml:289 only glob *.go directly inside
pkg/vmcp/config (not subpackages), so crdref-gen never iterates the
subpackage and the template bug stays dormant.

Side effect: pkg/vmcp/config can no longer reference RuntimeConfig
without a circular import (runtime imports config). The YAMLLoader.Load
signature reverts to returning *Config — runtime.RuntimeConfig is a
write-side-only wrapper for now. When a future PR lands a sidecar field
on RuntimeConfig and a vMCP runtime consumer needs it, that PR adds a
runtime.Load helper that does its own RuntimeConfig parse with strict
validation.

The drift test, byte-identity test, round-trip test, and disjoint-tag
test all move with the type into the runtime subpackage. The operator's
ConfigMap write path imports vmcpruntimeconfig and wraps Config in
RuntimeConfig before marshal — single write path, single read path.

Acceptance gate (verified):
- task operator-manifests, task operator-generate, task crdref-gen all
  produce zero diff under deploy/charts/operator-crds/ and
  docs/operator/crd-api.md.
- All tests pass: pkg/vmcp/config, pkg/vmcp/config/runtime,
  cmd/thv-operator/pkg/spectoconfig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels May 9, 2026
revive flagged runtime.RuntimeConfig as a stutter — packages should not
repeat their name in the type. The idiomatic fix is to rename the type
to Config; disambiguation between this package's Config and the parent
pkg/vmcp/config.Config happens via import alias at call sites
(vmcpconfig vs vmcpruntimeconfig), which is the standard Go pattern.

Test function names (TestRuntimeConfig_*) and subtest descriptions are
descriptive labels so they keep their full names. Production-code
references update to the qualified runtime.Config form, including
failure messages in the drift test so a developer who hits one can grep
straight to the right symbol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels May 9, 2026
@ChrisJBurns ChrisJBurns marked this pull request as draft May 9, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant