Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e607823
Add StorageVersionMigrator controller (opt-in, default off)
ChrisJBurns May 21, 2026
d6ca39c
Address PR review feedback
ChrisJBurns May 21, 2026
1199a8f
Sync single-tenancy chainsaw RBAC fixture with regenerated ClusterRole
ChrisJBurns May 21, 2026
d1c7da0
Merge branch 'main' into chris/svm-controller
ChrisJBurns May 21, 2026
b491b9a
Merge branch 'main' into chris/svm-controller
amirejaz May 22, 2026
435f547
Merge branch 'main' into chris/svm-controller
ChrisJBurns May 22, 2026
b783471
Add unit tests for StorageVersionMigrator controller helpers
ChrisJBurns May 22, 2026
a0cceb0
Extend SVM unit tests to ensureInitialized and Reconcile early returns
ChrisJBurns May 22, 2026
e778b70
Add fake-client unit tests for restoreOne/restoreCRs/patchStoredVersions
ChrisJBurns May 22, 2026
047047d
Refactor SVM unit tests for conciseness
ChrisJBurns May 22, 2026
9c15e0b
Address second-round PR review feedback (F1, F2, F3, F4, F5, F6)
ChrisJBurns May 22, 2026
bad9fc3
Address test-review feedback (T2, T3, T6, T7 + coverage gaps)
ChrisJBurns May 22, 2026
40b74c5
Fix lint findings on SVM unit tests
ChrisJBurns May 22, 2026
43333c1
Merge branch 'main' into chris/svm-controller
ChrisJBurns May 23, 2026
a29a740
Fix codespell findings in SVM unit tests
ChrisJBurns May 23, 2026
9af966c
Merge branch 'main' into chris/svm-controller
ChrisJBurns May 26, 2026
8846ce6
Update cmd/thv-operator/controllers/storageversionmigrator_controller.go
ChrisJBurns May 27, 2026
b7c4116
Address JAORMX's PR review findings
ChrisJBurns May 27, 2026
0582b75
Recover lost controller changes from the previous rebase
ChrisJBurns May 27, 2026
a560016
Fix CI failures from review-feedback commit
ChrisJBurns May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions cmd/thv-operator/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
"fmt"
"log/slog"
"os"
"strconv"
"strings"

"github.com/go-logr/logr"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
Expand Down Expand Up @@ -42,10 +44,18 @@ var (
setupLog = log.Log.WithName("setup")
)

// envEnableStorageVersionMigrator is the opt-in for the StorageVersionMigrator
// controller. The controller defaults to OFF in this release so the change can
// ship safely without functional impact. Set to "true" (or "1", "t") to enable.
// A follow-up release will flip the default to true alongside the helm chart
// surface and user docs.
const envEnableStorageVersionMigrator = "TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR"

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(mcpv1alpha1.AddToScheme(scheme))
utilruntime.Must(mcpv1beta1.AddToScheme(scheme))
utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
//+kubebuilder:scaffold:scheme
}

Expand Down Expand Up @@ -150,10 +160,56 @@ func setupControllersAndWebhooks(mgr ctrl.Manager, imagePullSecretsDefaults imag
if err := setupAggregationControllers(mgr, imagePullSecretsDefaults); err != nil {
return err
}
enabled, err := isStorageVersionMigratorEnabled()
if err != nil {
return err
}
if enabled {
if err := setupStorageVersionMigrator(mgr); err != nil {
return err
}
} else {
setupLog.V(1).Info("StorageVersionMigrator disabled", "envVar", envEnableStorageVersionMigrator)
}
//+kubebuilder:scaffold:builder
return nil
}

