Skip to content

Add init.go to pkg/vmcp/cli/ for config scaffolding #4882

Description

@yrobla

Description

Implement pkg/vmcp/cli/init.go — the Init function that discovers running workloads in a named ToolHive group and renders a starter YAML config file pre-populated with those backends. Include unit tests for template rendering and output validation. This gives users a safe, low-friction starting point for config-file-driven thv vmcp serve without requiring manual YAML authoring.

Context

RFC THV-0059 specifies a thv vmcp init subcommand that acts as a scaffolding assistant: it calls the local group workload discoverer, converts each discovered workload into a StaticBackendConfig, and renders a commented YAML file that a user can review, customize, and then pass to thv vmcp serve --config. The rendered file must include groupRef, a backends list derived from live workloads, and inline comments explaining each section.

This item is purely additive — it creates one new file, pkg/vmcp/cli/init.go, alongside the serve.go and validate.go files produced by #4879. No existing files are modified here. The Init function will be wired into the thv vmcp init Cobra subcommand in #4885.

Dependencies: Depends on #4879 (the pkg/vmcp/cli/ package must exist before init.go can be added to it)
Blocks: #4885

Acceptance Criteria

  • pkg/vmcp/cli/init.go exists with the required SPDX copyright header (// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. / // SPDX-License-Identifier: Apache-2.0)
  • Init(ctx context.Context, cfg InitConfig) error is exported and is the sole public entry point
  • InitConfig includes at minimum: GroupName string, OutputPath string (empty means stdout), and Discoverer workloads.Discoverer (injected dependency, not constructed internally)
  • Init calls cfg.Discoverer.ListWorkloadsInGroup(ctx, cfg.GroupName) and then GetWorkloadAsVMCPBackend for each discovered workload to build the backend list
  • Workloads returned as nil from GetWorkloadAsVMCPBackend (not yet accessible) are skipped gracefully with a debug log entry
  • The rendered YAML includes groupRef, a backends list (one entry per accessible workload), and an outgoingAuth stub with source: inline
  • Each rendered backend block contains at minimum: name, url, and transport fields
  • Rendered YAML includes inline comments (#) explaining the purpose of key sections (groupRef, backends, outgoingAuth), making the file self-documenting
  • When OutputPath is empty or -, the rendered content is written to the provided io.Writer (or os.Stdout by default)
  • When OutputPath is a non-empty file path, the content is written atomically (temp file + rename, or os.WriteFile with 0o600 permissions)
  • go build ./pkg/vmcp/cli/... succeeds with no errors
  • go vet ./pkg/vmcp/cli/... passes with no issues
  • No new external Go module dependencies introduced
  • Unit tests in pkg/vmcp/cli/init_test.go cover the cases listed in the Testing Strategy below
  • All tests pass
  • Code reviewed and approved

Technical Approach

Recommended Implementation

Create pkg/vmcp/cli/init.go in the package established by #4879. The function accepts an InitConfig struct that carries the group name, output destination, and an injected workloads.Discoverer interface — this keeps the function fully testable without a real container runtime.

The rendering pipeline is:

  1. Call ListWorkloadsInGroup to enumerate workload names.
  2. For each workload, call GetWorkloadAsVMCPBackend to resolve its URL and transport type. Skip any that return nil (not yet accessible).
  3. Map each resolved *vmcp.Backend to a config.StaticBackendConfig using the backend's Name, BaseURL, and TransportType fields.
  4. Build a config.Config value with Group set to the group name, Backends set to the collected slice, and OutgoingAuth stubbed as {Source: "inline"}.
  5. Render to YAML using gopkg.in/yaml.v3. Since gopkg.in/yaml.v3 does not support inline comments natively, use a text/template to produce the final commented output (following the pattern in pkg/container/templates/templates.go), or marshal the struct and then prepend a YAML comment block header.
  6. Write to OutputPath or the configured writer.

The preferred rendering approach is a text/template embedded in the source file (no separate .tmpl file is necessary for a small template). Define a ConfigTemplate constant and parse it with template.New("vmcp-init").Parse(ConfigTemplate). Execute the template with a data struct that holds the marshalled YAML fields needed for interpolation.

Alternatively, use gopkg.in/yaml.v3's yaml.Node API to attach line comments to nodes, which is the idiomatic way to produce commented YAML without raw string templating. Either approach is acceptable; choose based on the volume of comments needed.

If no workloads are found in the group, Init should still produce a valid, commented YAML skeleton with an empty backends list and a comment explaining that no workloads were currently running.

Patterns & Frameworks

  • SPDX headers: Every new .go file must open with the two-line SPDX header
  • Package declaration: package cli (matching pkg/vmcp/cli/)
  • Dependency injection for Discoverer: Accept workloads.Discoverer in InitConfig; never construct it internally — this is required for unit testability
  • text/template rendering: Follow pkg/container/templates/templates.gotemplate.New(...).Parse(tmplStr), execute into a bytes.Buffer, write result
  • gopkg.in/yaml.v3 marshalling: Use yaml.Marshal to convert config.Config to bytes for the backend list sub-section, or use yaml.Node for comment-annotated output
  • Immutable variable assignment: Prefer x := ... over var x ...; x = ... across branches
  • Error wrapping: fmt.Errorf("...: %w", err) throughout; no silent failures
  • File output with safe permissions: os.WriteFile(path, data, 0o600) for config files (never world-readable)
  • Mockgen for tests: The workloads.Discoverer mock is already generated at pkg/vmcp/workloads/mocks/mock_discoverer.go — use it directly in tests

Code Pointers

  • pkg/vmcp/workloads/discoverer.go — Defines the Discoverer interface (ListWorkloadsInGroup, GetWorkloadAsVMCPBackend) and TypedWorkload type; this is what InitConfig.Discoverer must satisfy
  • pkg/vmcp/workloads/mocks/mock_discoverer.go — Pre-generated gomock mock for Discoverer; use in unit tests without any task gen run required
  • pkg/vmcp/config/config.goConfig, StaticBackendConfig, and OutgoingAuthConfig types; StaticBackendConfig.Name, .URL, .Transport are the fields to populate from vmcp.Backend
  • pkg/vmcp/types.goBackend struct (Name, BaseURL, TransportType fields) returned by GetWorkloadAsVMCPBackend
  • pkg/container/templates/templates.go — Reference for text/template + embed.FS pattern; note that for init.go a small inline template constant is simpler than a separate .tmpl file
  • planning-20260416-105417/scripts-20260416-111210/item_#4879_issue_body.mdExtract shared vMCP logic into pkg/vmcp/cli/ (serve + validate) #4879 spec; defines the pkg/vmcp/cli/ package structure and ServeConfig/ValidateConfig shapes that InitConfig should be consistent with

Component Interfaces

// pkg/vmcp/cli/init.go

// InitConfig holds all parameters for the Init command.
// Discoverer is injected rather than constructed internally to enable unit testing.
type InitConfig struct {
    // GroupName is the ToolHive group whose workloads are enumerated.
    GroupName string

    // OutputPath is the file path to write the generated config.
    // If empty or "-", content is written to Writer.
    OutputPath string

    // Writer is used when OutputPath is empty or "-".
    // Defaults to os.Stdout when nil.
    Writer io.Writer

    // Discoverer resolves running workloads in the group.
    // In production, callers pass a *groups.CLIManager or equivalent.
    Discoverer workloads.Discoverer
}

// Init discovers workloads in cfg.GroupName, renders a starter vMCP YAML
// config file with one backend entry per accessible workload, and writes
// the result to cfg.OutputPath or cfg.Writer.
func Init(ctx context.Context, cfg InitConfig) error { ... }

The output YAML should approximate the following shape (comments are illustrative):

# Generated by `thv vmcp init`. Review and customize before use.
# groupRef: the ToolHive group whose workloads are aggregated.
groupRef: my-group

# backends: one entry per running workload discovered in the group.
backends:
  - name: my-server
    url: http://127.0.0.1:45678/mcp
    transport: streamable-http

# outgoingAuth: controls how vMCP authenticates to backends.
# source: inline means auth config is fully specified here (no K8s discovery).
outgoingAuth:
  source: inline

Testing Strategy

Unit Tests (pkg/vmcp/cli/init_test.go)

  • TestInit_WritesToStdout: when OutputPath is empty, rendered YAML is written to cfg.Writer; assert groupRef and at least one backend name appear in output
  • TestInit_WritesToFile: when OutputPath is a valid temp-dir path, file is created with correct permissions (0o600) and contains valid YAML that gopkg.in/yaml.v3 can unmarshal into config.Config
  • TestInit_SkipsNilBackends: mock GetWorkloadAsVMCPBackend to return nil for one workload; assert it is omitted from the rendered backend list
  • TestInit_EmptyGroup: mock ListWorkloadsInGroup to return empty slice; assert rendered YAML is still valid, contains groupRef, and has an empty or absent backends key
  • TestInit_DiscoveryError: mock ListWorkloadsInGroup to return an error; assert Init propagates the error with a descriptive wrap
  • TestInit_RenderedYAMLIsValid: parse the rendered output with config.NewYAMLLoader and config.NewValidator; assert no validation errors (requires the rendered file to pass strict YAML parsing — this is the integration-style correctness check for the template)
  • TestInit_OutputFilePermissions: assert the written file has 0o600 permissions on Linux/Darwin

Integration Tests

  • Not required for this item; the TestInit_RenderedYAMLIsValid unit test acts as the integration-level correctness check

Edge Cases

  • GroupName empty string: return a descriptive error before calling Discoverer
  • Discoverer nil: return a descriptive error at the top of Init
  • OutputPath refers to a non-existent parent directory: return a wrapped os.PathError

Out of Scope

References

  • RFC THV-0059 — Phase 3 specifies the init command design including template shape and flag names
  • GitHub Issue #4808 — Parent tracking issue
  • pkg/vmcp/workloads/discoverer.goDiscoverer interface this item depends on
  • pkg/container/templates/templates.go — Template rendering pattern to follow
  • .claude/rules/go-style.md — SPDX headers, error wrapping, file permission conventions
  • .claude/rules/testing.md — gomock usage (go.uber.org/mock), parallel test safety, t.Cleanup for teardown

Metadata

Metadata

Assignees

No one assigned

    Labels

    cliChanges that impact CLI functionalityenhancementNew feature or requestvmcpVirtual MCP Server related issues
    No fields configured for Task 📋.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions