Skip to content
Draft
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/embeddingserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=emb;embedding,categories=toolhive
//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase"
//+kubebuilder:printcolumn:name="Model",type="string",JSONPath=".spec.model"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ type MCPExternalAuthConfigStatus struct {
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// +kubebuilder:subresource:status
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
// +kubebuilder:resource:shortName=extauth;mcpextauth,categories=toolhive
// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcpgroup_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=mcpg;mcpgroup,categories=toolhive
//+kubebuilder:printcolumn:name="Servers",type="integer",JSONPath=".status.serverCount"
//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcpoidcconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ type MCPOIDCConfigStatus struct {
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// +kubebuilder:subresource:status
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
// +kubebuilder:resource:shortName=mcpoidc,categories=toolhive
// +kubebuilder:printcolumn:name="Source",type=string,JSONPath=`.spec.type`
// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcpregistry_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase"
//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
//+kubebuilder:printcolumn:name="Replicas",type="integer",JSONPath=".status.readyReplicas"
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcpremoteproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=rp;mcprp,categories=toolhive
//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
//+kubebuilder:printcolumn:name="Remote URL",type="string",JSONPath=".spec.remoteUrl"
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=mcpserver;mcpservers,categories=toolhive
//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase"
//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcpserverentry_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=mcpentry,categories=toolhive
//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
//+kubebuilder:printcolumn:name="Transport",type="string",JSONPath=".spec.transport"
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/mcptelemetryconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ type MCPTelemetryConfigStatus struct {
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// +kubebuilder:subresource:status
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
// +kubebuilder:resource:shortName=mcpotel,categories=toolhive
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.spec.openTelemetry.endpoint`
// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/toolconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type MCPToolConfigStatus struct {
// +kubebuilder:object:root=true
// +kubebuilder:storageversion
// +kubebuilder:subresource:status
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
// +kubebuilder:resource:shortName=tc;toolconfig,categories=toolhive
// +kubebuilder:printcolumn:name="Valid",type=string,JSONPath=`.status.conditions[?(@.type=='Valid')].status`
// +kubebuilder:printcolumn:name="References",type=string,JSONPath=`.status.referencingWorkloads`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=vmcpctd;compositetool,categories=toolhive
//+kubebuilder:printcolumn:name="Workflow",type="string",JSONPath=".spec.name",description="Workflow name"
//+kubebuilder:printcolumn:name="Steps",type="integer",JSONPath=".spec.steps[*]",description="Number of steps"
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ const (
//+kubebuilder:object:root=true
//+kubebuilder:storageversion
//+kubebuilder:subresource:status
//+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//+kubebuilder:resource:shortName=vmcp;virtualmcp,categories=toolhive
//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="The phase of the VirtualMCPServer"
//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url",description="Virtual MCP server URL"
Expand Down
151 changes: 151 additions & 0 deletions cmd/thv-operator/controllers/marker_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package controllers

import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestV1beta1TypesMarkerCoverage guards against a subtle failure mode in the
// opt-in-label design for storage-version migration: if a new CRD root type is
// added to cmd/thv-operator/api/v1beta1/ without the
//
// +kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true
//
// marker, the StorageVersionMigrator controller silently excludes it from
// reconciliation. The problem surfaces only when a future release tries to
// drop a deprecated version — at which point it is far too late.
//
// The test scans every root type (marker block contains
// +kubebuilder:object:root=true AND +kubebuilder:storageversion) and fails if
// any lacks either the migrate marker or an explicit
// +thv:storage-version-migrator:exclude sibling marker. List types
// (+kubebuilder:object:root=true WITHOUT +kubebuilder:storageversion) are
// intentionally skipped — CRD-level labels are keyed on the root type, not
// the list type.
func TestV1beta1TypesMarkerCoverage(t *testing.T) {
t.Parallel()

typesDir := filepath.Join("..", "api", "v1beta1")
entries, err := os.ReadDir(typesDir)
require.NoError(t, err, "reading %s", typesDir)

const (
rootMarker = "+kubebuilder:object:root=true"
storageMarker = "+kubebuilder:storageversion"
migrateMarker = "+kubebuilder:metadata:labels=toolhive.stacklok.dev/auto-migrate-storage-version=true"
excludeMarker = "+thv:storage-version-migrator:exclude"
groupversionInfoGo = "groupversion_info.go"
zzGeneratedPrefix = "zz_generated"
suffixTypesGo = "_types.go"
)

type rootType struct {
file string
typeName string
markerBlock []string
}

var roots []rootType

for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, suffixTypesGo) {
continue
}
if name == groupversionInfoGo || strings.HasPrefix(name, zzGeneratedPrefix) {
continue
}

path := filepath.Join(typesDir, name)
f, err := os.Open(path)
require.NoError(t, err, "open %s", path)

scanner := bufio.NewScanner(f)
// Raise the max token size — some of the *_types.go files have very
// long comment lines (printcolumn JSONPath expressions).
scanner.Buffer(make([]byte, 64*1024), 1024*1024)

var block []string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())

switch {
case strings.HasPrefix(line, "//"):
// Accumulate every comment/marker line. kubebuilder convention
// often separates markers from the godoc comment with a blank
// line, so we keep the block alive across blanks and only
// reset on a non-comment, non-blank, non-type line.
block = append(block, strings.TrimSpace(strings.TrimPrefix(line, "//")))
case strings.HasPrefix(line, "type "):
// Only root-level types matter (must contain object:root=true
// AND storageversion markers). List types have only object:root=true.
if containsMarker(block, rootMarker) && containsMarker(block, storageMarker) {
typeName := extractTypeName(line)
if typeName != "" {
copied := append([]string(nil), block...)
roots = append(roots, rootType{file: name, typeName: typeName, markerBlock: copied})
}
}
block = nil
case line == "":
// Blank line — keep block alive (comment-then-blank-then-type
// is idiomatic Go + kubebuilder).
default:
// Anything else (e.g. struct body, package clause, import) —
// drop any in-flight comment block.
block = nil
}
}
require.NoError(t, scanner.Err(), "scan %s", path)
require.NoError(t, f.Close())
}

require.NotEmpty(t, roots,
"no v1beta1 root types found — scanner likely broken; this test is meaningless without coverage")

for _, r := range roots {
hasMigrate := containsMarker(r.markerBlock, migrateMarker)
hasExclude := containsMarker(r.markerBlock, excludeMarker)
assert.Truef(t, hasMigrate || hasExclude,
"v1beta1 root type %s.%s is missing either\n"+
" %s\n"+
"(opt in to storage-version migration) or\n"+
" %s\n"+
"(explicit opt-out). Every root type must declare one. See\n"+
"cmd/thv-operator/controllers/storageversionmigrator_controller.go for context.",
r.file, r.typeName, migrateMarker, excludeMarker)
}
}

// containsMarker returns true if any line in block contains the given
// marker substring.
func containsMarker(block []string, marker string) bool {
for _, l := range block {
if strings.Contains(l, marker) {
return true
}
}
return false
}

// extractTypeName returns the identifier in a line of the form `type Foo struct {`.
// Returns empty string if the line is not a type declaration we care about.
func extractTypeName(line string) string {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != "type" {
return ""
}
return fields[1]
}
Loading
Loading