// setupStorageVersionMigrator wires the StorageVersionMigrator controller into
// the manager. The controller reconciles status.storedVersions on opted-in
// toolhive.stacklok.dev CRDs so a future operator release can drop deprecated
// versions from spec.versions without orphaning etcd objects.
func setupStorageVersionMigrator(mgr ctrl.Manager) error {
if err := (&controllers.StorageVersionMigratorReconciler{
Client: mgr.GetClient(),
APIReader: mgr.GetAPIReader(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorder("storageversionmigrator-controller"),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller StorageVersionMigrator: %w", err)
}
return nil
}

// isStorageVersionMigratorEnabled reports whether the StorageVersionMigrator
// controller should be registered. Defaults to false in this release — admins
// must explicitly opt in via TOOLHIVE_ENABLE_STORAGE_VERSION_MIGRATOR=true.
// An unparsable value returns an error so startup fails loudly rather than
// silently disabling the feature an admin asked to turn on.
func isStorageVersionMigratorEnabled() (bool, error) {
Comment thread
ChrisJBurns marked this conversation as resolved.
value, found := os.LookupEnv(envEnableStorageVersionMigrator)
if !found {
return false, nil
}
enabled, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf(
"invalid value for %s: %q (expected true/false): %w",
envEnableStorageVersionMigrator, value, err)
}
return enabled, nil
}

// setupGroupRefFieldIndexes sets up field indexing for spec.groupRef on all resource types
// that can reference an MCPGroup. This enables efficient lookups by groupRef in controllers.
func setupGroupRefFieldIndexes(mgr ctrl.Manager) error {
Expand Down
115 changes: 115 additions & 0 deletions cmd/thv-operator/app/app_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"testing"

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

// TestIsStorageVersionMigratorEnabled exercises the env-var contract for the
// StorageVersionMigrator feature flag. The function must:
// - default to (false, nil) when the env var is unset (the controller is
// opt-in for this release),
// - accept strconv.ParseBool's full truth-table for explicit values,
// - fail loudly on unparsable values so a misconfigured admin sees a
// startup error instead of silently disabled migration.
//
// The error-case rows assert on both the env-var name AND the offending
// value being present in the error message, so a future refactor that
// drops either fragment fails this test.
func TestIsStorageVersionMigratorEnabled(t *testing.T) {
// Intentionally NOT t.Parallel(): subtests use t.Setenv, which
// panics if the test (or any ancestor) is parallel. Subtests are
// also serial for the same reason. The trade-off is negligible —
// the test is sub-millisecond — and matches Go 1.20+ guidance for
// env-var-driven tests.

tests := []struct {
name string
setEnv bool
envValue string
wantEnabled bool
wantErr bool
}{
{
name: "unset defaults to disabled",
setEnv: false,
wantEnabled: false,
wantErr: false,
},
{
name: "explicit true enables",
setEnv: true,
envValue: "true",
wantEnabled: true,
wantErr: false,
},
{
name: "explicit false disables",
setEnv: true,
envValue: "false",
wantEnabled: false,
wantErr: false,
},
{
name: "explicit 1 enables (ParseBool truthy)",
setEnv: true,
envValue: "1",
wantEnabled: true,
wantErr: false,
},
{
name: "explicit 0 disables (ParseBool falsy)",
setEnv: true,
envValue: "0",
wantEnabled: false,
wantErr: false,
},
{
name: "unparsable value errors with env-var name and bad value",
setEnv: true,
envValue: "garbage",
wantEnabled: false,
wantErr: true,
},
{
name: "empty string errors (ParseBool rejects empty)",
setEnv: true,
envValue: "",
wantEnabled: false,
wantErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Subtests intentionally do NOT call t.Parallel(): t.Setenv
// is incompatible with a parallel *testing.T (it panics with
// "test using t.Setenv ... can not use t.Parallel"). The
// outer test still runs in parallel with other tests in the
// package — only sibling subtests of this test serialize,
// which is fine because t.Setenv restores the prior value
// at the subtest's end.
if tc.setEnv {
t.Setenv(envEnableStorageVersionMigrator, tc.envValue)
}

got, err := isStorageVersionMigratorEnabled()

if tc.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), envEnableStorageVersionMigrator,
"error message must name the env var so admins can find the misconfiguration")
assert.Contains(t, err.Error(), `"`+tc.envValue+`"`,
"error message must quote the offending value so admins can spot typos")
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantEnabled, got)
})
}
}
Loading
Loading