From e7a6214abd1bc911abf34fb2df61b247c1c2db37 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:18:37 +0000 Subject: [PATCH 01/12] refactor: share controller scheme and setup helpers --- internal/app/controllerapp/controllerapp.go | 79 +++++++++++++++------ internal/app/sharedscheme/sharedscheme.go | 20 ++++++ main_test.go | 3 + 3 files changed, 82 insertions(+), 20 deletions(-) create mode 100644 internal/app/sharedscheme/sharedscheme.go diff --git a/internal/app/controllerapp/controllerapp.go b/internal/app/controllerapp/controllerapp.go index 65388326..1003e93a 100644 --- a/internal/app/controllerapp/controllerapp.go +++ b/internal/app/controllerapp/controllerapp.go @@ -10,12 +10,12 @@ import ( "time" "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/manager" - coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" + "github.com/coder/coder-k8s/internal/app/sharedscheme" "github.com/coder/coder-k8s/internal/coderbootstrap" "github.com/coder/coder-k8s/internal/controller" ) @@ -40,27 +40,19 @@ var setupLog = ctrl.Log.WithName("setup") // NewScheme builds the runtime scheme used by the controller application. func NewScheme() *runtime.Scheme { - scheme := runtime.NewScheme() - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(coderv1alpha1.AddToScheme(scheme)) - return scheme + return sharedscheme.New() } -// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch - -// Run starts the controller-runtime manager for the controller application mode. -func Run(ctx context.Context) error { - if ctx == nil { - return fmt.Errorf("assertion failed: context must not be nil") +// NewManager builds a controller-runtime manager for the controller application mode. +func NewManager(cfg *rest.Config, scheme *runtime.Scheme) (manager.Manager, error) { + if cfg == nil { + return nil, fmt.Errorf("assertion failed: config must not be nil") } - - scheme := NewScheme() if scheme == nil { - return fmt.Errorf("assertion failed: scheme is nil after successful construction") + return nil, fmt.Errorf("assertion failed: scheme must not be nil") } - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme, HealthProbeBindAddress: HealthProbeBindAddress, LeaderElection: true, @@ -69,10 +61,19 @@ func Run(ctx context.Context) error { LeaderElectionReleaseOnCancel: true, }) if err != nil { - return fmt.Errorf("unable to start manager: %w", err) + return nil, fmt.Errorf("unable to start manager: %w", err) + } + if mgr == nil { + return nil, fmt.Errorf("assertion failed: manager is nil after successful construction") } + + return mgr, nil +} + +// SetupControllers registers all controller reconcilers on the manager. +func SetupControllers(mgr manager.Manager) error { if mgr == nil { - return fmt.Errorf("assertion failed: manager is nil after successful construction") + return fmt.Errorf("assertion failed: manager must not be nil") } client := mgr.GetClient() @@ -112,6 +113,15 @@ func Run(ctx context.Context) error { return fmt.Errorf("unable to create provisioner controller: %w", err) } + return nil +} + +// SetupProbes configures health and readiness checks on the manager. +func SetupProbes(mgr manager.Manager) error { + if mgr == nil { + return fmt.Errorf("assertion failed: manager must not be nil") + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { return fmt.Errorf("unable to set up health check: %w", err) } @@ -126,6 +136,35 @@ func Run(ctx context.Context) error { return fmt.Errorf("unable to set up ready check: %w", err) } + return nil +} + +// +kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch + +// Run starts the controller-runtime manager for the controller application mode. +func Run(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("assertion failed: context must not be nil") + } + + scheme := NewScheme() + if scheme == nil { + return fmt.Errorf("assertion failed: scheme is nil after successful construction") + } + + mgr, err := NewManager(ctrl.GetConfigOrDie(), scheme) + if err != nil { + return err + } + + if err := SetupControllers(mgr); err != nil { + return err + } + if err := SetupProbes(mgr); err != nil { + return err + } + setupLog.Info("starting manager") if err := mgr.Start(ctx); err != nil { return fmt.Errorf("problem running manager: %w", err) diff --git a/internal/app/sharedscheme/sharedscheme.go b/internal/app/sharedscheme/sharedscheme.go new file mode 100644 index 00000000..136de400 --- /dev/null +++ b/internal/app/sharedscheme/sharedscheme.go @@ -0,0 +1,20 @@ +// Package sharedscheme provides reusable runtime scheme construction across app modes. +package sharedscheme + +import ( + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + aggregationv1alpha1 "github.com/coder/coder-k8s/api/aggregation/v1alpha1" + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" +) + +// New builds a runtime scheme with core Kubernetes, coder.com, and aggregation.coder.com APIs. +func New() *runtime.Scheme { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(coderv1alpha1.AddToScheme(scheme)) + utilruntime.Must(aggregationv1alpha1.AddToScheme(scheme)) + return scheme +} diff --git a/main_test.go b/main_test.go index 1dada6b8..4e818542 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" + aggregationv1alpha1 "github.com/coder/coder-k8s/api/aggregation/v1alpha1" coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" "github.com/coder/coder-k8s/internal/app/apiserverapp" "github.com/coder/coder-k8s/internal/app/controllerapp" @@ -41,6 +42,8 @@ func TestControllerSchemeRegistersCoderControlPlaneKinds(t *testing.T) { coderv1alpha1.GroupVersion.WithKind("CoderControlPlaneList"), coderv1alpha1.GroupVersion.WithKind("WorkspaceProxy"), coderv1alpha1.GroupVersion.WithKind("WorkspaceProxyList"), + aggregationv1alpha1.SchemeGroupVersion.WithKind("CoderWorkspace"), + aggregationv1alpha1.SchemeGroupVersion.WithKind("CoderWorkspaceList"), } { if !scheme.Recognizes(gvk) { t.Fatalf("expected scheme to recognize %s", gvk.String()) From ec482aee81027ee7b4cdf67b4a2d1525bea17958 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:17:37 +0000 Subject: [PATCH 02/12] refactor: add RunHTTPWithClients for injected k8s clients --- internal/app/mcpapp/http.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/app/mcpapp/http.go b/internal/app/mcpapp/http.go index 63600f4d..6b24e9ca 100644 --- a/internal/app/mcpapp/http.go +++ b/internal/app/mcpapp/http.go @@ -8,7 +8,9 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" + "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" ) const ( @@ -31,6 +33,21 @@ func RunHTTP(ctx context.Context) error { return err } + return RunHTTPWithClients(ctx, k8sClient, clientset) +} + +// RunHTTPWithClients starts the MCP server using streamable HTTP transport and the provided Kubernetes clients. +func RunHTTPWithClients(ctx context.Context, k8sClient client.Client, clientset kubernetes.Interface) error { + if ctx == nil { + return fmt.Errorf("assertion failed: context must not be nil") + } + if k8sClient == nil { + return fmt.Errorf("assertion failed: Kubernetes client must not be nil") + } + if clientset == nil { + return fmt.Errorf("assertion failed: Kubernetes clientset must not be nil") + } + server := NewServer(k8sClient, clientset) if server == nil { return fmt.Errorf("assertion failed: MCP server is nil after successful construction") From d887f5aa6c07489773c0575e8d567ce43c391fb1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:28:24 +0000 Subject: [PATCH 03/12] feat: add dynamic control plane client provider --- .../aggregated/coder/controlplane_provider.go | 240 +++ .../coder/controlplane_provider_test.go | 322 +++ vendor/modules.txt | 3 + .../pkg/client/fake/client.go | 1720 +++++++++++++++++ .../controller-runtime/pkg/client/fake/doc.go | 38 + .../pkg/client/fake/typeconverter.go | 60 + .../pkg/client/fake/versioned_tracker.go | 366 ++++ .../pkg/client/interceptor/intercept.go | 183 ++ .../pkg/internal/objectutil/objectutil.go | 42 + 9 files changed, 2974 insertions(+) create mode 100644 internal/aggregated/coder/controlplane_provider.go create mode 100644 internal/aggregated/coder/controlplane_provider_test.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/typeconverter.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/versioned_tracker.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go create mode 100644 vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go diff --git a/internal/aggregated/coder/controlplane_provider.go b/internal/aggregated/coder/controlplane_provider.go new file mode 100644 index 00000000..b7b4f09d --- /dev/null +++ b/internal/aggregated/coder/controlplane_provider.go @@ -0,0 +1,240 @@ +package coder + +import ( + "context" + "fmt" + "log" + "net/url" + "strings" + "time" + + "github.com/coder/coder/v2/codersdk" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ControlPlaneClientProvider resolves Coder SDK clients from eligible CoderControlPlane resources. +type ControlPlaneClientProvider struct { + cpReader client.Reader + secretReader client.Reader + requestTimeout time.Duration +} + +var _ ClientProvider = (*ControlPlaneClientProvider)(nil) + +// NewControlPlaneClientProvider constructs a dynamic ClientProvider backed by CoderControlPlane resources. +func NewControlPlaneClientProvider( + cpReader client.Reader, + secretReader client.Reader, + requestTimeout time.Duration, +) (*ControlPlaneClientProvider, error) { + if cpReader == nil { + return nil, fmt.Errorf("assertion failed: control plane reader must not be nil") + } + if secretReader == nil { + return nil, fmt.Errorf("assertion failed: secret reader must not be nil") + } + if requestTimeout < 0 { + return nil, fmt.Errorf("assertion failed: request timeout must not be negative") + } + + provider := &ControlPlaneClientProvider{ + cpReader: cpReader, + secretReader: secretReader, + requestTimeout: requestTimeout, + } + if provider.cpReader == nil { + return nil, fmt.Errorf("assertion failed: control plane reader is nil after successful construction") + } + if provider.secretReader == nil { + return nil, fmt.Errorf("assertion failed: secret reader is nil after successful construction") + } + + return provider, nil +} + +// ClientForNamespace resolves an SDK client from one eligible CoderControlPlane. +func (p *ControlPlaneClientProvider) ClientForNamespace(ctx context.Context, namespace string) (*codersdk.Client, error) { + if p == nil { + return nil, fmt.Errorf("assertion failed: control plane client provider must not be nil") + } + if ctx == nil { + return nil, fmt.Errorf("assertion failed: context must not be nil") + } + if p.cpReader == nil { + return nil, fmt.Errorf("assertion failed: control plane reader must not be nil") + } + if p.secretReader == nil { + return nil, fmt.Errorf("assertion failed: secret reader must not be nil") + } + + controlPlaneList := &coderv1alpha1.CoderControlPlaneList{} + listOptions := make([]client.ListOption, 0, 1) + if namespace != "" { + listOptions = append(listOptions, client.InNamespace(namespace)) + } + if err := p.cpReader.List(ctx, controlPlaneList, listOptions...); err != nil { + if namespace == "" { + return nil, fmt.Errorf("list CoderControlPlane resources across all namespaces: %w", err) + } + + return nil, fmt.Errorf("list CoderControlPlane resources in namespace %q: %w", namespace, err) + } + + eligible := make([]coderv1alpha1.CoderControlPlane, 0, 1) + for i := range controlPlaneList.Items { + controlPlane := controlPlaneList.Items[i] + if strings.Contains(controlPlane.Name, ".") { + log.Printf( + "warning: skipping CoderControlPlane %s/%s: names containing '.' are incompatible with aggregated naming", + controlPlane.Namespace, + controlPlane.Name, + ) + continue + } + if controlPlane.Spec.OperatorAccess.Disabled { + continue + } + if !controlPlane.Status.OperatorAccessReady { + continue + } + if controlPlane.Status.OperatorTokenSecretRef == nil { + continue + } + if strings.TrimSpace(controlPlane.Status.URL) == "" { + continue + } + + eligible = append(eligible, controlPlane) + } + + switch len(eligible) { + case 0: + return nil, apierrors.NewServiceUnavailable(noEligibleControlPlaneMessage(namespace)) + case 1: + // handled below + default: + return nil, apierrors.NewBadRequest(multipleEligibleControlPlaneMessage(namespace)) + } + + controlPlane := eligible[0] + if controlPlane.Status.OperatorTokenSecretRef == nil { + return nil, fmt.Errorf("assertion failed: eligible CoderControlPlane is missing status.operatorTokenSecretRef") + } + + secretName := strings.TrimSpace(controlPlane.Status.OperatorTokenSecretRef.Name) + if secretName == "" { + return nil, apierrors.NewServiceUnavailable( + fmt.Sprintf( + "eligible CoderControlPlane %s/%s is missing status.operatorTokenSecretRef.name", + controlPlane.Namespace, + controlPlane.Name, + ), + ) + } + + secretKey := strings.TrimSpace(controlPlane.Status.OperatorTokenSecretRef.Key) + if secretKey == "" { + secretKey = coderv1alpha1.DefaultTokenSecretKey + } + + tokenSecret := &corev1.Secret{} + if err := p.secretReader.Get( + ctx, + client.ObjectKey{Namespace: controlPlane.Namespace, Name: secretName}, + tokenSecret, + ); err != nil { + return nil, fmt.Errorf( + "read operator token secret %s/%s for CoderControlPlane %s/%s: %w", + controlPlane.Namespace, + secretName, + controlPlane.Namespace, + controlPlane.Name, + err, + ) + } + + tokenBytes, ok := tokenSecret.Data[secretKey] + if !ok { + return nil, apierrors.NewServiceUnavailable( + fmt.Sprintf( + "operator token secret %s/%s for CoderControlPlane %s/%s does not contain key %q", + controlPlane.Namespace, + secretName, + controlPlane.Namespace, + controlPlane.Name, + secretKey, + ), + ) + } + + sessionToken := string(tokenBytes) + if sessionToken == "" { + return nil, apierrors.NewServiceUnavailable( + fmt.Sprintf( + "operator token secret %s/%s for CoderControlPlane %s/%s contains an empty value for key %q", + controlPlane.Namespace, + secretName, + controlPlane.Namespace, + controlPlane.Name, + secretKey, + ), + ) + } + + coderURL := strings.TrimSpace(controlPlane.Status.URL) + parsedCoderURL, err := url.Parse(coderURL) + if err != nil { + return nil, fmt.Errorf( + "parse CoderControlPlane URL %q for %s/%s: %w", + coderURL, + controlPlane.Namespace, + controlPlane.Name, + err, + ) + } + if parsedCoderURL == nil { + return nil, fmt.Errorf("assertion failed: parsed CoderControlPlane URL must not be nil") + } + + sdkClient, err := NewSDKClient(Config{ + CoderURL: parsedCoderURL, + SessionToken: sessionToken, + RequestTimeout: p.requestTimeout, + }) + if err != nil { + return nil, fmt.Errorf( + "construct Coder SDK client for CoderControlPlane %s/%s: %w", + controlPlane.Namespace, + controlPlane.Name, + err, + ) + } + if sdkClient == nil { + return nil, fmt.Errorf("assertion failed: Coder SDK client is nil after successful construction") + } + + return sdkClient, nil +} + +func noEligibleControlPlaneMessage(namespace string) string { + if namespace == "" { + return "no eligible CoderControlPlane instances found across all namespaces" + } + + return fmt.Sprintf("no eligible CoderControlPlane instances found in namespace %q", namespace) +} + +func multipleEligibleControlPlaneMessage(namespace string) string { + if namespace == "" { + return "multiple eligible CoderControlPlane instances across namespaces; multi-instance support is planned" + } + + return fmt.Sprintf( + "multiple eligible CoderControlPlane instances in namespace %q; multi-instance support is planned", + namespace, + ) +} diff --git a/internal/aggregated/coder/controlplane_provider_test.go b/internal/aggregated/coder/controlplane_provider_test.go new file mode 100644 index 00000000..2ad6e726 --- /dev/null +++ b/internal/aggregated/coder/controlplane_provider_test.go @@ -0,0 +1,322 @@ +package coder + +import ( + "context" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + coderv1alpha1 "github.com/coder/coder-k8s/api/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestControlPlaneClientProviderClientForNamespaceHappyPath(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder") + controlPlane.Status.OperatorTokenSecretRef.Key = "api-token" + controlPlane.Status.URL = "https://coder.team-a.example.com" + + provider, secretReader := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + []corev1.Secret{ + secretWithStringData("team-a", "operator-token", map[string]string{ + "api-token": "session-token", + }), + }, + ) + + resolvedClient, err := provider.ClientForNamespace(context.Background(), "team-a") + if err != nil { + t.Fatalf("resolve client: %v", err) + } + if resolvedClient == nil { + t.Fatal("expected non-nil client") + } + if got, want := resolvedClient.SessionToken(), "session-token"; got != want { + t.Fatalf("expected session token %q, got %q", want, got) + } + if got, want := resolvedClient.URL.String(), "https://coder.team-a.example.com"; got != want { + t.Fatalf("expected URL %q, got %q", want, got) + } + if got, want := secretReader.getCalls, 1; got != want { + t.Fatalf("expected %d secret read, got %d", want, got) + } +} + +func TestControlPlaneClientProviderClientForNamespaceSkipsDisabledControlPlaneWithoutSecretRead(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder") + controlPlane.Spec.OperatorAccess.Disabled = true + + provider, secretReader := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + nil, + ) + + _, err := provider.ClientForNamespace(context.Background(), "team-a") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } + if !strings.Contains(err.Error(), "no eligible CoderControlPlane") { + t.Fatalf("expected no-eligible message, got %v", err) + } + if secretReader.getCalls != 0 { + t.Fatalf("expected disabled control plane to skip secret reads, got %d", secretReader.getCalls) + } +} + +func TestControlPlaneClientProviderClientForNamespaceSkipsControlPlaneWithOperatorAccessNotReady(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder") + controlPlane.Status.OperatorAccessReady = false + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + nil, + ) + + _, err := provider.ClientForNamespace(context.Background(), "team-a") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } +} + +func TestControlPlaneClientProviderClientForNamespaceSkipsControlPlaneWithNilSecretRef(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder") + controlPlane.Status.OperatorTokenSecretRef = nil + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + nil, + ) + + _, err := provider.ClientForNamespace(context.Background(), "team-a") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } +} + +func TestControlPlaneClientProviderClientForNamespaceSkipsControlPlaneWithEmptyURL(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder") + controlPlane.Status.URL = " " + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + nil, + ) + + _, err := provider.ClientForNamespace(context.Background(), "team-a") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } +} + +func TestControlPlaneClientProviderClientForNamespaceSkipsControlPlaneWithDotInName(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder.control-plane") + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + nil, + ) + + _, err := provider.ClientForNamespace(context.Background(), "team-a") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } +} + +func TestControlPlaneClientProviderClientForNamespaceReturnsServiceUnavailableWhenNoEligibleControlPlane(t *testing.T) { + t.Parallel() + + provider, _ := newControlPlaneProviderForTest(t, nil, nil) + + _, err := provider.ClientForNamespace(context.Background(), "") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } + if !strings.Contains(err.Error(), "no eligible CoderControlPlane") { + t.Fatalf("expected no-eligible message, got %v", err) + } +} + +func TestControlPlaneClientProviderClientForNamespaceReturnsBadRequestForMultipleEligibleControlPlanes(t *testing.T) { + t.Parallel() + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{ + eligibleControlPlane("team-a", "coder-a"), + eligibleControlPlane("team-a", "coder-b"), + }, + nil, + ) + + _, err := provider.ClientForNamespace(context.Background(), "team-a") + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsBadRequest(err) { + t.Fatalf("expected BadRequest, got %v", err) + } + if !strings.Contains(err.Error(), "multi-instance support is planned") { + t.Fatalf("expected multi-instance message, got %v", err) + } +} + +func TestControlPlaneClientProviderClientForNamespaceDefaultsSecretKeyToToken(t *testing.T) { + t.Parallel() + + controlPlane := eligibleControlPlane("team-a", "coder") + controlPlane.Status.OperatorTokenSecretRef.Key = "" + controlPlane.Status.URL = "https://coder.team-a.example.com" + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{controlPlane}, + []corev1.Secret{ + secretWithStringData("team-a", "operator-token", map[string]string{ + coderv1alpha1.DefaultTokenSecretKey: "default-token", + }), + }, + ) + + resolvedClient, err := provider.ClientForNamespace(context.Background(), "team-a") + if err != nil { + t.Fatalf("resolve client: %v", err) + } + if resolvedClient == nil { + t.Fatal("expected non-nil client") + } + if got, want := resolvedClient.SessionToken(), "default-token"; got != want { + t.Fatalf("expected session token %q, got %q", want, got) + } +} + +func newControlPlaneProviderForTest( + t *testing.T, + controlPlanes []coderv1alpha1.CoderControlPlane, + secrets []corev1.Secret, +) (*ControlPlaneClientProvider, *countingReader) { + t.Helper() + + scheme := newControlPlaneProviderTestScheme(t) + + cpReader := fake.NewClientBuilder(). + WithScheme(scheme). + WithLists(&coderv1alpha1.CoderControlPlaneList{Items: controlPlanes}). + Build() + + baseSecretReader := fake.NewClientBuilder(). + WithScheme(scheme). + WithLists(&corev1.SecretList{Items: secrets}). + Build() + secretReader := &countingReader{Reader: baseSecretReader} + + provider, err := NewControlPlaneClientProvider(cpReader, secretReader, 10*time.Second) + if err != nil { + t.Fatalf("new control plane client provider: %v", err) + } + if provider == nil { + t.Fatal("expected non-nil provider") + } + + return provider, secretReader +} + +func newControlPlaneProviderTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(coderv1alpha1.AddToScheme(scheme)) + + return scheme +} + +func eligibleControlPlane(namespace, name string) coderv1alpha1.CoderControlPlane { + return coderv1alpha1.CoderControlPlane{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Status: coderv1alpha1.CoderControlPlaneStatus{ + URL: "https://coder.example.com", + OperatorAccessReady: true, + OperatorTokenSecretRef: &coderv1alpha1.SecretKeySelector{ + Name: "operator-token", + Key: "token", + }, + }, + } +} + +func secretWithStringData(namespace, name string, data map[string]string) corev1.Secret { + secretData := make(map[string][]byte, len(data)) + for key, value := range data { + secretData[key] = []byte(value) + } + + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + }, + Data: secretData, + } +} + +type countingReader struct { + client.Reader + getCalls int +} + +func (r *countingReader) Get( + ctx context.Context, + key client.ObjectKey, + obj client.Object, + opts ...client.GetOption, +) error { + r.getCalls++ + return r.Reader.Get(ctx, key, obj, opts...) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 00959aa9..ee7c0d26 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -3144,6 +3144,8 @@ sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics sigs.k8s.io/controller-runtime/pkg/client sigs.k8s.io/controller-runtime/pkg/client/apiutil sigs.k8s.io/controller-runtime/pkg/client/config +sigs.k8s.io/controller-runtime/pkg/client/fake +sigs.k8s.io/controller-runtime/pkg/client/interceptor sigs.k8s.io/controller-runtime/pkg/cluster sigs.k8s.io/controller-runtime/pkg/config sigs.k8s.io/controller-runtime/pkg/controller @@ -3161,6 +3163,7 @@ sigs.k8s.io/controller-runtime/pkg/internal/flock sigs.k8s.io/controller-runtime/pkg/internal/httpserver sigs.k8s.io/controller-runtime/pkg/internal/log sigs.k8s.io/controller-runtime/pkg/internal/metrics +sigs.k8s.io/controller-runtime/pkg/internal/objectutil sigs.k8s.io/controller-runtime/pkg/internal/recorder sigs.k8s.io/controller-runtime/pkg/internal/source sigs.k8s.io/controller-runtime/pkg/internal/syncs diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go new file mode 100644 index 00000000..2a07bd40 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/client.go @@ -0,0 +1,1720 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fake + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "sync" + "time" + + /* + Stick with gopkg.in/evanphx/json-patch.v4 here to match + upstream Kubernetes code and avoid breaking changes introduced in v5. + - Kubernetes itself remains on json-patch v4 to avoid compatibility issues + tied to v5’s stricter RFC6902 compliance. + - The fake client code is adapted from client-go’s testing fixture, which also + relies on json-patch v4. + See: + https://github.com/kubernetes/kubernetes/pull/91622 (discussion of why K8s + stays on v4) + https://github.com/kubernetes/kubernetes/pull/120326 (v5.6.0+incompatible + missing a critical fix) + */ + + jsonpatch "gopkg.in/evanphx/json-patch.v4" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + policyv1beta1 "k8s.io/api/policy/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/apis/meta/v1/validation" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/managedfields" + utilrand "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/watch" + clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/testing" + "k8s.io/utils/ptr" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/internal/field/selector" + "sigs.k8s.io/controller-runtime/pkg/internal/objectutil" +) + +type fakeClient struct { + // trackerWriteLock must be acquired before writing to + // the tracker or performing reads that affect a following + // write. + trackerWriteLock sync.Mutex + tracker versionedTracker + + schemeLock sync.RWMutex + scheme *runtime.Scheme + + restMapper meta.RESTMapper + withStatusSubresource sets.Set[schema.GroupVersionKind] + + // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. + // The inner map maps from index name to IndexerFunc. + indexes map[schema.GroupVersionKind]map[string]client.IndexerFunc + // indexesLock must be held when accessing indexes. + indexesLock sync.RWMutex + + returnManagedFields bool +} + +var _ client.WithWatch = &fakeClient{} + +const ( + maxNameLength = 63 + randomLength = 5 + maxGeneratedNameLength = maxNameLength - randomLength + + subResourceScale = "scale" +) + +// NewFakeClient creates a new fake client for testing. +// You can choose to initialize it with a slice of runtime.Object. +func NewFakeClient(initObjs ...runtime.Object) client.WithWatch { + return NewClientBuilder().WithRuntimeObjects(initObjs...).Build() +} + +// NewClientBuilder returns a new builder to create a fake client. +func NewClientBuilder() *ClientBuilder { + return &ClientBuilder{} +} + +// ClientBuilder builds a fake client. +type ClientBuilder struct { + scheme *runtime.Scheme + restMapper meta.RESTMapper + initObject []client.Object + initLists []client.ObjectList + initRuntimeObjects []runtime.Object + withStatusSubresource []client.Object + objectTracker testing.ObjectTracker + interceptorFuncs *interceptor.Funcs + typeConverters []managedfields.TypeConverter + returnManagedFields bool + isBuilt bool + + // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. + // The inner map maps from index name to IndexerFunc. + indexes map[schema.GroupVersionKind]map[string]client.IndexerFunc +} + +// WithScheme sets this builder's internal scheme. +// If not set, defaults to client-go's global scheme.Scheme. +func (f *ClientBuilder) WithScheme(scheme *runtime.Scheme) *ClientBuilder { + f.scheme = scheme + return f +} + +// WithRESTMapper sets this builder's restMapper. +// The restMapper is directly set as mapper in the Client. This can be used for example +// with a meta.DefaultRESTMapper to provide a static rest mapping. +// If not set, defaults to an empty meta.DefaultRESTMapper. +func (f *ClientBuilder) WithRESTMapper(restMapper meta.RESTMapper) *ClientBuilder { + f.restMapper = restMapper + return f +} + +// WithObjects can be optionally used to initialize this fake client with client.Object(s). +func (f *ClientBuilder) WithObjects(initObjs ...client.Object) *ClientBuilder { + f.initObject = append(f.initObject, initObjs...) + return f +} + +// WithLists can be optionally used to initialize this fake client with client.ObjectList(s). +func (f *ClientBuilder) WithLists(initLists ...client.ObjectList) *ClientBuilder { + f.initLists = append(f.initLists, initLists...) + return f +} + +// WithRuntimeObjects can be optionally used to initialize this fake client with runtime.Object(s). +func (f *ClientBuilder) WithRuntimeObjects(initRuntimeObjs ...runtime.Object) *ClientBuilder { + f.initRuntimeObjects = append(f.initRuntimeObjects, initRuntimeObjs...) + return f +} + +// WithObjectTracker can be optionally used to initialize this fake client with testing.ObjectTracker. +// Setting this is incompatible with setting WithTypeConverters, as they are a setting on the +// tracker. +func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder { + f.objectTracker = ot + return f +} + +// WithIndex can be optionally used to register an index with name `field` and indexer `extractValue` +// for API objects of the same GroupVersionKind (GVK) as `obj` in the fake client. +// It can be invoked multiple times, both with objects of the same GVK or different ones. +// Invoking WithIndex twice with the same `field` and GVK (via `obj`) arguments will panic. +// WithIndex retrieves the GVK of `obj` using the scheme registered via WithScheme if +// WithScheme was previously invoked, the default scheme otherwise. +func (f *ClientBuilder) WithIndex(obj runtime.Object, field string, extractValue client.IndexerFunc) *ClientBuilder { + objScheme := f.scheme + if objScheme == nil { + objScheme = scheme.Scheme + } + + gvk, err := apiutil.GVKForObject(obj, objScheme) + if err != nil { + panic(err) + } + + // If this is the first index being registered, we initialize the map storing all the indexes. + if f.indexes == nil { + f.indexes = make(map[schema.GroupVersionKind]map[string]client.IndexerFunc) + } + + // If this is the first index being registered for the GroupVersionKind of `obj`, we initialize + // the map storing the indexes for that GroupVersionKind. + if f.indexes[gvk] == nil { + f.indexes[gvk] = make(map[string]client.IndexerFunc) + } + + if _, fieldAlreadyIndexed := f.indexes[gvk][field]; fieldAlreadyIndexed { + panic(fmt.Errorf("indexer conflict: field %s for GroupVersionKind %v is already indexed", + field, gvk)) + } + + f.indexes[gvk][field] = extractValue + + return f +} + +// WithStatusSubresource configures the passed object with a status subresource, which means +// calls to Update and Patch will not alter its status. +func (f *ClientBuilder) WithStatusSubresource(o ...client.Object) *ClientBuilder { + f.withStatusSubresource = append(f.withStatusSubresource, o...) + return f +} + +// WithInterceptorFuncs configures the client methods to be intercepted using the provided interceptor.Funcs. +func (f *ClientBuilder) WithInterceptorFuncs(interceptorFuncs interceptor.Funcs) *ClientBuilder { + f.interceptorFuncs = &interceptorFuncs + return f +} + +// WithTypeConverters sets the type converters for the fake client. The list is ordered and the first +// non-erroring converter is used. A type converter must be provided for all types the client is used +// for, otherwise it will error. +// +// This setting is incompatible with WithObjectTracker, as the type converters are a setting on the tracker. +// +// If unset, this defaults to: +// * clientgoapplyconfigurations.NewTypeConverter(scheme.Scheme), +// * managedfields.NewDeducedTypeConverter(), +// +// Be aware that the behavior of the `NewDeducedTypeConverter` might not match the behavior of the +// Kubernetes APIServer, it is recommended to provide a type converter for your types. TypeConverters +// are generated along with ApplyConfigurations. +func (f *ClientBuilder) WithTypeConverters(typeConverters ...managedfields.TypeConverter) *ClientBuilder { + f.typeConverters = append(f.typeConverters, typeConverters...) + return f +} + +// WithReturnManagedFields configures the fake client to return managedFields +// on objects. +func (f *ClientBuilder) WithReturnManagedFields() *ClientBuilder { + f.returnManagedFields = true + return f +} + +// Build builds and returns a new fake client. +func (f *ClientBuilder) Build() client.WithWatch { + if f.isBuilt { + panic("Build() must not be called multiple times when creating a ClientBuilder") + } + if f.scheme == nil { + f.scheme = scheme.Scheme + } + if f.restMapper == nil { + f.restMapper = meta.NewDefaultRESTMapper([]schema.GroupVersion{}) + } + + withStatusSubResource := sets.New(inTreeResourcesWithStatus()...) + for _, o := range f.withStatusSubresource { + gvk, err := apiutil.GVKForObject(o, f.scheme) + if err != nil { + panic(fmt.Errorf("failed to get gvk for object %T: %w", withStatusSubResource, err)) + } + withStatusSubResource.Insert(gvk) + } + + if f.objectTracker != nil && len(f.typeConverters) > 0 { + panic(errors.New("WithObjectTracker and WithTypeConverters are incompatible")) + } + + var usesFieldManagedObjectTracker bool + if f.objectTracker == nil { + if len(f.typeConverters) == 0 { + // Use corresponding scheme to ensure the converter error + // for types it can't handle. + clientGoScheme := runtime.NewScheme() + if err := scheme.AddToScheme(clientGoScheme); err != nil { + panic(fmt.Sprintf("failed to construct client-go scheme: %v", err)) + } + f.typeConverters = []managedfields.TypeConverter{ + clientgoapplyconfigurations.NewTypeConverter(clientGoScheme), + managedfields.NewDeducedTypeConverter(), + } + } + f.objectTracker = testing.NewFieldManagedObjectTracker( + f.scheme, + serializer.NewCodecFactory(f.scheme).UniversalDecoder(), + multiTypeConverter{upstream: f.typeConverters}, + ) + usesFieldManagedObjectTracker = true + } + tracker := versionedTracker{ + upstream: f.objectTracker, + scheme: f.scheme, + withStatusSubresource: withStatusSubResource, + usesFieldManagedObjectTracker: usesFieldManagedObjectTracker, + } + + for _, obj := range f.initObject { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add object %v to fake client: %w", obj, err)) + } + } + for _, obj := range f.initLists { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add list %v to fake client: %w", obj, err)) + } + } + for _, obj := range f.initRuntimeObjects { + if err := tracker.Add(obj); err != nil { + panic(fmt.Errorf("failed to add runtime object %v to fake client: %w", obj, err)) + } + } + + var result client.WithWatch = &fakeClient{ + tracker: tracker, + scheme: f.scheme, + restMapper: f.restMapper, + indexes: f.indexes, + withStatusSubresource: withStatusSubResource, + returnManagedFields: f.returnManagedFields, + } + + if f.interceptorFuncs != nil { + result = interceptor.NewClient(result, *f.interceptorFuncs) + } + + f.isBuilt = true + return result +} + +const trackerAddResourceVersion = "999" + +// convertFromUnstructuredIfNecessary will convert runtime.Unstructured for a GVK that is recognized +// by the schema into the whatever the schema produces with New() for said GVK. +// This is required because the tracker unconditionally saves on manipulations, but its List() implementation +// tries to assign whatever it finds into a ListType it gets from schema.New() - Thus we have to ensure +// we save as the very same type, otherwise subsequent List requests will fail. +func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (runtime.Object, error) { + u, isUnstructured := o.(runtime.Unstructured) + if !isUnstructured { + return o, nil + } + gvk := o.GetObjectKind().GroupVersionKind() + if !s.Recognizes(gvk) { + return o, nil + } + + typed, err := s.New(gvk) + if err != nil { + return nil, fmt.Errorf("scheme recognizes %s but failed to produce an object for it: %w", gvk, err) + } + if _, isTypedUnstructured := typed.(runtime.Unstructured); isTypedUnstructured { + return o, nil + } + + unstructuredSerialized, err := json.Marshal(u) + if err != nil { + return nil, fmt.Errorf("failed to serialize %T: %w", unstructuredSerialized, err) + } + if err := json.Unmarshal(unstructuredSerialized, typed); err != nil { + return nil, fmt.Errorf("failed to unmarshal the content of %T into %T: %w", u, typed, err) + } + + return typed, nil +} + +func (c *fakeClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + o, err := c.tracker.Get(gvr, key.Namespace, key.Name) + if err != nil { + return err + } + + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + + // If the final object is unstructuctured, the json + // representation must contain GVK or the apimachinery + // json serializer will error out. + ta.SetAPIVersion(gvk.GroupVersion().String()) + ta.SetKind(gvk.Kind) + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + if err := json.Unmarshal(j, obj); err != nil { + return err + } + + if !c.returnManagedFields { + obj.SetManagedFields(nil) + } + + return ensureTypeMeta(obj, gvk) +} + +func (c *fakeClient) Watch(ctx context.Context, list client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(list); err != nil { + return nil, err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + + gvk, err := apiutil.GVKForObject(list, c.scheme) + if err != nil { + return nil, err + } + + gvk.Kind = strings.TrimSuffix(gvk.Kind, "List") + + listOpts := client.ListOptions{} + listOpts.ApplyOptions(opts) + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return c.tracker.Watch(gvr, listOpts.Namespace) +} + +func (c *fakeClient) List(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + originalGVK := gvk + gvk.Kind = strings.TrimSuffix(gvk.Kind, "List") + listGVK := gvk + listGVK.Kind += "List" + + if _, isUnstructuredList := obj.(runtime.Unstructured); isUnstructuredList && !c.scheme.Recognizes(listGVK) { + // We need to register the ListKind with UnstructuredList: + // https://github.com/kubernetes/kubernetes/blob/7b2776b89fb1be28d4e9203bdeec079be903c103/staging/src/k8s.io/client-go/dynamic/fake/simple.go#L44-L51 + c.schemeLock.RUnlock() + c.schemeLock.Lock() + c.scheme.AddKnownTypeWithName(gvk.GroupVersion().WithKind(gvk.Kind+"List"), &unstructured.UnstructuredList{}) + c.schemeLock.Unlock() + c.schemeLock.RLock() + } + + listOpts := client.ListOptions{} + listOpts.ApplyOptions(opts) + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + o, err := c.tracker.List(gvr, gvk, listOpts.Namespace) + if err != nil { + return err + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + if err := ensureTypeMeta(obj, originalGVK); err != nil { + return err + } + objCopy := obj.DeepCopyObject().(client.ObjectList) + if err := json.Unmarshal(j, objCopy); err != nil { + return err + } + + objs, err := meta.ExtractList(objCopy) + if err != nil { + return err + } + + for _, o := range objs { + if err := ensureTypeMeta(o, gvk); err != nil { + return err + } + + if !c.returnManagedFields { + o.(metav1.Object).SetManagedFields(nil) + } + } + + if listOpts.LabelSelector == nil && listOpts.FieldSelector == nil { + return meta.SetList(obj, objs) + } + + // If we're here, either a label or field selector are specified (or both), so before we return + // the list we must filter it. If both selectors are set, they are ANDed. + filteredList, err := c.filterList(objs, gvk, listOpts.LabelSelector, listOpts.FieldSelector) + if err != nil { + return err + } + + return meta.SetList(obj, filteredList) +} + +func (c *fakeClient) filterList(list []runtime.Object, gvk schema.GroupVersionKind, ls labels.Selector, fs fields.Selector) ([]runtime.Object, error) { + // Filter the objects with the label selector + filteredList := list + if ls != nil { + objsFilteredByLabel, err := objectutil.FilterWithLabels(list, ls) + if err != nil { + return nil, err + } + filteredList = objsFilteredByLabel + } + + // Filter the result of the previous pass with the field selector + if fs != nil { + objsFilteredByField, err := c.filterWithFields(filteredList, gvk, fs) + if err != nil { + return nil, err + } + filteredList = objsFilteredByField + } + + return filteredList, nil +} + +func (c *fakeClient) filterWithFields(list []runtime.Object, gvk schema.GroupVersionKind, fs fields.Selector) ([]runtime.Object, error) { + requiresExact := selector.RequiresExactMatch(fs) + if !requiresExact { + return nil, fmt.Errorf(`field selector %s is not in one of the two supported forms "key==val" or "key=val"`, fs) + } + + c.indexesLock.RLock() + defer c.indexesLock.RUnlock() + // Field selection is mimicked via indexes, so there's no sane answer this function can give + // if there are no indexes registered for the GroupVersionKind of the objects in the list. + indexes := c.indexes[gvk] + for _, req := range fs.Requirements() { + if len(indexes) == 0 || indexes[req.Field] == nil { + return nil, fmt.Errorf("List on GroupVersionKind %v specifies selector on field %s, but no "+ + "index with name %s has been registered for GroupVersionKind %v", gvk, req.Field, req.Field, gvk) + } + } + + filteredList := make([]runtime.Object, 0, len(list)) + for _, obj := range list { + matches := true + for _, req := range fs.Requirements() { + indexExtractor := indexes[req.Field] + if !c.objMatchesFieldSelector(obj, indexExtractor, req.Value) { + matches = false + break + } + } + if matches { + filteredList = append(filteredList, obj) + } + } + return filteredList, nil +} + +func (c *fakeClient) objMatchesFieldSelector(o runtime.Object, extractIndex client.IndexerFunc, val string) bool { + obj, isClientObject := o.(client.Object) + if !isClientObject { + panic(fmt.Errorf("expected object %v to be of type client.Object, but it's not", o)) + } + + return slices.Contains(extractIndex(obj), val) +} + +func (c *fakeClient) Scheme() *runtime.Scheme { + return c.scheme +} + +func (c *fakeClient) RESTMapper() meta.RESTMapper { + return c.restMapper +} + +// GroupVersionKindFor returns the GroupVersionKind for the given object. +func (c *fakeClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return apiutil.GVKForObject(obj, c.scheme) +} + +// IsObjectNamespaced returns true if the GroupVersionKind of the object is namespaced. +func (c *fakeClient) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return apiutil.IsObjectNamespaced(obj, c.scheme, c.restMapper) +} + +func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + + createOptions := &client.CreateOptions{} + createOptions.ApplyOptions(opts) + + if slices.Contains(createOptions.DryRun, metav1.DryRunAll) { + return nil + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + if accessor.GetName() == "" && accessor.GetGenerateName() != "" { + base := accessor.GetGenerateName() + if len(base) > maxGeneratedNameLength { + base = base[:maxGeneratedNameLength] + } + accessor.SetName(fmt.Sprintf("%s%s", base, utilrand.String(randomLength))) + } + // Ignore attempts to set deletion timestamp + if !accessor.GetDeletionTimestamp().IsZero() { + accessor.SetDeletionTimestamp(nil) + } + + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + + if err := c.tracker.Create(gvr, obj, accessor.GetNamespace(), *createOptions.AsCreateOptions()); err != nil { + // The managed fields tracker sets gvk even on errors + _ = ensureTypeMeta(obj, gvk) + return err + } + + if !c.returnManagedFields { + obj.SetManagedFields(nil) + } + + return ensureTypeMeta(obj, gvk) +} + +func (c *fakeClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + delOptions := client.DeleteOptions{} + delOptions.ApplyOptions(opts) + + if slices.Contains(delOptions.DryRun, metav1.DryRunAll) { + return nil + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + // Check the ResourceVersion if that Precondition was specified. + if delOptions.Preconditions != nil && delOptions.Preconditions.ResourceVersion != nil { + name := accessor.GetName() + dbObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), name) + if err != nil { + return err + } + oldAccessor, err := meta.Accessor(dbObj) + if err != nil { + return err + } + actualRV := oldAccessor.GetResourceVersion() + expectRV := *delOptions.Preconditions.ResourceVersion + if actualRV != expectRV { + msg := fmt.Sprintf( + "the ResourceVersion in the precondition (%s) does not match the ResourceVersion in record (%s). "+ + "The object might have been modified", + expectRV, actualRV) + return apierrors.NewConflict(gvr.GroupResource(), name, errors.New(msg)) + } + } + + return c.deleteObjectLocked(gvr, accessor) +} + +func (c *fakeClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + dcOptions := client.DeleteAllOfOptions{} + dcOptions.ApplyOptions(opts) + + if slices.Contains(dcOptions.DryRun, metav1.DryRunAll) { + return nil + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + o, err := c.tracker.List(gvr, gvk, dcOptions.Namespace) + if err != nil { + return err + } + + objs, err := meta.ExtractList(o) + if err != nil { + return err + } + filteredObjs, err := objectutil.FilterWithLabels(objs, dcOptions.LabelSelector) + if err != nil { + return err + } + for _, o := range filteredObjs { + accessor, err := meta.Accessor(o) + if err != nil { + return err + } + err = c.deleteObjectLocked(gvr, accessor) + if err != nil { + return err + } + } + return nil +} + +func (c *fakeClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return c.update(obj, false, opts...) +} + +func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.UpdateOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + + updateOptions := &client.UpdateOptions{} + updateOptions.ApplyOptions(opts) + + if slices.Contains(updateOptions.DryRun, metav1.DryRunAll) { + return nil + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + + // Retain managed fields + // We can ignore all errors here since update will fail if we encounter an error. + obj.SetManagedFields(nil) + current, _ := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if currentMetaObj, ok := current.(metav1.Object); ok { + obj.SetManagedFields(currentMetaObj.GetManagedFields()) + } + + if err := c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus, false, *updateOptions.AsUpdateOptions()); err != nil { + return err + } + + if !c.returnManagedFields { + obj.SetManagedFields(nil) + } + + return ensureTypeMeta(obj, gvk) +} + +func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + return c.patch(obj, patch, opts...) +} + +func (c *fakeClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error { + applyOpts := &client.ApplyOptions{} + applyOpts.ApplyOptions(opts) + + data, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal apply configuration: %w", err) + } + + u := &unstructured.Unstructured{} + if err := json.Unmarshal(data, u); err != nil { + return fmt.Errorf("failed to unmarshal apply configuration: %w", err) + } + + applyPatch := &fakeApplyPatch{} + + patchOpts := &client.PatchOptions{} + patchOpts.Raw = applyOpts.AsPatchOptions() + + if err := c.patch(u, applyPatch, patchOpts); err != nil { + return err + } + + acJSON, err := json.Marshal(u) + if err != nil { + return fmt.Errorf("failed to marshal patched object: %w", err) + } + + // We have to zero the object in case it contained a status and there is a + // status subresource. If its the private `unstructuredApplyConfiguration` + // we can not zero all of it, as that will cause the embedded Unstructured + // to be nil which then causes a NPD in the json.Unmarshal below. + switch reflect.TypeOf(obj).String() { + case "*client.unstructuredApplyConfiguration": + zero(reflect.ValueOf(obj).Elem().FieldByName("Unstructured").Interface()) + default: + zero(obj) + } + if err := json.Unmarshal(acJSON, obj); err != nil { + return fmt.Errorf("failed to unmarshal patched object: %w", err) + } + + return nil +} + +type fakeApplyPatch struct{} + +func (p *fakeApplyPatch) Type() types.PatchType { + return types.ApplyPatchType +} + +func (p *fakeApplyPatch) Data(obj client.Object) ([]byte, error) { + return json.Marshal(obj) +} + +func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if err := c.addToSchemeIfUnknownAndUnstructuredOrPartial(obj); err != nil { + return err + } + + patchOptions := &client.PatchOptions{} + patchOptions.ApplyOptions(opts) + + if errs := validation.ValidatePatchOptions(patchOptions.AsPatchOptions(), patch.Type()); len(errs) > 0 { + return apierrors.NewInvalid(schema.GroupKind{Group: "meta.k8s.io", Kind: "PatchOptions"}, "", errs) + } + + c.schemeLock.RLock() + defer c.schemeLock.RUnlock() + + if slices.Contains(patchOptions.DryRun, metav1.DryRunAll) { + return nil + } + + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + var isApplyCreate bool + c.trackerWriteLock.Lock() + defer c.trackerWriteLock.Unlock() + oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if err != nil { + if !apierrors.IsNotFound(err) || patch.Type() != types.ApplyPatchType { + return err + } + oldObj = &unstructured.Unstructured{} + isApplyCreate = true + } + oldAccessor, err := meta.Accessor(oldObj) + if err != nil { + return err + } + + if patch.Type() == types.ApplyPatchType { + if isApplyCreate { + // Overwrite it unconditionally, this matches the apiserver behavior + // which allows to set it on create, but will then ignore it. + obj.SetResourceVersion("1") + } else { + // SSA deletionTimestamp updates are silently ignored + obj.SetDeletionTimestamp(oldAccessor.GetDeletionTimestamp()) + } + } + + data, err := patch.Data(obj) + if err != nil { + return err + } + + action := testing.NewPatchActionWithOptions( + gvr, + accessor.GetNamespace(), + accessor.GetName(), + patch.Type(), + data, + *patchOptions.AsPatchOptions(), + ) + + // Apply is implemented in the tracker and calling it has side-effects + // such as bumping RV and updating managedFields timestamps, hence we + // can not dry-run it. Luckily, the only validation we use it for + // doesn't apply to SSA - Creating objects with non-nil deletionTimestamp + // through SSA is possible and updating the deletionTimestamp is valid, + // but has no effect. + if patch.Type() != types.ApplyPatchType { + // Apply patch without updating object. + // To remain in accordance with the behavior of k8s api behavior, + // a patch must not allow for changes to the deletionTimestamp of an object. + // The reaction() function applies the patch to the object and calls Update(), + // whereas dryPatch() replicates this behavior but skips the call to Update(). + // This ensures that the patch may be rejected if a deletionTimestamp is modified, prior + // to updating the object. + o, err := dryPatch(action, c.tracker) + if err != nil { + return err + } + newObj, err := meta.Accessor(o) + if err != nil { + return err + } + + // Validate that deletionTimestamp has not been changed + if !deletionTimestampEqual(newObj, oldAccessor) { + return fmt.Errorf("rejected patch, metadata.deletionTimestamp immutable") + } + } + + reaction := testing.ObjectReaction(c.tracker) + handled, o, err := reaction(action) + if err != nil { + // The reaction calls tracker.Get after tracker.Apply to return the object, + // but we may have deleted it in tracker.Apply if there was no finalizer + // left. + if apierrors.IsNotFound(err) && + patch.Type() == types.ApplyPatchType && + oldAccessor.GetDeletionTimestamp() != nil && + len(obj.GetFinalizers()) == 0 { + return nil + } + return err + } + if !handled { + panic("tracker could not handle patch method") + } + + ta, err := meta.TypeAccessor(o) + if err != nil { + return err + } + + ta.SetAPIVersion(gvk.GroupVersion().String()) + ta.SetKind(gvk.Kind) + + j, err := json.Marshal(o) + if err != nil { + return err + } + zero(obj) + if err := json.Unmarshal(j, obj); err != nil { + return err + } + + if !c.returnManagedFields { + obj.SetManagedFields(nil) + } + + return ensureTypeMeta(obj, gvk) +} + +// Applying a patch results in a deletionTimestamp that is truncated to the nearest second. +// Check that the diff between a new and old deletion timestamp is within a reasonable threshold +// to be considered unchanged. +func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool { + newTime := newObj.GetDeletionTimestamp() + oldTime := obj.GetDeletionTimestamp() + + if newTime == nil || oldTime == nil { + return newTime == oldTime + } + return newTime.Time.Sub(oldTime.Time).Abs() < time.Second +} + +// The behavior of applying the patch is pulled out into dryPatch(), +// which applies the patch and returns an object, but does not Update() the object. +// This function returns a patched runtime object that may then be validated before a call to Update() is executed. +// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data +// and easier than refactoring the k8s client-go method upstream. +// Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194 +func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) { + ns := action.GetNamespace() + gvr := action.GetResource() + + obj, err := tracker.Get(gvr, ns, action.GetName()) + if err != nil { + if apierrors.IsNotFound(err) && action.GetPatchType() == types.ApplyPatchType { + return &unstructured.Unstructured{}, nil + } + return nil, err + } + + old, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + // reset the object in preparation to unmarshal, since unmarshal does not guarantee that fields + // in obj that are removed by patch are cleared + value := reflect.ValueOf(obj) + value.Elem().Set(reflect.New(value.Type().Elem()).Elem()) + + switch action.GetPatchType() { + case types.JSONPatchType: + patch, err := jsonpatch.DecodePatch(action.GetPatch()) + if err != nil { + return nil, err + } + modified, err := patch.Apply(old) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(modified, obj); err != nil { + return nil, err + } + case types.MergePatchType: + modified, err := jsonpatch.MergePatch(old, action.GetPatch()) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(modified, obj); err != nil { + return nil, err + } + case types.StrategicMergePatchType: + mergedByte, err := strategicpatch.StrategicMergePatch(old, action.GetPatch(), obj) + if err != nil { + return nil, err + } + if err = json.Unmarshal(mergedByte, obj); err != nil { + return nil, err + } + case types.ApplyCBORPatchType: + return nil, errors.New("apply CBOR patches are not supported in the fake client") + case types.ApplyPatchType: + return nil, errors.New("bug in controller-runtime: should not end up in dryPatch for SSA") + default: + return nil, fmt.Errorf("%s PatchType is not supported", action.GetPatchType()) + } + return obj, nil +} + +// copyStatusFrom copies the status from old into new +func copyStatusFrom(old, n runtime.Object) error { + oldMapStringAny, err := toMapStringAny(old) + if err != nil { + return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err) + } + newMapStringAny, err := toMapStringAny(n) + if err != nil { + return fmt.Errorf("failed to convert new to *unststructured.Unstructured: %w", err) + } + + newMapStringAny["status"] = oldMapStringAny["status"] + + if err := fromMapStringAny(newMapStringAny, n); err != nil { + return fmt.Errorf("failed to convert back from map[string]any: %w", err) + } + + return nil +} + +// copyFrom copies from old into new +func copyFrom(old, n runtime.Object) error { + oldMapStringAny, err := toMapStringAny(old) + if err != nil { + return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err) + } + if err := fromMapStringAny(oldMapStringAny, n); err != nil { + return fmt.Errorf("failed to convert back from map[string]any: %w", err) + } + + return nil +} + +func toMapStringAny(obj runtime.Object) (map[string]any, error) { + if unstructured, isUnstructured := obj.(*unstructured.Unstructured); isUnstructured { + return unstructured.Object, nil + } + + serialized, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + u := map[string]any{} + return u, json.Unmarshal(serialized, &u) +} + +func fromMapStringAny(u map[string]any, target runtime.Object) error { + if targetUnstructured, isUnstructured := target.(*unstructured.Unstructured); isUnstructured { + targetUnstructured.Object = u + return nil + } + + serialized, err := json.Marshal(u) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + + zero(target) + if err := json.Unmarshal(serialized, &target); err != nil { + return fmt.Errorf("failed to deserialize: %w", err) + } + + return nil +} + +func (c *fakeClient) Status() client.SubResourceWriter { + return c.SubResource("status") +} + +func (c *fakeClient) SubResource(subResource string) client.SubResourceClient { + return &fakeSubResourceClient{client: c, subResource: subResource} +} + +func (c *fakeClient) deleteObjectLocked(gvr schema.GroupVersionResource, accessor metav1.Object) error { + old, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) + if err == nil { + oldAccessor, err := meta.Accessor(old) + if err == nil { + if len(oldAccessor.GetFinalizers()) > 0 { + now := metav1.Now() + oldAccessor.SetDeletionTimestamp(&now) + // Call update directly with mutability parameter set to true to allow + // changes to deletionTimestamp + return c.tracker.update(gvr, old, accessor.GetNamespace(), false, true, metav1.UpdateOptions{}) + } + } + } + + // TODO: implement propagation + return c.tracker.Delete(gvr, accessor.GetNamespace(), accessor.GetName()) +} + +func getGVRFromObject(obj runtime.Object, scheme *runtime.Scheme) (schema.GroupVersionResource, error) { + gvk, err := apiutil.GVKForObject(obj, scheme) + if err != nil { + return schema.GroupVersionResource{}, err + } + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return gvr, nil +} + +type fakeSubResourceClient struct { + client *fakeClient + subResource string +} + +func (sw *fakeSubResourceClient) Get(ctx context.Context, obj, subResource client.Object, opts ...client.SubResourceGetOption) error { + switch sw.subResource { + case subResourceScale: + // Actual client looks up resource, then extracts the scale sub-resource: + // https://github.com/kubernetes/kubernetes/blob/fb6bbc9781d11a87688c398778525c4e1dcb0f08/pkg/registry/apps/deployment/storage/storage.go#L307 + if err := sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { + return err + } + scale, isScale := subResource.(*autoscalingv1.Scale) + if !isScale { + return apierrors.NewBadRequest(fmt.Sprintf("expected Scale, got %T", subResource)) + } + scaleOut, err := extractScale(obj) + if err != nil { + return err + } + *scale = *scaleOut + return nil + default: + return fmt.Errorf("fakeSubResourceClient does not support get for %s", sw.subResource) + } +} + +func (sw *fakeSubResourceClient) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + switch sw.subResource { + case "eviction": + _, isEviction := subResource.(*policyv1beta1.Eviction) + if !isEviction { + _, isEviction = subResource.(*policyv1.Eviction) + } + if !isEviction { + return apierrors.NewBadRequest(fmt.Sprintf("got invalid type %T, expected Eviction", subResource)) + } + if _, isPod := obj.(*corev1.Pod); !isPod { + return apierrors.NewNotFound(schema.GroupResource{}, "") + } + + return sw.client.Delete(ctx, obj) + case "token": + tokenRequest, isTokenRequest := subResource.(*authenticationv1.TokenRequest) + if !isTokenRequest { + return apierrors.NewBadRequest(fmt.Sprintf("got invalid type %T, expected TokenRequest", subResource)) + } + if _, isServiceAccount := obj.(*corev1.ServiceAccount); !isServiceAccount { + return apierrors.NewNotFound(schema.GroupResource{}, "") + } + + tokenRequest.Status.Token = "fake-token" + tokenRequest.Status.ExpirationTimestamp = metav1.Date(6041, 1, 1, 0, 0, 0, 0, time.UTC) + + return sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj) + default: + return fmt.Errorf("fakeSubResourceWriter does not support create for %s", sw.subResource) + } +} + +func (sw *fakeSubResourceClient) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + updateOptions := client.SubResourceUpdateOptions{} + updateOptions.ApplyOptions(opts) + + switch sw.subResource { + case subResourceScale: + if err := sw.client.Get(ctx, client.ObjectKeyFromObject(obj), obj.DeepCopyObject().(client.Object)); err != nil { + return err + } + if updateOptions.SubResourceBody == nil { + return apierrors.NewBadRequest("missing SubResourceBody") + } + + scale, isScale := updateOptions.SubResourceBody.(*autoscalingv1.Scale) + if !isScale { + return apierrors.NewBadRequest(fmt.Sprintf("expected Scale, got %T", updateOptions.SubResourceBody)) + } + if err := applyScale(obj, scale); err != nil { + return err + } + return sw.client.update(obj, false, &updateOptions.UpdateOptions) + default: + body := obj + if updateOptions.SubResourceBody != nil { + body = updateOptions.SubResourceBody + } + return sw.client.update(body, true, &updateOptions.UpdateOptions) + } +} + +func (sw *fakeSubResourceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + patchOptions := client.SubResourcePatchOptions{} + patchOptions.ApplyOptions(opts) + + body := obj + if patchOptions.SubResourceBody != nil { + body = patchOptions.SubResourceBody + } + + // this is necessary to identify that last call was made for status patch, through stack trace. + if sw.subResource == "status" { + return sw.statusPatch(body, patch, patchOptions) + } + + return sw.client.patch(body, patch, &patchOptions.PatchOptions) +} + +func (sw *fakeSubResourceClient) statusPatch(body client.Object, patch client.Patch, patchOptions client.SubResourcePatchOptions) error { + return sw.client.patch(body, patch, &patchOptions.PatchOptions) +} + +func (sw *fakeSubResourceClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + if sw.subResource != "status" { + return errors.New("fakeSubResourceClient currently only supports Apply for status subresource") + } + + applyOpts := &client.SubResourceApplyOptions{} + applyOpts.ApplyOpts(opts) + + data, err := json.Marshal(obj) + if err != nil { + return fmt.Errorf("failed to marshal apply configuration: %w", err) + } + + u := &unstructured.Unstructured{} + if err := json.Unmarshal(data, u); err != nil { + return fmt.Errorf("failed to unmarshal apply configuration: %w", err) + } + + patchOpts := &client.SubResourcePatchOptions{} + patchOpts.Raw = applyOpts.AsPatchOptions() + + if applyOpts.SubResourceBody != nil { + subResourceBodySerialized, err := json.Marshal(applyOpts.SubResourceBody) + if err != nil { + return fmt.Errorf("failed to serialize subresource body: %w", err) + } + subResourceBody := &unstructured.Unstructured{} + if err := json.Unmarshal(subResourceBodySerialized, subResourceBody); err != nil { + return fmt.Errorf("failed to unmarshal subresource body: %w", err) + } + patchOpts.SubResourceBody = subResourceBody + } + + return sw.Patch(ctx, u, &fakeApplyPatch{}, patchOpts) +} + +func allowsUnconditionalUpdate(gvk schema.GroupVersionKind) bool { + switch gvk.Group { + case "apps": + switch gvk.Kind { + case "ControllerRevision", "DaemonSet", "Deployment", "ReplicaSet", "StatefulSet": + return true + } + case "autoscaling": + switch gvk.Kind { + case "HorizontalPodAutoscaler": + return true + } + case "batch": + switch gvk.Kind { + case "CronJob", "Job": + return true + } + case "certificates": + switch gvk.Kind { + case "Certificates": + return true + } + case "flowcontrol": + switch gvk.Kind { + case "FlowSchema", "PriorityLevelConfiguration": + return true + } + case "networking": + switch gvk.Kind { + case "Ingress", "IngressClass", "NetworkPolicy": + return true + } + case "policy": + switch gvk.Kind { + case "PodSecurityPolicy": + return true + } + case "rbac.authorization.k8s.io": + switch gvk.Kind { + case "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding": + return true + } + case "scheduling": + switch gvk.Kind { + case "PriorityClass": + return true + } + case "settings": + switch gvk.Kind { + case "PodPreset": + return true + } + case "storage": + switch gvk.Kind { + case "StorageClass": + return true + } + case "": + switch gvk.Kind { + case "ConfigMap", "Endpoint", "Event", "LimitRange", "Namespace", "Node", + "PersistentVolume", "PersistentVolumeClaim", "Pod", "PodTemplate", + "ReplicationController", "ResourceQuota", "Secret", "Service", + "ServiceAccount", "EndpointSlice": + return true + } + } + + return false +} + +func allowsCreateOnUpdate(gvk schema.GroupVersionKind) bool { + switch gvk.Group { + case "coordination": + switch gvk.Kind { + case "Lease": + return true + } + case "node": + switch gvk.Kind { + case "RuntimeClass": + return true + } + case "rbac": + switch gvk.Kind { + case "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding": + return true + } + case "": + switch gvk.Kind { + case "Endpoint", "Event", "LimitRange", "Service": + return true + } + } + + return false +} + +func inTreeResourcesWithStatus() []schema.GroupVersionKind { + return []schema.GroupVersionKind{ + {Version: "v1", Kind: "Namespace"}, + {Version: "v1", Kind: "Node"}, + {Version: "v1", Kind: "PersistentVolumeClaim"}, + {Version: "v1", Kind: "PersistentVolume"}, + {Version: "v1", Kind: "Pod"}, + {Version: "v1", Kind: "ReplicationController"}, + {Version: "v1", Kind: "Service"}, + + {Group: "apps", Version: "v1", Kind: "Deployment"}, + {Group: "apps", Version: "v1", Kind: "DaemonSet"}, + {Group: "apps", Version: "v1", Kind: "ReplicaSet"}, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}, + + {Group: "autoscaling", Version: "v1", Kind: "HorizontalPodAutoscaler"}, + + {Group: "batch", Version: "v1", Kind: "CronJob"}, + {Group: "batch", Version: "v1", Kind: "Job"}, + + {Group: "certificates.k8s.io", Version: "v1", Kind: "CertificateSigningRequest"}, + + {Group: "networking.k8s.io", Version: "v1", Kind: "Ingress"}, + {Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"}, + + {Group: "policy", Version: "v1", Kind: "PodDisruptionBudget"}, + + {Group: "storage.k8s.io", Version: "v1", Kind: "VolumeAttachment"}, + + {Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, + + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "FlowSchema"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "PriorityLevelConfiguration"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1", Kind: "FlowSchema"}, + {Group: "flowcontrol.apiserver.k8s.io", Version: "v1", Kind: "PriorityLevelConfiguration"}, + } +} + +// zero zeros the value of a pointer. +func zero(x any) { + if x == nil { + return + } + res := reflect.ValueOf(x).Elem() + res.Set(reflect.Zero(res.Type())) +} + +// getSingleOrZeroOptions returns the single options value in the slice, its +// zero value if the slice is empty, or an error if the slice contains more than +// one option value. +func getSingleOrZeroOptions[T any](opts []T) (opt T, err error) { + switch len(opts) { + case 0: + case 1: + opt = opts[0] + default: + err = fmt.Errorf("expected single or no options value, got %d values", len(opts)) + } + return +} + +func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { + switch obj := obj.(type) { + case *appsv1.Deployment: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + case *appsv1.ReplicaSet: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + case *corev1.ReplicationController: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: labels.Set(obj.Spec.Selector).String(), + }, + }, nil + case *appsv1.StatefulSet: + var replicas int32 = 1 + if obj.Spec.Replicas != nil { + replicas = *obj.Spec.Replicas + } + var selector string + if obj.Spec.Selector != nil { + selector = obj.Spec.Selector.String() + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.Namespace, + Name: obj.Name, + UID: obj.UID, + ResourceVersion: obj.ResourceVersion, + CreationTimestamp: obj.CreationTimestamp, + }, + Spec: autoscalingv1.ScaleSpec{ + Replicas: replicas, + }, + Status: autoscalingv1.ScaleStatus{ + Replicas: obj.Status.Replicas, + Selector: selector, + }, + }, nil + default: + // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + return nil, fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } +} + +func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { + switch obj := obj.(type) { + case *appsv1.Deployment: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *appsv1.ReplicaSet: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *corev1.ReplicationController: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + case *appsv1.StatefulSet: + obj.Spec.Replicas = ptr.To(scale.Spec.Replicas) + default: + // TODO: CRDs https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource + return fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } + return nil +} + +// AddIndex adds an index to a fake client. It will panic if used with a client that is not a fake client. +// It will error if there is already an index for given object with the same name as field. +// +// It can be used to test code that adds indexes to the cache at runtime. +func AddIndex(c client.Client, obj runtime.Object, field string, extractValue client.IndexerFunc) error { + fakeClient, isFakeClient := c.(*fakeClient) + if !isFakeClient { + panic("AddIndex can only be used with a fake client") + } + fakeClient.indexesLock.Lock() + defer fakeClient.indexesLock.Unlock() + + if fakeClient.indexes == nil { + fakeClient.indexes = make(map[schema.GroupVersionKind]map[string]client.IndexerFunc, 1) + } + + gvk, err := apiutil.GVKForObject(obj, fakeClient.scheme) + if err != nil { + return fmt.Errorf("failed to get gvk for %T: %w", obj, err) + } + + if fakeClient.indexes[gvk] == nil { + fakeClient.indexes[gvk] = make(map[string]client.IndexerFunc, 1) + } + + if fakeClient.indexes[gvk][field] != nil { + return fmt.Errorf("index %s already exists", field) + } + + fakeClient.indexes[gvk][field] = extractValue + + return nil +} + +func (c *fakeClient) addToSchemeIfUnknownAndUnstructuredOrPartial(obj runtime.Object) error { + c.schemeLock.Lock() + defer c.schemeLock.Unlock() + + _, isUnstructured := obj.(*unstructured.Unstructured) + _, isUnstructuredList := obj.(*unstructured.UnstructuredList) + _, isPartial := obj.(*metav1.PartialObjectMetadata) + _, isPartialList := obj.(*metav1.PartialObjectMetadataList) + if !isUnstructured && !isUnstructuredList && !isPartial && !isPartialList { + return nil + } + + gvk, err := apiutil.GVKForObject(obj, c.scheme) + if err != nil { + return err + } + + if isUnstructuredList || isPartialList { + if !strings.HasSuffix(gvk.Kind, "List") { + gvk.Kind += "List" + } + } + + if !c.scheme.Recognizes(gvk) { + c.scheme.AddKnownTypeWithName(gvk, obj) + } + + return nil +} + +func ensureTypeMeta(obj runtime.Object, gvk schema.GroupVersionKind) error { + ta, err := meta.TypeAccessor(obj) + if err != nil { + return err + } + _, isUnstructured := obj.(runtime.Unstructured) + _, isPartialObject := obj.(*metav1.PartialObjectMetadata) + _, isPartialObjectList := obj.(*metav1.PartialObjectMetadataList) + if !isUnstructured && !isPartialObject && !isPartialObjectList { + ta.SetKind("") + ta.SetAPIVersion("") + return nil + } + + ta.SetKind(gvk.Kind) + ta.SetAPIVersion(gvk.GroupVersion().String()) + + return nil +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go new file mode 100644 index 00000000..47cad398 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/doc.go @@ -0,0 +1,38 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package fake provides a fake client for testing. + +A fake client is backed by its simple object store indexed by GroupVersionResource. +You can create a fake client with optional objects. + + client := NewClientBuilder().WithScheme(scheme).WithObjects(initObjs...).Build() + +You can invoke the methods defined in the Client interface. + +When in doubt, it's almost always better not to use this package and instead use +envtest.Environment with a real client and API server. + +WARNING: ⚠️ Current Limitations / Known Issues with the fake Client ⚠️ + - This client does not have a way to inject specific errors to test handled vs. unhandled errors. + - There is some support for sub resources which can cause issues with tests if you're trying to update + e.g. metadata and status in the same reconcile. + - No OpenAPI validation is performed when creating or updating objects. + - ObjectMeta's `Generation` and `ResourceVersion` don't behave properly, Patch or Update + operations that rely on these fields will fail, or give false positives. +*/ +package fake diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/typeconverter.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/typeconverter.go new file mode 100644 index 00000000..3cb3a0dc --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/typeconverter.go @@ -0,0 +1,60 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fake + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/managedfields" + "sigs.k8s.io/structured-merge-diff/v6/typed" +) + +type multiTypeConverter struct { + upstream []managedfields.TypeConverter +} + +func (m multiTypeConverter) ObjectToTyped(r runtime.Object, o ...typed.ValidationOptions) (*typed.TypedValue, error) { + var errs []error + for _, u := range m.upstream { + res, err := u.ObjectToTyped(r, o...) + if err != nil { + errs = append(errs, err) + continue + } + + return res, nil + } + + return nil, fmt.Errorf("failed to convert Object to TypedValue: %w", kerrors.NewAggregate(errs)) +} + +func (m multiTypeConverter) TypedToObject(v *typed.TypedValue) (runtime.Object, error) { + var errs []error + for _, u := range m.upstream { + res, err := u.TypedToObject(v) + if err != nil { + errs = append(errs, err) + continue + } + + return res, nil + } + + return nil, fmt.Errorf("failed to convert TypedValue to Object: %w", kerrors.NewAggregate(errs)) +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/versioned_tracker.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/versioned_tracker.go new file mode 100644 index 00000000..f2242527 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/fake/versioned_tracker.go @@ -0,0 +1,366 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fake + +import ( + "bytes" + "errors" + "fmt" + "runtime/debug" + "strconv" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/managedfields" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/testing" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +var _ testing.ObjectTracker = (*versionedTracker)(nil) + +type versionedTracker struct { + upstream testing.ObjectTracker + scheme *runtime.Scheme + withStatusSubresource sets.Set[schema.GroupVersionKind] + usesFieldManagedObjectTracker bool +} + +func (t versionedTracker) Add(obj runtime.Object) error { + var objects []runtime.Object + if meta.IsListType(obj) { + var err error + objects, err = meta.ExtractList(obj) + if err != nil { + return err + } + } else { + objects = []runtime.Object{obj} + } + for _, obj := range objects { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetDeletionTimestamp() != nil && len(accessor.GetFinalizers()) == 0 { + return fmt.Errorf("refusing to create obj %s with metadata.deletionTimestamp but no finalizers", accessor.GetName()) + } + if accessor.GetResourceVersion() == "" { + // We use a "magic" value of 999 here because this field + // is parsed as uint and and 0 is already used in Update. + // As we can't go lower, go very high instead so this can + // be recognized + accessor.SetResourceVersion(trackerAddResourceVersion) + } + + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + + // If the fieldManager can not decode fields, it will just silently clear them. This is pretty + // much guaranteed not to be what someone that initializes a fake client with objects that + // have them set wants, so validate them here. + // Ref https://github.com/kubernetes/kubernetes/blob/a956ef4862993b825bcd524a19260192ff1da72d/staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/fieldmanager.go#L105 + if t.usesFieldManagedObjectTracker { + if err := managedfields.ValidateManagedFields(accessor.GetManagedFields()); err != nil { + return fmt.Errorf("invalid managedFields on %T: %w", obj, err) + } + } + if err := t.upstream.Add(obj); err != nil { + return err + } + } + + return nil +} + +func (t versionedTracker) Create(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.CreateOptions) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetName() == "" { + gvk, _ := apiutil.GVKForObject(obj, t.scheme) + return apierrors.NewInvalid( + gvk.GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + if accessor.GetResourceVersion() != "" { + return apierrors.NewBadRequest("resourceVersion can not be set for Create requests") + } + accessor.SetResourceVersion("1") + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + if err != nil { + return err + } + if err := t.upstream.Create(gvr, obj, ns, opts...); err != nil { + accessor.SetResourceVersion("") + return err + } + + return nil +} + +func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.UpdateOptions) error { + updateOpts, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + return t.update(gvr, obj, ns, false, false, updateOpts) +} + +func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, opts metav1.UpdateOptions) error { + gvk, err := apiutil.GVKForObject(obj, t.scheme) + if err != nil { + return err + } + obj, needsCreate, err := t.updateObject(gvr, gvk, obj, ns, isStatus, deleting, allowsCreateOnUpdate(gvk), opts.DryRun) + if err != nil { + return err + } + + if needsCreate { + opts := metav1.CreateOptions{DryRun: opts.DryRun, FieldManager: opts.FieldManager} + return t.Create(gvr, obj, ns, opts) + } + + if obj == nil { // Object was deleted in updateObject + return nil + } + + if u, unstructured := obj.(*unstructured.Unstructured); unstructured { + u.SetGroupVersionKind(gvk) + } + + return t.upstream.Update(gvr, obj, ns, opts) +} + +func (t versionedTracker) Patch(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.PatchOptions) error { + patchOptions, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + + gvk, err := apiutil.GVKForObject(obj, t.scheme) + if err != nil { + return err + } + + // We apply patches using a client-go reaction that ends up calling the trackers Patch. As we can't change + // that reaction, we use the callstack to figure out if this originated from the status client. + isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) + + obj, needsCreate, err := t.updateObject(gvr, gvk, obj, ns, isStatus, false, allowsCreateOnUpdate(gvk), patchOptions.DryRun) + if err != nil { + return err + } + if needsCreate { + opts := metav1.CreateOptions{DryRun: patchOptions.DryRun, FieldManager: patchOptions.FieldManager} + return t.Create(gvr, obj, ns, opts) + } + + if obj == nil { // Object was deleted in updateObject + return nil + } + + return t.upstream.Patch(gvr, obj, ns, patchOptions) +} + +// updateObject performs a number of validations and changes related to +// object updates, such as checking and updating the resourceVersion. +func (t versionedTracker) updateObject( + gvr schema.GroupVersionResource, + gvk schema.GroupVersionKind, + obj runtime.Object, + ns string, + isStatus bool, + deleting bool, + allowCreateOnUpdate bool, + dryRun []string, +) (result runtime.Object, needsCreate bool, _ error) { + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, false, fmt.Errorf("failed to get accessor for object: %w", err) + } + + if accessor.GetName() == "" { + return nil, false, apierrors.NewInvalid( + gvk.GroupKind(), + accessor.GetName(), + field.ErrorList{field.Required(field.NewPath("metadata.name"), "name is required")}) + } + + oldObject, err := t.Get(gvr, ns, accessor.GetName()) + if err != nil { + // If the resource is not found and the resource allows create on update, issue a + // create instead. + if apierrors.IsNotFound(err) && allowCreateOnUpdate { + // Pass this info to the caller rather than create, because in the SSA case it + // must be created by calling Apply in the upstream tracker, not Create. + // This is because SSA considers Apply and Non-Apply operations to be different + // even when they use the same fieldManager. This behavior is also observable + // with a real Kubernetes apiserver. + // + // Ref https://kubernetes.slack.com/archives/C0EG7JC6T/p1757868204458989?thread_ts=1757808656.002569&cid=C0EG7JC6T + return obj, true, nil + } + return obj, false, err + } + + if t.withStatusSubresource.Has(gvk) { + if isStatus { // copy everything but status, managedFields and metadata.ResourceVersion from original object + if err := copyStatusFrom(obj, oldObject); err != nil { + return nil, false, fmt.Errorf("failed to copy non-status field for object with status subresouce: %w", err) + } + passedRV := accessor.GetResourceVersion() + passedManagedFields := accessor.GetManagedFields() + if err := copyFrom(oldObject, obj); err != nil { + return nil, false, fmt.Errorf("failed to restore non-status fields: %w", err) + } + accessor.SetResourceVersion(passedRV) + accessor.SetManagedFields(passedManagedFields) + } else { // copy status from original object + if err := copyStatusFrom(oldObject, obj); err != nil { + return nil, false, fmt.Errorf("failed to copy the status for object with status subresource: %w", err) + } + } + } else if isStatus { + return nil, false, apierrors.NewNotFound(gvr.GroupResource(), accessor.GetName()) + } + + oldAccessor, err := meta.Accessor(oldObject) + if err != nil { + return nil, false, err + } + + // If the new object does not have the resource version set and it allows unconditional update, + // default it to the resource version of the existing resource + if accessor.GetResourceVersion() == "" { + switch { + case allowsUnconditionalUpdate(gvk): + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + // This is needed because if the patch explicitly sets the RV to null, the client-go reaction we use + // to apply it and whose output we process here will have it unset. It is not clear why the Kubernetes + // apiserver accepts such a patch, but it does so we just copy that behavior. + // Kubernetes apiserver behavior can be checked like this: + // `kubectl patch configmap foo --patch '{"metadata":{"annotations":{"foo":"bar"},"resourceVersion":null}}' -v=9` + case bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Patch")): + // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change + // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Patch" func. + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + case bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeClient).Apply")): + // We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change + // that reaction, we use the callstack to figure out if this originated from the "fakeClient.Apply" func. + accessor.SetResourceVersion(oldAccessor.GetResourceVersion()) + } + } + + if accessor.GetResourceVersion() != oldAccessor.GetResourceVersion() { + return nil, false, apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), errors.New("object was modified")) + } + if oldAccessor.GetResourceVersion() == "" { + oldAccessor.SetResourceVersion("0") + } + intResourceVersion, err := strconv.ParseUint(oldAccessor.GetResourceVersion(), 10, 64) + if err != nil { + return nil, false, fmt.Errorf("can not convert resourceVersion %q to int: %w", oldAccessor.GetResourceVersion(), err) + } + intResourceVersion++ + accessor.SetResourceVersion(strconv.FormatUint(intResourceVersion, 10)) + + if !deleting && !deletionTimestampEqual(accessor, oldAccessor) { + return nil, false, fmt.Errorf("error: Unable to edit %s: metadata.deletionTimestamp field is immutable", accessor.GetName()) + } + + if !accessor.GetDeletionTimestamp().IsZero() && len(accessor.GetFinalizers()) == 0 { + return nil, false, t.Delete(gvr, accessor.GetNamespace(), accessor.GetName(), metav1.DeleteOptions{DryRun: dryRun}) + } + + obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj) + return obj, false, err +} + +func (t versionedTracker) Apply(gvr schema.GroupVersionResource, applyConfiguration runtime.Object, ns string, opts ...metav1.PatchOptions) error { + patchOptions, err := getSingleOrZeroOptions(opts) + if err != nil { + return err + } + gvk, err := apiutil.GVKForObject(applyConfiguration, t.scheme) + if err != nil { + return err + } + isStatus := bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).statusPatch")) + + applyConfiguration, needsCreate, err := t.updateObject(gvr, gvk, applyConfiguration, ns, isStatus, false, true, patchOptions.DryRun) + if err != nil { + return err + } + + if needsCreate { + // https://github.com/kubernetes/kubernetes/blob/81affffa1b8d8079836f4cac713ea8d1b2bbf10f/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L606 + accessor, err := meta.Accessor(applyConfiguration) + if err != nil { + return fmt.Errorf("failed to get accessor for object: %w", err) + } + if accessor.GetUID() != "" { + return apierrors.NewConflict(gvr.GroupResource(), accessor.GetName(), fmt.Errorf("uid mismatch: the provided object specified uid %s, and no existing object was found", accessor.GetUID())) + } + + if t.withStatusSubresource.Has(gvk) { + // Clear out status for create, for update this is handled in updateObject + if err := copyStatusFrom(&unstructured.Unstructured{}, applyConfiguration); err != nil { + return err + } + } + } + + if applyConfiguration == nil { // Object was deleted in updateObject + return nil + } + + if isStatus { + // We restore everything but status from the tracker where we don't put GVK + // into the object but it must be set for the ManagedFieldsObjectTracker + applyConfiguration.GetObjectKind().SetGroupVersionKind(gvk) + } + return t.upstream.Apply(gvr, applyConfiguration, ns, opts...) +} + +func (t versionedTracker) Delete(gvr schema.GroupVersionResource, ns, name string, opts ...metav1.DeleteOptions) error { + return t.upstream.Delete(gvr, ns, name, opts...) +} + +func (t versionedTracker) Get(gvr schema.GroupVersionResource, ns, name string, opts ...metav1.GetOptions) (runtime.Object, error) { + return t.upstream.Get(gvr, ns, name, opts...) +} + +func (t versionedTracker) List(gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, ns string, opts ...metav1.ListOptions) (runtime.Object, error) { + return t.upstream.List(gvr, gvk, ns, opts...) +} + +func (t versionedTracker) Watch(gvr schema.GroupVersionResource, ns string, opts ...metav1.ListOptions) (watch.Interface, error) { + return t.upstream.Watch(gvr, ns, opts...) +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go b/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go new file mode 100644 index 00000000..b98af1a6 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/client/interceptor/intercept.go @@ -0,0 +1,183 @@ +package interceptor + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Funcs contains functions that are called instead of the underlying client's methods. +type Funcs struct { + Get func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error + List func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error + Create func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error + Delete func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error + DeleteAllOf func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteAllOfOption) error + Update func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error + Patch func(ctx context.Context, client client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error + Apply func(ctx context.Context, client client.WithWatch, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error + Watch func(ctx context.Context, client client.WithWatch, obj client.ObjectList, opts ...client.ListOption) (watch.Interface, error) + SubResource func(client client.WithWatch, subResource string) client.SubResourceClient + SubResourceGet func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error + SubResourceCreate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error + SubResourceUpdate func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, opts ...client.SubResourceUpdateOption) error + SubResourcePatch func(ctx context.Context, client client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error + SubResourceApply func(ctx context.Context, client client.Client, subResourceName string, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error +} + +// NewClient returns a new interceptor client that calls the functions in funcs instead of the underlying client's methods, if they are not nil. +func NewClient(interceptedClient client.WithWatch, funcs Funcs) client.WithWatch { + return interceptor{ + client: interceptedClient, + funcs: funcs, + } +} + +type interceptor struct { + client client.WithWatch + funcs Funcs +} + +var _ client.WithWatch = &interceptor{} + +func (c interceptor) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + return c.client.GroupVersionKindFor(obj) +} + +func (c interceptor) IsObjectNamespaced(obj runtime.Object) (bool, error) { + return c.client.IsObjectNamespaced(obj) +} + +func (c interceptor) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if c.funcs.Get != nil { + return c.funcs.Get(ctx, c.client, key, obj, opts...) + } + return c.client.Get(ctx, key, obj, opts...) +} + +func (c interceptor) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if c.funcs.List != nil { + return c.funcs.List(ctx, c.client, list, opts...) + } + return c.client.List(ctx, list, opts...) +} + +func (c interceptor) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if c.funcs.Create != nil { + return c.funcs.Create(ctx, c.client, obj, opts...) + } + return c.client.Create(ctx, obj, opts...) +} + +func (c interceptor) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if c.funcs.Delete != nil { + return c.funcs.Delete(ctx, c.client, obj, opts...) + } + return c.client.Delete(ctx, obj, opts...) +} + +func (c interceptor) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if c.funcs.Update != nil { + return c.funcs.Update(ctx, c.client, obj, opts...) + } + return c.client.Update(ctx, obj, opts...) +} + +func (c interceptor) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if c.funcs.Patch != nil { + return c.funcs.Patch(ctx, c.client, obj, patch, opts...) + } + return c.client.Patch(ctx, obj, patch, opts...) +} + +func (c interceptor) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error { + if c.funcs.Apply != nil { + return c.funcs.Apply(ctx, c.client, obj, opts...) + } + + return c.client.Apply(ctx, obj, opts...) +} + +func (c interceptor) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + if c.funcs.DeleteAllOf != nil { + return c.funcs.DeleteAllOf(ctx, c.client, obj, opts...) + } + return c.client.DeleteAllOf(ctx, obj, opts...) +} + +func (c interceptor) Status() client.SubResourceWriter { + return c.SubResource("status") +} + +func (c interceptor) SubResource(subResource string) client.SubResourceClient { + if c.funcs.SubResource != nil { + return c.funcs.SubResource(c.client, subResource) + } + return subResourceInterceptor{ + subResourceName: subResource, + client: c.client, + funcs: c.funcs, + } +} + +func (c interceptor) Scheme() *runtime.Scheme { + return c.client.Scheme() +} + +func (c interceptor) RESTMapper() meta.RESTMapper { + return c.client.RESTMapper() +} + +func (c interceptor) Watch(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + if c.funcs.Watch != nil { + return c.funcs.Watch(ctx, c.client, obj, opts...) + } + return c.client.Watch(ctx, obj, opts...) +} + +type subResourceInterceptor struct { + subResourceName string + client client.Client + funcs Funcs +} + +var _ client.SubResourceClient = &subResourceInterceptor{} + +func (s subResourceInterceptor) Get(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error { + if s.funcs.SubResourceGet != nil { + return s.funcs.SubResourceGet(ctx, s.client, s.subResourceName, obj, subResource, opts...) + } + return s.client.SubResource(s.subResourceName).Get(ctx, obj, subResource, opts...) +} + +func (s subResourceInterceptor) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + if s.funcs.SubResourceCreate != nil { + return s.funcs.SubResourceCreate(ctx, s.client, s.subResourceName, obj, subResource, opts...) + } + return s.client.SubResource(s.subResourceName).Create(ctx, obj, subResource, opts...) +} + +func (s subResourceInterceptor) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if s.funcs.SubResourceUpdate != nil { + return s.funcs.SubResourceUpdate(ctx, s.client, s.subResourceName, obj, opts...) + } + return s.client.SubResource(s.subResourceName).Update(ctx, obj, opts...) +} + +func (s subResourceInterceptor) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if s.funcs.SubResourcePatch != nil { + return s.funcs.SubResourcePatch(ctx, s.client, s.subResourceName, obj, patch, opts...) + } + return s.client.SubResource(s.subResourceName).Patch(ctx, obj, patch, opts...) +} + +func (s subResourceInterceptor) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + if s.funcs.SubResourceApply != nil { + return s.funcs.SubResourceApply(ctx, s.client, s.subResourceName, obj, opts...) + } + return s.client.SubResource(s.subResourceName).Apply(ctx, obj, opts...) +} diff --git a/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go b/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go new file mode 100644 index 00000000..0189c043 --- /dev/null +++ b/vendor/sigs.k8s.io/controller-runtime/pkg/internal/objectutil/objectutil.go @@ -0,0 +1,42 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package objectutil + +import ( + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" +) + +// FilterWithLabels returns a copy of the items in objs matching labelSel. +func FilterWithLabels(objs []runtime.Object, labelSel labels.Selector) ([]runtime.Object, error) { + outItems := make([]runtime.Object, 0, len(objs)) + for _, obj := range objs { + meta, err := apimeta.Accessor(obj) + if err != nil { + return nil, err + } + if labelSel != nil { + lbls := labels.Set(meta.GetLabels()) + if !labelSel.Matches(lbls) { + continue + } + } + outItems = append(outItems, obj.DeepCopyObject()) + } + return outItems, nil +} From 0051e847da35f980e07b6eebce8f8f0572a8d2d9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:28:28 +0000 Subject: [PATCH 04/12] feat: allow apiserver client provider overrides --- internal/app/apiserverapp/apiserverapp.go | 15 ++++- .../app/apiserverapp/apiserverapp_test.go | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/internal/app/apiserverapp/apiserverapp.go b/internal/app/apiserverapp/apiserverapp.go index bd8ec0a2..44bed2bd 100644 --- a/internal/app/apiserverapp/apiserverapp.go +++ b/internal/app/apiserverapp/apiserverapp.go @@ -55,6 +55,9 @@ type Options struct { CoderNamespace string // CoderRequestTimeout for SDK calls. Default 30s. CoderRequestTimeout time.Duration + // ClientProvider overrides the default static provider. + // When set, CoderURL/CoderSessionToken/CoderNamespace flags are ignored. + ClientProvider coder.ClientProvider } type errClientProvider struct { @@ -272,9 +275,15 @@ func RunWithOptions(ctx context.Context, opts Options) error { requestTimeout = 30 * time.Second } - provider, err := buildClientProvider(opts, requestTimeout) - if err != nil { - return fmt.Errorf("build coder client provider: %w", err) + var provider coder.ClientProvider + if opts.ClientProvider != nil { + provider = opts.ClientProvider + } else { + var err error + provider, err = buildClientProvider(opts, requestTimeout) + if err != nil { + return fmt.Errorf("build coder client provider: %w", err) + } } if provider == nil { return fmt.Errorf("assertion failed: coder client provider is nil after successful construction") diff --git a/internal/app/apiserverapp/apiserverapp_test.go b/internal/app/apiserverapp/apiserverapp_test.go index 7e3dee5a..26f66b8b 100644 --- a/internal/app/apiserverapp/apiserverapp_test.go +++ b/internal/app/apiserverapp/apiserverapp_test.go @@ -196,6 +196,62 @@ func TestRunWithOptionsRejectsPartialCoderConfig(t *testing.T) { } } +func TestRunWithOptionsUsesClientProviderOverride(t *testing.T) { + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("create test listener: %v", err) + } + defer func() { + _ = listener.Close() + }() + + coderURL, err := url.Parse("https://coder.example.com") + if err != nil { + t.Fatalf("parse test coder URL: %v", err) + } + provider, err := coderhelper.NewStaticClientProvider( + coderhelper.Config{ + CoderURL: coderURL, + SessionToken: "test-session-token", + }, + "control-plane", + ) + if err != nil { + t.Fatalf("build static client provider: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + go func() { + errCh <- RunWithOptions(ctx, Options{ + Listener: listener, + CoderURL: "https://coder.example.com", + ClientProvider: provider, + }) + }() + + select { + case runErr := <-errCh: + t.Fatalf("expected startup to continue with provider override, got %v", runErr) + case <-time.After(300 * time.Millisecond): + } + + cancel() + + select { + case runErr := <-errCh: + if runErr != nil && !errors.Is(runErr, context.Canceled) { + t.Fatalf("expected graceful shutdown after cancellation, got %v", runErr) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for aggregated apiserver shutdown") + } +} + func TestBuildClientProviderRejectsMissingCoderNamespaceWhenBackendConfigured(t *testing.T) { t.Parallel() From 8206f0bb27a8e1732063193a8567bc4c87456720 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:38:50 +0000 Subject: [PATCH 05/12] feat: add all app composition root and default dispatch mode --- app_dispatch.go | 10 +- internal/app/allapp/allapp.go | 179 +++++++++++++++++++++++++++++ internal/app/allapp/allapp_test.go | 66 +++++++++++ main_test.go | 51 +++++++- 4 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 internal/app/allapp/allapp.go create mode 100644 internal/app/allapp/allapp_test.go diff --git a/app_dispatch.go b/app_dispatch.go index 6a500aa6..53418f23 100644 --- a/app_dispatch.go +++ b/app_dispatch.go @@ -10,14 +10,16 @@ import ( ctrl "sigs.k8s.io/controller-runtime" + "github.com/coder/coder-k8s/internal/app/allapp" "github.com/coder/coder-k8s/internal/app/apiserverapp" "github.com/coder/coder-k8s/internal/app/controllerapp" "github.com/coder/coder-k8s/internal/app/mcpapp" ) -const supportedAppModes = "controller, aggregated-apiserver, mcp-http" +const supportedAppModes = "all, controller, aggregated-apiserver, mcp-http" var ( + runAllApp = allapp.Run runControllerApp = controllerapp.Run runAggregatedAPIServerApp = func(ctx context.Context, opts apiserverapp.Options) error { return apiserverapp.RunWithOptions(ctx, opts) @@ -35,7 +37,7 @@ func run(args []string) error { coderNamespace string coderRequestTimeout time.Duration ) - fs.StringVar(&appMode, "app", "", "Application mode (controller, aggregated-apiserver, mcp-http)") + fs.StringVar(&appMode, "app", "all", "Application mode (all, controller, aggregated-apiserver, mcp-http)") fs.StringVar( &coderSessionToken, "coder-session-token", @@ -82,6 +84,8 @@ func run(args []string) error { } switch appMode { + case "all": + return runAllApp(setupSignalHandler()) case "controller": return runControllerApp(setupSignalHandler()) case "aggregated-apiserver": @@ -94,8 +98,6 @@ func run(args []string) error { return runAggregatedAPIServerApp(setupSignalHandler(), opts) case "mcp-http": return runMCPHTTPApp(setupSignalHandler()) - case "": - return fmt.Errorf("assertion failed: --app flag is required; must be one of: %s", supportedAppModes) default: return fmt.Errorf("assertion failed: unsupported --app value %q; must be one of: %s", appMode, supportedAppModes) } diff --git a/internal/app/allapp/allapp.go b/internal/app/allapp/allapp.go new file mode 100644 index 00000000..6392661e --- /dev/null +++ b/internal/app/allapp/allapp.go @@ -0,0 +1,179 @@ +// Package allapp composes controller, aggregated API server, and MCP app modes in one process. +package allapp + +import ( + "context" + "fmt" + "time" + + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/coder/coder-k8s/internal/aggregated/coder" + "github.com/coder/coder-k8s/internal/app/apiserverapp" + "github.com/coder/coder-k8s/internal/app/controllerapp" + "github.com/coder/coder-k8s/internal/app/mcpapp" + "github.com/coder/coder-k8s/internal/app/sharedscheme" +) + +const ( + cacheSyncTimeout = 30 * time.Second + coderRequestTimeout = 30 * time.Second +) + +var ( + newManager = controllerapp.NewManager + setupControllers = controllerapp.SetupControllers + setupProbes = controllerapp.SetupProbes + runAggregatedAPIServer = func(ctx context.Context, opts apiserverapp.Options) error { + return apiserverapp.RunWithOptions(ctx, opts) + } + runMCPHTTPWithClients = mcpapp.RunHTTPWithClients + newClientset = kubernetes.NewForConfig +) + +var _ manager.LeaderElectionRunnable = nonLeaderRunnable{} + +type nonLeaderRunnable struct { + run func(context.Context) error +} + +func (r nonLeaderRunnable) Start(ctx context.Context) error { + if r.run == nil { + return fmt.Errorf("assertion failed: runnable function must not be nil") + } + return r.run(ctx) +} + +func (nonLeaderRunnable) NeedLeaderElection() bool { + return false +} + +// Run starts all app modes together using a shared controller-runtime manager/cache. +func Run(ctx context.Context) error { + if ctx == nil { + return fmt.Errorf("assertion failed: context must not be nil") + } + + scheme := sharedscheme.New() + if scheme == nil { + return fmt.Errorf("assertion failed: scheme is nil after successful construction") + } + + cfg := ctrl.GetConfigOrDie() + if cfg == nil { + return fmt.Errorf("assertion failed: config is nil after successful construction") + } + + mgr, err := newManager(cfg, scheme) + if err != nil { + return err + } + if mgr == nil { + return fmt.Errorf("assertion failed: manager is nil after successful construction") + } + + if err := setupControllers(mgr); err != nil { + return err + } + if err := setupProbes(mgr); err != nil { + return err + } + + if err := mgr.Add(nonLeaderRunnable{ + run: func(runnableCtx context.Context) error { + if runnableCtx == nil { + return fmt.Errorf("assertion failed: context must not be nil") + } + + if err := waitForCacheSync(runnableCtx, mgr, "aggregated-apiserver"); err != nil { + return err + } + + managerClient := mgr.GetClient() + if managerClient == nil { + return fmt.Errorf("assertion failed: manager client is nil") + } + + apiReader := mgr.GetAPIReader() + if apiReader == nil { + return fmt.Errorf("assertion failed: manager API reader is nil") + } + + provider, err := coder.NewControlPlaneClientProvider(managerClient, apiReader, coderRequestTimeout) + if err != nil { + return fmt.Errorf("build control plane client provider: %w", err) + } + if provider == nil { + return fmt.Errorf("assertion failed: control plane client provider is nil after successful construction") + } + + return runAggregatedAPIServer(runnableCtx, apiserverapp.Options{ClientProvider: provider}) + }, + }); err != nil { + return fmt.Errorf("add aggregated-apiserver runnable: %w", err) + } + + if err := mgr.Add(nonLeaderRunnable{ + run: func(runnableCtx context.Context) error { + if runnableCtx == nil { + return fmt.Errorf("assertion failed: context must not be nil") + } + + if err := waitForCacheSync(runnableCtx, mgr, "mcp-http"); err != nil { + return err + } + + managerClient := mgr.GetClient() + if managerClient == nil { + return fmt.Errorf("assertion failed: manager client is nil") + } + + managerConfig := mgr.GetConfig() + if managerConfig == nil { + return fmt.Errorf("assertion failed: manager config is nil") + } + + clientset, err := newClientset(managerConfig) + if err != nil { + return fmt.Errorf("build Kubernetes clientset: %w", err) + } + if clientset == nil { + return fmt.Errorf("assertion failed: Kubernetes clientset is nil after successful construction") + } + + return runMCPHTTPWithClients(runnableCtx, managerClient, clientset) + }, + }); err != nil { + return fmt.Errorf("add mcp-http runnable: %w", err) + } + + return mgr.Start(ctx) +} + +func waitForCacheSync(ctx context.Context, mgr manager.Manager, runnableName string) error { + if ctx == nil { + return fmt.Errorf("assertion failed: context must not be nil") + } + if mgr == nil { + return fmt.Errorf("assertion failed: manager must not be nil") + } + if runnableName == "" { + return fmt.Errorf("assertion failed: runnable name must not be empty") + } + + managerCache := mgr.GetCache() + if managerCache == nil { + return fmt.Errorf("assertion failed: manager cache is nil") + } + + syncCtx, cancel := context.WithTimeout(ctx, cacheSyncTimeout) + defer cancel() + + if synced := managerCache.WaitForCacheSync(syncCtx); !synced { + return fmt.Errorf("cache did not sync within %s for %s", cacheSyncTimeout, runnableName) + } + + return nil +} diff --git a/internal/app/allapp/allapp_test.go b/internal/app/allapp/allapp_test.go new file mode 100644 index 00000000..ad0525fe --- /dev/null +++ b/internal/app/allapp/allapp_test.go @@ -0,0 +1,66 @@ +package allapp + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestRunRejectsNilContext(t *testing.T) { + t.Helper() + + var nilCtx context.Context + err := Run(nilCtx) + if err == nil { + t.Fatal("expected an error when context is nil") + } + if !strings.Contains(err.Error(), "context must not be nil") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNonLeaderRunnableNeedLeaderElection(t *testing.T) { + t.Helper() + + runnable := nonLeaderRunnable{} + if runnable.NeedLeaderElection() { + t.Fatal("expected non-leader runnable to disable leader election") + } +} + +func TestNonLeaderRunnableStartCallsRun(t *testing.T) { + t.Helper() + + expectedErr := errors.New("sentinel runnable error") + called := false + runnable := nonLeaderRunnable{ + run: func(ctx context.Context) error { + called = true + if ctx == nil { + t.Fatal("expected non-nil context") + } + return expectedErr + }, + } + + err := runnable.Start(context.Background()) + if !called { + t.Fatal("expected runnable callback to be called") + } + if !errors.Is(err, expectedErr) { + t.Fatalf("expected sentinel error %v, got %v", expectedErr, err) + } +} + +func TestNonLeaderRunnableStartRequiresRunFunction(t *testing.T) { + t.Helper() + + err := nonLeaderRunnable{}.Start(context.Background()) + if err == nil { + t.Fatal("expected an error when runnable callback is nil") + } + if !strings.Contains(err.Error(), "runnable function must not be nil") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/main_test.go b/main_test.go index 4e818542..fc134ee0 100644 --- a/main_test.go +++ b/main_test.go @@ -72,15 +72,56 @@ func TestReconcilerSetupWithManagerRequiresManager(t *testing.T) { } } -func TestRunRejectsEmptyMode(t *testing.T) { +func TestRunDefaultsToAllMode(t *testing.T) { t.Helper() + installMockSignalHandler(t) + + previous := runAllApp + t.Cleanup(func() { + runAllApp = previous + }) + + expectedErr := errors.New("sentinel all error") + called := false + runAllApp = func(ctx context.Context) error { + called = true + if ctx == nil { + t.Fatal("expected non-nil context") + } + return expectedErr + } err := run([]string{}) - if err == nil { - t.Fatal("expected an error when --app is missing") + if !called { + t.Fatal("expected all runner to be called") } - if !strings.Contains(err.Error(), "--app flag is required") { - t.Fatalf("unexpected error: %v", err) + if !errors.Is(err, expectedErr) { + t.Fatalf("expected sentinel, got %v", err) + } +} + +func TestRunDispatchesAllMode(t *testing.T) { + t.Helper() + installMockSignalHandler(t) + + previous := runAllApp + t.Cleanup(func() { + runAllApp = previous + }) + + expectedErr := errors.New("sentinel all error") + called := false + runAllApp = func(context.Context) error { + called = true + return expectedErr + } + + err := run([]string{"--app=all"}) + if !called { + t.Fatal("expected all runner to be called") + } + if !errors.Is(err, expectedErr) { + t.Fatalf("expected sentinel, got %v", err) } } From 60b597c00e3873755518f671a0fcf64851fe61d5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:36:36 +0000 Subject: [PATCH 06/12] deploy: unify manifests for app=all deployment --- config/e2e/deployment.yaml | 5 +- deploy/apiserver-deployment.yaml | 23 ---- deploy/apiserver-service.yaml | 2 +- ...roller-deployment.yaml => deployment.yaml} | 15 ++- deploy/mcp-deployment.yaml | 31 ----- deploy/mcp-service.yaml | 2 +- deploy/rbac.yaml | 112 +++++++++--------- 7 files changed, 72 insertions(+), 118 deletions(-) delete mode 100644 deploy/apiserver-deployment.yaml rename deploy/{controller-deployment.yaml => deployment.yaml} (68%) delete mode 100644 deploy/mcp-deployment.yaml diff --git a/config/e2e/deployment.yaml b/config/e2e/deployment.yaml index 039bbebf..74a7fc9a 100644 --- a/config/e2e/deployment.yaml +++ b/config/e2e/deployment.yaml @@ -19,11 +19,14 @@ spec: containers: - name: manager image: ghcr.io/coder/coder-k8s:e2e - args: ["--app=controller"] imagePullPolicy: Never ports: - containerPort: 8081 name: health + - containerPort: 6443 + name: https + - containerPort: 8090 + name: mcp livenessProbe: httpGet: path: /healthz diff --git a/deploy/apiserver-deployment.yaml b/deploy/apiserver-deployment.yaml deleted file mode 100644 index 69f884dc..00000000 --- a/deploy/apiserver-deployment.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: coder-k8s-apiserver - namespace: coder-system -spec: - replicas: 1 - selector: - matchLabels: - app: coder-k8s-apiserver - template: - metadata: - labels: - app: coder-k8s-apiserver - spec: - serviceAccountName: coder-k8s-apiserver - containers: - - name: apiserver - image: ghcr.io/coder/coder-k8s:latest - args: ["--app=aggregated-apiserver"] - ports: - - containerPort: 6443 - name: https diff --git a/deploy/apiserver-service.yaml b/deploy/apiserver-service.yaml index d9a1f91f..3707a21a 100644 --- a/deploy/apiserver-service.yaml +++ b/deploy/apiserver-service.yaml @@ -5,7 +5,7 @@ metadata: namespace: coder-system spec: selector: - app: coder-k8s-apiserver + app: coder-k8s ports: - name: https port: 443 diff --git a/deploy/controller-deployment.yaml b/deploy/deployment.yaml similarity index 68% rename from deploy/controller-deployment.yaml rename to deploy/deployment.yaml index ff01fca7..c21de71e 100644 --- a/deploy/controller-deployment.yaml +++ b/deploy/deployment.yaml @@ -1,26 +1,29 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: coder-k8s-controller + name: coder-k8s namespace: coder-system spec: replicas: 1 selector: matchLabels: - app: coder-k8s-controller + app: coder-k8s template: metadata: labels: - app: coder-k8s-controller + app: coder-k8s spec: - serviceAccountName: coder-k8s-controller + serviceAccountName: coder-k8s containers: - - name: controller + - name: coder-k8s image: ghcr.io/coder/coder-k8s:latest - args: ["--app=controller"] ports: - containerPort: 8081 name: health + - containerPort: 6443 + name: https + - containerPort: 8090 + name: mcp livenessProbe: httpGet: path: /healthz diff --git a/deploy/mcp-deployment.yaml b/deploy/mcp-deployment.yaml deleted file mode 100644 index 788f72b2..00000000 --- a/deploy/mcp-deployment.yaml +++ /dev/null @@ -1,31 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: coder-k8s-mcp - namespace: coder-system -spec: - replicas: 1 - selector: - matchLabels: - app: coder-k8s-mcp - template: - metadata: - labels: - app: coder-k8s-mcp - spec: - serviceAccountName: coder-k8s-mcp - containers: - - name: mcp - image: ghcr.io/coder/coder-k8s:latest - args: ["--app=mcp-http"] - ports: - - containerPort: 8090 - name: mcp - livenessProbe: - httpGet: - path: /healthz - port: mcp - readinessProbe: - httpGet: - path: /readyz - port: mcp diff --git a/deploy/mcp-service.yaml b/deploy/mcp-service.yaml index feb00958..aceb30dc 100644 --- a/deploy/mcp-service.yaml +++ b/deploy/mcp-service.yaml @@ -5,7 +5,7 @@ metadata: namespace: coder-system spec: selector: - app: coder-k8s-mcp + app: coder-k8s ports: - name: mcp port: 8090 diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml index ca207922..3dc00642 100644 --- a/deploy/rbac.yaml +++ b/deploy/rbac.yaml @@ -1,22 +1,15 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: coder-k8s-controller + name: coder-k8s namespace: coder-system --- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: coder-k8s-apiserver - namespace: coder-system ---- -# ClusterRole for the controller to manage CoderControlPlane resources. -# Permissions match the kubebuilder RBAC markers on CoderControlPlaneReconciler. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: coder-k8s-controller + name: coder-k8s rules: + # Controller: manage CoderControlPlane resources - apiGroups: ["coder.com"] resources: ["codercontrolplanes"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] @@ -26,37 +19,84 @@ rules: - apiGroups: ["coder.com"] resources: ["codercontrolplanes/finalizers"] verbs: ["update"] + # Controller: manage WorkspaceProxy resources + - apiGroups: ["coder.com"] + resources: ["workspaceproxies"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["coder.com"] + resources: ["workspaceproxies/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["coder.com"] + resources: ["workspaceproxies/finalizers"] + verbs: ["update"] + # Controller: manage CoderProvisioner resources + - apiGroups: ["coder.com"] + resources: ["coderprovisioners"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["coder.com"] + resources: ["coderprovisioners/status"] + verbs: ["get", "update", "patch"] + - apiGroups: ["coder.com"] + resources: ["coderprovisioners/finalizers"] + verbs: ["update"] + # Controller: leader election + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Controller: events + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Controller: manage deployments and services for control planes + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Dynamic provider: read operator token secrets + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + # MCP: read aggregated API resources + - apiGroups: ["aggregation.coder.com"] + resources: ["coderworkspaces", "codertemplates"] + verbs: ["get", "list", "watch", "update", "patch"] + # MCP: read pods, logs, namespaces + - apiGroups: [""] + resources: ["pods", "pods/log", "namespaces"] + verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: coder-k8s-controller + name: coder-k8s roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: coder-k8s-controller + name: coder-k8s subjects: - kind: ServiceAccount - name: coder-k8s-controller + name: coder-k8s namespace: coder-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: coder-k8s-apiserver-auth-delegator + name: coder-k8s-auth-delegator roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: system:auth-delegator subjects: - kind: ServiceAccount - name: coder-k8s-apiserver + name: coder-k8s namespace: coder-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: coder-k8s-apiserver-authentication-reader + name: coder-k8s-authentication-reader namespace: kube-system roleRef: apiGroup: rbac.authorization.k8s.io @@ -64,43 +104,5 @@ roleRef: name: extension-apiserver-authentication-reader subjects: - kind: ServiceAccount - name: coder-k8s-apiserver - namespace: coder-system ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: coder-k8s-mcp - namespace: coder-system ---- -# ClusterRole for the MCP server to read operator resources. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: coder-k8s-mcp -rules: - - apiGroups: ["coder.com"] - resources: ["codercontrolplanes", "codercontrolplanes/status"] - verbs: ["get", "list", "watch"] - - apiGroups: ["aggregation.coder.com"] - resources: ["coderworkspaces", "codertemplates"] - verbs: ["get", "list", "watch", "update", "patch"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["services", "pods", "pods/log", "events", "namespaces"] - verbs: ["get", "list", "watch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: coder-k8s-mcp -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: coder-k8s-mcp -subjects: - - kind: ServiceAccount - name: coder-k8s-mcp + name: coder-k8s namespace: coder-system From 160a0e26fb337990da17bff11286eca53dac89c3 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:50:38 +0000 Subject: [PATCH 07/12] fix: isolate coderbootstrap SDK transports --- internal/coderbootstrap/client.go | 7 +++++++ internal/coderbootstrap/provisionerkeys.go | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/internal/coderbootstrap/client.go b/internal/coderbootstrap/client.go index 6e0b2a0c..b498c7b5 100644 --- a/internal/coderbootstrap/client.go +++ b/internal/coderbootstrap/client.go @@ -70,6 +70,13 @@ func (c *SDKClient) EnsureWorkspaceProxy(ctx context.Context, req RegisterWorksp if client.HTTPClient == nil { client.HTTPClient = &http.Client{} } + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return RegisterWorkspaceProxyResponse{}, xerrors.New("assertion failed: http.DefaultTransport is not *http.Transport") + } + // Use a dedicated transport to avoid sharing http.DefaultTransport's + // connection pool across parallel test servers. + client.HTTPClient.Transport = defaultTransport.Clone() client.HTTPClient.Timeout = coderSDKRequestTimeout existing, err := client.WorkspaceProxyByName(ctx, req.ProxyName) diff --git a/internal/coderbootstrap/provisionerkeys.go b/internal/coderbootstrap/provisionerkeys.go index cb3eead8..e1934ca9 100644 --- a/internal/coderbootstrap/provisionerkeys.go +++ b/internal/coderbootstrap/provisionerkeys.go @@ -160,6 +160,13 @@ func newAuthenticatedClient(coderURL, sessionToken string) (*codersdk.Client, er if client.HTTPClient == nil { client.HTTPClient = &http.Client{} } + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, xerrors.New("assertion failed: http.DefaultTransport is not *http.Transport") + } + // Use a dedicated transport to avoid sharing http.DefaultTransport's + // connection pool across parallel test servers. + client.HTTPClient.Transport = defaultTransport.Clone() client.HTTPClient.Timeout = coderSDKRequestTimeout return client, nil From 25feb31a48b4141a13edbec68b44ea14058cee7e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 21:54:25 +0000 Subject: [PATCH 08/12] fix: add event read verbs to unified ClusterRole --- deploy/rbac.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml index 3dc00642..478ebf73 100644 --- a/deploy/rbac.yaml +++ b/deploy/rbac.yaml @@ -43,10 +43,10 @@ rules: - apiGroups: ["coordination.k8s.io"] resources: ["leases"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Controller: events + # Controller + MCP: events - apiGroups: [""] resources: ["events"] - verbs: ["create", "patch"] + verbs: ["get", "list", "watch", "create", "patch"] # Controller: manage deployments and services for control planes - apiGroups: ["apps"] resources: ["deployments"] From 1aed078ae7319ce8f1a36785b85e952c32518db8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 22:01:35 +0000 Subject: [PATCH 09/12] fix: support list namespace resolution for control-plane provider --- .../aggregated/coder/controlplane_provider.go | 127 ++++++++++++------ .../coder/controlplane_provider_test.go | 62 +++++++++ internal/aggregated/coder/provider.go | 25 +++- internal/aggregated/coder/provider_test.go | 54 ++++++++ .../storage/list_conversion_test.go | 92 +++++++++++++ internal/aggregated/storage/template.go | 2 +- internal/aggregated/storage/workspace.go | 27 +++- 7 files changed, 339 insertions(+), 50 deletions(-) create mode 100644 internal/aggregated/storage/list_conversion_test.go diff --git a/internal/aggregated/coder/controlplane_provider.go b/internal/aggregated/coder/controlplane_provider.go index b7b4f09d..23e4f309 100644 --- a/internal/aggregated/coder/controlplane_provider.go +++ b/internal/aggregated/coder/controlplane_provider.go @@ -23,7 +23,10 @@ type ControlPlaneClientProvider struct { requestTimeout time.Duration } -var _ ClientProvider = (*ControlPlaneClientProvider)(nil) +var ( + _ ClientProvider = (*ControlPlaneClientProvider)(nil) + _ NamespaceResolver = (*ControlPlaneClientProvider)(nil) +) // NewControlPlaneClientProvider constructs a dynamic ClientProvider backed by CoderControlPlane resources. func NewControlPlaneClientProvider( @@ -64,51 +67,13 @@ func (p *ControlPlaneClientProvider) ClientForNamespace(ctx context.Context, nam if ctx == nil { return nil, fmt.Errorf("assertion failed: context must not be nil") } - if p.cpReader == nil { - return nil, fmt.Errorf("assertion failed: control plane reader must not be nil") - } if p.secretReader == nil { return nil, fmt.Errorf("assertion failed: secret reader must not be nil") } - controlPlaneList := &coderv1alpha1.CoderControlPlaneList{} - listOptions := make([]client.ListOption, 0, 1) - if namespace != "" { - listOptions = append(listOptions, client.InNamespace(namespace)) - } - if err := p.cpReader.List(ctx, controlPlaneList, listOptions...); err != nil { - if namespace == "" { - return nil, fmt.Errorf("list CoderControlPlane resources across all namespaces: %w", err) - } - - return nil, fmt.Errorf("list CoderControlPlane resources in namespace %q: %w", namespace, err) - } - - eligible := make([]coderv1alpha1.CoderControlPlane, 0, 1) - for i := range controlPlaneList.Items { - controlPlane := controlPlaneList.Items[i] - if strings.Contains(controlPlane.Name, ".") { - log.Printf( - "warning: skipping CoderControlPlane %s/%s: names containing '.' are incompatible with aggregated naming", - controlPlane.Namespace, - controlPlane.Name, - ) - continue - } - if controlPlane.Spec.OperatorAccess.Disabled { - continue - } - if !controlPlane.Status.OperatorAccessReady { - continue - } - if controlPlane.Status.OperatorTokenSecretRef == nil { - continue - } - if strings.TrimSpace(controlPlane.Status.URL) == "" { - continue - } - - eligible = append(eligible, controlPlane) + eligible, err := p.findEligibleControlPlanes(ctx, namespace) + if err != nil { + return nil, err } switch len(eligible) { @@ -220,6 +185,84 @@ func (p *ControlPlaneClientProvider) ClientForNamespace(ctx context.Context, nam return sdkClient, nil } +// DefaultNamespace resolves the namespace for all-namespaces LIST requests. +func (p *ControlPlaneClientProvider) DefaultNamespace(ctx context.Context) (string, error) { + eligible, err := p.findEligibleControlPlanes(ctx, "") + if err != nil { + return "", err + } + + switch len(eligible) { + case 0: + return "", apierrors.NewServiceUnavailable(noEligibleControlPlaneMessage("")) + case 1: + resolvedNamespace := strings.TrimSpace(eligible[0].Namespace) + if resolvedNamespace == "" { + return "", fmt.Errorf("assertion failed: eligible CoderControlPlane namespace must not be empty") + } + return resolvedNamespace, nil + default: + return "", apierrors.NewBadRequest(multipleEligibleControlPlaneMessage("")) + } +} + +func (p *ControlPlaneClientProvider) findEligibleControlPlanes( + ctx context.Context, + namespace string, +) ([]coderv1alpha1.CoderControlPlane, error) { + if p == nil { + return nil, fmt.Errorf("assertion failed: control plane client provider must not be nil") + } + if ctx == nil { + return nil, fmt.Errorf("assertion failed: context must not be nil") + } + if p.cpReader == nil { + return nil, fmt.Errorf("assertion failed: control plane reader must not be nil") + } + + controlPlaneList := &coderv1alpha1.CoderControlPlaneList{} + listOptions := make([]client.ListOption, 0, 1) + if namespace != "" { + listOptions = append(listOptions, client.InNamespace(namespace)) + } + if err := p.cpReader.List(ctx, controlPlaneList, listOptions...); err != nil { + if namespace == "" { + return nil, fmt.Errorf("list CoderControlPlane resources across all namespaces: %w", err) + } + + return nil, fmt.Errorf("list CoderControlPlane resources in namespace %q: %w", namespace, err) + } + + eligible := make([]coderv1alpha1.CoderControlPlane, 0, 1) + for i := range controlPlaneList.Items { + controlPlane := controlPlaneList.Items[i] + if strings.Contains(controlPlane.Name, ".") { + log.Printf( + "warning: skipping CoderControlPlane %s/%s: names containing '.' are incompatible with aggregated naming", + controlPlane.Namespace, + controlPlane.Name, + ) + continue + } + if controlPlane.Spec.OperatorAccess.Disabled { + continue + } + if !controlPlane.Status.OperatorAccessReady { + continue + } + if controlPlane.Status.OperatorTokenSecretRef == nil { + continue + } + if strings.TrimSpace(controlPlane.Status.URL) == "" { + continue + } + + eligible = append(eligible, controlPlane) + } + + return eligible, nil +} + func noEligibleControlPlaneMessage(namespace string) string { if namespace == "" { return "no eligible CoderControlPlane instances found across all namespaces" diff --git a/internal/aggregated/coder/controlplane_provider_test.go b/internal/aggregated/coder/controlplane_provider_test.go index 2ad6e726..701ff94e 100644 --- a/internal/aggregated/coder/controlplane_provider_test.go +++ b/internal/aggregated/coder/controlplane_provider_test.go @@ -204,6 +204,68 @@ func TestControlPlaneClientProviderClientForNamespaceReturnsBadRequestForMultipl } } +func TestControlPlaneClientProviderDefaultNamespaceHappyPath(t *testing.T) { + t.Parallel() + + provider, secretReader := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{eligibleControlPlane("team-a", "coder")}, + nil, + ) + + resolvedNamespace, err := provider.DefaultNamespace(context.Background()) + if err != nil { + t.Fatalf("resolve default namespace: %v", err) + } + if got, want := resolvedNamespace, "team-a"; got != want { + t.Fatalf("expected default namespace %q, got %q", want, got) + } + if got, want := secretReader.getCalls, 0; got != want { + t.Fatalf("expected %d secret reads, got %d", want, got) + } +} + +func TestControlPlaneClientProviderDefaultNamespaceReturnsServiceUnavailableWhenNoEligibleControlPlane(t *testing.T) { + t.Parallel() + + provider, _ := newControlPlaneProviderForTest(t, nil, nil) + + _, err := provider.DefaultNamespace(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } + if !strings.Contains(err.Error(), "no eligible CoderControlPlane") { + t.Fatalf("expected no-eligible message, got %v", err) + } +} + +func TestControlPlaneClientProviderDefaultNamespaceReturnsBadRequestForMultipleEligibleControlPlanes(t *testing.T) { + t.Parallel() + + provider, _ := newControlPlaneProviderForTest( + t, + []coderv1alpha1.CoderControlPlane{ + eligibleControlPlane("team-a", "coder-a"), + eligibleControlPlane("team-b", "coder-b"), + }, + nil, + ) + + _, err := provider.DefaultNamespace(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsBadRequest(err) { + t.Fatalf("expected BadRequest, got %v", err) + } + if !strings.Contains(err.Error(), "multi-instance support is planned") { + t.Fatalf("expected multi-instance message, got %v", err) + } +} + func TestControlPlaneClientProviderClientForNamespaceDefaultsSecretKeyToToken(t *testing.T) { t.Parallel() diff --git a/internal/aggregated/coder/provider.go b/internal/aggregated/coder/provider.go index db5c6415..a50a6795 100644 --- a/internal/aggregated/coder/provider.go +++ b/internal/aggregated/coder/provider.go @@ -14,13 +14,24 @@ type ClientProvider interface { ClientForNamespace(ctx context.Context, namespace string) (*codersdk.Client, error) } +// NamespaceResolver can be implemented by ClientProvider implementations that +// support resolving a default namespace when the request namespace is empty. +type NamespaceResolver interface { + // DefaultNamespace returns the namespace to use for object metadata when the + // request namespace is empty (all-namespaces LIST). + DefaultNamespace(ctx context.Context) (string, error) +} + // StaticClientProvider returns one static client, optionally restricted to one namespace. type StaticClientProvider struct { Client *codersdk.Client Namespace string // If non-empty, only this namespace is allowed. } -var _ ClientProvider = (*StaticClientProvider)(nil) +var ( + _ ClientProvider = (*StaticClientProvider)(nil) + _ NamespaceResolver = (*StaticClientProvider)(nil) +) // ClientForNamespace returns the static client. func (p *StaticClientProvider) ClientForNamespace(ctx context.Context, namespace string) (*codersdk.Client, error) { @@ -54,6 +65,18 @@ func (p *StaticClientProvider) ClientForNamespace(ctx context.Context, namespace return p.Client, nil } +// DefaultNamespace resolves the pinned namespace for all-namespaces LIST requests. +func (p *StaticClientProvider) DefaultNamespace(_ context.Context) (string, error) { + if p == nil { + return "", fmt.Errorf("assertion failed: static client provider must not be nil") + } + if p.Namespace == "" { + return "", apierrors.NewServiceUnavailable("static provider has no default namespace") + } + + return p.Namespace, nil +} + // NewStaticClientProvider creates a StaticClientProvider from cfg and optional namespace restriction. func NewStaticClientProvider(cfg Config, namespace string) (*StaticClientProvider, error) { client, err := NewSDKClient(cfg) diff --git a/internal/aggregated/coder/provider_test.go b/internal/aggregated/coder/provider_test.go index f81246e9..dadd46f8 100644 --- a/internal/aggregated/coder/provider_test.go +++ b/internal/aggregated/coder/provider_test.go @@ -155,6 +155,60 @@ func TestStaticClientProviderClientForNamespaceAllowsClusterScopedListNamespace( } } +func TestStaticClientProviderDefaultNamespace(t *testing.T) { + t.Parallel() + + provider := &StaticClientProvider{Namespace: "control-plane"} + resolvedNamespace, err := provider.DefaultNamespace(context.Background()) + if err != nil { + t.Fatalf("resolve default namespace: %v", err) + } + if got, want := resolvedNamespace, "control-plane"; got != want { + t.Fatalf("expected default namespace %q, got %q", want, got) + } +} + +func TestStaticClientProviderDefaultNamespaceAssertions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + provider *StaticClientProvider + wantErrContains string + wantServiceDown bool + }{ + { + name: "rejects nil provider", + provider: nil, + wantErrContains: "assertion failed: static client provider must not be nil", + }, + { + name: "rejects unpinned provider", + provider: &StaticClientProvider{}, + wantErrContains: "static provider has no default namespace", + wantServiceDown: true, + }, + } + + for _, testCase := range tests { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + _, err := testCase.provider.DefaultNamespace(context.Background()) + if err == nil { + t.Fatalf("expected error containing %q, got nil", testCase.wantErrContains) + } + if !strings.Contains(err.Error(), testCase.wantErrContains) { + t.Fatalf("expected error containing %q, got %q", testCase.wantErrContains, err.Error()) + } + if testCase.wantServiceDown && !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } + }) + } +} + func TestNewStaticClientProvider(t *testing.T) { t.Parallel() diff --git a/internal/aggregated/storage/list_conversion_test.go b/internal/aggregated/storage/list_conversion_test.go new file mode 100644 index 00000000..beeeb15a --- /dev/null +++ b/internal/aggregated/storage/list_conversion_test.go @@ -0,0 +1,92 @@ +package storage + +import ( + "context" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/coder/coder-k8s/internal/aggregated/coder" + "github.com/coder/coder/v2/codersdk" +) + +func TestNamespaceForListConversionUsesResolvedNamespaceForAllNamespaces(t *testing.T) { + t.Parallel() + + provider := &listConversionResolverProvider{defaultNamespace: "control-plane"} + + resolvedNamespace, err := namespaceForListConversion(context.Background(), "", provider) + if err != nil { + t.Fatalf("resolve namespace for all-namespaces list: %v", err) + } + if got, want := resolvedNamespace, "control-plane"; got != want { + t.Fatalf("expected resolved namespace %q, got %q", want, got) + } + if got, want := provider.defaultNamespaceCalls, 1; got != want { + t.Fatalf("expected DefaultNamespace to be called %d time, got %d", want, got) + } +} + +func TestNamespaceForListConversionRejectsProviderWithoutNamespaceResolver(t *testing.T) { + t.Parallel() + + provider := &listConversionClientOnlyProvider{} + + _, err := namespaceForListConversion(context.Background(), "", provider) + if err == nil { + t.Fatal("expected error") + } + if !apierrors.IsServiceUnavailable(err) { + t.Fatalf("expected ServiceUnavailable, got %v", err) + } +} + +func TestNamespaceForListConversionKeepsRequestNamespace(t *testing.T) { + t.Parallel() + + provider := &listConversionResolverProvider{defaultNamespace: "control-plane"} + + resolvedNamespace, err := namespaceForListConversion(context.Background(), "request-namespace", provider) + if err != nil { + t.Fatalf("resolve namespace for namespaced list: %v", err) + } + if got, want := resolvedNamespace, "request-namespace"; got != want { + t.Fatalf("expected request namespace %q, got %q", want, got) + } + if provider.defaultNamespaceCalls != 0 { + t.Fatalf("expected DefaultNamespace not to be called for namespaced requests, got %d calls", provider.defaultNamespaceCalls) + } +} + +type listConversionClientOnlyProvider struct{} + +var _ coder.ClientProvider = (*listConversionClientOnlyProvider)(nil) + +func (*listConversionClientOnlyProvider) ClientForNamespace( + _ context.Context, + _ string, +) (*codersdk.Client, error) { + return nil, nil +} + +type listConversionResolverProvider struct { + defaultNamespace string + defaultNamespaceCalls int +} + +var ( + _ coder.ClientProvider = (*listConversionResolverProvider)(nil) + _ coder.NamespaceResolver = (*listConversionResolverProvider)(nil) +) + +func (*listConversionResolverProvider) ClientForNamespace( + _ context.Context, + _ string, +) (*codersdk.Client, error) { + return nil, nil +} + +func (p *listConversionResolverProvider) DefaultNamespace(context.Context) (string, error) { + p.defaultNamespaceCalls++ + return p.defaultNamespace, nil +} diff --git a/internal/aggregated/storage/template.go b/internal/aggregated/storage/template.go index 8c94e211..cab6baf8 100644 --- a/internal/aggregated/storage/template.go +++ b/internal/aggregated/storage/template.go @@ -122,7 +122,7 @@ func (s *TemplateStorage) List(ctx context.Context, _ *metainternalversion.ListO return nil, badNamespaceErr } - responseNamespace, responseNamespaceErr := namespaceForListConversion(namespace, s.provider) + responseNamespace, responseNamespaceErr := namespaceForListConversion(ctx, namespace, s.provider) if responseNamespaceErr != nil { return nil, responseNamespaceErr } diff --git a/internal/aggregated/storage/workspace.go b/internal/aggregated/storage/workspace.go index a3471559..694e3949 100644 --- a/internal/aggregated/storage/workspace.go +++ b/internal/aggregated/storage/workspace.go @@ -122,7 +122,7 @@ func (s *WorkspaceStorage) List(ctx context.Context, _ *metainternalversion.List return nil, badNamespaceErr } - responseNamespace, responseNamespaceErr := namespaceForListConversion(namespace, s.provider) + responseNamespace, responseNamespaceErr := namespaceForListConversion(ctx, namespace, s.provider) if responseNamespaceErr != nil { return nil, responseNamespaceErr } @@ -534,7 +534,14 @@ func requiredNamespaceFromRequestContext(ctx context.Context) (string, error) { return namespace, nil } -func namespaceForListConversion(requestNamespace string, provider coder.ClientProvider) (string, error) { +func namespaceForListConversion( + ctx context.Context, + requestNamespace string, + provider coder.ClientProvider, +) (string, error) { + if ctx == nil { + return "", fmt.Errorf("assertion failed: context must not be nil") + } if requestNamespace != "" { return requestNamespace, nil } @@ -542,14 +549,22 @@ func namespaceForListConversion(requestNamespace string, provider coder.ClientPr return "", fmt.Errorf("assertion failed: client provider must not be nil") } - staticProvider, ok := provider.(*coder.StaticClientProvider) - if !ok || staticProvider.Namespace == "" { + resolver, ok := provider.(coder.NamespaceResolver) + if !ok { return "", apierrors.NewServiceUnavailable( - "all-namespaces list requires a namespace-pinned static provider; configure --coder-namespace", + "all-namespaces list requires a provider that implements namespace resolution", ) } - return staticProvider.Namespace, nil + resolvedNamespace, err := resolver.DefaultNamespace(ctx) + if err != nil { + return "", err + } + if resolvedNamespace == "" { + return "", fmt.Errorf("assertion failed: namespace resolver returned an empty namespace") + } + + return resolvedNamespace, nil } func equalInt64Ptr(a, b *int64) bool { From 110b428af7b05b790019e157978f72085a92e477 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 22:14:21 +0000 Subject: [PATCH 10/12] fix: restore controller app mode in e2e deployment --- config/e2e/deployment.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/config/e2e/deployment.yaml b/config/e2e/deployment.yaml index 74a7fc9a..ae2eb4f7 100644 --- a/config/e2e/deployment.yaml +++ b/config/e2e/deployment.yaml @@ -19,14 +19,13 @@ spec: containers: - name: manager image: ghcr.io/coder/coder-k8s:e2e + # E2E tests validate controller mode only; all mode requires + # additional infrastructure (APIService, TLS) not provisioned here. + args: ["--app=controller"] imagePullPolicy: Never ports: - containerPort: 8081 name: health - - containerPort: 6443 - name: https - - containerPort: 8090 - name: mcp livenessProbe: httpGet: path: /healthz From 7f5c16d313d8ef33ea8b550cf50bea7bf216bd55 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 22:28:09 +0000 Subject: [PATCH 11/12] docs: use unified deployment manifest in guides --- docs/how-to/deploy-aggregated-apiserver.md | 10 +++++++--- docs/how-to/deploy-controller.md | 12 ++++++++---- docs/how-to/mcp-server.md | 6 ++++-- examples/cloudnativepg/README.md | 8 +++++--- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/how-to/deploy-aggregated-apiserver.md b/docs/how-to/deploy-aggregated-apiserver.md index 6c28d82f..c17110b8 100644 --- a/docs/how-to/deploy-aggregated-apiserver.md +++ b/docs/how-to/deploy-aggregated-apiserver.md @@ -16,7 +16,7 @@ kubectl create namespace coder-system ## 2. Apply RBAC -The RBAC manifest includes service accounts for both the controller and the aggregated API server. +The RBAC manifest creates the unified `coder-k8s` ServiceAccount used by all app modes. ```bash kubectl apply -f deploy/rbac.yaml @@ -26,9 +26,13 @@ kubectl apply -f deploy/rbac.yaml ```bash kubectl apply -f deploy/apiserver-service.yaml -kubectl apply -f deploy/apiserver-deployment.yaml +kubectl apply -f deploy/deployment.yaml ``` +`deploy/deployment.yaml` defaults to `--app=all`, which runs the controller, aggregated API server, and MCP server in a single pod. + +For split deployments, you can still run individual components by setting `--app=controller`, `--app=aggregated-apiserver`, or `--app=mcp-http` in the Deployment args. + ## 4. Register the APIService ```bash @@ -40,7 +44,7 @@ kubectl apply -f deploy/apiserver-apiservice.yaml Wait for the deployment: ```bash -kubectl rollout status deployment/coder-k8s-apiserver -n coder-system +kubectl rollout status deployment/coder-k8s -n coder-system ``` Check the APIService: diff --git a/docs/how-to/deploy-controller.md b/docs/how-to/deploy-controller.md index bf2ba44d..64c4b0f3 100644 --- a/docs/how-to/deploy-controller.md +++ b/docs/how-to/deploy-controller.md @@ -24,19 +24,23 @@ kubectl apply -f config/crd/bases/ kubectl apply -f deploy/rbac.yaml ``` -## 4. Deploy the controller +## 4. Deploy `coder-k8s` ```bash -kubectl apply -f deploy/controller-deployment.yaml +kubectl apply -f deploy/deployment.yaml ``` +`deploy/deployment.yaml` defaults to `--app=all`, which runs the controller, aggregated API server, and MCP server in a single pod. + +For split deployments, you can still run individual components by setting `--app=controller`, `--app=aggregated-apiserver`, or `--app=mcp-http` in the Deployment args. + ## 5. Verify ```bash -kubectl rollout status deployment/coder-k8s-controller -n coder-system +kubectl rollout status deployment/coder-k8s -n coder-system kubectl get pods -n coder-system ``` ## Customizing the image -By default, `deploy/controller-deployment.yaml` uses `ghcr.io/coder/coder-k8s:latest`. For a different image tag, edit the deployment manifest before applying it. +By default, `deploy/deployment.yaml` uses `ghcr.io/coder/coder-k8s:latest`. For a different image tag, edit the deployment manifest before applying it. diff --git a/docs/how-to/mcp-server.md b/docs/how-to/mcp-server.md index 2c3a9c64..b7f315a9 100644 --- a/docs/how-to/mcp-server.md +++ b/docs/how-to/mcp-server.md @@ -2,7 +2,7 @@ This guide shows how to run the `coder-k8s` **MCP server** for local development and in-cluster access. -The MCP server runs in HTTP mode (`--app=mcp-http`). +`deploy/deployment.yaml` defaults to `--app=all`, which runs the controller, aggregated API server, and MCP server in a single pod. For split deployments, set `--app=mcp-http` (or `--app=controller` / `--app=aggregated-apiserver`) in the Deployment args. ## 1. Overview @@ -21,10 +21,12 @@ Apply RBAC, deployment, and service manifests: ```bash kubectl apply -f deploy/rbac.yaml -kubectl apply -f deploy/mcp-deployment.yaml +kubectl apply -f deploy/deployment.yaml kubectl apply -f deploy/mcp-service.yaml ``` +The RBAC manifest creates the unified `coder-k8s` ServiceAccount used by the Deployment. + Port-forward the MCP service: ```bash diff --git a/examples/cloudnativepg/README.md b/examples/cloudnativepg/README.md index 7f593c7d..d0e28e8a 100644 --- a/examples/cloudnativepg/README.md +++ b/examples/cloudnativepg/README.md @@ -24,7 +24,7 @@ helm upgrade --install cnpg cnpg/cloudnative-pg \ --create-namespace ``` -## 2. Install the coder-k8s controller +## 2. Install `coder-k8s` Follow [Deploy the controller (in-cluster)](../../docs/how-to/deploy-controller.md), or run: @@ -32,10 +32,12 @@ Follow [Deploy the controller (in-cluster)](../../docs/how-to/deploy-controller. kubectl create namespace coder-system kubectl apply -f config/crd/bases/ kubectl apply -f deploy/rbac.yaml -kubectl apply -f deploy/controller-deployment.yaml -kubectl rollout status deployment/coder-k8s-controller -n coder-system +kubectl apply -f deploy/deployment.yaml +kubectl rollout status deployment/coder-k8s -n coder-system ``` +`deploy/deployment.yaml` defaults to `--app=all`, which runs the controller, aggregated API server, and MCP server in a single pod. For split deployments, you can set `--app=controller`, `--app=aggregated-apiserver`, or `--app=mcp-http` in the Deployment args. + ## 3. Deploy this example ```bash From d9b017da730919836ffb3e036b66fe282ade371c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 11 Feb 2026 22:41:09 +0000 Subject: [PATCH 12/12] allapp: plumb coder request timeout in all mode --- app_dispatch.go | 8 ++++---- internal/app/allapp/allapp.go | 21 ++++++++++++++++----- internal/app/allapp/allapp_test.go | 3 ++- main_test.go | 15 ++++++++++++--- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/app_dispatch.go b/app_dispatch.go index 53418f23..872b4478 100644 --- a/app_dispatch.go +++ b/app_dispatch.go @@ -19,9 +19,9 @@ import ( const supportedAppModes = "all, controller, aggregated-apiserver, mcp-http" var ( - runAllApp = allapp.Run - runControllerApp = controllerapp.Run - runAggregatedAPIServerApp = func(ctx context.Context, opts apiserverapp.Options) error { + runAllApp func(context.Context, time.Duration) error = allapp.Run + runControllerApp = controllerapp.Run + runAggregatedAPIServerApp = func(ctx context.Context, opts apiserverapp.Options) error { return apiserverapp.RunWithOptions(ctx, opts) } runMCPHTTPApp = mcpapp.RunHTTP @@ -85,7 +85,7 @@ func run(args []string) error { switch appMode { case "all": - return runAllApp(setupSignalHandler()) + return runAllApp(setupSignalHandler(), coderRequestTimeout) case "controller": return runControllerApp(setupSignalHandler()) case "aggregated-apiserver": diff --git a/internal/app/allapp/allapp.go b/internal/app/allapp/allapp.go index 6392661e..d7b68acb 100644 --- a/internal/app/allapp/allapp.go +++ b/internal/app/allapp/allapp.go @@ -18,8 +18,8 @@ import ( ) const ( - cacheSyncTimeout = 30 * time.Second - coderRequestTimeout = 30 * time.Second + cacheSyncTimeout = 30 * time.Second + defaultCoderRequestTimeout = 30 * time.Second ) var ( @@ -51,10 +51,18 @@ func (nonLeaderRunnable) NeedLeaderElection() bool { } // Run starts all app modes together using a shared controller-runtime manager/cache. -func Run(ctx context.Context) error { +func Run(ctx context.Context, coderRequestTimeout time.Duration) error { if ctx == nil { return fmt.Errorf("assertion failed: context must not be nil") } + if coderRequestTimeout < 0 { + return fmt.Errorf("assertion failed: coder request timeout must not be negative: %s", coderRequestTimeout) + } + + requestTimeout := coderRequestTimeout + if requestTimeout == 0 { + requestTimeout = defaultCoderRequestTimeout + } scheme := sharedscheme.New() if scheme == nil { @@ -101,7 +109,7 @@ func Run(ctx context.Context) error { return fmt.Errorf("assertion failed: manager API reader is nil") } - provider, err := coder.NewControlPlaneClientProvider(managerClient, apiReader, coderRequestTimeout) + provider, err := coder.NewControlPlaneClientProvider(managerClient, apiReader, requestTimeout) if err != nil { return fmt.Errorf("build control plane client provider: %w", err) } @@ -109,7 +117,10 @@ func Run(ctx context.Context) error { return fmt.Errorf("assertion failed: control plane client provider is nil after successful construction") } - return runAggregatedAPIServer(runnableCtx, apiserverapp.Options{ClientProvider: provider}) + return runAggregatedAPIServer(runnableCtx, apiserverapp.Options{ + ClientProvider: provider, + CoderRequestTimeout: requestTimeout, + }) }, }); err != nil { return fmt.Errorf("add aggregated-apiserver runnable: %w", err) diff --git a/internal/app/allapp/allapp_test.go b/internal/app/allapp/allapp_test.go index ad0525fe..24fea250 100644 --- a/internal/app/allapp/allapp_test.go +++ b/internal/app/allapp/allapp_test.go @@ -5,13 +5,14 @@ import ( "errors" "strings" "testing" + "time" ) func TestRunRejectsNilContext(t *testing.T) { t.Helper() var nilCtx context.Context - err := Run(nilCtx) + err := Run(nilCtx, 30*time.Second) if err == nil { t.Fatal("expected an error when context is nil") } diff --git a/main_test.go b/main_test.go index fc134ee0..d630a8b1 100644 --- a/main_test.go +++ b/main_test.go @@ -83,11 +83,14 @@ func TestRunDefaultsToAllMode(t *testing.T) { expectedErr := errors.New("sentinel all error") called := false - runAllApp = func(ctx context.Context) error { + runAllApp = func(ctx context.Context, timeout time.Duration) error { called = true if ctx == nil { t.Fatal("expected non-nil context") } + if got, want := timeout, 30*time.Second; got != want { + t.Fatalf("expected coder request timeout %v, got %v", want, got) + } return expectedErr } @@ -111,12 +114,18 @@ func TestRunDispatchesAllMode(t *testing.T) { expectedErr := errors.New("sentinel all error") called := false - runAllApp = func(context.Context) error { + runAllApp = func(ctx context.Context, timeout time.Duration) error { called = true + if ctx == nil { + t.Fatal("expected non-nil context") + } + if got, want := timeout, 45*time.Second; got != want { + t.Fatalf("expected coder request timeout %v, got %v", want, got) + } return expectedErr } - err := run([]string{"--app=all"}) + err := run([]string{"--app=all", "--coder-request-timeout=45s"}) if !called { t.Fatal("expected all runner to be called") }