Skip to content

DON'T REVIEW YET: Introduce RuntimeConfig wrapper for vMCP ConfigMap surface#5238

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

DON'T REVIEW YET: Introduce RuntimeConfig wrapper for vMCP ConfigMap surface#5238
ChrisJBurns wants to merge 4 commits into
mainfrom
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 (6733a54) to head (471ae22).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5238      +/-   ##
==========================================
- Coverage   67.97%   67.96%   -0.01%     
==========================================
  Files         612      612              
  Lines       62723    62724       +1     
==========================================
  Hits        42633    42633              
+ Misses      16908    16900       -8     
- Partials     3182     3191       +9     

☔ 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
@ChrisJBurns ChrisJBurns changed the title Introduce RuntimeConfig wrapper for vMCP ConfigMap surface DON'T REVIEW YET: Introduce RuntimeConfig wrapper for vMCP ConfigMap surface May 10, 2026
@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 10, 2026
Copy link
Copy Markdown
Contributor

@jhrozek jhrozek left a comment

Choose a reason for hiding this comment

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

Two small nits on comments — both easy fixes before merge. The overall design is solid and the test coverage for the seam invariants is thorough.

Comment on lines +36 to +41
// Note: when the operator writes the vMCP ConfigMap it wraps Config in
// runtime.RuntimeConfig (see pkg/vmcp/config/runtime). Today the wrapper
// adds no extra keys, so parsing into Config directly succeeds. When a
// future operator-resolved sidecar field lands on RuntimeConfig, callers
// that need it should use runtime.Load instead, which parses into the
// wrapper.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: two stale references from before the RuntimeConfig → Config rename — runtime.RuntimeConfig on line 37 should be runtime.Config, and runtime.Load on line 40 doesn't exist anywhere in the codebase.

Suggested change
// Note: when the operator writes the vMCP ConfigMap it wraps Config in
// runtime.RuntimeConfig (see pkg/vmcp/config/runtime). Today the wrapper
// adds no extra keys, so parsing into Config directly succeeds. When a
// future operator-resolved sidecar field lands on RuntimeConfig, callers
// that need it should use runtime.Load instead, which parses into the
// wrapper.
// Note: when the operator writes the vMCP ConfigMap it wraps Config in
// runtime.Config (see pkg/vmcp/config/runtime). Today the wrapper adds no
// extra keys, so parsing into Config directly succeeds. When a future
// operator-resolved sidecar field lands on runtime.Config, a new
// runtime.Load function will need to be created that parses into runtime.Config
// instead of vmcpconfig.Config.

// `,inline`) handle key collisions differently — yaml.v3 errors or has
// a different precedence than encoding/json's outer-wins rule. The
// disjoint-tag test in runtime_config_test.go pins this.
type Config struct {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit / question: when the first operator-only field lands here, YAMLLoader.Load() uses KnownFields(true) and decodes into vmcpconfig.Config, so it will reject the new YAML key and the vMCP binary will fail to start. The fix at that point is to create a runtime.Load() function and update pkg/vmcp/cli/serve.go and pkg/vmcp/cli/validate.go to use it. Is there a plan to track this? A TODO comment on the struct would make the coupling visible to whoever adds the first field here.

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.

2 participants