diff --git a/cmd/gateway.go b/cmd/gateway.go index 96361e5..ca5a7d5 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -65,7 +65,7 @@ func initializeSentry(ctx context.Context, log *logger.Logger) error { defaultCfg.Image.Name, defaultCfg.Image.Tag, ) if err != nil { - log.Fatal().Err(err).Msg("Sentry init failed") + return fmt.Errorf("sentry init failed: %w", err) } defer openmfpcontext.Recover(log) @@ -76,14 +76,14 @@ func initializeTracing(ctx context.Context, log *logger.Logger) (func(ctx contex if defaultCfg.Tracing.Enabled { shutdown, err := traces.InitProvider(ctx, defaultCfg.Tracing.Collector) if err != nil { - log.Fatal().Err(err).Msg("unable to start gRPC-Sidecar TracerProvider") + return nil, fmt.Errorf("unable to start gRPC-Sidecar TracerProvider: %w", err) } return shutdown, nil } shutdown, err := traces.InitLocalProvider(ctx, defaultCfg.Tracing.Collector, false) if err != nil { - log.Fatal().Err(err).Msg("unable to start local TracerProvider") + return nil, fmt.Errorf("unable to start local TracerProvider: %w", err) } return shutdown, nil } diff --git a/cmd/listener.go b/cmd/listener.go index dd61782..ce4ad6b 100644 --- a/cmd/listener.go +++ b/cmd/listener.go @@ -3,6 +3,7 @@ package cmd import ( "context" "crypto/tls" + "errors" kcpapis "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" kcpcore "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" @@ -77,7 +78,20 @@ var listenCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { log.Info().Str("LogLevel", log.GetLevel().String()).Msg("Starting the Listener...") - ctx := ctrl.SetupSignalHandler() + // Set up signal handler and create a cancellable context for coordinated shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Error channel to distinguish error-triggered shutdown from normal signals + errCh := make(chan error, 1) + + // Set up signal handling + signalCtx := ctrl.SetupSignalHandler() + go func() { + <-signalCtx.Done() + log.Info().Msg("received shutdown signal, initiating graceful shutdown") + cancel() + }() + restCfg := ctrl.GetConfigOrDie() mgrOpts := ctrl.Options{ @@ -105,23 +119,32 @@ var listenCmd = &cobra.Command{ } // Create the appropriate reconciler based on configuration - var reconcilerInstance reconciler.CustomReconciler + var reconcilerInstance reconciler.ControllerProvider if appCfg.EnableKcp { - kcpReconciler, err := kcp.NewKCPReconciler(appCfg, reconcilerOpts, log) + kcpManager, err := kcp.NewKCPManager(appCfg, reconcilerOpts, log) if err != nil { - log.Fatal().Err(err).Msg("unable to create KCP reconciler") + log.Fatal().Err(err).Msg("unable to create KCP manager") } + reconcilerInstance = kcpManager // Start virtual workspace watching if path is configured if appCfg.Listener.VirtualWorkspacesConfigPath != "" { go func() { - if err := kcpReconciler.StartVirtualWorkspaceWatching(ctx, appCfg.Listener.VirtualWorkspacesConfigPath); err != nil { - log.Fatal().Err(err).Msg("failed to start virtual workspace watching") + if err := kcpManager.StartVirtualWorkspaceWatching(ctx, appCfg.Listener.VirtualWorkspacesConfigPath); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.Info().Msg("virtual workspace watching stopped due to context cancellation") + cancel() + } else { + log.Error().Err(err).Msg("virtual workspace watching failed, initiating graceful shutdown") + select { + case errCh <- err: + default: + } + cancel() + } } }() } - - reconcilerInstance = kcpReconciler } else { ioHandler, err := workspacefile.NewIOHandler(appCfg.OpenApiDefinitionsPath) if err != nil { @@ -135,14 +158,25 @@ var listenCmd = &cobra.Command{ } // Setup reconciler with its own manager and start everything + // Use the original context for the manager - it will be cancelled if watcher fails if err := startManagerWithReconciler(ctx, reconcilerInstance); err != nil { log.Fatal().Err(err).Msg("failed to start manager with reconciler") } + + // Determine exit reason: error-triggered vs. normal signal + select { + case err := <-errCh: + if err != nil { + log.Fatal().Err(err).Msg("exiting due to critical component failure") + } + default: + log.Info().Msg("graceful shutdown complete") + } }, } // startManagerWithReconciler handles the common manager setup and start operations -func startManagerWithReconciler(ctx context.Context, reconciler reconciler.CustomReconciler) error { +func startManagerWithReconciler(ctx context.Context, reconciler reconciler.ControllerProvider) error { mgr := reconciler.GetManager() if err := reconciler.SetupWithManager(mgr); err != nil { diff --git a/cmd/root.go b/cmd/root.go index cad4a4a..65e31ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,6 +78,7 @@ func initConfig() { // Gateway URL v.SetDefault("gateway-url-virtual-workspace-prefix", "virtual-workspace") v.SetDefault("gateway-url-default-kcp-workspace", "root") + v.SetDefault("gateway-url-kcp-workspace-pattern", "root:orgs:{org}") v.SetDefault("gateway-url-graphql-suffix", "graphql") } diff --git a/common/auth/export_test.go b/common/auth/export_test.go new file mode 100644 index 0000000..eb370e5 --- /dev/null +++ b/common/auth/export_test.go @@ -0,0 +1,6 @@ +package auth + +// ExtractCAFromKubeconfigB64ForTest exposes the extractCAFromKubeconfigB64 method for testing +func (m *MetadataInjector) ExtractCAFromKubeconfigB64ForTest(kubeconfigB64 string) []byte { + return m.extractCAFromKubeconfigB64(kubeconfigB64) +} diff --git a/common/auth/metadata_injector_test.go b/common/auth/metadata_injector_test.go index 00fb8e0..ad5b0a1 100644 --- a/common/auth/metadata_injector_test.go +++ b/common/auth/metadata_injector_test.go @@ -623,3 +623,97 @@ users: assert.Nil(t, result) }) } + +func TestMetadataInjector_ExtractCAFromKubeconfigB64(t *testing.T) { + log := testlogger.New().HideLogOutput().Logger + injector := NewMetadataInjector(log, nil) + + tests := []struct { + name string + kubeconfigB64 string + expectedResult bool // true if CA data should be extracted, false if nil + expectError bool // true if we expect a warning to be logged + }{ + { + name: "valid_kubeconfig_with_ca_data", + kubeconfigB64: base64.StdEncoding.EncodeToString([]byte(` +apiVersion: v1 +kind: Config +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t + server: https://example.com + name: test-cluster +contexts: +- context: + cluster: test-cluster + user: test-user + name: test-context +current-context: test-context +users: +- name: test-user + user: + token: test-token +`)), + expectedResult: true, + expectError: false, + }, + { + name: "valid_kubeconfig_without_ca_data", + kubeconfigB64: base64.StdEncoding.EncodeToString([]byte(` +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://example.com + name: test-cluster +contexts: +- context: + cluster: test-cluster + user: test-user + name: test-context +current-context: test-context +users: +- name: test-user + user: + token: test-token +`)), + expectedResult: false, + expectError: false, + }, + { + name: "invalid_base64", + kubeconfigB64: "invalid-base64-!@#$%", + expectedResult: false, + expectError: true, + }, + { + name: "empty_string", + kubeconfigB64: "", + expectedResult: false, + expectError: false, + }, + { + name: "invalid_yaml_content", + kubeconfigB64: base64.StdEncoding.EncodeToString([]byte(` +invalid yaml content +not a kubeconfig +`)), + expectedResult: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := injector.ExtractCAFromKubeconfigB64ForTest(tt.kubeconfigB64) + + if tt.expectedResult { + assert.NotNil(t, result, "Expected CA data to be extracted") + assert.Greater(t, len(result), 0, "Expected non-empty CA data") + } else { + assert.Nil(t, result, "Expected no CA data to be extracted") + } + }) + } +} diff --git a/common/config/config.go b/common/config/config.go index 8e46a94..a8f6f0f 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -9,6 +9,7 @@ type Config struct { Url struct { VirtualWorkspacePrefix string `mapstructure:"gateway-url-virtual-workspace-prefix"` DefaultKcpWorkspace string `mapstructure:"gateway-url-default-kcp-workspace"` + KcpWorkspacePattern string `mapstructure:"gateway-url-kcp-workspace-pattern"` GraphqlSuffix string `mapstructure:"gateway-url-graphql-suffix"` } `mapstructure:",squash"` diff --git a/common/mocks/mock_Client.go b/common/mocks/mock_Client.go index 0491bbd..d46cd3c 100644 --- a/common/mocks/mock_Client.go +++ b/common/mocks/mock_Client.go @@ -29,6 +29,68 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter { return &MockClient_Expecter{mock: &_m.Mock} } +// Apply provides a mock function with given fields: ctx, obj, opts +func (_m *MockClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, obj) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Apply") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, runtime.ApplyConfiguration, ...client.ApplyOption) error); ok { + r0 = rf(ctx, obj, opts...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockClient_Apply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Apply' +type MockClient_Apply_Call struct { + *mock.Call +} + +// Apply is a helper method to define mock.On call +// - ctx context.Context +// - obj runtime.ApplyConfiguration +// - opts ...client.ApplyOption +func (_e *MockClient_Expecter) Apply(ctx interface{}, obj interface{}, opts ...interface{}) *MockClient_Apply_Call { + return &MockClient_Apply_Call{Call: _e.mock.On("Apply", + append([]interface{}{ctx, obj}, opts...)...)} +} + +func (_c *MockClient_Apply_Call) Run(run func(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption)) *MockClient_Apply_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]client.ApplyOption, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(client.ApplyOption) + } + } + run(args[0].(context.Context), args[1].(runtime.ApplyConfiguration), variadicArgs...) + }) + return _c +} + +func (_c *MockClient_Apply_Call) Return(_a0 error) *MockClient_Apply_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockClient_Apply_Call) RunAndReturn(run func(context.Context, runtime.ApplyConfiguration, ...client.ApplyOption) error) *MockClient_Apply_Call { + _c.Call.Return(run) + return _c +} + // Create provides a mock function with given fields: ctx, obj, opts func (_m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { _va := make([]interface{}, len(opts)) diff --git a/common/mocks/mock_WithWatch.go b/common/mocks/mock_WithWatch.go index c53f70c..277ae00 100644 --- a/common/mocks/mock_WithWatch.go +++ b/common/mocks/mock_WithWatch.go @@ -31,6 +31,68 @@ func (_m *MockWithWatch) EXPECT() *MockWithWatch_Expecter { return &MockWithWatch_Expecter{mock: &_m.Mock} } +// Apply provides a mock function with given fields: ctx, obj, opts +func (_m *MockWithWatch) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, obj) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Apply") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, runtime.ApplyConfiguration, ...client.ApplyOption) error); ok { + r0 = rf(ctx, obj, opts...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockWithWatch_Apply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Apply' +type MockWithWatch_Apply_Call struct { + *mock.Call +} + +// Apply is a helper method to define mock.On call +// - ctx context.Context +// - obj runtime.ApplyConfiguration +// - opts ...client.ApplyOption +func (_e *MockWithWatch_Expecter) Apply(ctx interface{}, obj interface{}, opts ...interface{}) *MockWithWatch_Apply_Call { + return &MockWithWatch_Apply_Call{Call: _e.mock.On("Apply", + append([]interface{}{ctx, obj}, opts...)...)} +} + +func (_c *MockWithWatch_Apply_Call) Run(run func(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption)) *MockWithWatch_Apply_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]client.ApplyOption, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(client.ApplyOption) + } + } + run(args[0].(context.Context), args[1].(runtime.ApplyConfiguration), variadicArgs...) + }) + return _c +} + +func (_c *MockWithWatch_Apply_Call) Return(_a0 error) *MockWithWatch_Apply_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWithWatch_Apply_Call) RunAndReturn(run func(context.Context, runtime.ApplyConfiguration, ...client.ApplyOption) error) *MockWithWatch_Apply_Call { + _c.Call.Return(run) + return _c +} + // Create provides a mock function with given fields: ctx, obj, opts func (_m *MockWithWatch) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { _va := make([]interface{}, len(opts)) diff --git a/common/watcher/watcher_test.go b/common/watcher/watcher_test.go index e2e4d1e..92188a1 100644 --- a/common/watcher/watcher_test.go +++ b/common/watcher/watcher_test.go @@ -362,6 +362,8 @@ func TestWatchSingleFile_InvalidDirectory(t *testing.T) { } func TestWatchSingleFile_RealFile(t *testing.T) { + t.Parallel() // Run in parallel to avoid resource conflicts + log := testlogger.New().HideLogOutput().Logger handler := &MockFileEventHandler{} @@ -388,15 +390,15 @@ func TestWatchSingleFile_RealFile(t *testing.T) { watchDone <- watcher.WatchSingleFile(ctx, tempFile, 50) // 50ms debounce }() - // Give the watcher time to start - time.Sleep(30 * time.Millisecond) + // Give the watcher more time to start (increased for parallel execution) + time.Sleep(100 * time.Millisecond) // Modify the file to trigger an event err = os.WriteFile(tempFile, []byte("modified"), 0644) require.NoError(t, err) - // Give time for file change to be detected and debounced - time.Sleep(150 * time.Millisecond) // 50ms debounce + extra buffer + // Give more time for file change to be detected and debounced (increased for parallel execution) + time.Sleep(300 * time.Millisecond) // 50ms debounce + extra buffer for parallel tests // Wait for watch to finish (should timeout after remaining time) err = <-watchDone @@ -626,6 +628,8 @@ func TestHandleEvent_StatError(t *testing.T) { } func TestWatchSingleFile_WithDebounceTimer(t *testing.T) { + t.Parallel() // Run in parallel to avoid resource conflicts + log := testlogger.New().HideLogOutput().Logger handler := &MockFileEventHandler{} @@ -976,6 +980,8 @@ func TestWatchDirectory_ErrorInLoop(t *testing.T) { // TestWatchSingleFile_TimerStop tests the timer stop path in watchWithDebounce func TestWatchSingleFile_TimerStop(t *testing.T) { + t.Parallel() // Run in parallel to avoid resource conflicts + log := testlogger.New().HideLogOutput().Logger handler := &MockFileEventHandler{} diff --git a/docs/virtual-workspaces.md b/docs/virtual-workspaces.md index 07b530a..e1442be 100644 --- a/docs/virtual-workspaces.md +++ b/docs/virtual-workspaces.md @@ -11,9 +11,11 @@ virtualWorkspaces: - name: example url: https://192.168.1.118:6443/services/apiexport/root/configmaps-view kubeconfig: PATH_TO_KCP_KUBECONFIG -- name: another-service - url: https://your-kcp-server:6443/services/apiexport/root/your-export - kubeconfig: PATH_TO_KCP_KUBECONFIG + # The URL must contain both workspace path and export name + # Format: /services/apiexport/{workspace-path}/{export-name} + # Workspace is resolved dynamically from user request: + # User request: /virtual-workspace/example/root:orgs:alpha/query + # → Connects to: /services/apiexport/root/configmaps-view/clusters/root:orgs:alpha/api/v1/configmaps ``` ### Configuration Options @@ -21,14 +23,26 @@ virtualWorkspaces: - `virtualWorkspaces`: Array of virtual workspace definitions - `name`: Unique identifier for the virtual workspace (used in URL paths) - `url`: Full URL to the virtual workspace or API export - - `kubeconfig`: path to kcp kubeconfig + - `kubeconfig`: Path to KCP kubeconfig -## Environment Variables +### Dynamic Workspace Resolution + +Virtual workspaces use **dynamic workspace resolution**: +- Workspace is extracted from the GraphQL request URL at runtime +- Each request can target different workspaces: `/virtual-workspace/contentconfigurations/root:orgs:alpha/query` +- No need to predefine target workspaces in configuration -Set the configuration path using: +## Environment Variables ```bash -export VIRTUAL_WORKSPACES_CONFIG_PATH="./bin/virtual-workspaces/config.yaml" +# Virtual workspaces configuration file path +export VIRTUAL_WORKSPACES_CONFIG_PATH="./config/virtual-workspaces.yaml" + +# Default workspace for schema generation (default: "root") +export GATEWAY_URL_DEFAULT_KCP_WORKSPACE="root" + +# Workspace pattern for building full paths (default: "root:orgs:{org}") +export GATEWAY_URL_KCP_WORKSPACE_PATTERN="root:orgs:{org}" ``` ## URL Pattern @@ -46,8 +60,12 @@ For example: ## How It Works 1. **Configuration Watching**: The listener watches the virtual workspaces configuration file for changes -2. **Schema Generation**: For each virtual workspace, the listener: - - Creates a discovery client pointing to the virtual workspace URL - - Generates OpenAPI schemas for the available resources +2. **Generic Schema Generation**: For each virtual workspace, the listener: + - Creates a discovery client pointing to the virtual workspace URL with a default workspace + - Generates generic OpenAPI schemas for the available resources - Stores the schema in a file at `virtual-workspace/{name}` -3. **Gateway Integration**: The gateway watches the schema files and exposes virtual workspaces as GraphQL endpoints +3. **Dynamic Workspace Resolution**: When a user makes a GraphQL request: + - The gateway extracts the workspace from the URL (e.g., `root:orgs:alpha`) + - The roundtripper modifies the backend request to include the specific workspace + - Example: `/services/contentconfigurations/api/v1/configmaps` → `/services/contentconfigurations/clusters/root:orgs:alpha/api/v1/configmaps` +4. **Gateway Integration**: The gateway exposes virtual workspaces as GraphQL endpoints with dynamic workspace targeting diff --git a/gateway/manager/context/keys.go b/gateway/manager/context/keys.go new file mode 100644 index 0000000..34fbecc --- /dev/null +++ b/gateway/manager/context/keys.go @@ -0,0 +1,80 @@ +// Package context provides centralized context key definitions and helper functions +// for the gateway manager components. +package context + +import ( + "context" + + "github.com/kcp-dev/logicalcluster/v3" +) + +// Context key types - using unexported struct types for type safety and collision avoidance +type ( + tokenCtxKey struct{} + kcpWorkspaceCtxKey struct{} + clusterNameCtxKey struct{} +) + +// Context key instances +var ( + tokenKey = tokenCtxKey{} + kcpWorkspaceKey = kcpWorkspaceCtxKey{} + clusterNameKey = clusterNameCtxKey{} +) + +// Token context helpers + +// TokenCtxKey is the exported type for token context keys +type TokenCtxKey = tokenCtxKey + +// TokenKey returns the context key for storing authentication tokens +func TokenKey() tokenCtxKey { + return tokenKey +} + +// WithToken stores an authentication token in the context +func WithToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, tokenKey, token) +} + +// TokenFromContext retrieves an authentication token from the context +func TokenFromContext(ctx context.Context) (string, bool) { + token, ok := ctx.Value(tokenKey).(string) + return token, ok +} + +// KCP Workspace context helpers + +// KcpWorkspaceKey returns the context key for storing KCP workspace information +func KcpWorkspaceKey() kcpWorkspaceCtxKey { + return kcpWorkspaceKey +} + +// WithKcpWorkspace stores a KCP workspace identifier in the context +func WithKcpWorkspace(ctx context.Context, workspace string) context.Context { + return context.WithValue(ctx, kcpWorkspaceKey, workspace) +} + +// KcpWorkspaceFromContext retrieves a KCP workspace identifier from the context +func KcpWorkspaceFromContext(ctx context.Context) (string, bool) { + workspace, ok := ctx.Value(kcpWorkspaceKey).(string) + return workspace, ok +} + +// Cluster Name context helpers + +// ClusterNameKey returns the context key for storing logical cluster names +func ClusterNameKey() clusterNameCtxKey { + return clusterNameKey +} + +// WithClusterName stores a logical cluster name in the context +func WithClusterName(ctx context.Context, name logicalcluster.Name) context.Context { + return context.WithValue(ctx, clusterNameKey, name) +} + +// ClusterNameFromContext retrieves a logical cluster name from the context +func ClusterNameFromContext(ctx context.Context) (logicalcluster.Name, bool) { + name, ok := ctx.Value(clusterNameKey).(logicalcluster.Name) + return name, ok +} diff --git a/gateway/manager/context/keys_test.go b/gateway/manager/context/keys_test.go new file mode 100644 index 0000000..0f04760 --- /dev/null +++ b/gateway/manager/context/keys_test.go @@ -0,0 +1,207 @@ +package context_test + +import ( + "context" + "testing" + + "github.com/kcp-dev/logicalcluster/v3" + "github.com/stretchr/testify/assert" + + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" +) + +func TestTokenContextHelpers(t *testing.T) { + t.Run("TokenKey", func(t *testing.T) { + key1 := ctxkeys.TokenKey() + key2 := ctxkeys.TokenKey() + assert.Equal(t, key1, key2, "TokenKey should return consistent values") + }) + + t.Run("WithToken_and_TokenFromContext", func(t *testing.T) { + token := "test-token-123" + + // Store token in context + ctxWithToken := ctxkeys.WithToken(t.Context(), token) + assert.NotEqual(t, t.Context(), ctxWithToken, "Context should be different after adding token") + + // Retrieve token from context + retrievedToken, ok := ctxkeys.TokenFromContext(ctxWithToken) + assert.True(t, ok, "Token should be found in context") + assert.Equal(t, token, retrievedToken, "Retrieved token should match stored token") + }) + + t.Run("TokenFromContext_empty_context", func(t *testing.T) { + token, ok := ctxkeys.TokenFromContext(t.Context()) + assert.False(t, ok, "Token should not be found in empty context") + assert.Empty(t, token, "Token should be empty when not found") + }) + + t.Run("TokenFromContext_wrong_type", func(t *testing.T) { + // Store a non-string value using the token key + ctxWithWrongType := context.WithValue(t.Context(), ctxkeys.TokenKey(), 123) + + token, ok := ctxkeys.TokenFromContext(ctxWithWrongType) + assert.False(t, ok, "Token should not be found when wrong type is stored") + assert.Empty(t, token, "Token should be empty when wrong type is stored") + }) + + t.Run("WithToken_empty_string", func(t *testing.T) { + emptyToken := "" + + ctxWithToken := ctxkeys.WithToken(t.Context(), emptyToken) + retrievedToken, ok := ctxkeys.TokenFromContext(ctxWithToken) + assert.True(t, ok, "Empty token should still be found in context") + assert.Equal(t, emptyToken, retrievedToken, "Empty token should be retrievable") + }) +} + +func TestKcpWorkspaceContextHelpers(t *testing.T) { + t.Run("KcpWorkspaceKey", func(t *testing.T) { + key1 := ctxkeys.KcpWorkspaceKey() + key2 := ctxkeys.KcpWorkspaceKey() + assert.Equal(t, key1, key2, "KcpWorkspaceKey should return consistent values") + }) + + t.Run("WithKcpWorkspace_and_KcpWorkspaceFromContext", func(t *testing.T) { + workspace := "root:orgs:default" + + // Store workspace in context + ctxWithWorkspace := ctxkeys.WithKcpWorkspace(t.Context(), workspace) + assert.NotEqual(t, t.Context(), ctxWithWorkspace, "Context should be different after adding workspace") + + // Retrieve workspace from context + retrievedWorkspace, ok := ctxkeys.KcpWorkspaceFromContext(ctxWithWorkspace) + assert.True(t, ok, "Workspace should be found in context") + assert.Equal(t, workspace, retrievedWorkspace, "Retrieved workspace should match stored workspace") + }) + + t.Run("KcpWorkspaceFromContext_empty_context", func(t *testing.T) { + workspace, ok := ctxkeys.KcpWorkspaceFromContext(t.Context()) + assert.False(t, ok, "Workspace should not be found in empty context") + assert.Empty(t, workspace, "Workspace should be empty when not found") + }) + + t.Run("KcpWorkspaceFromContext_wrong_type", func(t *testing.T) { + // Store a non-string value using the workspace key + ctxWithWrongType := context.WithValue(t.Context(), ctxkeys.KcpWorkspaceKey(), 456) + + workspace, ok := ctxkeys.KcpWorkspaceFromContext(ctxWithWrongType) + assert.False(t, ok, "Workspace should not be found when wrong type is stored") + assert.Empty(t, workspace, "Workspace should be empty when wrong type is stored") + }) + + t.Run("WithKcpWorkspace_complex_path", func(t *testing.T) { + complexWorkspace := "root:orgs:company:team:project" + + ctxWithWorkspace := ctxkeys.WithKcpWorkspace(t.Context(), complexWorkspace) + retrievedWorkspace, ok := ctxkeys.KcpWorkspaceFromContext(ctxWithWorkspace) + assert.True(t, ok, "Complex workspace should be found in context") + assert.Equal(t, complexWorkspace, retrievedWorkspace, "Complex workspace should be retrievable") + }) +} + +func TestClusterNameContextHelpers(t *testing.T) { + t.Run("ClusterNameKey", func(t *testing.T) { + key1 := ctxkeys.ClusterNameKey() + key2 := ctxkeys.ClusterNameKey() + assert.Equal(t, key1, key2, "ClusterNameKey should return consistent values") + }) + + t.Run("WithClusterName_and_ClusterNameFromContext", func(t *testing.T) { + clusterName := logicalcluster.Name("test-cluster") + + // Store cluster name in context + ctxWithClusterName := ctxkeys.WithClusterName(t.Context(), clusterName) + assert.NotEqual(t, t.Context(), ctxWithClusterName, "Context should be different after adding cluster name") + + // Retrieve cluster name from context + retrievedClusterName, ok := ctxkeys.ClusterNameFromContext(ctxWithClusterName) + assert.True(t, ok, "Cluster name should be found in context") + assert.Equal(t, clusterName, retrievedClusterName, "Retrieved cluster name should match stored cluster name") + }) + + t.Run("ClusterNameFromContext_empty_context", func(t *testing.T) { + clusterName, ok := ctxkeys.ClusterNameFromContext(t.Context()) + assert.False(t, ok, "Cluster name should not be found in empty context") + assert.Equal(t, logicalcluster.Name(""), clusterName, "Cluster name should be empty when not found") + }) + + t.Run("ClusterNameFromContext_wrong_type", func(t *testing.T) { + // Store a non-logicalcluster.Name value using the cluster name key + ctxWithWrongType := context.WithValue(t.Context(), ctxkeys.ClusterNameKey(), "wrong-type") + + clusterName, ok := ctxkeys.ClusterNameFromContext(ctxWithWrongType) + assert.False(t, ok, "Cluster name should not be found when wrong type is stored") + assert.Equal(t, logicalcluster.Name(""), clusterName, "Cluster name should be empty when wrong type is stored") + }) + + t.Run("WithClusterName_root_cluster", func(t *testing.T) { + rootCluster := logicalcluster.Name("root") + + ctxWithClusterName := ctxkeys.WithClusterName(t.Context(), rootCluster) + retrievedClusterName, ok := ctxkeys.ClusterNameFromContext(ctxWithClusterName) + assert.True(t, ok, "Root cluster name should be found in context") + assert.Equal(t, rootCluster, retrievedClusterName, "Root cluster name should be retrievable") + }) + + t.Run("WithClusterName_empty_name", func(t *testing.T) { + emptyName := logicalcluster.Name("") + + ctxWithClusterName := ctxkeys.WithClusterName(t.Context(), emptyName) + retrievedClusterName, ok := ctxkeys.ClusterNameFromContext(ctxWithClusterName) + assert.True(t, ok, "Empty cluster name should still be found in context") + assert.Equal(t, emptyName, retrievedClusterName, "Empty cluster name should be retrievable") + }) +} + +func TestContextKeyIsolation(t *testing.T) { + t.Run("different_keys_dont_interfere", func(t *testing.T) { + // Store values with different keys + token := "test-token" + workspace := "test-workspace" + clusterName := logicalcluster.Name("test-cluster") + + ctx := ctxkeys.WithToken(t.Context(), token) + ctx = ctxkeys.WithKcpWorkspace(ctx, workspace) + ctx = ctxkeys.WithClusterName(ctx, clusterName) + + // Verify all values can be retrieved independently + retrievedToken, tokenOk := ctxkeys.TokenFromContext(ctx) + retrievedWorkspace, workspaceOk := ctxkeys.KcpWorkspaceFromContext(ctx) + retrievedClusterName, clusterOk := ctxkeys.ClusterNameFromContext(ctx) + + assert.True(t, tokenOk, "Token should be retrievable") + assert.True(t, workspaceOk, "Workspace should be retrievable") + assert.True(t, clusterOk, "Cluster name should be retrievable") + + assert.Equal(t, token, retrievedToken, "Token should match") + assert.Equal(t, workspace, retrievedWorkspace, "Workspace should match") + assert.Equal(t, clusterName, retrievedClusterName, "Cluster name should match") + }) + + t.Run("overwriting_values", func(t *testing.T) { + // Store initial values + initialToken := "initial-token" + initialWorkspace := "initial-workspace" + + ctx := ctxkeys.WithToken(t.Context(), initialToken) + ctx = ctxkeys.WithKcpWorkspace(ctx, initialWorkspace) + + // Overwrite with new values + newToken := "new-token" + newWorkspace := "new-workspace" + + ctx = ctxkeys.WithToken(ctx, newToken) + ctx = ctxkeys.WithKcpWorkspace(ctx, newWorkspace) + + // Verify new values are retrieved + retrievedToken, tokenOk := ctxkeys.TokenFromContext(ctx) + retrievedWorkspace, workspaceOk := ctxkeys.KcpWorkspaceFromContext(ctx) + + assert.True(t, tokenOk, "Token should be retrievable") + assert.True(t, workspaceOk, "Workspace should be retrievable") + + assert.Equal(t, newToken, retrievedToken, "Should get new token value") + assert.Equal(t, newWorkspace, retrievedWorkspace, "Should get new workspace value") + }) +} diff --git a/gateway/manager/roundtripper/export_test.go b/gateway/manager/roundtripper/export_test.go new file mode 100644 index 0000000..0534316 --- /dev/null +++ b/gateway/manager/roundtripper/export_test.go @@ -0,0 +1,13 @@ +package roundtripper + +import "net/http" + +// NewUnauthorizedRoundTripperForTest creates an unauthorizedRoundTripper for testing +func NewUnauthorizedRoundTripperForTest() http.RoundTripper { + return &unauthorizedRoundTripper{} +} + +// IsWorkspaceQualifiedForTest exports isWorkspaceQualified for testing +func IsWorkspaceQualifiedForTest(path string) bool { + return isWorkspaceQualified(path) +} diff --git a/gateway/manager/roundtripper/roundtripper.go b/gateway/manager/roundtripper/roundtripper.go index ec5fdd3..4f48639 100644 --- a/gateway/manager/roundtripper/roundtripper.go +++ b/gateway/manager/roundtripper/roundtripper.go @@ -9,10 +9,9 @@ import ( "k8s.io/client-go/transport" "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" ) -type TokenKey struct{} - type roundTripper struct { log *logger.Logger adminRT, unauthorizedRT http.RoundTripper @@ -45,6 +44,9 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { Str("usernameClaim", rt.appCfg.Gateway.UsernameClaim). Msg("RoundTripper processing request") + // Handle virtual workspace URL modification + req = rt.handleVirtualWorkspaceURL(req) + if rt.appCfg.LocalDevelopment { rt.log.Debug().Str("path", req.URL.Path).Msg("Local development mode, using admin credentials") return rt.adminRT.RoundTrip(req) @@ -58,7 +60,7 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return rt.adminRT.RoundTrip(req) } - token, ok := req.Context().Value(TokenKey{}).(string) + token, ok := ctxkeys.TokenFromContext(req.Context()) if !ok || token == "" { rt.log.Error().Str("path", req.URL.Path).Msg("No token found for resource request, denying") return rt.unauthorizedRT.RoundTrip(req) @@ -113,39 +115,102 @@ func (u *unauthorizedRoundTripper) RoundTrip(req *http.Request) (*http.Response, } func isDiscoveryRequest(req *http.Request) bool { - // Only GET requests can be discovery requests if req.Method != http.MethodGet { return false } - // Parse and clean the URL path path := req.URL.Path - path = strings.Trim(path, "/") // remove leading and trailing slashes + path = strings.Trim(path, "/") if path == "" { return false } parts := strings.Split(path, "/") - // Remove workspace prefixes to get the actual API path - if len(parts) >= 5 && parts[0] == "services" && parts[2] == "clusters" { - // Handle virtual workspace prefixes first: /services//clusters//api - parts = parts[4:] // Remove /services//clusters/ prefix - } else if len(parts) >= 3 && parts[0] == "clusters" { - // Handle KCP workspace prefixes: /clusters//api - parts = parts[2:] // Remove /clusters/ prefix - } + parts = stripWorkspacePrefix(parts) - // Check if the remaining path matches Kubernetes discovery API patterns switch { case len(parts) == 1 && (parts[0] == "api" || parts[0] == "apis"): - return true // /api or /apis (root discovery endpoints) + return true // /api or /apis case len(parts) == 2 && parts[0] == "apis": - return true // /apis/ (group discovery) + return true // /apis/ case len(parts) == 2 && parts[0] == "api": - return true // /api/v1 (core API version discovery) + return true // /api/v1 case len(parts) == 3 && parts[0] == "apis": - return true // /apis// (group version discovery) + return true // /apis// default: return false } } + +func stripWorkspacePrefix(parts []string) []string { + if len(parts) >= 5 && parts[0] == "services" && parts[2] == "clusters" { + return parts[4:] // /services//clusters//api/... + } + if len(parts) >= 3 && parts[0] == "services" { + return parts[2:] // /services//api/... + } + if len(parts) >= 3 && parts[0] == "clusters" { + return parts[2:] // /clusters//api/... + } + return parts +} + +func isWorkspaceQualified(path string) bool { + path = strings.Trim(path, "/") + if path == "" { + return false + } + segments := strings.Split(path, "/") + if len(segments) > 0 && segments[0] == "clusters" { + return true + } + if len(segments) >= 4 && segments[0] == "services" && segments[2] == "clusters" && segments[3] != "" { + return true + } + return false +} + +// handleVirtualWorkspaceURL modifies the request URL for virtual workspace requests +// to include the workspace from the request context +func (rt *roundTripper) handleVirtualWorkspaceURL(req *http.Request) *http.Request { + // Check if this is a virtual workspace request by looking for KCP workspace in context + kcpWorkspace, ok := ctxkeys.KcpWorkspaceFromContext(req.Context()) + if !ok || kcpWorkspace == "" { + // Not a virtual workspace request, return as-is + return req + } + + if isWorkspaceQualified(req.URL.Path) { + return req + } + + parsedURL := *req.URL + + // Modify the URL to include the workspace path + // Transform: /services/contentconfigurations/api/v1/configmaps + // To: /services/contentconfigurations/clusters/root:orgs:alpha/api/v1/configmaps + if strings.HasPrefix(parsedURL.Path, "/services/") { + parts := strings.SplitN(parsedURL.Path, "/", 4) // [, services, serviceName, restOfPath] + if len(parts) >= 3 { + serviceName := parts[2] + restOfPath := "" + if len(parts) > 3 { + restOfPath = "/" + parts[3] + } + + // Reconstruct the URL with the workspace + parsedURL.Path = "/services/" + serviceName + "/clusters/" + kcpWorkspace + restOfPath + + rt.log.Debug(). + Str("originalPath", req.URL.Path). + Str("modifiedPath", parsedURL.Path). + Str("workspace", kcpWorkspace). + Msg("Modified virtual workspace URL") + } + } + + newReq := req.Clone(req.Context()) + newReq.URL = &parsedURL + + return newReq +} diff --git a/gateway/manager/roundtripper/roundtripper_test.go b/gateway/manager/roundtripper/roundtripper_test.go index 9c449bc..68a8a89 100644 --- a/gateway/manager/roundtripper/roundtripper_test.go +++ b/gateway/manager/roundtripper/roundtripper_test.go @@ -1,7 +1,6 @@ package roundtripper_test import ( - "context" "net/http" "net/http/httptest" "testing" @@ -13,6 +12,7 @@ import ( "github.com/stretchr/testify/require" appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/mocks" "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/roundtripper" ) @@ -81,8 +81,7 @@ func TestRoundTripper_RoundTrip(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v1/pods", nil) if tt.token != "" { - ctx := context.WithValue(req.Context(), roundtripper.TokenKey{}, tt.token) - req = req.WithContext(ctx) + req = req.WithContext(ctxkeys.WithToken(req.Context(), tt.token)) } resp, err := rt.RoundTrip(req) @@ -380,8 +379,7 @@ func TestRoundTripper_ComprehensiveFunctionality(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v1/pods", nil) if tt.token != "" { - ctx := context.WithValue(req.Context(), roundtripper.TokenKey{}, tt.token) - req = req.WithContext(ctx) + req = req.WithContext(ctxkeys.WithToken(req.Context(), tt.token)) } resp, err := rt.RoundTrip(req) @@ -535,7 +533,7 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeTokenAuth(t *testing.T) req.Header.Set("Authorization", "Bearer admin-token-that-should-be-removed") // Add the token to context - req = req.WithContext(context.WithValue(req.Context(), roundtripper.TokenKey{}, "user-token")) + req = req.WithContext(ctxkeys.WithToken(req.Context(), "user-token")) resp, err := rt.RoundTrip(req) require.NoError(t, err) @@ -582,7 +580,7 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeImpersonation(t *testin require.NoError(t, err) // Add the token to context - req = req.WithContext(context.WithValue(req.Context(), roundtripper.TokenKey{}, tokenString)) + req = req.WithContext(ctxkeys.WithToken(req.Context(), tokenString)) resp, err := rt.RoundTrip(req) require.NoError(t, err) @@ -600,3 +598,203 @@ func TestRoundTripper_ExistingAuthHeadersAreCleanedBeforeImpersonation(t *testin impersonateHeader := capturedRequest.Header.Get("Impersonate-User") assert.Equal(t, "test-user", impersonateHeader) } + +func TestUnauthorizedRoundTripper_RoundTrip(t *testing.T) { + // Test the unauthorizedRoundTripper directly to cover the 0% coverage function + rt := roundtripper.NewUnauthorizedRoundTripperForTest() + + req := httptest.NewRequest(http.MethodGet, "http://example.com/api/v1/pods", nil) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, req, resp.Request) + assert.Equal(t, http.NoBody, resp.Body) +} + +func TestIsWorkspaceQualified(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + { + name: "clusters_as_first_segment", + path: "/clusters/workspace1/api/v1/pods", + expected: true, + }, + { + name: "clusters_as_first_segment_no_trailing", + path: "/clusters/workspace1", + expected: true, + }, + { + name: "clusters_as_first_segment_with_trailing_slash", + path: "/clusters/", + expected: true, + }, + { + name: "clusters_only", + path: "/clusters", + expected: true, + }, + { + name: "services_then_clusters_false_positive", + path: "/services/clusters/api/v1/pods", + expected: false, + }, + { + name: "services_with_clusters_as_third_segment", + path: "/services/myservice/clusters/workspace1/api/v1/pods", + expected: true, + }, + { + name: "services_with_clusters_short_path_no_workspace", + path: "/services/myservice/clusters", + expected: false, + }, + { + name: "services_with_clusters_empty_workspace", + path: "/services/myservice/clusters/", + expected: false, + }, + { + name: "services_without_clusters", + path: "/services/myservice/api/v1/pods", + expected: false, + }, + { + name: "api_path", + path: "/api/v1/pods", + expected: false, + }, + { + name: "empty_path", + path: "", + expected: false, + }, + { + name: "root_path", + path: "/", + expected: false, + }, + { + name: "clusters_in_query_param", + path: "/api?param=clusters", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := roundtripper.IsWorkspaceQualifiedForTest(tt.path) + assert.Equal(t, tt.expected, result, + "isWorkspaceQualified(%s) = %v, expected %v", tt.path, result, tt.expected) + }) + } +} + +func TestRoundTripper_WorkspaceQualifiedPathDetection(t *testing.T) { + tests := []struct { + name string + path string + workspace string + expectedOriginalPath string + expectedModifiedPath string + }{ + { + name: "clusters_first_segment_is_qualified", + path: "/clusters/workspace1/api/v1/pods", + workspace: "workspace2", + expectedOriginalPath: "/clusters/workspace1/api/v1/pods", + expectedModifiedPath: "/clusters/workspace1/api/v1/pods", + }, + { + name: "clusters_first_segment_short_path", + path: "/clusters/workspace1", + workspace: "workspace2", + expectedOriginalPath: "/clusters/workspace1", + expectedModifiedPath: "/clusters/workspace1", + }, + { + name: "services_with_clusters_in_middle_not_qualified", + path: "/services/clusters/api/v1/pods", + workspace: "workspace1", + expectedOriginalPath: "/services/clusters/api/v1/pods", + expectedModifiedPath: "/services/clusters/clusters/workspace1/api/v1/pods", + }, + { + name: "services_normal_path_not_qualified", + path: "/services/myservice/api/v1/pods", + workspace: "workspace1", + expectedOriginalPath: "/services/myservice/api/v1/pods", + expectedModifiedPath: "/services/myservice/clusters/workspace1/api/v1/pods", + }, + { + name: "services_with_existing_clusters_qualified", + path: "/services/myservice/clusters/workspace1/api/v1/pods", + workspace: "workspace2", + expectedOriginalPath: "/services/myservice/clusters/workspace1/api/v1/pods", + expectedModifiedPath: "/services/myservice/clusters/workspace1/api/v1/pods", + }, + { + name: "api_path_not_qualified", + path: "/api/v1/pods", + workspace: "workspace1", + expectedOriginalPath: "/api/v1/pods", + expectedModifiedPath: "/api/v1/pods", + }, + { + name: "root_path_not_qualified", + path: "/", + workspace: "workspace1", + expectedOriginalPath: "/", + expectedModifiedPath: "/", + }, + { + name: "empty_workspace_no_modification", + path: "/services/myservice/api/v1/pods", + workspace: "", + expectedOriginalPath: "/services/myservice/api/v1/pods", + expectedModifiedPath: "/services/myservice/api/v1/pods", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAdmin := &mocks.MockRoundTripper{} + mockUnauthorized := &mocks.MockRoundTripper{} + + var capturedRequest *http.Request + mockAdmin.EXPECT().RoundTrip(mock.Anything).Return(&http.Response{StatusCode: http.StatusOK}, nil).Run(func(req *http.Request) { + capturedRequest = req + }) + + appCfg := appConfig.Config{ + LocalDevelopment: true, + } + + rt := roundtripper.New(testlogger.New().Logger, appCfg, mockAdmin, mockUnauthorized) + + req := httptest.NewRequest(http.MethodGet, "http://example.com"+tt.path, nil) + + if tt.workspace != "" { + req = req.WithContext(ctxkeys.WithKcpWorkspace(req.Context(), tt.workspace)) + } + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + require.NotNil(t, resp) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + require.NotNil(t, capturedRequest) + assert.Equal(t, tt.expectedModifiedPath, capturedRequest.URL.Path, + "Expected path to be %s but got %s", tt.expectedModifiedPath, capturedRequest.URL.Path) + + mockAdmin.AssertExpectations(t) + }) + } +} diff --git a/gateway/manager/targetcluster/cluster.go b/gateway/manager/targetcluster/cluster.go index 612b279..e0f898b 100644 --- a/gateway/manager/targetcluster/cluster.go +++ b/gateway/manager/targetcluster/cluster.go @@ -11,7 +11,6 @@ import ( "github.com/platform-mesh/golang-commons/logger" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/kcp" "github.com/platform-mesh/kubernetes-graphql-gateway/common/auth" appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" @@ -120,12 +119,8 @@ func (tc *TargetCluster) connect(appCfg appConfig.Config, metadata *ClusterMetad }) } - // Create client - use KCP-aware client only for KCP mode, standard client otherwise - if appCfg.EnableKcp { - tc.client, err = kcp.NewClusterAwareClientWithWatch(tc.restCfg, client.Options{}) - } else { - tc.client, err = client.NewWithWatch(tc.restCfg, client.Options{}) - } + // multicluster-runtime uses standard client.WithWatch internally for each cluster it manages. + tc.client, err = client.NewWithWatch(tc.restCfg, client.Options{}) if err != nil { return fmt.Errorf("failed to create cluster client: %w", err) } diff --git a/gateway/manager/targetcluster/cluster_test.go b/gateway/manager/targetcluster/cluster_test.go index fe6d5af..b507818 100644 --- a/gateway/manager/targetcluster/cluster_test.go +++ b/gateway/manager/targetcluster/cluster_test.go @@ -356,6 +356,44 @@ users: } } +func TestTargetCluster_GetName(t *testing.T) { + tests := []struct { + name string + clusterName string + }{ + { + name: "simple_name", + clusterName: "production", + }, + { + name: "name_with_dashes", + clusterName: "staging-cluster", + }, + { + name: "virtual_workspace_name", + clusterName: "virtual-workspace/my-workspace", + }, + { + name: "empty_name", + clusterName: "", + }, + { + name: "single_character", + clusterName: "a", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc := targetcluster.NewTestTargetCluster(tt.clusterName) + + result := tc.GetName() + + assert.Equal(t, tt.clusterName, result) + }) + } +} + func TestTargetCluster_GetEndpoint(t *testing.T) { tests := []struct { name string diff --git a/gateway/manager/targetcluster/export_test.go b/gateway/manager/targetcluster/export_test.go index 21ea083..951d331 100644 --- a/gateway/manager/targetcluster/export_test.go +++ b/gateway/manager/targetcluster/export_test.go @@ -30,3 +30,8 @@ func CreateTestConfig(localDev bool, gatewayPort string) appConfig.Config { config.Url.GraphqlSuffix = "graphql" return config } + +// ExtractClusterNameFromPathForTest exposes the extractClusterNameFromPath method for testing +func (cr *ClusterRegistry) ExtractClusterNameFromPathForTest(schemaFilePath string) string { + return cr.extractClusterNameFromPath(schemaFilePath) +} diff --git a/gateway/manager/targetcluster/graphql.go b/gateway/manager/targetcluster/graphql.go index a0f63ac..718ae0d 100644 --- a/gateway/manager/targetcluster/graphql.go +++ b/gateway/manager/targetcluster/graphql.go @@ -2,7 +2,6 @@ package targetcluster import ( "bytes" - "context" "encoding/json" "fmt" "io" @@ -12,12 +11,11 @@ import ( "github.com/graphql-go/graphql" "github.com/graphql-go/handler" "github.com/kcp-dev/logicalcluster/v3" - "sigs.k8s.io/controller-runtime/pkg/kontext" "github.com/platform-mesh/golang-commons/logger" appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" - "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/roundtripper" + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" ) // GraphQLHandler wraps a GraphQL schema and HTTP handler @@ -57,16 +55,16 @@ func (s *GraphQLServer) CreateHandler(schema *graphql.Schema) *GraphQLHandler { // SetContexts sets the required contexts for KCP and authentication func SetContexts(r *http.Request, workspace, token string, enableKcp bool) *http.Request { if enableKcp { - // For virtual workspaces, use the KCP workspace from the request context if available - // This allows the URL to specify the actual KCP workspace (e.g., root, root:orgs) - // while keeping the file mapping based on the virtual workspace name + // For virtual workspaces, the multicluster-runtime will handle cluster context + // We just need to store the workspace name in the context for potential future use kcpWorkspaceName := workspace - if kcpWorkspace, ok := r.Context().Value(kcpWorkspaceKey).(string); ok && kcpWorkspace != "" { + if kcpWorkspace, ok := ctxkeys.KcpWorkspaceFromContext(r.Context()); ok && kcpWorkspace != "" { kcpWorkspaceName = kcpWorkspace } - r = r.WithContext(kontext.WithCluster(r.Context(), logicalcluster.Name(kcpWorkspaceName))) + // Store the logical cluster name in context + r = r.WithContext(ctxkeys.WithClusterName(r.Context(), logicalcluster.Name(kcpWorkspaceName))) } - return r.WithContext(context.WithValue(r.Context(), roundtripper.TokenKey{}, token)) + return r.WithContext(ctxkeys.WithToken(r.Context(), token)) } // GetToken extracts the token from the request Authorization header diff --git a/gateway/manager/targetcluster/graphql_test.go b/gateway/manager/targetcluster/graphql_test.go index f0b27fa..131affd 100644 --- a/gateway/manager/targetcluster/graphql_test.go +++ b/gateway/manager/targetcluster/graphql_test.go @@ -11,12 +11,11 @@ import ( "github.com/graphql-go/graphql" "github.com/kcp-dev/logicalcluster/v3" - "sigs.k8s.io/controller-runtime/pkg/kontext" "github.com/platform-mesh/golang-commons/logger/testlogger" "github.com/platform-mesh/kubernetes-graphql-gateway/common" appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" - "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/roundtripper" + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/targetcluster" ) @@ -190,15 +189,15 @@ func TestSetContexts(t *testing.T) { result := targetcluster.SetContexts(req, tt.workspace, tt.token, tt.enableKcp) // Check token context - tokenFromCtx := result.Context().Value(roundtripper.TokenKey{}) + tokenFromCtx, _ := ctxkeys.TokenFromContext(result.Context()) if tokenFromCtx != tt.token { t.Errorf("expected token %q in context, got %q", tt.token, tokenFromCtx) } // Check KCP context if tt.expectKcp { - clusterFromCtx, _ := kontext.ClusterFrom(result.Context()) - if clusterFromCtx != logicalcluster.Name(tt.workspace) { + clusterFromCtx, ok := ctxkeys.ClusterNameFromContext(result.Context()) + if !ok || clusterFromCtx != logicalcluster.Name(tt.workspace) { t.Errorf("expected cluster %q in context, got %q", tt.workspace, clusterFromCtx) } } diff --git a/gateway/manager/targetcluster/registry.go b/gateway/manager/targetcluster/registry.go index 26e3a56..32474ca 100644 --- a/gateway/manager/targetcluster/registry.go +++ b/gateway/manager/targetcluster/registry.go @@ -12,16 +12,10 @@ import ( "github.com/platform-mesh/golang-commons/logger" appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" - "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/roundtripper" + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" "k8s.io/client-go/rest" ) -// contextKey is a custom type for context keys to avoid collisions -type contextKey string - -// kcpWorkspaceKey is the context key for storing KCP workspace information -const kcpWorkspaceKey contextKey = "kcpWorkspace" - // RoundTripperFactory creates HTTP round trippers for authentication type RoundTripperFactory func(http.RoundTripper, rest.TLSClientConfig) http.RoundTripper @@ -279,7 +273,7 @@ func (cr *ClusterRegistry) validateToken(ctx context.Context, token string, clus // Set the token in the request context so the roundtripper can use it // This leverages the same authentication logic as normal requests - req = req.WithContext(context.WithValue(req.Context(), roundtripper.TokenKey{}, token)) + req = req.WithContext(ctxkeys.WithToken(req.Context(), token)) cr.log.Debug().Str("cluster", cluster.name).Str("url", apiURL).Msg("Making token validation request") @@ -331,7 +325,7 @@ func (cr *ClusterRegistry) extractClusterName(w http.ResponseWriter, r *http.Req // Store the KCP workspace name in the request context if present if kcpWorkspace != "" { - r = r.WithContext(context.WithValue(r.Context(), kcpWorkspaceKey, kcpWorkspace)) + r = r.WithContext(ctxkeys.WithKcpWorkspace(r.Context(), kcpWorkspace)) } return clusterName, r, true diff --git a/gateway/manager/targetcluster/registry_test.go b/gateway/manager/targetcluster/registry_test.go index 4e1d381..1d30f2f 100644 --- a/gateway/manager/targetcluster/registry_test.go +++ b/gateway/manager/targetcluster/registry_test.go @@ -1,13 +1,12 @@ package targetcluster import ( - "context" "net/http/httptest" "testing" "github.com/platform-mesh/golang-commons/logger/testlogger" appConfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" - "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/roundtripper" + ctxkeys "github.com/platform-mesh/kubernetes-graphql-gateway/gateway/manager/context" ) func TestExtractClusterNameWithKCPWorkspace(t *testing.T) { @@ -212,7 +211,7 @@ func TestExtractClusterNameWithKCPWorkspace(t *testing.T) { } // Check KCP workspace in context - use the modified request returned by extractClusterName - if kcpWorkspace, ok := modifiedReq.Context().Value(kcpWorkspaceKey).(string); ok { + if kcpWorkspace, ok := ctxkeys.KcpWorkspaceFromContext(modifiedReq.Context()); ok { if kcpWorkspace != tt.expectedKCPWorkspace { t.Errorf("KCP workspace in context = %v, want %v", kcpWorkspace, tt.expectedKCPWorkspace) } @@ -223,6 +222,125 @@ func TestExtractClusterNameWithKCPWorkspace(t *testing.T) { } } +func TestClusterRegistry_Close(t *testing.T) { + log := testlogger.New().HideLogOutput().Logger + appCfg := appConfig.Config{} + + registry := NewClusterRegistry(log, appCfg, nil) + + // Test that Close() doesn't panic and returns no error + err := registry.Close() + + if err != nil { + t.Errorf("Close() returned error: %v", err) + } + + // Test that Close() can be called multiple times without error + err = registry.Close() + if err != nil { + t.Errorf("Second Close() call returned error: %v", err) + } +} + +func TestClusterRegistry_ExtractClusterNameFromPath(t *testing.T) { + log := testlogger.New().HideLogOutput().Logger + appCfg := appConfig.Config{} + + registry := NewClusterRegistry(log, appCfg, nil) + + tests := []struct { + name string + schemaFilePath string + expected string + }{ + { + name: "path_with_definitions_directory", + schemaFilePath: "/path/to/definitions/cluster-name.json", + expected: "cluster-name", + }, + { + name: "path_with_definitions_directory_and_extension", + schemaFilePath: "/path/to/definitions/my-cluster.graphql", + expected: "my-cluster", + }, + { + name: "nested_path_with_definitions", + schemaFilePath: "/very/deep/path/definitions/production-cluster.yaml", + expected: "production-cluster", + }, + { + name: "multiple_definitions_in_path", + schemaFilePath: "/definitions/old/definitions/new-cluster.txt", + expected: "new-cluster", + }, + { + name: "path_without_definitions_directory", + schemaFilePath: "/path/to/schema/cluster-name.json", + expected: "cluster-name", + }, + { + name: "filename_only", + schemaFilePath: "cluster-name.json", + expected: "cluster-name", + }, + { + name: "filename_without_extension", + schemaFilePath: "cluster-name", + expected: "cluster-name", + }, + { + name: "path_with_no_extension", + schemaFilePath: "/path/to/definitions/cluster-name", + expected: "cluster-name", + }, + { + name: "empty_path", + schemaFilePath: "", + expected: ".", + }, + { + name: "path_with_multiple_dots", + schemaFilePath: "/path/definitions/cluster.name.with.dots.json", + expected: "cluster.name.with.dots", + }, + { + name: "path_with_special_characters", + schemaFilePath: "/path/definitions/cluster-name_123.yaml", + expected: "cluster-name_123", + }, + { + name: "windows_style_path", + schemaFilePath: "C:\\path\\definitions\\cluster-name.json", + expected: "C:\\path\\definitions\\cluster-name", + }, + { + name: "relative_path_with_definitions", + schemaFilePath: "./definitions/cluster-name.json", + expected: "cluster-name", + }, + { + name: "path_ending_with_definitions", + schemaFilePath: "/path/to/definitions", + expected: "definitions", + }, + { + name: "definitions_as_filename", + schemaFilePath: "/path/to/definitions.json", + expected: "definitions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := registry.ExtractClusterNameFromPathForTest(tt.schemaFilePath) + + if result != tt.expected { + t.Errorf("extractClusterNameFromPath() = %v, want %v", result, tt.expected) + } + }) + } +} + func TestSetContextsWithKCPWorkspace(t *testing.T) { tests := []struct { name string @@ -266,7 +384,7 @@ func TestSetContextsWithKCPWorkspace(t *testing.T) { // Create a test request with KCP workspace in context if provided req := httptest.NewRequest("GET", "/test", nil) if tt.contextKCPWorkspace != "" { - req = req.WithContext(context.WithValue(req.Context(), kcpWorkspaceKey, tt.contextKCPWorkspace)) + req = req.WithContext(ctxkeys.WithKcpWorkspace(req.Context(), tt.contextKCPWorkspace)) } // Call SetContexts @@ -279,7 +397,7 @@ func TestSetContextsWithKCPWorkspace(t *testing.T) { } // Verify token context is set - if token, ok := resultReq.Context().Value(roundtripper.TokenKey{}).(string); ok { + if token, ok := ctxkeys.TokenFromContext(resultReq.Context()); ok { if token != "test-token" { t.Errorf("Token in context = %v, want %v", token, "test-token") } diff --git a/gateway/schema/scalars.go b/gateway/schema/scalars.go index b9f931f..aff36f7 100644 --- a/gateway/schema/scalars.go +++ b/gateway/schema/scalars.go @@ -81,20 +81,28 @@ var stringMapScalar = graphql.NewScalar(graphql.ScalarConfig{ return nil } + // Process each object to extract key-value pair + var currentKey, currentValue string + var hasKey, hasValue bool for _, field := range obj.Fields { switch field.Name.Value { case "key": if key, ok := field.Value.GetValue().(string); ok { - result[key] = "" + currentKey = key + hasKey = true } case "value": if val, ok := field.Value.GetValue().(string); ok { - for key := range result { - result[key] = val - } + currentValue = val + hasValue = true } } } + + // Only set the key-value pair if both key and value are present and valid + if hasKey && hasValue && currentKey != "" { + result[currentKey] = currentValue + } } return result diff --git a/gateway/schema/scalars_test.go b/gateway/schema/scalars_test.go index 77a77fd..8eefa47 100644 --- a/gateway/schema/scalars_test.go +++ b/gateway/schema/scalars_test.go @@ -13,28 +13,141 @@ import ( func TestStringMapScalar_ParseValue(t *testing.T) { tests := []struct { + name string input interface{} expected interface{} }{ { + name: "map_string_interface", input: map[string]interface{}{"key": "val"}, expected: map[string]interface{}{"key": "val"}, }, { + name: "map_string_string", input: map[string]string{"a": "b"}, expected: map[string]string{"a": "b"}, }, { + name: "invalid_string_input", input: "key=val", expected: nil, }, + { + name: "nil_input", + input: nil, + expected: nil, + }, + { + name: "number_input", + input: 123, + expected: nil, + }, + { + name: "array_of_key_value_objects", + input: []interface{}{ + map[string]interface{}{"key": "name", "value": "test"}, + map[string]interface{}{"key": "env", "value": "prod"}, + }, + expected: map[string]string{"name": "test", "env": "prod"}, + }, + { + name: "array_with_invalid_objects", + input: []interface{}{ + map[string]interface{}{"key": "name", "value": "test"}, + "invalid-item", + map[string]interface{}{"key": "env", "value": "prod"}, + }, + expected: map[string]string{"name": "test", "env": "prod"}, + }, + { + name: "array_with_missing_key", + input: []interface{}{ + map[string]interface{}{"value": "test"}, + map[string]interface{}{"key": "env", "value": "prod"}, + }, + expected: map[string]string{"env": "prod"}, + }, + { + name: "array_with_missing_value", + input: []interface{}{ + map[string]interface{}{"key": "name"}, + map[string]interface{}{"key": "env", "value": "prod"}, + }, + expected: map[string]string{"env": "prod"}, + }, + { + name: "array_with_non_string_key", + input: []interface{}{ + map[string]interface{}{"key": 123, "value": "test"}, + map[string]interface{}{"key": "env", "value": "prod"}, + }, + expected: map[string]string{"env": "prod"}, + }, + { + name: "array_with_non_string_value", + input: []interface{}{ + map[string]interface{}{"key": "name", "value": 123}, + map[string]interface{}{"key": "env", "value": "prod"}, + }, + expected: map[string]string{"env": "prod"}, + }, + { + name: "empty_array", + input: []interface{}{}, + expected: map[string]string{}, + }, } - for _, test := range tests { - out := schema.StringMapScalarForTest.ParseValue(test.input) - if !reflect.DeepEqual(out, test.expected) { - t.Errorf("ParseValue(%v) = %v; want %v", test.input, out, test.expected) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := schema.StringMapScalarForTest.ParseValue(tt.input) + if !reflect.DeepEqual(out, tt.expected) { + t.Errorf("ParseValue(%v) = %v; want %v", tt.input, out, tt.expected) + } + }) + } +} + +func TestStringMapScalar_Serialize(t *testing.T) { + tests := []struct { + name string + input interface{} + expected interface{} + }{ + { + name: "map_string_string", + input: map[string]string{"key": "value"}, + expected: map[string]string{"key": "value"}, + }, + { + name: "map_string_interface", + input: map[string]interface{}{"key": "value"}, + expected: map[string]interface{}{"key": "value"}, + }, + { + name: "nil_input", + input: nil, + expected: nil, + }, + { + name: "string_input", + input: "test", + expected: "test", + }, + { + name: "number_input", + input: 123, + expected: 123, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := schema.StringMapScalarForTest.Serialize(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("Serialize(%v) = %v; want %v", tt.input, result, tt.expected) + } + }) } } @@ -61,11 +174,151 @@ func TestStringMapScalar_ParseLiteral(t *testing.T) { }, expected: map[string]string{"key": "val", "key2": "val2"}, }, + { + name: "object_value_with_non_string_field", + input: &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "key"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "val"}, + }, + { + Name: &ast.Name{Value: "number"}, + Value: &ast.IntValue{Kind: kinds.IntValue, Value: "123"}, + }, + }, + }, + expected: map[string]string{"key": "val", "number": "123"}, + }, + { + name: "empty_object_value", + input: &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{}, + }, + expected: map[string]string{}, + }, + { + name: "list_value_with_key_value_objects", + input: &ast.ListValue{ + Kind: kinds.ListValue, + Values: []ast.Value{ + &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "key"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "name"}, + }, + { + Name: &ast.Name{Value: "value"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "test"}, + }, + }, + }, + &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "key"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "env"}, + }, + { + Name: &ast.Name{Value: "value"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "prod"}, + }, + }, + }, + }, + }, + expected: map[string]string{"name": "test", "env": "prod"}, + }, + { + name: "list_value_with_invalid_item", + input: &ast.ListValue{ + Kind: kinds.ListValue, + Values: []ast.Value{ + &ast.StringValue{Kind: kinds.StringValue, Value: "invalid"}, + &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "key"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "name"}, + }, + { + Name: &ast.Name{Value: "value"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "test"}, + }, + }, + }, + }, + }, + expected: nil, + }, + { + name: "list_value_with_non_string_key", + input: &ast.ListValue{ + Kind: kinds.ListValue, + Values: []ast.Value{ + &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "key"}, + Value: &ast.IntValue{Kind: kinds.IntValue, Value: "123"}, + }, + { + Name: &ast.Name{Value: "value"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "test"}, + }, + }, + }, + }, + }, + expected: map[string]string{"123": "test"}, + }, + { + name: "list_value_with_non_string_value", + input: &ast.ListValue{ + Kind: kinds.ListValue, + Values: []ast.Value{ + &ast.ObjectValue{ + Kind: kinds.ObjectValue, + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "key"}, + Value: &ast.StringValue{Kind: kinds.StringValue, Value: "name"}, + }, + { + Name: &ast.Name{Value: "value"}, + Value: &ast.IntValue{Kind: kinds.IntValue, Value: "123"}, + }, + }, + }, + }, + }, + expected: map[string]string{"name": "123"}, + }, + { + name: "empty_list_value", + input: &ast.ListValue{ + Kind: kinds.ListValue, + Values: []ast.Value{}, + }, + expected: map[string]string{}, + }, { name: "invalid_string_value", input: &ast.StringValue{Kind: kinds.StringValue, Value: "key=val"}, expected: nil, }, + { + name: "invalid_int_value", + input: &ast.IntValue{Kind: kinds.IntValue, Value: "123"}, + expected: nil, + }, } for _, tt := range tests { @@ -155,50 +408,221 @@ func TestGenerateTypeName(t *testing.T) { } } -func TestJSONStringScalar_ProperSerialization(t *testing.T) { - testObject := map[string]interface{}{ - "name": "example-config", - "namespace": "default", - "labels": map[string]string{ - "hello": "world", +func TestJSONStringScalar_Serialize(t *testing.T) { + tests := []struct { + name string + input interface{} + expected string + }{ + { + name: "valid_object", + input: map[string]interface{}{ + "name": "example-config", + "namespace": "default", + "labels": map[string]string{ + "hello": "world", + }, + }, + expected: `{"labels":{"hello":"world"},"name":"example-config","namespace":"default"}`, }, - "annotations": map[string]string{ - "kcp.io/cluster": "root", + { + name: "string_input", + input: "test-string", + expected: `"test-string"`, + }, + { + name: "number_input", + input: 42, + expected: "42", + }, + { + name: "boolean_input", + input: true, + expected: "true", + }, + { + name: "nil_input", + input: nil, + expected: "null", + }, + { + name: "empty_map", + input: map[string]interface{}{}, + expected: "{}", + }, + { + name: "array_input", + input: []string{"a", "b", "c"}, + expected: `["a","b","c"]`, }, } - // Test the JSONString scalar serialization - result := schema.JSONStringScalarForTest.Serialize(testObject) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := schema.JSONStringScalarForTest.Serialize(tt.input) - if result == nil { - t.Fatal("JSONStringScalar.Serialize returned nil") - } + if result == nil { + t.Fatal("JSONStringScalar.Serialize returned nil") + } + + resultStr, ok := result.(string) + if !ok { + t.Fatalf("JSONStringScalar.Serialize returned %T, expected string", result) + } + + // For complex objects, we can't guarantee exact string match due to map ordering + // So we parse both and compare + if tt.name == "valid_object" { + var expectedParsed, resultParsed map[string]interface{} + err1 := json.Unmarshal([]byte(tt.expected), &expectedParsed) + err2 := json.Unmarshal([]byte(resultStr), &resultParsed) - resultStr, ok := result.(string) - if !ok { - t.Fatalf("JSONStringScalar.Serialize returned %T, expected string", result) + if err1 != nil || err2 != nil { + t.Fatalf("Failed to parse JSON: expected err=%v, result err=%v", err1, err2) + } + + if !reflect.DeepEqual(expectedParsed, resultParsed) { + t.Errorf("JSONStringScalar.Serialize() = %v, want %v", resultParsed, expectedParsed) + } + } else { + if resultStr != tt.expected { + t.Errorf("JSONStringScalar.Serialize() = %q, want %q", resultStr, tt.expected) + } + } + }) } +} + +func TestJSONStringScalar_Serialize_MarshalError(t *testing.T) { + // Test with a value that cannot be marshaled to JSON (e.g., function, channel) + invalidInput := make(chan int) + + result := schema.JSONStringScalarForTest.Serialize(invalidInput) - // Verify it's valid JSON - var parsed map[string]interface{} - err := json.Unmarshal([]byte(resultStr), &parsed) - if err != nil { - t.Fatalf("Result is not valid JSON: %s\nResult: %s", err, resultStr) + if result != "{}" { + t.Errorf("JSONStringScalar.Serialize() with invalid input = %q, want %q", result, "{}") } +} - // Verify the content is preserved - if parsed["name"] != "example-config" { - t.Errorf("Name not preserved: got %v, want %v", parsed["name"], "example-config") +func TestJSONStringScalar_ParseValue(t *testing.T) { + tests := []struct { + name string + input interface{} + expected interface{} + }{ + { + name: "valid_json_string", + input: `{"name":"test","value":42}`, + expected: map[string]interface{}{"name": "test", "value": float64(42)}, + }, + { + name: "valid_json_array", + input: `["a","b","c"]`, + expected: []interface{}{"a", "b", "c"}, + }, + { + name: "valid_json_number", + input: `123`, + expected: float64(123), + }, + { + name: "valid_json_boolean", + input: `true`, + expected: true, + }, + { + name: "valid_json_null", + input: `null`, + expected: nil, + }, + { + name: "invalid_json_string", + input: `{"invalid": json}`, + expected: nil, + }, + { + name: "non_string_input", + input: 123, + expected: nil, + }, + { + name: "empty_string", + input: "", + expected: nil, + }, + { + name: "nil_input", + input: nil, + expected: nil, + }, } - if parsed["namespace"] != "default" { - t.Errorf("Namespace not preserved: got %v, want %v", parsed["namespace"], "default") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := schema.JSONStringScalarForTest.ParseValue(tt.input) + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("JSONStringScalar.ParseValue() = %v, want %v", result, tt.expected) + } + }) } +} - // Verify it's NOT Go map format - if len(resultStr) > 10 && resultStr[:4] == "map[" { - t.Errorf("Result is in Go map format, not JSON: %s", resultStr) +func TestJSONStringScalar_ParseLiteral(t *testing.T) { + tests := []struct { + name string + input ast.Value + expected interface{} + }{ + { + name: "valid_json_string", + input: &ast.StringValue{ + Kind: kinds.StringValue, + Value: `{"name":"test","value":42}`, + }, + expected: map[string]interface{}{"name": "test", "value": float64(42)}, + }, + { + name: "valid_json_array", + input: &ast.StringValue{ + Kind: kinds.StringValue, + Value: `["a","b","c"]`, + }, + expected: []interface{}{"a", "b", "c"}, + }, + { + name: "invalid_json_string", + input: &ast.StringValue{ + Kind: kinds.StringValue, + Value: `{"invalid": json}`, + }, + expected: nil, + }, + { + name: "non_string_ast_value", + input: &ast.IntValue{ + Kind: kinds.IntValue, + Value: "123", + }, + expected: nil, + }, + { + name: "empty_json_string", + input: &ast.StringValue{ + Kind: kinds.StringValue, + Value: "", + }, + expected: nil, + }, } - t.Logf("Proper JSON output: %s", resultStr) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := schema.JSONStringScalarForTest.ParseLiteral(tt.input) + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("JSONStringScalar.ParseLiteral() = %v, want %v", result, tt.expected) + } + }) + } } diff --git a/go.mod b/go.mod index 59f447f..32b4b49 100644 --- a/go.mod +++ b/go.mod @@ -2,16 +2,9 @@ module github.com/platform-mesh/kubernetes-graphql-gateway go 1.24.3 -replace ( - github.com/google/cel-go => github.com/google/cel-go v0.26.1 - // this PR introduces newer version of graphiQL that supports headers - // https://github.com/graphql-go/handler/pull/93 - github.com/graphql-go/handler => github.com/vertex451/handler v0.0.0-20250124125145-ed328e3cf42a - k8s.io/api => k8s.io/api v0.33.3 - k8s.io/apimachinery => k8s.io/apimachinery v0.33.3 - k8s.io/client-go => k8s.io/client-go v0.32.4 - sigs.k8s.io/controller-runtime => github.com/kcp-dev/controller-runtime v0.19.0-kcp.1.0.20250129100209-5eaf4c7b6056 -) +// this PR introduces newer version of graphiQL that supports headers +// https://github.com/graphql-go/handler/pull/93 +replace github.com/graphql-go/handler => github.com/vertex451/handler v0.0.0-20250124125145-ed328e3cf42a require ( github.com/fsnotify/fsnotify v1.9.0 @@ -24,6 +17,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/kcp-dev/kcp/sdk v0.28.3 github.com/kcp-dev/logicalcluster/v3 v3.0.5 + github.com/kcp-dev/multicluster-provider v0.0.0-20250827085327-2b5ca378b7b4 github.com/pkg/errors v0.9.1 github.com/platform-mesh/account-operator v0.3.1 github.com/platform-mesh/golang-commons v0.1.32 @@ -37,12 +31,13 @@ require ( golang.org/x/exp v0.0.0-20250911091902-df9299821621 golang.org/x/text v0.29.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.33.3 - k8s.io/apiextensions-apiserver v0.33.3 - k8s.io/apimachinery v0.33.3 - k8s.io/client-go v0.33.3 - k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 + k8s.io/api v0.34.0 + k8s.io/apiextensions-apiserver v0.34.0 + k8s.io/apimachinery v0.34.0 + k8s.io/client-go v0.34.0 + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b sigs.k8s.io/controller-runtime v0.22.1 + sigs.k8s.io/multicluster-runtime v0.21.0-alpha.9 ) require ( @@ -54,10 +49,10 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getsentry/sentry-go v0.35.2 // indirect github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -76,22 +71,21 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250512171935-ebb573a40077 // indirect + github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250728122101-adbf20db3e51 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.36.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -136,13 +130,13 @@ require ( google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiserver v0.33.3 // indirect - k8s.io/component-base v0.33.3 // indirect + k8s.io/apiserver v0.34.0 // indirect + k8s.io/component-base v0.34.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 72a24de..41eed74 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= -github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.8.0+incompatible h1:1Av9pn2FyxPdvrWNQszj1g6D6YthSmvCfcN6SYclTJg= github.com/evanphx/json-patch v5.8.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -32,8 +32,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/sentry-go v0.35.2 h1:jKuujpRwa8FFRYMIwwZpu83Xh0voll9bmvyc6310WBM= github.com/getsentry/sentry-go v0.35.2/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -86,11 +86,12 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= -github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -109,22 +110,20 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250512171935-ebb573a40077 h1:lDi9nZ75ypmRJwDFXUN70Cdu8+HxAjPU1kcnn+l4MvI= -github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250512171935-ebb573a40077/go.mod h1:jnMZxVnCuKlkIXc4J1Qtmy1Lyo171CDF/RQhNAo0tvA= -github.com/kcp-dev/controller-runtime v0.19.0-kcp.1.0.20250129100209-5eaf4c7b6056 h1:NaEaA34bHNawPL3npJN8J7jyQhA3eG+UQ0xZvTnOfYo= -github.com/kcp-dev/controller-runtime v0.19.0-kcp.1.0.20250129100209-5eaf4c7b6056/go.mod h1:jwK5sBnpu/xJJ+xdpSzzI0aM52E/EvF0uLF9bR61h/Y= +github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250728122101-adbf20db3e51 h1:l38RDS+VUMx9etvyaCgJIZa4nM7FaNevNubWN0kDZY4= +github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250728122101-adbf20db3e51/go.mod h1:rF1jfvUfPjFXs+HV/LN1BtPzAz1bfjJOwVa+hAVfroQ= github.com/kcp-dev/kcp/sdk v0.28.3 h1:TS2nJOVBjenBd3fz1+y3aNrqZWqmakalNAIcQM9SukQ= github.com/kcp-dev/kcp/sdk v0.28.3/go.mod h1:8oZpWxkoMu2TDpx5DgdIGDigByKHKkeqVMA4GiWneoI= github.com/kcp-dev/logicalcluster/v3 v3.0.5 h1:JbYakokb+5Uinz09oTXomSUJVQsqfxEvU4RyHUYxHOU= github.com/kcp-dev/logicalcluster/v3 v3.0.5/go.mod h1:EWBUBxdr49fUB1cLMO4nOdBWmYifLbP1LfoL20KkXYY= +github.com/kcp-dev/multicluster-provider v0.0.0-20250827085327-2b5ca378b7b4 h1:GUihV22j2J/cGF5Svr/zGVvfqTJepIO+sOnYTn9o4Vc= +github.com/kcp-dev/multicluster-provider v0.0.0-20250827085327-2b5ca378b7b4/go.mod h1:E/NxN2SMtC7b6iXgFMlQYWA7lJIfDPqRkPdvpxOEQLA= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -147,8 +146,9 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.22.1 h1:QW7tbJAUDyVDVOM5dFa7qaybo+CRfR7bemlQUN6Z8aM= @@ -246,8 +246,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/goleak v1.3.1-0.20241121203838-4ff5fa6529ee h1:uOMbcH1Dmxv45VkkpZQYoerZFeDncWpjbN7ATiQOO7c= +go.uber.org/goleak v1.3.1-0.20241121203838-4ff5fa6529ee/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -326,33 +326,35 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= -k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= -k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs= -k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8= -k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= -k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4= -k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= -k8s.io/client-go v0.32.4 h1:zaGJS7xoYOYumoWIFXlcVrsiYioRPrXGO7dBfVC5R6M= -k8s.io/client-go v0.32.4/go.mod h1:k0jftcyYnEtwlFW92xC7MTtFv5BNcZBr+zn9jPlT9Ic= -k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= -k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= +k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= +k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= +k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= +k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= +k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= +k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= +k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= +k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= +k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= +k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 h1:gAXU86Fmbr/ktY17lkHwSjw5aoThQvhnstGGIYKlKYc= -k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911/go.mod h1:GLOk5B+hDbRROvt0X2+hqX64v/zO3vXN7J78OUmBSKw= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= +sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/multicluster-runtime v0.21.0-alpha.9 h1:baonM4f081WWct3U7O4EfqrxcUGtmCrFDbsT1FQ8xlo= +sigs.k8s.io/multicluster-runtime v0.21.0-alpha.9/go.mod h1:CpBzLMLQKdm+UCchd2FiGPiDdCxM5dgCCPKuaQ6Fsv0= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/listener/pkg/apischema/crd_resolver_test.go b/listener/pkg/apischema/crd_resolver_test.go index 7e056b1..eeb3e3d 100644 --- a/listener/pkg/apischema/crd_resolver_test.go +++ b/listener/pkg/apischema/crd_resolver_test.go @@ -12,6 +12,7 @@ import ( "github.com/platform-mesh/golang-commons/logger/testlogger" apischema "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/apischema" apischemaMocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/apischema/mocks" + kcpMocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp/mocks" "github.com/stretchr/testify/assert" ) @@ -299,8 +300,8 @@ func TestResolveSchema(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - dc := apischemaMocks.NewMockDiscoveryInterface(t) - rm := apischemaMocks.NewMockRESTMapper(t) + dc := kcpMocks.NewMockDiscoveryInterface(t) + rm := kcpMocks.NewMockRESTMapper(t) // First call in resolveSchema dc.EXPECT().ServerPreferredResources().Return(tc.preferredResources, tc.err) diff --git a/listener/pkg/apischema/mocks/mock_DiscoveryInterface.go b/listener/pkg/apischema/mocks/mock_DiscoveryInterface.go deleted file mode 100644 index 5c7f8f9..0000000 --- a/listener/pkg/apischema/mocks/mock_DiscoveryInterface.go +++ /dev/null @@ -1,595 +0,0 @@ -// Code generated by mockery v2.52.3. DO NOT EDIT. - -package mocks - -import ( - mock "github.com/stretchr/testify/mock" - discovery "k8s.io/client-go/discovery" - - openapi "k8s.io/client-go/openapi" - - openapi_v2 "github.com/google/gnostic-models/openapiv2" - - rest "k8s.io/client-go/rest" - - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - version "k8s.io/apimachinery/pkg/version" -) - -// MockDiscoveryInterface is an autogenerated mock type for the DiscoveryInterface type -type MockDiscoveryInterface struct { - mock.Mock -} - -type MockDiscoveryInterface_Expecter struct { - mock *mock.Mock -} - -func (_m *MockDiscoveryInterface) EXPECT() *MockDiscoveryInterface_Expecter { - return &MockDiscoveryInterface_Expecter{mock: &_m.Mock} -} - -// OpenAPISchema provides a mock function with no fields -func (_m *MockDiscoveryInterface) OpenAPISchema() (*openapi_v2.Document, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for OpenAPISchema") - } - - var r0 *openapi_v2.Document - var r1 error - if rf, ok := ret.Get(0).(func() (*openapi_v2.Document, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() *openapi_v2.Document); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*openapi_v2.Document) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryInterface_OpenAPISchema_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenAPISchema' -type MockDiscoveryInterface_OpenAPISchema_Call struct { - *mock.Call -} - -// OpenAPISchema is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) OpenAPISchema() *MockDiscoveryInterface_OpenAPISchema_Call { - return &MockDiscoveryInterface_OpenAPISchema_Call{Call: _e.mock.On("OpenAPISchema")} -} - -func (_c *MockDiscoveryInterface_OpenAPISchema_Call) Run(run func()) *MockDiscoveryInterface_OpenAPISchema_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_OpenAPISchema_Call) Return(_a0 *openapi_v2.Document, _a1 error) *MockDiscoveryInterface_OpenAPISchema_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryInterface_OpenAPISchema_Call) RunAndReturn(run func() (*openapi_v2.Document, error)) *MockDiscoveryInterface_OpenAPISchema_Call { - _c.Call.Return(run) - return _c -} - -// OpenAPIV3 provides a mock function with no fields -func (_m *MockDiscoveryInterface) OpenAPIV3() openapi.Client { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for OpenAPIV3") - } - - var r0 openapi.Client - if rf, ok := ret.Get(0).(func() openapi.Client); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(openapi.Client) - } - } - - return r0 -} - -// MockDiscoveryInterface_OpenAPIV3_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenAPIV3' -type MockDiscoveryInterface_OpenAPIV3_Call struct { - *mock.Call -} - -// OpenAPIV3 is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) OpenAPIV3() *MockDiscoveryInterface_OpenAPIV3_Call { - return &MockDiscoveryInterface_OpenAPIV3_Call{Call: _e.mock.On("OpenAPIV3")} -} - -func (_c *MockDiscoveryInterface_OpenAPIV3_Call) Run(run func()) *MockDiscoveryInterface_OpenAPIV3_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_OpenAPIV3_Call) Return(_a0 openapi.Client) *MockDiscoveryInterface_OpenAPIV3_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockDiscoveryInterface_OpenAPIV3_Call) RunAndReturn(run func() openapi.Client) *MockDiscoveryInterface_OpenAPIV3_Call { - _c.Call.Return(run) - return _c -} - -// RESTClient provides a mock function with no fields -func (_m *MockDiscoveryInterface) RESTClient() rest.Interface { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for RESTClient") - } - - var r0 rest.Interface - if rf, ok := ret.Get(0).(func() rest.Interface); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(rest.Interface) - } - } - - return r0 -} - -// MockDiscoveryInterface_RESTClient_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RESTClient' -type MockDiscoveryInterface_RESTClient_Call struct { - *mock.Call -} - -// RESTClient is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) RESTClient() *MockDiscoveryInterface_RESTClient_Call { - return &MockDiscoveryInterface_RESTClient_Call{Call: _e.mock.On("RESTClient")} -} - -func (_c *MockDiscoveryInterface_RESTClient_Call) Run(run func()) *MockDiscoveryInterface_RESTClient_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_RESTClient_Call) Return(_a0 rest.Interface) *MockDiscoveryInterface_RESTClient_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockDiscoveryInterface_RESTClient_Call) RunAndReturn(run func() rest.Interface) *MockDiscoveryInterface_RESTClient_Call { - _c.Call.Return(run) - return _c -} - -// ServerGroups provides a mock function with no fields -func (_m *MockDiscoveryInterface) ServerGroups() (*v1.APIGroupList, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for ServerGroups") - } - - var r0 *v1.APIGroupList - var r1 error - if rf, ok := ret.Get(0).(func() (*v1.APIGroupList, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() *v1.APIGroupList); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*v1.APIGroupList) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryInterface_ServerGroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerGroups' -type MockDiscoveryInterface_ServerGroups_Call struct { - *mock.Call -} - -// ServerGroups is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) ServerGroups() *MockDiscoveryInterface_ServerGroups_Call { - return &MockDiscoveryInterface_ServerGroups_Call{Call: _e.mock.On("ServerGroups")} -} - -func (_c *MockDiscoveryInterface_ServerGroups_Call) Run(run func()) *MockDiscoveryInterface_ServerGroups_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_ServerGroups_Call) Return(_a0 *v1.APIGroupList, _a1 error) *MockDiscoveryInterface_ServerGroups_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryInterface_ServerGroups_Call) RunAndReturn(run func() (*v1.APIGroupList, error)) *MockDiscoveryInterface_ServerGroups_Call { - _c.Call.Return(run) - return _c -} - -// ServerGroupsAndResources provides a mock function with no fields -func (_m *MockDiscoveryInterface) ServerGroupsAndResources() ([]*v1.APIGroup, []*v1.APIResourceList, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for ServerGroupsAndResources") - } - - var r0 []*v1.APIGroup - var r1 []*v1.APIResourceList - var r2 error - if rf, ok := ret.Get(0).(func() ([]*v1.APIGroup, []*v1.APIResourceList, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() []*v1.APIGroup); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*v1.APIGroup) - } - } - - if rf, ok := ret.Get(1).(func() []*v1.APIResourceList); ok { - r1 = rf() - } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).([]*v1.APIResourceList) - } - } - - if rf, ok := ret.Get(2).(func() error); ok { - r2 = rf() - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 -} - -// MockDiscoveryInterface_ServerGroupsAndResources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerGroupsAndResources' -type MockDiscoveryInterface_ServerGroupsAndResources_Call struct { - *mock.Call -} - -// ServerGroupsAndResources is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) ServerGroupsAndResources() *MockDiscoveryInterface_ServerGroupsAndResources_Call { - return &MockDiscoveryInterface_ServerGroupsAndResources_Call{Call: _e.mock.On("ServerGroupsAndResources")} -} - -func (_c *MockDiscoveryInterface_ServerGroupsAndResources_Call) Run(run func()) *MockDiscoveryInterface_ServerGroupsAndResources_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_ServerGroupsAndResources_Call) Return(_a0 []*v1.APIGroup, _a1 []*v1.APIResourceList, _a2 error) *MockDiscoveryInterface_ServerGroupsAndResources_Call { - _c.Call.Return(_a0, _a1, _a2) - return _c -} - -func (_c *MockDiscoveryInterface_ServerGroupsAndResources_Call) RunAndReturn(run func() ([]*v1.APIGroup, []*v1.APIResourceList, error)) *MockDiscoveryInterface_ServerGroupsAndResources_Call { - _c.Call.Return(run) - return _c -} - -// ServerPreferredNamespacedResources provides a mock function with no fields -func (_m *MockDiscoveryInterface) ServerPreferredNamespacedResources() ([]*v1.APIResourceList, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for ServerPreferredNamespacedResources") - } - - var r0 []*v1.APIResourceList - var r1 error - if rf, ok := ret.Get(0).(func() ([]*v1.APIResourceList, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() []*v1.APIResourceList); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*v1.APIResourceList) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryInterface_ServerPreferredNamespacedResources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerPreferredNamespacedResources' -type MockDiscoveryInterface_ServerPreferredNamespacedResources_Call struct { - *mock.Call -} - -// ServerPreferredNamespacedResources is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) ServerPreferredNamespacedResources() *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call { - return &MockDiscoveryInterface_ServerPreferredNamespacedResources_Call{Call: _e.mock.On("ServerPreferredNamespacedResources")} -} - -func (_c *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call) Run(run func()) *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call) Return(_a0 []*v1.APIResourceList, _a1 error) *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call) RunAndReturn(run func() ([]*v1.APIResourceList, error)) *MockDiscoveryInterface_ServerPreferredNamespacedResources_Call { - _c.Call.Return(run) - return _c -} - -// ServerPreferredResources provides a mock function with no fields -func (_m *MockDiscoveryInterface) ServerPreferredResources() ([]*v1.APIResourceList, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for ServerPreferredResources") - } - - var r0 []*v1.APIResourceList - var r1 error - if rf, ok := ret.Get(0).(func() ([]*v1.APIResourceList, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() []*v1.APIResourceList); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*v1.APIResourceList) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryInterface_ServerPreferredResources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerPreferredResources' -type MockDiscoveryInterface_ServerPreferredResources_Call struct { - *mock.Call -} - -// ServerPreferredResources is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) ServerPreferredResources() *MockDiscoveryInterface_ServerPreferredResources_Call { - return &MockDiscoveryInterface_ServerPreferredResources_Call{Call: _e.mock.On("ServerPreferredResources")} -} - -func (_c *MockDiscoveryInterface_ServerPreferredResources_Call) Run(run func()) *MockDiscoveryInterface_ServerPreferredResources_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_ServerPreferredResources_Call) Return(_a0 []*v1.APIResourceList, _a1 error) *MockDiscoveryInterface_ServerPreferredResources_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryInterface_ServerPreferredResources_Call) RunAndReturn(run func() ([]*v1.APIResourceList, error)) *MockDiscoveryInterface_ServerPreferredResources_Call { - _c.Call.Return(run) - return _c -} - -// ServerResourcesForGroupVersion provides a mock function with given fields: groupVersion -func (_m *MockDiscoveryInterface) ServerResourcesForGroupVersion(groupVersion string) (*v1.APIResourceList, error) { - ret := _m.Called(groupVersion) - - if len(ret) == 0 { - panic("no return value specified for ServerResourcesForGroupVersion") - } - - var r0 *v1.APIResourceList - var r1 error - if rf, ok := ret.Get(0).(func(string) (*v1.APIResourceList, error)); ok { - return rf(groupVersion) - } - if rf, ok := ret.Get(0).(func(string) *v1.APIResourceList); ok { - r0 = rf(groupVersion) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*v1.APIResourceList) - } - } - - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(groupVersion) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryInterface_ServerResourcesForGroupVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerResourcesForGroupVersion' -type MockDiscoveryInterface_ServerResourcesForGroupVersion_Call struct { - *mock.Call -} - -// ServerResourcesForGroupVersion is a helper method to define mock.On call -// - groupVersion string -func (_e *MockDiscoveryInterface_Expecter) ServerResourcesForGroupVersion(groupVersion interface{}) *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call { - return &MockDiscoveryInterface_ServerResourcesForGroupVersion_Call{Call: _e.mock.On("ServerResourcesForGroupVersion", groupVersion)} -} - -func (_c *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call) Run(run func(groupVersion string)) *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call) Return(_a0 *v1.APIResourceList, _a1 error) *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call) RunAndReturn(run func(string) (*v1.APIResourceList, error)) *MockDiscoveryInterface_ServerResourcesForGroupVersion_Call { - _c.Call.Return(run) - return _c -} - -// ServerVersion provides a mock function with no fields -func (_m *MockDiscoveryInterface) ServerVersion() (*version.Info, error) { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for ServerVersion") - } - - var r0 *version.Info - var r1 error - if rf, ok := ret.Get(0).(func() (*version.Info, error)); ok { - return rf() - } - if rf, ok := ret.Get(0).(func() *version.Info); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*version.Info) - } - } - - if rf, ok := ret.Get(1).(func() error); ok { - r1 = rf() - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryInterface_ServerVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerVersion' -type MockDiscoveryInterface_ServerVersion_Call struct { - *mock.Call -} - -// ServerVersion is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) ServerVersion() *MockDiscoveryInterface_ServerVersion_Call { - return &MockDiscoveryInterface_ServerVersion_Call{Call: _e.mock.On("ServerVersion")} -} - -func (_c *MockDiscoveryInterface_ServerVersion_Call) Run(run func()) *MockDiscoveryInterface_ServerVersion_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_ServerVersion_Call) Return(_a0 *version.Info, _a1 error) *MockDiscoveryInterface_ServerVersion_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryInterface_ServerVersion_Call) RunAndReturn(run func() (*version.Info, error)) *MockDiscoveryInterface_ServerVersion_Call { - _c.Call.Return(run) - return _c -} - -// WithLegacy provides a mock function with no fields -func (_m *MockDiscoveryInterface) WithLegacy() discovery.DiscoveryInterface { - ret := _m.Called() - - if len(ret) == 0 { - panic("no return value specified for WithLegacy") - } - - var r0 discovery.DiscoveryInterface - if rf, ok := ret.Get(0).(func() discovery.DiscoveryInterface); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(discovery.DiscoveryInterface) - } - } - - return r0 -} - -// MockDiscoveryInterface_WithLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithLegacy' -type MockDiscoveryInterface_WithLegacy_Call struct { - *mock.Call -} - -// WithLegacy is a helper method to define mock.On call -func (_e *MockDiscoveryInterface_Expecter) WithLegacy() *MockDiscoveryInterface_WithLegacy_Call { - return &MockDiscoveryInterface_WithLegacy_Call{Call: _e.mock.On("WithLegacy")} -} - -func (_c *MockDiscoveryInterface_WithLegacy_Call) Run(run func()) *MockDiscoveryInterface_WithLegacy_Call { - _c.Call.Run(func(args mock.Arguments) { - run() - }) - return _c -} - -func (_c *MockDiscoveryInterface_WithLegacy_Call) Return(_a0 discovery.DiscoveryInterface) *MockDiscoveryInterface_WithLegacy_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MockDiscoveryInterface_WithLegacy_Call) RunAndReturn(run func() discovery.DiscoveryInterface) *MockDiscoveryInterface_WithLegacy_Call { - _c.Call.Return(run) - return _c -} - -// NewMockDiscoveryInterface creates a new instance of MockDiscoveryInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockDiscoveryInterface(t interface { - mock.TestingT - Cleanup(func()) -}) *MockDiscoveryInterface { - mock := &MockDiscoveryInterface{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/listener/pkg/apischema/mocks/mock_RESTMapper.go b/listener/pkg/apischema/mocks/mock_RESTMapper.go deleted file mode 100644 index 18bae31..0000000 --- a/listener/pkg/apischema/mocks/mock_RESTMapper.go +++ /dev/null @@ -1,467 +0,0 @@ -// Code generated by mockery v2.52.3. DO NOT EDIT. - -package mocks - -import ( - mock "github.com/stretchr/testify/mock" - meta "k8s.io/apimachinery/pkg/api/meta" - - schema "k8s.io/apimachinery/pkg/runtime/schema" -) - -// MockRESTMapper is an autogenerated mock type for the RESTMapper type -type MockRESTMapper struct { - mock.Mock -} - -type MockRESTMapper_Expecter struct { - mock *mock.Mock -} - -func (_m *MockRESTMapper) EXPECT() *MockRESTMapper_Expecter { - return &MockRESTMapper_Expecter{mock: &_m.Mock} -} - -// KindFor provides a mock function with given fields: resource -func (_m *MockRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) { - ret := _m.Called(resource) - - if len(ret) == 0 { - panic("no return value specified for KindFor") - } - - var r0 schema.GroupVersionKind - var r1 error - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) (schema.GroupVersionKind, error)); ok { - return rf(resource) - } - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) schema.GroupVersionKind); ok { - r0 = rf(resource) - } else { - r0 = ret.Get(0).(schema.GroupVersionKind) - } - - if rf, ok := ret.Get(1).(func(schema.GroupVersionResource) error); ok { - r1 = rf(resource) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_KindFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KindFor' -type MockRESTMapper_KindFor_Call struct { - *mock.Call -} - -// KindFor is a helper method to define mock.On call -// - resource schema.GroupVersionResource -func (_e *MockRESTMapper_Expecter) KindFor(resource interface{}) *MockRESTMapper_KindFor_Call { - return &MockRESTMapper_KindFor_Call{Call: _e.mock.On("KindFor", resource)} -} - -func (_c *MockRESTMapper_KindFor_Call) Run(run func(resource schema.GroupVersionResource)) *MockRESTMapper_KindFor_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(schema.GroupVersionResource)) - }) - return _c -} - -func (_c *MockRESTMapper_KindFor_Call) Return(_a0 schema.GroupVersionKind, _a1 error) *MockRESTMapper_KindFor_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockRESTMapper_KindFor_Call) RunAndReturn(run func(schema.GroupVersionResource) (schema.GroupVersionKind, error)) *MockRESTMapper_KindFor_Call { - _c.Call.Return(run) - return _c -} - -// KindsFor provides a mock function with given fields: resource -func (_m *MockRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) { - ret := _m.Called(resource) - - if len(ret) == 0 { - panic("no return value specified for KindsFor") - } - - var r0 []schema.GroupVersionKind - var r1 error - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) ([]schema.GroupVersionKind, error)); ok { - return rf(resource) - } - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) []schema.GroupVersionKind); ok { - r0 = rf(resource) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]schema.GroupVersionKind) - } - } - - if rf, ok := ret.Get(1).(func(schema.GroupVersionResource) error); ok { - r1 = rf(resource) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_KindsFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KindsFor' -type MockRESTMapper_KindsFor_Call struct { - *mock.Call -} - -// KindsFor is a helper method to define mock.On call -// - resource schema.GroupVersionResource -func (_e *MockRESTMapper_Expecter) KindsFor(resource interface{}) *MockRESTMapper_KindsFor_Call { - return &MockRESTMapper_KindsFor_Call{Call: _e.mock.On("KindsFor", resource)} -} - -func (_c *MockRESTMapper_KindsFor_Call) Run(run func(resource schema.GroupVersionResource)) *MockRESTMapper_KindsFor_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(schema.GroupVersionResource)) - }) - return _c -} - -func (_c *MockRESTMapper_KindsFor_Call) Return(_a0 []schema.GroupVersionKind, _a1 error) *MockRESTMapper_KindsFor_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockRESTMapper_KindsFor_Call) RunAndReturn(run func(schema.GroupVersionResource) ([]schema.GroupVersionKind, error)) *MockRESTMapper_KindsFor_Call { - _c.Call.Return(run) - return _c -} - -// RESTMapping provides a mock function with given fields: gk, versions -func (_m *MockRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { - _va := make([]interface{}, len(versions)) - for _i := range versions { - _va[_i] = versions[_i] - } - var _ca []interface{} - _ca = append(_ca, gk) - _ca = append(_ca, _va...) - ret := _m.Called(_ca...) - - if len(ret) == 0 { - panic("no return value specified for RESTMapping") - } - - var r0 *meta.RESTMapping - var r1 error - if rf, ok := ret.Get(0).(func(schema.GroupKind, ...string) (*meta.RESTMapping, error)); ok { - return rf(gk, versions...) - } - if rf, ok := ret.Get(0).(func(schema.GroupKind, ...string) *meta.RESTMapping); ok { - r0 = rf(gk, versions...) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*meta.RESTMapping) - } - } - - if rf, ok := ret.Get(1).(func(schema.GroupKind, ...string) error); ok { - r1 = rf(gk, versions...) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_RESTMapping_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RESTMapping' -type MockRESTMapper_RESTMapping_Call struct { - *mock.Call -} - -// RESTMapping is a helper method to define mock.On call -// - gk schema.GroupKind -// - versions ...string -func (_e *MockRESTMapper_Expecter) RESTMapping(gk interface{}, versions ...interface{}) *MockRESTMapper_RESTMapping_Call { - return &MockRESTMapper_RESTMapping_Call{Call: _e.mock.On("RESTMapping", - append([]interface{}{gk}, versions...)...)} -} - -func (_c *MockRESTMapper_RESTMapping_Call) Run(run func(gk schema.GroupKind, versions ...string)) *MockRESTMapper_RESTMapping_Call { - _c.Call.Run(func(args mock.Arguments) { - variadicArgs := make([]string, len(args)-1) - for i, a := range args[1:] { - if a != nil { - variadicArgs[i] = a.(string) - } - } - run(args[0].(schema.GroupKind), variadicArgs...) - }) - return _c -} - -func (_c *MockRESTMapper_RESTMapping_Call) Return(_a0 *meta.RESTMapping, _a1 error) *MockRESTMapper_RESTMapping_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockRESTMapper_RESTMapping_Call) RunAndReturn(run func(schema.GroupKind, ...string) (*meta.RESTMapping, error)) *MockRESTMapper_RESTMapping_Call { - _c.Call.Return(run) - return _c -} - -// RESTMappings provides a mock function with given fields: gk, versions -func (_m *MockRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) { - _va := make([]interface{}, len(versions)) - for _i := range versions { - _va[_i] = versions[_i] - } - var _ca []interface{} - _ca = append(_ca, gk) - _ca = append(_ca, _va...) - ret := _m.Called(_ca...) - - if len(ret) == 0 { - panic("no return value specified for RESTMappings") - } - - var r0 []*meta.RESTMapping - var r1 error - if rf, ok := ret.Get(0).(func(schema.GroupKind, ...string) ([]*meta.RESTMapping, error)); ok { - return rf(gk, versions...) - } - if rf, ok := ret.Get(0).(func(schema.GroupKind, ...string) []*meta.RESTMapping); ok { - r0 = rf(gk, versions...) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*meta.RESTMapping) - } - } - - if rf, ok := ret.Get(1).(func(schema.GroupKind, ...string) error); ok { - r1 = rf(gk, versions...) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_RESTMappings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RESTMappings' -type MockRESTMapper_RESTMappings_Call struct { - *mock.Call -} - -// RESTMappings is a helper method to define mock.On call -// - gk schema.GroupKind -// - versions ...string -func (_e *MockRESTMapper_Expecter) RESTMappings(gk interface{}, versions ...interface{}) *MockRESTMapper_RESTMappings_Call { - return &MockRESTMapper_RESTMappings_Call{Call: _e.mock.On("RESTMappings", - append([]interface{}{gk}, versions...)...)} -} - -func (_c *MockRESTMapper_RESTMappings_Call) Run(run func(gk schema.GroupKind, versions ...string)) *MockRESTMapper_RESTMappings_Call { - _c.Call.Run(func(args mock.Arguments) { - variadicArgs := make([]string, len(args)-1) - for i, a := range args[1:] { - if a != nil { - variadicArgs[i] = a.(string) - } - } - run(args[0].(schema.GroupKind), variadicArgs...) - }) - return _c -} - -func (_c *MockRESTMapper_RESTMappings_Call) Return(_a0 []*meta.RESTMapping, _a1 error) *MockRESTMapper_RESTMappings_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockRESTMapper_RESTMappings_Call) RunAndReturn(run func(schema.GroupKind, ...string) ([]*meta.RESTMapping, error)) *MockRESTMapper_RESTMappings_Call { - _c.Call.Return(run) - return _c -} - -// ResourceFor provides a mock function with given fields: input -func (_m *MockRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) { - ret := _m.Called(input) - - if len(ret) == 0 { - panic("no return value specified for ResourceFor") - } - - var r0 schema.GroupVersionResource - var r1 error - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) (schema.GroupVersionResource, error)); ok { - return rf(input) - } - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) schema.GroupVersionResource); ok { - r0 = rf(input) - } else { - r0 = ret.Get(0).(schema.GroupVersionResource) - } - - if rf, ok := ret.Get(1).(func(schema.GroupVersionResource) error); ok { - r1 = rf(input) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_ResourceFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResourceFor' -type MockRESTMapper_ResourceFor_Call struct { - *mock.Call -} - -// ResourceFor is a helper method to define mock.On call -// - input schema.GroupVersionResource -func (_e *MockRESTMapper_Expecter) ResourceFor(input interface{}) *MockRESTMapper_ResourceFor_Call { - return &MockRESTMapper_ResourceFor_Call{Call: _e.mock.On("ResourceFor", input)} -} - -func (_c *MockRESTMapper_ResourceFor_Call) Run(run func(input schema.GroupVersionResource)) *MockRESTMapper_ResourceFor_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(schema.GroupVersionResource)) - }) - return _c -} - -func (_c *MockRESTMapper_ResourceFor_Call) Return(_a0 schema.GroupVersionResource, _a1 error) *MockRESTMapper_ResourceFor_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockRESTMapper_ResourceFor_Call) RunAndReturn(run func(schema.GroupVersionResource) (schema.GroupVersionResource, error)) *MockRESTMapper_ResourceFor_Call { - _c.Call.Return(run) - return _c -} - -// ResourceSingularizer provides a mock function with given fields: resource -func (_m *MockRESTMapper) ResourceSingularizer(resource string) (string, error) { - ret := _m.Called(resource) - - if len(ret) == 0 { - panic("no return value specified for ResourceSingularizer") - } - - var r0 string - var r1 error - if rf, ok := ret.Get(0).(func(string) (string, error)); ok { - return rf(resource) - } - if rf, ok := ret.Get(0).(func(string) string); ok { - r0 = rf(resource) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(resource) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_ResourceSingularizer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResourceSingularizer' -type MockRESTMapper_ResourceSingularizer_Call struct { - *mock.Call -} - -// ResourceSingularizer is a helper method to define mock.On call -// - resource string -func (_e *MockRESTMapper_Expecter) ResourceSingularizer(resource interface{}) *MockRESTMapper_ResourceSingularizer_Call { - return &MockRESTMapper_ResourceSingularizer_Call{Call: _e.mock.On("ResourceSingularizer", resource)} -} - -func (_c *MockRESTMapper_ResourceSingularizer_Call) Run(run func(resource string)) *MockRESTMapper_ResourceSingularizer_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *MockRESTMapper_ResourceSingularizer_Call) Return(singular string, err error) *MockRESTMapper_ResourceSingularizer_Call { - _c.Call.Return(singular, err) - return _c -} - -func (_c *MockRESTMapper_ResourceSingularizer_Call) RunAndReturn(run func(string) (string, error)) *MockRESTMapper_ResourceSingularizer_Call { - _c.Call.Return(run) - return _c -} - -// ResourcesFor provides a mock function with given fields: input -func (_m *MockRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) { - ret := _m.Called(input) - - if len(ret) == 0 { - panic("no return value specified for ResourcesFor") - } - - var r0 []schema.GroupVersionResource - var r1 error - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) ([]schema.GroupVersionResource, error)); ok { - return rf(input) - } - if rf, ok := ret.Get(0).(func(schema.GroupVersionResource) []schema.GroupVersionResource); ok { - r0 = rf(input) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]schema.GroupVersionResource) - } - } - - if rf, ok := ret.Get(1).(func(schema.GroupVersionResource) error); ok { - r1 = rf(input) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockRESTMapper_ResourcesFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResourcesFor' -type MockRESTMapper_ResourcesFor_Call struct { - *mock.Call -} - -// ResourcesFor is a helper method to define mock.On call -// - input schema.GroupVersionResource -func (_e *MockRESTMapper_Expecter) ResourcesFor(input interface{}) *MockRESTMapper_ResourcesFor_Call { - return &MockRESTMapper_ResourcesFor_Call{Call: _e.mock.On("ResourcesFor", input)} -} - -func (_c *MockRESTMapper_ResourcesFor_Call) Run(run func(input schema.GroupVersionResource)) *MockRESTMapper_ResourcesFor_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(schema.GroupVersionResource)) - }) - return _c -} - -func (_c *MockRESTMapper_ResourcesFor_Call) Return(_a0 []schema.GroupVersionResource, _a1 error) *MockRESTMapper_ResourcesFor_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockRESTMapper_ResourcesFor_Call) RunAndReturn(run func(schema.GroupVersionResource) ([]schema.GroupVersionResource, error)) *MockRESTMapper_ResourcesFor_Call { - _c.Call.Return(run) - return _c -} - -// NewMockRESTMapper creates a new instance of MockRESTMapper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockRESTMapper(t interface { - mock.TestingT - Cleanup(func()) -}) *MockRESTMapper { - mock := &MockRESTMapper{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/listener/pkg/apischema/resolver_test.go b/listener/pkg/apischema/resolver_test.go index 7433a5a..78c3493 100644 --- a/listener/pkg/apischema/resolver_test.go +++ b/listener/pkg/apischema/resolver_test.go @@ -9,6 +9,7 @@ import ( "github.com/platform-mesh/golang-commons/logger/testlogger" apischemaMocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/apischema/mocks" + kcpMocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp/mocks" ) // Compile-time check that ResolverProvider implements Resolver interface @@ -63,8 +64,8 @@ func TestResolverProvider_Resolve(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resolver := NewResolver(testlogger.New().Logger) - dc := apischemaMocks.NewMockDiscoveryInterface(t) - rm := apischemaMocks.NewMockRESTMapper(t) + dc := kcpMocks.NewMockDiscoveryInterface(t) + rm := kcpMocks.NewMockRESTMapper(t) // First call in resolveSchema dc.EXPECT().ServerPreferredResources().Return(tt.preferredResources, tt.err) diff --git a/listener/reconciler/clusteraccess/reconciler.go b/listener/reconciler/clusteraccess/reconciler.go index fbd6aa2..518fcfc 100644 --- a/listener/reconciler/clusteraccess/reconciler.go +++ b/listener/reconciler/clusteraccess/reconciler.go @@ -39,7 +39,7 @@ func NewClusterAccessReconciler( ioHandler workspacefile.IOHandler, schemaResolver apischema.Resolver, log *logger.Logger, -) (reconciler.CustomReconciler, error) { +) (reconciler.ControllerProvider, error) { // Validate required dependencies if ioHandler == nil { return nil, fmt.Errorf("ioHandler is required") @@ -96,7 +96,7 @@ func NewReconciler( ioHandler workspacefile.IOHandler, schemaResolver apischema.Resolver, log *logger.Logger, -) (reconciler.CustomReconciler, error) { +) (reconciler.ControllerProvider, error) { // Create standard manager mgr, err := ctrl.NewManager(opts.Config, opts.ManagerOpts) if err != nil { diff --git a/listener/reconciler/kcp/apibinding_controller.go b/listener/reconciler/kcp/apibinding_controller.go deleted file mode 100644 index a301837..0000000 --- a/listener/reconciler/kcp/apibinding_controller.go +++ /dev/null @@ -1,122 +0,0 @@ -package kcp - -import ( - "bytes" - "context" - "errors" - "io/fs" - "strings" - - kcpapis "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/discovery" - "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/platform-mesh/golang-commons/logger" - - "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/apischema" - "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/workspacefile" -) - -// APIBindingReconciler reconciles an APIBinding object -type APIBindingReconciler struct { - Client client.Client - Scheme *runtime.Scheme - RestConfig *rest.Config - IOHandler workspacefile.IOHandler - DiscoveryFactory DiscoveryFactory - APISchemaResolver apischema.Resolver - ClusterPathResolver ClusterPathResolver - Log *logger.Logger -} - -func (r *APIBindingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - // ignore system workspaces (e.g. system:shard) - if strings.HasPrefix(req.ClusterName, "system") { - return ctrl.Result{}, nil - } - - logger := r.Log.With().Str("cluster", req.ClusterName).Str("name", req.Name).Logger() - - clusterClt, err := r.ClusterPathResolver.ClientForCluster(req.ClusterName) - if err != nil { - logger.Error().Err(err).Msg("failed to get cluster client") - return ctrl.Result{}, err - } - - clusterPath, err := PathForCluster(req.ClusterName, clusterClt) - if err != nil { - if errors.Is(err, ErrClusterIsDeleted) { - logger.Info().Msg("cluster is deleted, triggering cleanup") - if err = r.IOHandler.Delete(clusterPath); err != nil { - logger.Error().Err(err).Msg("failed to delete workspace file after cluster deletion") - return ctrl.Result{}, err - } - return ctrl.Result{}, nil - } - logger.Error().Err(err).Msg("failed to get cluster path") - return ctrl.Result{}, err - } - - logger = logger.With().Str("clusterPath", clusterPath).Logger() - logger.Info().Msg("starting reconciliation...") - - dc, err := r.DiscoveryFactory.ClientForCluster(clusterPath) - if err != nil { - logger.Error().Err(err).Msg("failed to create discovery client for cluster") - return ctrl.Result{}, err - } - - rm, err := r.DiscoveryFactory.RestMapperForCluster(clusterPath) - if err != nil { - logger.Error().Err(err).Msg("failed to create rest mapper for cluster") - return ctrl.Result{}, err - } - - // Generate current schema - currentSchema, err := r.generateCurrentSchema(dc, rm, clusterPath) - if err != nil { - return ctrl.Result{}, err - } - - // Read existing schema (if it exists) - savedSchema, err := r.IOHandler.Read(clusterPath) - if err != nil && !errors.Is(err, fs.ErrNotExist) { - logger.Error().Err(err).Msg("failed to read existing schema file") - return ctrl.Result{}, err - } - - // Write if file doesn't exist or content has changed - if errors.Is(err, fs.ErrNotExist) || !bytes.Equal(currentSchema, savedSchema) { - if err := r.IOHandler.Write(currentSchema, clusterPath); err != nil { - logger.Error().Err(err).Msg("failed to write schema to filesystem") - return ctrl.Result{}, err - } - logger.Info().Msg("schema file updated") - } - - return ctrl.Result{}, nil -} - -// generateCurrentSchema is a subroutine that resolves the current API schema and injects KCP metadata -func (r *APIBindingReconciler) generateCurrentSchema(dc discovery.DiscoveryInterface, rm meta.RESTMapper, clusterPath string) ([]byte, error) { - // Use shared schema generation logic - return generateSchemaWithMetadata( - SchemaGenerationParams{ - ClusterPath: clusterPath, - DiscoveryClient: dc, - RESTMapper: rm, - // No HostOverride for regular workspaces - uses environment kubeconfig - }, - r.APISchemaResolver, - r.Log, - ) -} -func (r *APIBindingReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&kcpapis.APIBinding{}). - Complete(r) -} diff --git a/listener/reconciler/kcp/apibinding_controller_test.go b/listener/reconciler/kcp/apibinding_controller_test.go deleted file mode 100644 index ccf45a9..0000000 --- a/listener/reconciler/kcp/apibinding_controller_test.go +++ /dev/null @@ -1,585 +0,0 @@ -package kcp_test - -import ( - "context" - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - kcpcore "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/platform-mesh/golang-commons/logger" - "github.com/platform-mesh/kubernetes-graphql-gateway/common/mocks" - apschemamocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/apischema/mocks" - workspacefilemocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/workspacefile/mocks" - "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp" - kcpmocks "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp/mocks" -) - -func TestAPIBindingReconciler_Reconcile(t *testing.T) { - // Set up a minimal kubeconfig for tests to avoid reading complex system kubeconfig - tempDir, err := os.MkdirTemp("", "kcp-test-") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - kubeconfigContent := `apiVersion: v1 -kind: Config -current-context: test -contexts: -- context: {cluster: test, user: test} - name: test -clusters: -- cluster: {server: 'https://test.example.com'} - name: test -users: -- name: test - user: {token: test-token} -` - kubeconfigPath := filepath.Join(tempDir, "config") - err = os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600) - if err != nil { - t.Fatalf("Failed to write kubeconfig: %v", err) - } - - originalKubeconfig := os.Getenv("KUBECONFIG") - os.Setenv("KUBECONFIG", kubeconfigPath) - defer func() { - if originalKubeconfig != "" { - os.Setenv("KUBECONFIG", originalKubeconfig) - } else { - os.Unsetenv("KUBECONFIG") - } - }() - - mockLogger, _ := logger.New(logger.DefaultConfig()) - - tests := []struct { - name string - req ctrl.Request - mockSetup func(*mocks.MockClient, *workspacefilemocks.MockIOHandler, *kcpmocks.MockDiscoveryFactory, *apschemamocks.MockResolver, *kcpmocks.MockClusterPathResolver) - wantResult ctrl.Result - wantErr bool - errContains string - }{ - { - name: "system_workspace_ignored", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "system:shard", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - // No expectations set as system workspaces should be ignored - }, - wantResult: ctrl.Result{}, - wantErr: false, - }, - { - name: "cluster_client_error", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "test-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mcpr.EXPECT().ClientForCluster("test-cluster"). - Return(nil, errors.New("cluster client error")).Once() - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "cluster client error", - }, - { - name: "cluster_is_deleted_triggers_cleanup", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "deleted-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mcpr.EXPECT().ClientForCluster("deleted-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock the client.Get call that happens in PathForCluster - // Create a deleted LogicalCluster (with DeletionTimestamp set) - now := metav1.Now() - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:deleted-cluster", - }, - DeletionTimestamp: &now, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - // Mock the cleanup - IOHandler.Delete should be called - mio.EXPECT().Delete("root:org:deleted-cluster"). - Return(nil).Once() - }, - wantResult: ctrl.Result{}, - wantErr: false, // Cleanup should succeed without error - }, - { - name: "path_for_cluster_error", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "error-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mcpr.EXPECT().ClientForCluster("error-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock the Get call that PathForCluster makes internally - mockClusterClient.EXPECT().Get( - mock.Anything, - client.ObjectKey{Name: "cluster"}, - mock.AnythingOfType("*v1alpha1.LogicalCluster"), - ).Return(errors.New("get cluster failed")).Once() - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "failed to get logicalcluster resource", - }, - { - name: "discovery_client_creation_error", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "test-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mcpr.EXPECT().ClientForCluster("test-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:test-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:test-cluster"). - Return(nil, errors.New("discovery client error")).Once() - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "discovery client error", - }, - { - name: "rest_mapper_creation_error", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "test-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - - mcpr.EXPECT().ClientForCluster("test-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:test-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:test-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:test-cluster"). - Return(nil, errors.New("rest mapper error")).Once() - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "rest mapper error", - }, - { - name: "file_not_exists_creates_new_schema", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "new-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - mockRestMapper := kcpmocks.NewMockRESTMapper(t) - - mcpr.EXPECT().ClientForCluster("new-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:new-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:new-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:new-cluster"). - Return(mockRestMapper, nil).Once() - - mio.EXPECT().Read("root:org:new-cluster"). - Return(nil, fs.ErrNotExist).Once() - - schemaJSON := []byte(`{"schema": "test"}`) - mar.EXPECT().Resolve(mockDiscoveryClient, mockRestMapper). - Return(schemaJSON, nil).Once() - - // Expect schema with KCP metadata injected - mio.EXPECT().Write(mock.MatchedBy(func(data []byte) bool { - return strings.Contains(string(data), `"schema":"test"`) && - strings.Contains(string(data), `"x-cluster-metadata"`) && - strings.Contains(string(data), `"host":"https://test.example.com"`) && - strings.Contains(string(data), `"path":"root:org:new-cluster"`) - }), "root:org:new-cluster").Return(nil).Once() - }, - wantResult: ctrl.Result{}, - wantErr: false, - }, - { - name: "schema_resolution_error_on_new_file", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "schema-error-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - mockRestMapper := kcpmocks.NewMockRESTMapper(t) - - mcpr.EXPECT().ClientForCluster("schema-error-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:schema-error-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:schema-error-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:schema-error-cluster"). - Return(mockRestMapper, nil).Once() - - mar.EXPECT().Resolve(mockDiscoveryClient, mockRestMapper). - Return(nil, errors.New("schema resolution failed")).Once() - - // No Read call expected since schema generation fails early - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "schema resolution failed", - }, - { - name: "file_write_error_on_new_file", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "write-error-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - mockRestMapper := kcpmocks.NewMockRESTMapper(t) - - mcpr.EXPECT().ClientForCluster("write-error-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:write-error-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:write-error-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:write-error-cluster"). - Return(mockRestMapper, nil).Once() - - mio.EXPECT().Read("root:org:write-error-cluster"). - Return(nil, fs.ErrNotExist).Once() - - schemaJSON := []byte(`{"schema": "test"}`) - mar.EXPECT().Resolve(mockDiscoveryClient, mockRestMapper). - Return(schemaJSON, nil).Once() - - // Expect schema with KCP metadata injected - mio.EXPECT().Write(mock.MatchedBy(func(data []byte) bool { - return strings.Contains(string(data), `"schema":"test"`) && - strings.Contains(string(data), `"x-cluster-metadata"`) - }), "root:org:write-error-cluster").Return(errors.New("write failed")).Once() - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "write failed", - }, - { - name: "file_read_error", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "read-error-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - mockRestMapper := kcpmocks.NewMockRESTMapper(t) - - mcpr.EXPECT().ClientForCluster("read-error-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:read-error-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:read-error-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:read-error-cluster"). - Return(mockRestMapper, nil).Once() - - // Schema generation happens before read, so we need this expectation - schemaJSON := []byte(`{"schema": "test"}`) - mar.EXPECT().Resolve(mockDiscoveryClient, mockRestMapper). - Return(schemaJSON, nil).Once() - - mio.EXPECT().Read("root:org:read-error-cluster"). - Return(nil, errors.New("read failed")).Once() - }, - wantResult: ctrl.Result{}, - wantErr: true, - errContains: "read failed", - }, - { - name: "schema_unchanged_no_write", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "unchanged-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - mockRestMapper := kcpmocks.NewMockRESTMapper(t) - - mcpr.EXPECT().ClientForCluster("unchanged-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:unchanged-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:unchanged-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:unchanged-cluster"). - Return(mockRestMapper, nil).Once() - - savedJSON := []byte(`{"schema": "existing"}`) - mio.EXPECT().Read("root:org:unchanged-cluster"). - Return(savedJSON, nil).Once() - - // Return the same schema - no changes - mar.EXPECT().Resolve(mockDiscoveryClient, mockRestMapper). - Return(savedJSON, nil).Once() - - // Write call expected since metadata injection makes the schemas different - mio.EXPECT().Write(mock.MatchedBy(func(data []byte) bool { - return strings.Contains(string(data), `"schema":"existing"`) && - strings.Contains(string(data), `"x-cluster-metadata"`) && - strings.Contains(string(data), `"path":"root:org:unchanged-cluster"`) - }), "root:org:unchanged-cluster").Return(nil).Once() - }, - wantResult: ctrl.Result{}, - wantErr: false, - }, - { - name: "schema_changed_writes_update", - req: ctrl.Request{ - NamespacedName: types.NamespacedName{Name: "test-binding"}, - ClusterName: "changed-cluster", - }, - mockSetup: func(mc *mocks.MockClient, mio *workspacefilemocks.MockIOHandler, mdf *kcpmocks.MockDiscoveryFactory, mar *apschemamocks.MockResolver, mcpr *kcpmocks.MockClusterPathResolver) { - mockClusterClient := mocks.NewMockClient(t) - mockDiscoveryClient := kcpmocks.NewMockDiscoveryInterface(t) - mockRestMapper := kcpmocks.NewMockRESTMapper(t) - - mcpr.EXPECT().ClientForCluster("changed-cluster"). - Return(mockClusterClient, nil).Once() - - // Mock successful LogicalCluster get - lc := &kcpcore.LogicalCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", - Annotations: map[string]string{ - "kcp.io/path": "root:org:changed-cluster", - }, - }, - } - mockClusterClient.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). - RunAndReturn(func(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - lcObj := obj.(*kcpcore.LogicalCluster) - *lcObj = *lc - return nil - }).Once() - - mdf.EXPECT().ClientForCluster("root:org:changed-cluster"). - Return(mockDiscoveryClient, nil).Once() - - mdf.EXPECT().RestMapperForCluster("root:org:changed-cluster"). - Return(mockRestMapper, nil).Once() - - savedJSON := []byte(`{"schema": "old"}`) - mio.EXPECT().Read("root:org:changed-cluster"). - Return(savedJSON, nil).Once() - - newJSON := []byte(`{"schema": "new"}`) - mar.EXPECT().Resolve(mockDiscoveryClient, mockRestMapper). - Return(newJSON, nil).Once() - - // Expect schema with KCP metadata injected - mio.EXPECT().Write(mock.MatchedBy(func(data []byte) bool { - return strings.Contains(string(data), `"schema":"new"`) && - strings.Contains(string(data), `"x-cluster-metadata"`) && - strings.Contains(string(data), `"path":"root:org:changed-cluster"`) - }), "root:org:changed-cluster").Return(nil).Once() - }, - wantResult: ctrl.Result{}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockClient := mocks.NewMockClient(t) - mockIOHandler := workspacefilemocks.NewMockIOHandler(t) - mockDiscoveryFactory := kcpmocks.NewMockDiscoveryFactory(t) - mockAPISchemaResolver := apschemamocks.NewMockResolver(t) - mockClusterPathResolver := kcpmocks.NewMockClusterPathResolver(t) - - tt.mockSetup(mockClient, mockIOHandler, mockDiscoveryFactory, mockAPISchemaResolver, mockClusterPathResolver) - - reconciler := &kcp.ExportedAPIBindingReconciler{ - Client: mockClient, - Scheme: runtime.NewScheme(), - RestConfig: &rest.Config{Host: "https://test.example.com"}, - IOHandler: mockIOHandler, - DiscoveryFactory: mockDiscoveryFactory, - APISchemaResolver: mockAPISchemaResolver, - ClusterPathResolver: mockClusterPathResolver, - Log: mockLogger, - } - - // Note: This test setup is simplified as we cannot easily mock the PathForCluster function - // which is called internally. In a real test scenario, you might need to: - // 1. Refactor the code to make PathForCluster injectable - // 2. Use integration tests for the full flow - // 3. Create a wrapper that can be mocked - - got, err := reconciler.Reconcile(t.Context(), tt.req) - - if tt.wantErr { - assert.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.wantResult, got) - }) - } -} diff --git a/listener/reconciler/kcp/cluster_path.go b/listener/reconciler/kcp/cluster_path.go index b639f2f..eebda68 100644 --- a/listener/reconciler/kcp/cluster_path.go +++ b/listener/reconciler/kcp/cluster_path.go @@ -5,11 +5,15 @@ import ( "errors" "fmt" "net/url" + "strings" kcpcore "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" + "github.com/kcp-dev/logicalcluster/v3" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/platform-mesh/golang-commons/logger" ) var ( @@ -55,47 +59,150 @@ type ClusterPathResolverProvider struct { *runtime.Scheme *rest.Config clientFactory + log *logger.Logger } -func NewClusterPathResolver(cfg *rest.Config, scheme *runtime.Scheme) (*ClusterPathResolverProvider, error) { +func NewClusterPathResolver(cfg *rest.Config, scheme *runtime.Scheme, log *logger.Logger) (*ClusterPathResolverProvider, error) { if cfg == nil { return nil, ErrNilConfig } if scheme == nil { return nil, ErrNilScheme } + if log == nil { + return nil, fmt.Errorf("logger cannot be nil") + } return &ClusterPathResolverProvider{ Scheme: scheme, Config: cfg, clientFactory: client.New, + log: log, }, nil } -func (rf *ClusterPathResolverProvider) ClientForCluster(name string) (client.Client, error) { - clusterConfig, err := ConfigForKCPCluster(name, rf.Config) +func (cp *ClusterPathResolverProvider) ClientForCluster(name string) (client.Client, error) { + clusterConfig, err := ConfigForKCPCluster(name, cp.Config) if err != nil { return nil, errors.Join(ErrGetClusterConfig, err) } - return rf.clientFactory(clusterConfig, client.Options{Scheme: rf.Scheme}) + return cp.clientFactory(clusterConfig, client.Options{Scheme: cp.Scheme}) } -func PathForCluster(name string, clt client.Client) (string, error) { +func (cp *ClusterPathResolverProvider) PathForCluster(name string, clt client.Client) (string, error) { if name == "root" { return name, nil } + + // Try to get LogicalCluster resource to extract workspace path lc := &kcpcore.LogicalCluster{} - if err := clt.Get(context.TODO(), client.ObjectKey{Name: "cluster"}, lc); err != nil { - return "", errors.Join(ErrGetLogicalCluster, err) + err := clt.Get(context.TODO(), client.ObjectKey{Name: "cluster"}, lc) + if err != nil { + cp.log.Debug(). + Err(err). + Str("clusterName", name). + Msg("LogicalCluster resource not accessible, using cluster name as fallback") + return name, nil + } + + if lc.DeletionTimestamp != nil { + // Try to get the workspace name even if the cluster is being deleted + // First try the kcp.io/path annotation (most reliable) + if lc.Annotations != nil { + if path, ok := lc.Annotations["kcp.io/path"]; ok { + return path, ErrClusterIsDeleted + } + } + // Fallback to logicalcluster.From() + workspaceName := logicalcluster.From(lc).String() + if workspaceName != "" { + return workspaceName, ErrClusterIsDeleted + } + return name, ErrClusterIsDeleted } - path, ok := lc.GetAnnotations()["kcp.io/path"] - if !ok { - return "", ErrMissingPathAnnotation + // Primary approach: Extract the workspace path from the kcp.io/path annotation + // This is the most reliable method as proven by our debug script + if lc.Annotations != nil { + if path, ok := lc.Annotations["kcp.io/path"]; ok { + return path, nil + } } - if lc.DeletionTimestamp != nil { - return path, ErrClusterIsDeleted + // Fallback: Use logicalcluster.From() to get the actual workspace name + // This is the same approach used by the virtual-workspaces resolver + workspaceName := logicalcluster.From(lc).String() + if workspaceName != "" { + return workspaceName, nil + } + + // Final fallback: use cluster name as-is + return name, nil +} + +// PathForClusterFromConfig attempts to extract cluster identifier from cluster configuration +// Returns either a workspace path or cluster hash depending on the URL type. +// This is an alternative approach when LogicalCluster resource is not accessible. +func PathForClusterFromConfig(clusterName string, cfg *rest.Config) (string, error) { + if clusterName == "root" { + return clusterName, nil + } + + if cfg == nil { + return clusterName, nil + } + + // Parse the cluster config host URL to extract workspace information + parsedURL, err := url.Parse(cfg.Host) + if err != nil { + return clusterName, nil + } + + // Check if the URL path contains cluster information + // KCP URLs typically follow patterns like: + // - /clusters/{workspace-path} for direct cluster access (e.g., /clusters/root:orgs:default) + // - /services/apiexport/{workspace-path}/{export-name} for virtual workspaces + if strings.HasPrefix(parsedURL.Path, "/clusters/") { + // Extract workspace path from URL path + pathParts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") + if len(pathParts) >= 2 && pathParts[0] == "clusters" { + // The cluster name in the URL should be the workspace path + urlWorkspacePath := pathParts[1] + // If the URL workspace path looks like a workspace path (contains colons), use it + if strings.Contains(urlWorkspacePath, ":") { + return urlWorkspacePath, nil + } + // Even if it doesn't contain colons, it might still be a valid workspace path + // (e.g., "root" or single-level workspaces) + if urlWorkspacePath != clusterName { + return urlWorkspacePath, nil + } + } + } + + // Check for virtual workspace patterns + if strings.HasPrefix(parsedURL.Path, "/services/apiexport/") { + // Pattern: /services/apiexport/{workspace-path}/{export-name} + // First try to extract workspace path directly from URL path + pathParts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") + // Expected: ["services", "apiexport", "{workspace-path}", "{export-name}"] + if len(pathParts) >= 3 && pathParts[0] == "services" && pathParts[1] == "apiexport" { + workspacePathSegment := pathParts[2] + // Check if the workspace path segment is usable (contains ":" or is different from clusterName) + if strings.Contains(workspacePathSegment, ":") || workspacePathSegment != clusterName { + return workspacePathSegment, nil + } + } + + // Fallback: try to extract using the full parser for validation + workspacePath, _, err := extractAPIExportRef(parsedURL.String()) + if err != nil { + // If we can't parse the APIExport URL properly, fall back to cluster name + return clusterName, nil + } + // Return the workspace path from the parsed APIExport URL + return workspacePath, nil } - return path, nil + // If we can't extract meaningful cluster identifier, fall back to cluster name + return clusterName, nil } diff --git a/listener/reconciler/kcp/cluster_path_test.go b/listener/reconciler/kcp/cluster_path_test.go index ff9dacf..31770af 100644 --- a/listener/reconciler/kcp/cluster_path_test.go +++ b/listener/reconciler/kcp/cluster_path_test.go @@ -13,6 +13,7 @@ import ( "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/platform-mesh/golang-commons/logger/testlogger" "github.com/platform-mesh/kubernetes-graphql-gateway/common/mocks" "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp" ) @@ -121,7 +122,7 @@ func TestNewClusterPathResolver(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := kcp.NewClusterPathResolverExported(tt.config, tt.scheme) + got, err := kcp.NewClusterPathResolverExported(tt.config, tt.scheme, testlogger.New().Logger) if tt.wantErr { assert.Error(t, err) @@ -175,7 +176,7 @@ func TestClusterPathResolverProvider_ClientForCluster(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resolver := kcp.NewClusterPathResolverProviderWithFactory(baseConfig, scheme, tt.clientFactory) + resolver := kcp.NewClusterPathResolverProviderWithFactory(baseConfig, scheme, testlogger.New().Logger, tt.clientFactory) got, err := resolver.ClientForCluster(tt.clusterName) @@ -273,9 +274,9 @@ func TestPathForCluster(t *testing.T) { return nil }).Once() }, - want: "", - wantErr: true, - errContains: "failed to get cluster path from kcp.io/path annotation", + want: "no-path-workspace", // Now returns cluster name as fallback + wantErr: false, // No longer an error + errContains: "", }, { name: "client_get_error", @@ -284,9 +285,9 @@ func TestPathForCluster(t *testing.T) { m.EXPECT().Get(mock.Anything, client.ObjectKey{Name: "cluster"}, mock.AnythingOfType("*v1alpha1.LogicalCluster")). Return(errors.New("API server error")).Once() }, - want: "", - wantErr: true, - errContains: "failed to get logicalcluster resource", + want: "error-workspace", // Now returns cluster name as fallback + wantErr: false, // No longer an error + errContains: "", }, } @@ -295,7 +296,13 @@ func TestPathForCluster(t *testing.T) { mockClient := mocks.NewMockClient(t) tt.mockSetup(mockClient) - got, err := kcp.PathForClusterExported(tt.clusterName, mockClient) + // Create a resolver to test the method + scheme := runtime.NewScheme() + config := &rest.Config{Host: "https://test.example.com"} + resolver, resolverErr := kcp.NewClusterPathResolverExported(config, scheme, testlogger.New().Logger) + assert.NoError(t, resolverErr) + + got, err := resolver.PathForCluster(tt.clusterName, mockClient) if tt.wantErr { assert.Error(t, err) @@ -317,6 +324,132 @@ func TestPathForCluster(t *testing.T) { } } +func TestPathForClusterFromConfig(t *testing.T) { + tests := []struct { + name string + clusterName string + config *rest.Config + want string + }{ + { + name: "root_cluster_returns_root", + clusterName: "root", + config: &rest.Config{Host: "https://kcp.example.com"}, + want: "root", + }, + { + name: "nil_config_returns_cluster_name", + clusterName: "test-cluster", + config: nil, + want: "test-cluster", + }, + { + name: "invalid_url_returns_cluster_name", + clusterName: "test-cluster", + config: &rest.Config{Host: "://invalid-url"}, + want: "test-cluster", + }, + { + name: "cluster_url_with_workspace_path", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/clusters/root:org:workspace"}, + want: "root:org:workspace", + }, + { + name: "cluster_url_with_hash_only", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/clusters/hash123"}, + want: "hash123", + }, + { + name: "virtual_workspace_url_extracts_workspace_path", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root:orgs:default/some-export"}, + want: "root:orgs:default", + }, + { + name: "virtual_workspace_url_simple_workspace", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root/some-export"}, + want: "root", + }, + { + name: "cluster_url_different_from_hash", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/clusters/simple-workspace"}, + want: "simple-workspace", + }, + { + name: "non_matching_url_returns_cluster_name", + clusterName: "test-cluster", + config: &rest.Config{Host: "https://kcp.example.com/other/path"}, + want: "test-cluster", + }, + { + name: "cluster_url_without_path_returns_cluster_name", + clusterName: "test-cluster", + config: &rest.Config{Host: "https://kcp.example.com"}, + want: "test-cluster", + }, + { + name: "apiexport_url_with_query_params", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root:orgs:test/export?timeout=30s"}, + want: "root:orgs:test", + }, + { + name: "apiexport_url_with_fragment", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root/export#section"}, + want: "root", + }, + { + name: "cluster_url_with_query_params", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/clusters/root:orgs:test?timeout=30s"}, + want: "root:orgs:test", + }, + { + name: "apiexport_url_workspace_equals_cluster_name", + clusterName: "root", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root/export"}, + want: "root", + }, + { + name: "apiexport_url_malformed_fallback_to_extractAPIExportRef", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/workspace/export/extra"}, + want: "workspace", + }, + { + name: "apiexport_url_invalid_format_fallback_to_cluster_name", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/incomplete"}, + want: "incomplete", + }, + { + name: "cluster_url_with_empty_segments", + clusterName: "test-cluster", + config: &rest.Config{Host: "https://kcp.example.com/clusters//empty"}, + want: "", // Function returns empty string for malformed path with empty segments + }, + { + name: "cluster_url_with_trailing_slash", + clusterName: "hash123", + config: &rest.Config{Host: "https://kcp.example.com/clusters/root:orgs:test/"}, + want: "root:orgs:test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := kcp.PathForClusterFromConfigExported(tt.clusterName, tt.config) + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func TestConstants(t *testing.T) { t.Run("error_variables", func(t *testing.T) { assert.Equal(t, "config cannot be nil", kcp.ErrNilConfigExported.Error()) diff --git a/listener/reconciler/kcp/export_test.go b/listener/reconciler/kcp/export_test.go index a15e261..24ca328 100644 --- a/listener/reconciler/kcp/export_test.go +++ b/listener/reconciler/kcp/export_test.go @@ -1,9 +1,16 @@ package kcp import ( + "context" + "fmt" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + + "github.com/platform-mesh/golang-commons/logger" ) // Exported functions for testing private functions @@ -11,12 +18,16 @@ import ( // Cluster path exports var ConfigForKCPClusterExported = ConfigForKCPCluster -func NewClusterPathResolverExported(cfg *rest.Config, scheme interface{}) (*ClusterPathResolverProvider, error) { - return NewClusterPathResolver(cfg, scheme.(*runtime.Scheme)) +func NewClusterPathResolverExported(cfg *rest.Config, scheme interface{}, log *logger.Logger) (*ClusterPathResolverProvider, error) { + s, ok := scheme.(*runtime.Scheme) + if !ok { + return nil, fmt.Errorf("expected *runtime.Scheme, got %T", scheme) + } + return NewClusterPathResolver(cfg, s, log) } -func PathForClusterExported(name string, clt client.Client) (string, error) { - return PathForCluster(name, clt) +func PathForClusterFromConfigExported(clusterName string, cfg *rest.Config) (string, error) { + return PathForClusterFromConfig(clusterName, cfg) } // Discovery factory exports @@ -45,14 +56,47 @@ type ExportedClusterPathResolver = ClusterPathResolver type ExportedClusterPathResolverProvider = ClusterPathResolverProvider type ExportedDiscoveryFactory = DiscoveryFactory type ExportedDiscoveryFactoryProvider = DiscoveryFactoryProvider -type ExportedAPIBindingReconciler = APIBindingReconciler -type ExportedKCPReconciler = KCPReconciler + +type ExportedKCPManager struct { + *KCPManager +} + +// Export private methods for testing +func (e *ExportedKCPManager) ResolveWorkspacePath(ctx context.Context, clusterName string, clusterClient client.Client) (string, error) { + return e.KCPManager.resolveWorkspacePath(ctx, clusterName, clusterClient) +} + +func (e *ExportedKCPManager) GenerateAndWriteSchemaForWorkspace(ctx context.Context, workspacePath, clusterName string) error { + return e.KCPManager.generateAndWriteSchemaForWorkspace(ctx, workspacePath, clusterName) +} + +func (e *ExportedKCPManager) CreateProviderRunnableForTesting(log *logger.Logger) ProviderRunnableInterface { + return &providerRunnable{ + provider: e.KCPManager.provider, + mcMgr: e.KCPManager.mcMgr, + log: log, + } +} + +func (e *ExportedKCPManager) ReconcileAPIBinding(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + return e.KCPManager.reconcileAPIBinding(ctx, req) +} + +// Interface for testing provider runnable +type ProviderRunnableInterface interface { + Start(ctx context.Context) error +} + +// Helper function exports +var StripAPIExportPathExported = stripAPIExportPath +var ExtractAPIExportRefExported = extractAPIExportRef // Helper function to create ClusterPathResolverProvider with custom clientFactory for testing -func NewClusterPathResolverProviderWithFactory(cfg *rest.Config, scheme *runtime.Scheme, factory func(config *rest.Config, options client.Options) (client.Client, error)) *ClusterPathResolverProvider { +func NewClusterPathResolverProviderWithFactory(cfg *rest.Config, scheme *runtime.Scheme, log *logger.Logger, factory func(config *rest.Config, options client.Options) (client.Client, error)) *ClusterPathResolverProvider { return &ClusterPathResolverProvider{ Scheme: scheme, Config: cfg, clientFactory: factory, + log: log, } } diff --git a/listener/reconciler/kcp/helper_functions.go b/listener/reconciler/kcp/helper_functions.go new file mode 100644 index 0000000..136eb52 --- /dev/null +++ b/listener/reconciler/kcp/helper_functions.go @@ -0,0 +1,50 @@ +package kcp + +import ( + "fmt" + "net/url" + "strings" +) + +// stripAPIExportPath removes APIExport virtual workspace paths from a URL to get the base KCP host +func stripAPIExportPath(hostURL string) string { + parsedURL, err := url.Parse(hostURL) + if err != nil { + // If we can't parse the URL, return it as-is + return hostURL + } + + // Check if the path contains an APIExport pattern: /services/apiexport/... + if strings.HasPrefix(parsedURL.Path, "/services/apiexport/") { + // Strip the APIExport path to get the base KCP host + parsedURL.Path = "" + return parsedURL.String() + } + + // If it's not an APIExport URL, return as-is + return hostURL +} + +// extractAPIExportRef extracts workspace path and export name from an APIExport URL +// Returns (workspacePath, exportName, error) +// Expected format: https://host/services/apiexport/{workspace-path}/{export-name}/ +func extractAPIExportRef(hostURL string) (string, string, error) { + parsedURL, err := url.Parse(hostURL) + if err != nil { + return "", "", fmt.Errorf("failed to parse URL: %w", err) + } + + // Check if this is an APIExport URL + if !strings.HasPrefix(parsedURL.Path, "/services/apiexport/") { + return "", "", fmt.Errorf("not an APIExport URL: %s", hostURL) + } + + // Split the path and extract workspace path and export name + pathParts := strings.Split(strings.Trim(parsedURL.Path, "/"), "/") + // Expected: ["services", "apiexport", "{workspace-path}", "{export-name}"] + if len(pathParts) < 4 || pathParts[0] != "services" || pathParts[1] != "apiexport" { + return "", "", fmt.Errorf("invalid APIExport URL format, expected /services/apiexport/{workspace-path}/{export-name}: %s", hostURL) + } + + return pathParts[2], pathParts[3], nil +} diff --git a/listener/reconciler/kcp/helper_functions_test.go b/listener/reconciler/kcp/helper_functions_test.go new file mode 100644 index 0000000..d842125 --- /dev/null +++ b/listener/reconciler/kcp/helper_functions_test.go @@ -0,0 +1,208 @@ +package kcp_test + +import ( + "testing" + + "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp" + "github.com/stretchr/testify/assert" +) + +func TestStripAPIExportPath(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid_apiexport_url", + input: "https://kcp.example.com/services/apiexport/root:orgs:default/core.platform-mesh.io", + expected: "https://kcp.example.com", + }, + { + name: "apiexport_url_with_port", + input: "https://kcp.example.com:6443/services/apiexport/root/core.platform-mesh.io", + expected: "https://kcp.example.com:6443", + }, + { + name: "apiexport_url_with_query_params", + input: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io?timeout=30s", + expected: "https://kcp.example.com?timeout=30s", + }, + { + name: "non_apiexport_url", + input: "https://kcp.example.com/clusters/root", + expected: "https://kcp.example.com/clusters/root", + }, + { + name: "base_url_no_path", + input: "https://kcp.example.com", + expected: "https://kcp.example.com", + }, + { + name: "invalid_url", + input: "not-a-valid-url", + expected: "not-a-valid-url", + }, + { + name: "empty_string", + input: "", + expected: "", + }, + { + name: "url_with_fragment", + input: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io#section", + expected: "https://kcp.example.com#section", + }, + { + name: "http_url", + input: "http://localhost:8080/services/apiexport/test/export", + expected: "http://localhost:8080", + }, + { + name: "apiexport_with_complex_workspace_path", + input: "https://kcp.example.com/services/apiexport/root:orgs:company:team:project/my.export.name", + expected: "https://kcp.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := kcp.StripAPIExportPathExported(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractAPIExportRef(t *testing.T) { + tests := []struct { + name string + input string + expectedWorkspace string + expectedExport string + expectErr bool + errContains string + }{ + { + name: "valid_simple_apiexport", + input: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io", + expectedWorkspace: "root", + expectedExport: "core.platform-mesh.io", + expectErr: false, + }, + { + name: "valid_complex_workspace_path", + input: "https://kcp.example.com/services/apiexport/root:orgs:default/core.platform-mesh.io", + expectedWorkspace: "root:orgs:default", + expectedExport: "core.platform-mesh.io", + expectErr: false, + }, + { + name: "valid_with_port", + input: "https://kcp.example.com:6443/services/apiexport/root/core.platform-mesh.io", + expectedWorkspace: "root", + expectedExport: "core.platform-mesh.io", + expectErr: false, + }, + { + name: "valid_with_trailing_slash", + input: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io/", + expectedWorkspace: "root", + expectedExport: "core.platform-mesh.io", + expectErr: false, + }, + { + name: "valid_with_query_params", + input: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io?timeout=30s", + expectedWorkspace: "root", + expectedExport: "core.platform-mesh.io", + expectErr: false, + }, + { + name: "valid_http_url", + input: "http://localhost:8080/services/apiexport/test/my-export", + expectedWorkspace: "test", + expectedExport: "my-export", + expectErr: false, + }, + { + name: "valid_complex_export_name", + input: "https://kcp.example.com/services/apiexport/root:orgs:company/io.kubernetes.core.v1", + expectedWorkspace: "root:orgs:company", + expectedExport: "io.kubernetes.core.v1", + expectErr: false, + }, + { + name: "invalid_url", + input: "not-a-valid-url", + expectErr: true, + errContains: "not an APIExport URL", + }, + { + name: "not_apiexport_url", + input: "https://kcp.example.com/clusters/root", + expectErr: true, + errContains: "not an APIExport URL", + }, + { + name: "missing_export_name", + input: "https://kcp.example.com/services/apiexport/root", + expectErr: true, + errContains: "invalid APIExport URL format", + }, + { + name: "missing_workspace_path", + input: "https://kcp.example.com/services/apiexport/", + expectErr: true, + errContains: "invalid APIExport URL format", + }, + { + name: "wrong_services_path", + input: "https://kcp.example.com/service/apiexport/root/export", + expectErr: true, + errContains: "not an APIExport URL", + }, + { + name: "wrong_apiexport_path", + input: "https://kcp.example.com/services/api-export/root/export", + expectErr: true, + errContains: "not an APIExport URL", + }, + { + name: "empty_string", + input: "", + expectErr: true, + errContains: "not an APIExport URL", + }, + { + name: "only_services", + input: "https://kcp.example.com/services/", + expectErr: true, + errContains: "not an APIExport URL", + }, + { + name: "malformed_path_structure", + input: "https://kcp.example.com/services/apiexport", + expectErr: true, + errContains: "not an APIExport URL", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workspace, export, err := kcp.ExtractAPIExportRefExported(tt.input) + + if tt.expectErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + assert.Empty(t, workspace) + assert.Empty(t, export) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedWorkspace, workspace) + assert.Equal(t, tt.expectedExport, export) + } + }) + } +} diff --git a/listener/reconciler/kcp/mocks/mock_ClusterPathResolver.go b/listener/reconciler/kcp/mocks/mock_ClusterPathResolver.go deleted file mode 100644 index 6adab34..0000000 --- a/listener/reconciler/kcp/mocks/mock_ClusterPathResolver.go +++ /dev/null @@ -1,93 +0,0 @@ -// Code generated by mockery v2.52.3. DO NOT EDIT. - -package mocks - -import ( - mock "github.com/stretchr/testify/mock" - client "sigs.k8s.io/controller-runtime/pkg/client" -) - -// MockClusterPathResolver is an autogenerated mock type for the ClusterPathResolver type -type MockClusterPathResolver struct { - mock.Mock -} - -type MockClusterPathResolver_Expecter struct { - mock *mock.Mock -} - -func (_m *MockClusterPathResolver) EXPECT() *MockClusterPathResolver_Expecter { - return &MockClusterPathResolver_Expecter{mock: &_m.Mock} -} - -// ClientForCluster provides a mock function with given fields: name -func (_m *MockClusterPathResolver) ClientForCluster(name string) (client.Client, error) { - ret := _m.Called(name) - - if len(ret) == 0 { - panic("no return value specified for ClientForCluster") - } - - var r0 client.Client - var r1 error - if rf, ok := ret.Get(0).(func(string) (client.Client, error)); ok { - return rf(name) - } - if rf, ok := ret.Get(0).(func(string) client.Client); ok { - r0 = rf(name) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(client.Client) - } - } - - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(name) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockClusterPathResolver_ClientForCluster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientForCluster' -type MockClusterPathResolver_ClientForCluster_Call struct { - *mock.Call -} - -// ClientForCluster is a helper method to define mock.On call -// - name string -func (_e *MockClusterPathResolver_Expecter) ClientForCluster(name interface{}) *MockClusterPathResolver_ClientForCluster_Call { - return &MockClusterPathResolver_ClientForCluster_Call{Call: _e.mock.On("ClientForCluster", name)} -} - -func (_c *MockClusterPathResolver_ClientForCluster_Call) Run(run func(name string)) *MockClusterPathResolver_ClientForCluster_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *MockClusterPathResolver_ClientForCluster_Call) Return(_a0 client.Client, _a1 error) *MockClusterPathResolver_ClientForCluster_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockClusterPathResolver_ClientForCluster_Call) RunAndReturn(run func(string) (client.Client, error)) *MockClusterPathResolver_ClientForCluster_Call { - _c.Call.Return(run) - return _c -} - -// NewMockClusterPathResolver creates a new instance of MockClusterPathResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockClusterPathResolver(t interface { - mock.TestingT - Cleanup(func()) -}) *MockClusterPathResolver { - mock := &MockClusterPathResolver{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/listener/reconciler/kcp/mocks/mock_DiscoveryFactory.go b/listener/reconciler/kcp/mocks/mock_DiscoveryFactory.go deleted file mode 100644 index e76dd67..0000000 --- a/listener/reconciler/kcp/mocks/mock_DiscoveryFactory.go +++ /dev/null @@ -1,152 +0,0 @@ -// Code generated by mockery v2.52.3. DO NOT EDIT. - -package mocks - -import ( - mock "github.com/stretchr/testify/mock" - meta "k8s.io/apimachinery/pkg/api/meta" - discovery "k8s.io/client-go/discovery" -) - -// MockDiscoveryFactory is an autogenerated mock type for the DiscoveryFactory type -type MockDiscoveryFactory struct { - mock.Mock -} - -type MockDiscoveryFactory_Expecter struct { - mock *mock.Mock -} - -func (_m *MockDiscoveryFactory) EXPECT() *MockDiscoveryFactory_Expecter { - return &MockDiscoveryFactory_Expecter{mock: &_m.Mock} -} - -// ClientForCluster provides a mock function with given fields: name -func (_m *MockDiscoveryFactory) ClientForCluster(name string) (discovery.DiscoveryInterface, error) { - ret := _m.Called(name) - - if len(ret) == 0 { - panic("no return value specified for ClientForCluster") - } - - var r0 discovery.DiscoveryInterface - var r1 error - if rf, ok := ret.Get(0).(func(string) (discovery.DiscoveryInterface, error)); ok { - return rf(name) - } - if rf, ok := ret.Get(0).(func(string) discovery.DiscoveryInterface); ok { - r0 = rf(name) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(discovery.DiscoveryInterface) - } - } - - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(name) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryFactory_ClientForCluster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClientForCluster' -type MockDiscoveryFactory_ClientForCluster_Call struct { - *mock.Call -} - -// ClientForCluster is a helper method to define mock.On call -// - name string -func (_e *MockDiscoveryFactory_Expecter) ClientForCluster(name interface{}) *MockDiscoveryFactory_ClientForCluster_Call { - return &MockDiscoveryFactory_ClientForCluster_Call{Call: _e.mock.On("ClientForCluster", name)} -} - -func (_c *MockDiscoveryFactory_ClientForCluster_Call) Run(run func(name string)) *MockDiscoveryFactory_ClientForCluster_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *MockDiscoveryFactory_ClientForCluster_Call) Return(_a0 discovery.DiscoveryInterface, _a1 error) *MockDiscoveryFactory_ClientForCluster_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryFactory_ClientForCluster_Call) RunAndReturn(run func(string) (discovery.DiscoveryInterface, error)) *MockDiscoveryFactory_ClientForCluster_Call { - _c.Call.Return(run) - return _c -} - -// RestMapperForCluster provides a mock function with given fields: name -func (_m *MockDiscoveryFactory) RestMapperForCluster(name string) (meta.RESTMapper, error) { - ret := _m.Called(name) - - if len(ret) == 0 { - panic("no return value specified for RestMapperForCluster") - } - - var r0 meta.RESTMapper - var r1 error - if rf, ok := ret.Get(0).(func(string) (meta.RESTMapper, error)); ok { - return rf(name) - } - if rf, ok := ret.Get(0).(func(string) meta.RESTMapper); ok { - r0 = rf(name) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(meta.RESTMapper) - } - } - - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(name) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockDiscoveryFactory_RestMapperForCluster_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestMapperForCluster' -type MockDiscoveryFactory_RestMapperForCluster_Call struct { - *mock.Call -} - -// RestMapperForCluster is a helper method to define mock.On call -// - name string -func (_e *MockDiscoveryFactory_Expecter) RestMapperForCluster(name interface{}) *MockDiscoveryFactory_RestMapperForCluster_Call { - return &MockDiscoveryFactory_RestMapperForCluster_Call{Call: _e.mock.On("RestMapperForCluster", name)} -} - -func (_c *MockDiscoveryFactory_RestMapperForCluster_Call) Run(run func(name string)) *MockDiscoveryFactory_RestMapperForCluster_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *MockDiscoveryFactory_RestMapperForCluster_Call) Return(_a0 meta.RESTMapper, _a1 error) *MockDiscoveryFactory_RestMapperForCluster_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockDiscoveryFactory_RestMapperForCluster_Call) RunAndReturn(run func(string) (meta.RESTMapper, error)) *MockDiscoveryFactory_RestMapperForCluster_Call { - _c.Call.Return(run) - return _c -} - -// NewMockDiscoveryFactory creates a new instance of MockDiscoveryFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockDiscoveryFactory(t interface { - mock.TestingT - Cleanup(func()) -}) *MockDiscoveryFactory { - mock := &MockDiscoveryFactory{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/listener/reconciler/kcp/reconciler.go b/listener/reconciler/kcp/reconciler.go index c4c7ae6..a51617d 100644 --- a/listener/reconciler/kcp/reconciler.go +++ b/listener/reconciler/kcp/reconciler.go @@ -1,41 +1,69 @@ package kcp import ( + "bytes" "context" + "fmt" + "math/rand" + "strings" + "time" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" - kcpctrl "sigs.k8s.io/controller-runtime/pkg/kcp" + "sigs.k8s.io/controller-runtime/pkg/client" kcpapis "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" + "github.com/kcp-dev/multicluster-provider/apiexport" "github.com/platform-mesh/golang-commons/logger" - "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" + appconfig "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/apischema" "github.com/platform-mesh/kubernetes-graphql-gateway/listener/pkg/workspacefile" "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler" + + mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder" + mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" +) + +// Exponential backoff configuration +const ( + initialDelay = 1 * time.Second + maxDelay = 60 * time.Second + backoffFactor = 2.0 + jitterFactor = 0.1 ) -type KCPReconciler struct { - mgr ctrl.Manager - apiBindingReconciler *APIBindingReconciler +// KCPManager manages multicluster KCP components and schema generation. +// It coordinates the multicluster provider, virtual workspaces, and API binding controllers. +// Unlike traditional reconcilers, KCPManager acts as a coordinator that sets up multicluster +// controllers rather than directly reconciling resources. +type KCPManager struct { + mcMgr mcmanager.Manager + provider *apiexport.Provider + ioHandler workspacefile.IOHandler + schemaResolver apischema.Resolver virtualWorkspaceReconciler *VirtualWorkspaceReconciler configWatcher *ConfigWatcher log *logger.Logger + manager ctrl.Manager // Local controller-runtime manager + clusterPathResolver *ClusterPathResolverProvider } -func NewKCPReconciler( - appCfg config.Config, +func NewKCPManager( + appCfg appconfig.Config, opts reconciler.ReconcilerOpts, log *logger.Logger, -) (*KCPReconciler, error) { - log.Info().Msg("Setting up KCP reconciler with workspace discovery") - - // Create KCP-aware manager - mgr, err := kcpctrl.NewClusterAwareManager(opts.Config, opts.ManagerOpts) - if err != nil { - log.Error().Err(err).Msg("failed to create KCP-aware manager") - return nil, err +) (*KCPManager, error) { + // Validate inputs first before using the logger + if log == nil { + return nil, fmt.Errorf("logger should not be nil") + } + if opts.Scheme == nil { + return nil, fmt.Errorf("scheme should not be nil") } + log.Info().Msg("Setting up KCP reconciler with multicluster-provider") + // Create IO handler for schema files ioHandler, err := workspacefile.NewIOHandler(appCfg.OpenApiDefinitionsPath) if err != nil { @@ -46,30 +74,49 @@ func NewKCPReconciler( // Create schema resolver schemaResolver := apischema.NewResolver(log) - // Create cluster path resolver - clusterPathResolver, err := NewClusterPathResolver(opts.Config, opts.Scheme) + // Create the apiexport provider for multicluster-runtime + // Configure the provider to use the APIExport endpoint + // The multicluster-provider needs to connect to the specific APIExport endpoint + // to discover workspaces, not the base KCP host + apiexportConfig := rest.CopyConfig(opts.Config) + + // Extract base KCP host from kubeconfig, stripping any APIExport paths + // This ensures we work with both base KCP hosts and APIExport URLs in kubeconfig + originalHost := opts.Config.Host + baseHost := stripAPIExportPath(originalHost) + + log.Info(). + Str("originalHost", originalHost). + Str("baseHost", baseHost). + Msg("Extracted base KCP host from kubeconfig") + + // Extract workspace path and export name from the original APIExport URL + // Fail fast if the URL doesn't contain the required information + workspacePath, exportName, err := extractAPIExportRef(originalHost) if err != nil { - log.Error().Err(err).Msg("failed to create cluster path resolver") - return nil, err + log.Error().Err(err).Str("originalHost", originalHost).Msg("failed to extract workspace path and export name from APIExport URL") + return nil, fmt.Errorf("invalid APIExport URL in kubeconfig: %w", err) } - // Create discovery factory - discoveryFactory, err := NewDiscoveryFactory(opts.Config) + // Construct the APIExport URL using the parsed workspace path and export name + apiexportURL := fmt.Sprintf("%s/services/apiexport/%s/%s", baseHost, workspacePath, exportName) + + log.Info().Str("baseHost", baseHost).Str("apiexportURL", apiexportURL).Msg("Using APIExport URL for multicluster provider") + apiexportConfig.Host = apiexportURL + + provider, err := apiexport.New(apiexportConfig, apiexport.Options{ + Scheme: opts.Scheme, + }) if err != nil { - log.Error().Err(err).Msg("failed to create discovery factory") + log.Error().Err(err).Msg("failed to create apiexport provider") return nil, err } - // Create APIBinding reconciler (but don't set up controller yet) - apiBindingReconciler := &APIBindingReconciler{ - Client: mgr.GetClient(), - Scheme: opts.Scheme, - RestConfig: opts.Config, - IOHandler: ioHandler, - DiscoveryFactory: discoveryFactory, - APISchemaResolver: schemaResolver, - ClusterPathResolver: clusterPathResolver, - Log: log, + // Create multicluster manager + mcMgr, err := mcmanager.New(opts.Config, provider, opts.ManagerOpts) + if err != nil { + log.Error().Err(err).Msg("failed to create multicluster manager") + return nil, err } // Setup virtual workspace components @@ -87,61 +134,309 @@ func NewKCPReconciler( return nil, err } - reconcilerInstance := &KCPReconciler{ - mgr: mgr, - apiBindingReconciler: apiBindingReconciler, + // Create cluster path resolver for workspace path resolution + clusterPathResolver, err := NewClusterPathResolver(mcMgr.GetLocalManager().GetConfig(), mcMgr.GetLocalManager().GetScheme(), log) + if err != nil { + log.Error().Err(err).Msg("failed to create cluster path resolver") + return nil, err + } + + managerInstance := &KCPManager{ + mcMgr: mcMgr, + provider: provider, + ioHandler: ioHandler, + schemaResolver: schemaResolver, virtualWorkspaceReconciler: virtualWorkspaceReconciler, configWatcher: configWatcher, log: log, + manager: mcMgr.GetLocalManager(), // Use the local manager directly + clusterPathResolver: clusterPathResolver, } - log.Info().Msg("Successfully configured KCP reconciler with workspace discovery") - return reconcilerInstance, nil + log.Info().Msg("Successfully configured KCP manager with multicluster-provider") + return managerInstance, nil } -func (r *KCPReconciler) GetManager() ctrl.Manager { - return r.mgr +func (m *KCPManager) GetManager() ctrl.Manager { + return m.manager } -func (r *KCPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - // This method is required by the reconciler.CustomReconciler interface but is not used directly. - // Actual reconciliation is handled by the APIBinding controller set up in SetupWithManager(). - // KCPReconciler acts as a coordinator/manager rather than a direct reconciler. +func (m *KCPManager) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // This method is required by the reconciler.ControllerProvider interface but is not used directly. + // Actual reconciliation is handled by the multicluster controller set up in SetupWithManager(). + // KCPManager acts as a coordinator/manager rather than a direct reconciler. return ctrl.Result{}, nil } -func (r *KCPReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Handle cases where the reconciler wasn't properly initialized (e.g., in tests) - if r.apiBindingReconciler == nil { - return nil +func (m *KCPManager) SetupWithManager(mgr ctrl.Manager) error { + // Setup the multicluster APIBinding controller + err := mcbuilder.ControllerManagedBy(m.mcMgr). + Named("kcp-apibinding-schema-controller"). + For(&kcpapis.APIBinding{}). + Complete(mcreconcile.Func(m.reconcileAPIBinding)) + if err != nil { + m.log.Error().Err(err).Msg("failed to setup multicluster APIBinding controller") + return err } - // Setup the APIBinding controller with cluster context - this is crucial for req.ClusterName - if err := ctrl.NewControllerManagedBy(mgr). - For(&kcpapis.APIBinding{}). - Complete(kcpctrl.WithClusterInContext(r.apiBindingReconciler)); err != nil { - r.log.Error().Err(err).Msg("failed to setup APIBinding controller") + // Add the provider as a runnable to the manager so it starts with the manager + err = mgr.Add(&providerRunnable{ + provider: m.provider, + mcMgr: m.mcMgr, + log: m.log, + }) + if err != nil { + m.log.Error().Err(err).Msg("failed to add provider runnable to manager") return err } - r.log.Info().Msg("Successfully set up APIBinding controller") + m.log.Info().Msg("Successfully set up multicluster APIBinding controller and provider") + return nil +} + +// reconcileAPIBinding handles APIBinding reconciliation across multiple clusters +func (m *KCPManager) reconcileAPIBinding(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) { + logger := m.log.With().Str("cluster", req.ClusterName).Str("name", req.Name).Logger() + + // Get the cluster from the multicluster manager + cluster, err := m.mcMgr.GetCluster(ctx, req.ClusterName) + if err != nil { + logger.Error().Err(err).Msg("failed to get cluster from multicluster manager") + return ctrl.Result{}, fmt.Errorf("failed to get cluster: %w", err) + } + + client := cluster.GetClient() + + // Check if APIBinding still exists (it might have been deleted) + apiBinding := &kcpapis.APIBinding{} + err = client.Get(ctx, req.NamespacedName, apiBinding) + if err != nil { + logger.Info().Msg("APIBinding not found, skipping reconciliation") + return ctrl.Result{}, nil + } + + // Resolve cluster name (hash) to workspace path (e.g., orgs:openmfp:default) + // This ensures compatibility with GraphQL gateway which expects workspace names + workspacePath, err := m.resolveWorkspacePath(ctx, req.ClusterName, client) + if err != nil { + logger.Error().Err(err).Str("clusterName", req.ClusterName).Msg("failed to resolve cluster name to workspace path") + return ctrl.Result{}, fmt.Errorf("failed to resolve workspace path: %w", err) + } + + // If we got the same value back (cluster name), try alternative approach using cluster config + if workspacePath == req.ClusterName { + logger.Debug().Str("clusterName", req.ClusterName).Str("configHost", cluster.GetConfig().Host).Msg("LogicalCluster approach returned cluster name, trying config-based approach") + configBasedPath, configErr := PathForClusterFromConfig(req.ClusterName, cluster.GetConfig()) + if configErr == nil && configBasedPath != req.ClusterName { + workspacePath = configBasedPath + logger.Info().Str("clusterName", req.ClusterName).Str("workspacePath", workspacePath).Str("configHost", cluster.GetConfig().Host).Msg("resolved workspace path from cluster config") + } else { + // Log the cluster config URL for debugging + logger.Info().Str("clusterName", req.ClusterName).Str("configHost", cluster.GetConfig().Host).Str("workspacePath", workspacePath).Msg("using cluster name as workspace path (no LogicalCluster or config-based resolution available)") + } + } else { + logger.Info().Str("clusterName", req.ClusterName).Str("workspacePath", workspacePath).Msg("resolved cluster name to workspace path from LogicalCluster") + } + + // Generate and write schema for this cluster using the workspace path + // Create a direct workspace client instead of using the APIExport cluster + // This ensures we get the full API resource list from the workspace, not just exported APIs + err = m.generateAndWriteSchemaForWorkspace(ctx, workspacePath, req.ClusterName) + if err != nil { + logger.Error().Err(err).Msg("failed to generate and write schema") + return ctrl.Result{}, err + } + + logger.Info().Str("workspacePath", workspacePath).Msg("successfully reconciled APIBinding schema") + return ctrl.Result{}, nil +} + +// generateAndWriteSchemaForWorkspace generates the OpenAPI schema for a workspace using direct access +func (m *KCPManager) generateAndWriteSchemaForWorkspace(ctx context.Context, workspacePath, clusterName string) error { + // Create direct workspace config for discovery + // This ensures we get the full API resource list from the workspace, not just exported APIs + workspaceConfig, err := ConfigForKCPCluster(clusterName, m.mcMgr.GetLocalManager().GetConfig()) + if err != nil { + return fmt.Errorf("failed to create workspace config: %w", err) + } + + // WORKAROUND: Use the original approach from main branch + // Create discovery client but ensure it doesn't make /api requests to KCP front proxy + // Use the existing discovery factory which should handle KCP properly + discoveryFactory, err := NewDiscoveryFactory(workspaceConfig) + if err != nil { + return fmt.Errorf("failed to create discovery factory: %w", err) + } + + discoveryClient, err := discoveryFactory.ClientForCluster(clusterName) + if err != nil { + return fmt.Errorf("failed to create discovery client: %w", err) + } + + restMapper, err := discoveryFactory.RestMapperForCluster(clusterName) + if err != nil { + return fmt.Errorf("failed to create REST mapper: %w", err) + } + + // Use direct workspace URLs like the main branch for gateway compatibility + // The multicluster-provider is only used for workspace discovery in the listener + // The gateway will use standard Kubernetes clients with direct workspace URLs + baseConfig := m.mcMgr.GetLocalManager().GetConfig() + + // Strip APIExport path from the base config host to get the clean KCP host + baseHost := stripAPIExportPath(baseConfig.Host) + + // Construct direct workspace URL like main branch: /clusters/{workspace} + directWorkspaceHost := fmt.Sprintf("%s/clusters/%s", baseHost, workspacePath) + + m.log.Info(). + Str("clusterName", clusterName). + Str("workspacePath", workspacePath). + Str("baseHost", baseHost). + Str("directWorkspaceHost", directWorkspaceHost). + Msg("Using direct workspace URL for gateway compatibility (same as main branch)") + + // Generate current schema using direct workspace access + currentSchema, err := generateSchemaWithMetadata( + SchemaGenerationParams{ + ClusterPath: workspacePath, + DiscoveryClient: discoveryClient, + RESTMapper: restMapper, + HostOverride: directWorkspaceHost, // Use direct workspace URL like main branch + }, + m.schemaResolver, + m.log, + ) + if err != nil { + return fmt.Errorf("failed to generate schema: %w", err) + } + + // Read existing schema (if it exists) + savedSchema, err := m.ioHandler.Read(workspacePath) + if err != nil && !strings.Contains(err.Error(), "file does not exist") && !strings.Contains(err.Error(), "no such file") { + return fmt.Errorf("failed to read existing schema: %w", err) + } + + // Write if file doesn't exist or content has changed + if err != nil || !bytes.Equal(currentSchema, savedSchema) { + err = m.ioHandler.Write(currentSchema, workspacePath) + if err != nil { + return fmt.Errorf("failed to write schema: %w", err) + } + m.log.Info().Str("clusterPath", workspacePath).Msg("schema file updated") + } + return nil } // StartVirtualWorkspaceWatching starts watching virtual workspace configuration -func (r *KCPReconciler) StartVirtualWorkspaceWatching(ctx context.Context, configPath string) error { +func (m *KCPManager) StartVirtualWorkspaceWatching(ctx context.Context, configPath string) error { if configPath == "" { - r.log.Info().Msg("no virtual workspace config path provided, skipping virtual workspace watching") + m.log.Info().Msg("no virtual workspace config path provided, skipping virtual workspace watching") return nil } - r.log.Info().Str("configPath", configPath).Msg("starting virtual workspace configuration watching") + m.log.Info().Str("configPath", configPath).Msg("starting virtual workspace configuration watching") // Start config watcher with a wrapper function changeHandler := func(config *VirtualWorkspacesConfig) { - if err := r.virtualWorkspaceReconciler.ReconcileConfig(ctx, config); err != nil { - r.log.Error().Err(err).Msg("failed to reconcile virtual workspaces config") + if err := m.virtualWorkspaceReconciler.ReconcileConfig(ctx, config); err != nil { + m.log.Error().Err(err).Msg("failed to reconcile virtual workspaces config") + } + } + return m.configWatcher.Watch(ctx, configPath, changeHandler) +} + +// resolveWorkspacePath resolves a cluster name/hash to a human-readable workspace path +func (m *KCPManager) resolveWorkspacePath(ctx context.Context, clusterName string, clusterClient client.Client) (string, error) { + // For multicluster-provider, we need to create a client that connects directly to the cluster hash + // The clusterClient passed in might not be correctly configured for the specific cluster + + // Get a client specifically for this cluster using the pre-initialized resolver + specificClusterClient, err := m.clusterPathResolver.ClientForCluster(clusterName) + if err != nil { + // Use the resolver with the provided client as fallback + return m.clusterPathResolver.PathForCluster(clusterName, clusterClient) + } + + // Use the cluster-specific client to resolve the workspace path with logger + workspacePath, err := m.clusterPathResolver.PathForCluster(clusterName, specificClusterClient) + if err != nil { + return clusterName, err + } + + return workspacePath, nil +} + +// providerRunnable wraps the apiexport provider to make it compatible with controller-runtime manager +type providerRunnable struct { + provider *apiexport.Provider + mcMgr mcmanager.Manager + log *logger.Logger +} + +func (p *providerRunnable) Start(ctx context.Context) error { + p.log.Info().Msg("Starting KCP provider with multicluster manager") + + delay := initialDelay + attempt := 1 + + for { + select { + case <-ctx.Done(): + p.log.Info().Msg("Context cancelled, stopping KCP provider") + return ctx.Err() + default: + } + + p.log.Info().Int("attempt", attempt).Msg("Starting KCP provider attempt") + + // Run the provider + err := p.provider.Run(ctx, p.mcMgr) + + // Check if context was cancelled during provider run + if ctx.Err() != nil { + p.log.Info().Msg("Context cancelled during provider run") + return ctx.Err() + } + + // If provider returned without context cancellation, it's an error + if err != nil { + p.log.Error(). + Err(err). + Int("attempt", attempt). + Dur("nextRetryIn", delay). + Msg("KCP provider failed, retrying with backoff") + } else { + p.log.Warn(). + Int("attempt", attempt). + Dur("nextRetryIn", delay). + Msg("KCP provider stopped unexpectedly, retrying with backoff") + } + + // Wait with exponential backoff and jitter + jitter := time.Duration(float64(delay) * jitterFactor * (2*rand.Float64() - 1)) + backoffDelay := delay + jitter + + p.log.Debug(). + Dur("baseDelay", delay). + Dur("jitter", jitter). + Dur("totalDelay", backoffDelay). + Msg("Applying backoff before retry") + + select { + case <-time.After(backoffDelay): + // Continue to next attempt + case <-ctx.Done(): + p.log.Info().Msg("Context cancelled during backoff, stopping KCP provider") + return ctx.Err() + } + + // Increase delay for next attempt with cap + delay = time.Duration(float64(delay) * backoffFactor) + if delay > maxDelay { + delay = maxDelay } + attempt++ } - return r.configWatcher.Watch(ctx, configPath, changeHandler) } diff --git a/listener/reconciler/kcp/reconciler_test.go b/listener/reconciler/kcp/reconciler_test.go index c42161c..758041a 100644 --- a/listener/reconciler/kcp/reconciler_test.go +++ b/listener/reconciler/kcp/reconciler_test.go @@ -1,25 +1,67 @@ package kcp_test import ( + "context" + "os" + "path/filepath" "testing" + "time" kcpapis "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" kcpcore "github.com/kcp-dev/kcp/sdk/apis/core/v1alpha1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/metrics/server" + mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" "github.com/platform-mesh/golang-commons/logger" + "github.com/platform-mesh/golang-commons/logger/testlogger" "github.com/platform-mesh/kubernetes-graphql-gateway/common/config" "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler" "github.com/platform-mesh/kubernetes-graphql-gateway/listener/reconciler/kcp" ) -func TestNewKCPReconciler(t *testing.T) { - mockLogger, _ := logger.New(logger.DefaultConfig()) +// Test helpers + +func createTestScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + _ = kcpapis.AddToScheme(scheme) + _ = kcpcore.AddToScheme(scheme) + return scheme +} + +func createTestReconcilerOpts() reconciler.ReconcilerOpts { + return reconciler.ReconcilerOpts{ + Config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io"}, + Scheme: createTestScheme(), + ManagerOpts: ctrl.Options{ + Metrics: server.Options{BindAddress: "0"}, + Scheme: createTestScheme(), + }, + } +} + +func createTestKCPManager(t *testing.T) *kcp.KCPManager { + tempDir := t.TempDir() + log := testlogger.New().HideLogOutput().Logger + + manager, err := kcp.NewKCPManager( + config.Config{OpenApiDefinitionsPath: tempDir}, + createTestReconcilerOpts(), + log, + ) + require.NoError(t, err) + return manager +} + +// Tests + +func TestNewKCPManager(t *testing.T) { + log := testlogger.New().HideLogOutput().Logger tests := []struct { name string @@ -29,100 +71,70 @@ func TestNewKCPReconciler(t *testing.T) { errContains string }{ { - name: "successful_creation", - appCfg: config.Config{ - OpenApiDefinitionsPath: t.TempDir(), - }, - opts: reconciler.ReconcilerOpts{ - Config: &rest.Config{ - Host: "https://kcp.example.com", - }, - Scheme: func() *runtime.Scheme { - scheme := runtime.NewScheme() - // Register KCP types - _ = kcpapis.AddToScheme(scheme) - _ = kcpcore.AddToScheme(scheme) - return scheme - }(), - ManagerOpts: ctrl.Options{ - Metrics: server.Options{BindAddress: "0"}, // Disable metrics for tests - Scheme: func() *runtime.Scheme { - scheme := runtime.NewScheme() - // Register KCP types - _ = kcpapis.AddToScheme(scheme) - _ = kcpcore.AddToScheme(scheme) - return scheme - }(), - }, - }, + name: "success", + appCfg: config.Config{OpenApiDefinitionsPath: t.TempDir()}, + opts: createTestReconcilerOpts(), wantErr: false, }, { - name: "invalid_openapi_definitions_path", - appCfg: config.Config{ - OpenApiDefinitionsPath: "/invalid/path/that/does/not/exist", - }, - opts: reconciler.ReconcilerOpts{ - Config: &rest.Config{ - Host: "https://kcp.example.com", - }, - Scheme: runtime.NewScheme(), - ManagerOpts: ctrl.Options{ - Metrics: server.Options{BindAddress: "0"}, - }, - }, + name: "invalid_path", + appCfg: config.Config{OpenApiDefinitionsPath: "/invalid/path"}, + opts: createTestReconcilerOpts(), wantErr: true, errContains: "failed to create or access schemas directory", }, { - name: "nil_scheme", - appCfg: config.Config{ - OpenApiDefinitionsPath: t.TempDir(), - }, + name: "nil_scheme", + appCfg: config.Config{OpenApiDefinitionsPath: t.TempDir()}, opts: reconciler.ReconcilerOpts{ - Config: &rest.Config{ - Host: "https://kcp.example.com", - }, - Scheme: nil, - ManagerOpts: ctrl.Options{ - Metrics: server.Options{BindAddress: "0"}, - }, + Config: &rest.Config{Host: "https://kcp.example.com/services/apiexport/root/core.platform-mesh.io"}, + Scheme: nil, + ManagerOpts: ctrl.Options{Metrics: server.Options{BindAddress: "0"}}, }, wantErr: true, errContains: "scheme should not be nil", }, + { + name: "nil_logger", + appCfg: config.Config{OpenApiDefinitionsPath: t.TempDir()}, + opts: createTestReconcilerOpts(), + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reconciler, err := kcp.NewKCPReconciler(tt.appCfg, tt.opts, mockLogger) + var testLog *logger.Logger + if tt.name != "nil_logger" { + testLog = log + } + + manager, err := kcp.NewKCPManager(tt.appCfg, tt.opts, testLog) if tt.wantErr { assert.Error(t, err) if tt.errContains != "" { assert.Contains(t, err.Error(), tt.errContains) } - assert.Nil(t, reconciler) + assert.Nil(t, manager) } else { assert.NoError(t, err) - assert.NotNil(t, reconciler) - assert.NotNil(t, reconciler.GetManager()) + assert.NotNil(t, manager) + assert.NotNil(t, manager.GetManager()) } }) } } -func TestKCPReconciler_GetManager(t *testing.T) { - reconciler := &kcp.ExportedKCPReconciler{} - - // Since GetManager() just returns the manager field, we can test it simply - assert.Nil(t, reconciler.GetManager()) - - // Test with a real manager would require more setup, so we'll keep this simple +func TestKCPManager_GetManager(t *testing.T) { + t.Run("initialized_manager", func(t *testing.T) { + manager := createTestKCPManager(t) + assert.NotNil(t, manager.GetManager()) + }) } -func TestKCPReconciler_Reconcile(t *testing.T) { - reconciler := &kcp.ExportedKCPReconciler{} +func TestKCPManager_Reconcile(t *testing.T) { + manager := createTestKCPManager(t) req := ctrl.Request{ NamespacedName: types.NamespacedName{ @@ -131,19 +143,284 @@ func TestKCPReconciler_Reconcile(t *testing.T) { }, } - // The Reconcile method should be a no-op and always return empty result with no error - result, err := reconciler.Reconcile(t.Context(), req) - + // Test that Reconcile is a no-op + result, err := manager.Reconcile(context.Background(), req) assert.NoError(t, err) assert.Equal(t, ctrl.Result{}, result) -} -func TestKCPReconciler_SetupWithManager(t *testing.T) { - reconciler := &kcp.ExportedKCPReconciler{} + // Test multiple calls return consistent results + for i := 0; i < 3; i++ { + result, err := manager.Reconcile(context.Background(), req) + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + } +} - // The SetupWithManager method should be a no-op and always return no error - // since controllers are set up in the constructor - err := reconciler.SetupWithManager(nil) +func TestKCPManager_SetupWithManager(t *testing.T) { + kcpManager := createTestKCPManager(t) + // Test with the manager's own manager (should work) + err := kcpManager.SetupWithManager(kcpManager.GetManager()) assert.NoError(t, err) } + +func TestKCPManager_StartVirtualWorkspaceWatching(t *testing.T) { + manager := createTestKCPManager(t) + tempDir := t.TempDir() + + tests := []struct { + name string + configPath string + setupConfig func(string) error + expectErr bool + timeout time.Duration + }{ + { + name: "empty_path", + configPath: "", + setupConfig: func(string) error { return nil }, + expectErr: false, + timeout: 100 * time.Millisecond, + }, + { + name: "valid_config", + configPath: filepath.Join(tempDir, "config.yaml"), + setupConfig: func(path string) error { + content := `virtualWorkspaces: + - name: "test-workspace" + url: "https://test.cluster"` + return os.WriteFile(path, []byte(content), 0644) + }, + expectErr: false, + timeout: 200 * time.Millisecond, + }, + { + name: "nonexistent_file", + configPath: filepath.Join(tempDir, "nonexistent.yaml"), + setupConfig: func(string) error { return nil }, + expectErr: true, + timeout: 100 * time.Millisecond, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.setupConfig(tt.configPath) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), tt.timeout) + defer cancel() + + err = manager.StartVirtualWorkspaceWatching(ctx, tt.configPath) + + if tt.expectErr { + assert.Error(t, err) + } else { + // Should either succeed or be cancelled by context timeout + if err != nil { + t.Logf("Got error (possibly expected): %v", err) + } + } + }) + } +} + +func TestKCPManager_ResolveWorkspacePath(t *testing.T) { + manager := createTestKCPManager(t) + ctx := context.Background() + + tests := []struct { + name string + clusterName string + expectErr bool + expectedLen int // minimum expected length for workspace path + }{ + { + name: "simple_cluster_name", + clusterName: "test-cluster", + expectErr: false, + expectedLen: 1, + }, + { + name: "root_cluster", + clusterName: "root", + expectErr: false, + expectedLen: 1, + }, + { + name: "empty_cluster_name", + clusterName: "", + expectErr: false, + expectedLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use the exported method for testing + exported := kcp.ExportedKCPManager{KCPManager: manager} + result, err := exported.ResolveWorkspacePath(ctx, tt.clusterName, nil) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.True(t, len(result) >= tt.expectedLen) + } + }) + } +} + +func TestKCPManager_GenerateAndWriteSchemaForWorkspace(t *testing.T) { + manager := createTestKCPManager(t) + ctx := context.Background() + + tests := []struct { + name string + workspacePath string + clusterName string + expectErr bool + }{ + { + name: "valid_workspace", + workspacePath: "root:orgs:test", + clusterName: "test-cluster", + expectErr: false, // Should not error even if connection fails + }, + { + name: "root_workspace", + workspacePath: "root", + clusterName: "root", + expectErr: false, + }, + { + name: "empty_workspace", + workspacePath: "", + clusterName: "empty", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use the exported method for testing + exported := kcp.ExportedKCPManager{KCPManager: manager} + err := exported.GenerateAndWriteSchemaForWorkspace(ctx, tt.workspacePath, tt.clusterName) + + if tt.expectErr { + assert.Error(t, err) + } else { + // The function may return an error due to connection issues in tests, + // but we're testing that it doesn't panic and handles the parameters correctly + t.Logf("Schema generation result for %s: %v", tt.workspacePath, err) + } + }) + } +} + +func TestProviderRunnable_Start(t *testing.T) { + t.Parallel() + + log := testlogger.New().HideLogOutput().Logger + + tests := []struct { + name string + contextTimeout time.Duration + expectErr bool + }{ + { + name: "context_cancelled_immediately", + contextTimeout: 1 * time.Millisecond, + expectErr: true, + }, + { + name: "context_cancelled_during_retry", + contextTimeout: 50 * time.Millisecond, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), tt.contextTimeout) + defer cancel() + + // Create a mock provider runnable for testing + exported := kcp.ExportedKCPManager{KCPManager: createTestKCPManager(t)} + runnable := exported.CreateProviderRunnableForTesting(log) + + err := runnable.Start(ctx) + + if tt.expectErr { + assert.Error(t, err) + // Should return context.DeadlineExceeded or context.Canceled + assert.True(t, err == context.DeadlineExceeded || err == context.Canceled) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestKCPManager_EdgeCases(t *testing.T) { + manager := createTestKCPManager(t) + + t.Run("get_manager_not_nil", func(t *testing.T) { + mgr := manager.GetManager() + assert.NotNil(t, mgr) + }) + + t.Run("reconcile_returns_empty_result", func(t *testing.T) { + ctx := context.Background() + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test", Namespace: "default"}} + + result, err := manager.Reconcile(ctx, req) + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + }) + +} + +func TestKCPManager_ReconcileAPIBinding(t *testing.T) { + manager := createTestKCPManager(t) + ctx := context.Background() + + tests := []struct { + name string + req mcreconcile.Request + expectErr bool + description string + }{ + { + name: "cluster_not_found", + req: mcreconcile.Request{ + ClusterName: "nonexistent-cluster", + }, + expectErr: true, + description: "Should error when cluster is not found in multicluster manager", + }, + { + name: "empty_cluster_name", + req: mcreconcile.Request{ + ClusterName: "", + }, + expectErr: false, + description: "Empty cluster name is handled gracefully", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exported := kcp.ExportedKCPManager{KCPManager: manager} + result, err := exported.ReconcileAPIBinding(ctx, tt.req) + + if tt.expectErr { + assert.Error(t, err) + t.Logf("Expected error for %s: %v", tt.description, err) + } else { + assert.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + } + }) + } +} diff --git a/listener/reconciler/kcp/virtual_workspace.go b/listener/reconciler/kcp/virtual_workspace.go index 36a92d3..78bafe6 100644 --- a/listener/reconciler/kcp/virtual_workspace.go +++ b/listener/reconciler/kcp/virtual_workspace.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "strings" "sync" "gopkg.in/yaml.v3" @@ -33,6 +34,33 @@ type VirtualWorkspace struct { Kubeconfig string `yaml:"kubeconfig,omitempty"` // Optional path to kubeconfig for authentication } +// Validate validates the virtual workspace configuration +func (v *VirtualWorkspace) Validate() error { + if v.Name == "" { + return fmt.Errorf("virtual workspace name cannot be empty") + } + + if v.URL == "" { + return fmt.Errorf("virtual workspace URL cannot be empty") + } + + // Validate APIExport URL format if it looks like an APIExport URL + if strings.Contains(v.URL, "/services/apiexport/") { + if _, _, err := extractAPIExportRef(v.URL); err != nil { + return fmt.Errorf("invalid APIExport URL format for workspace %s: %w", v.Name, err) + } + } + + // Validate kubeconfig file exists if specified + if v.Kubeconfig != "" { + if _, err := os.Stat(v.Kubeconfig); os.IsNotExist(err) { + return fmt.Errorf("kubeconfig file not found for workspace %s: %s", v.Name, v.Kubeconfig) + } + } + + return nil +} + // VirtualWorkspacesConfig represents the configuration file structure type VirtualWorkspacesConfig struct { VirtualWorkspaces []VirtualWorkspace `yaml:"virtualWorkspaces"` @@ -53,8 +81,22 @@ func (v *VirtualWorkspaceManager) GetWorkspacePath(workspace VirtualWorkspace) s return fmt.Sprintf("%s/%s", v.appCfg.Url.VirtualWorkspacePrefix, workspace.Name) } -// createVirtualConfig creates a REST config for a virtual workspace -func createVirtualConfig(workspace VirtualWorkspace) (*rest.Config, error) { +// resolveDefaultWorkspace resolves the default workspace using configuration +func resolveDefaultWorkspace(appCfg config.Config) string { + // Use configuration values for default workspace resolution + defaultOrg := appCfg.Url.DefaultKcpWorkspace + + // If the default workspace already contains ":", use it as-is (full path) + if strings.Contains(defaultOrg, ":") { + return defaultOrg + } + + // Replace {org} placeholder with the organization name + return strings.ReplaceAll(appCfg.Url.KcpWorkspacePattern, "{org}", defaultOrg) +} + +// createVirtualConfig creates a REST config for a virtual workspace with a specific target workspace +func createVirtualConfig(workspace VirtualWorkspace, targetWorkspace string) (*rest.Config, error) { if workspace.URL == "" { return nil, fmt.Errorf("%w: empty URL for workspace %s", ErrInvalidVirtualWorkspaceURL, workspace.Name) } @@ -65,6 +107,11 @@ func createVirtualConfig(workspace VirtualWorkspace) (*rest.Config, error) { return nil, fmt.Errorf("%w: %v", ErrParseVirtualWorkspaceURL, err) } + // Use the provided target workspace + if targetWorkspace == "" { + return nil, fmt.Errorf("target workspace cannot be empty for virtual workspace %s", workspace.Name) + } + var virtualConfig *rest.Config if workspace.Kubeconfig != "" { @@ -80,11 +127,11 @@ func createVirtualConfig(workspace VirtualWorkspace) (*rest.Config, error) { } virtualConfig = restConfig - virtualConfig.Host = workspace.URL + "/clusters/root" + virtualConfig.Host = workspace.URL + "/clusters/" + targetWorkspace } else { // Use minimal configuration for virtual workspaces without authentication virtualConfig = &rest.Config{ - Host: workspace.URL + "/clusters/root", + Host: workspace.URL + "/clusters/" + targetWorkspace, UserAgent: "kubernetes-graphql-gateway-listener", TLSClientConfig: rest.TLSClientConfig{ Insecure: true, @@ -95,9 +142,9 @@ func createVirtualConfig(workspace VirtualWorkspace) (*rest.Config, error) { return virtualConfig, nil } -// CreateDiscoveryClient creates a discovery client for the virtual workspace -func (v *VirtualWorkspaceManager) CreateDiscoveryClient(workspace VirtualWorkspace) (discovery.DiscoveryInterface, error) { - virtualConfig, err := createVirtualConfig(workspace) +// CreateDiscoveryClient creates a discovery client for the virtual workspace with a specific target workspace +func (v *VirtualWorkspaceManager) CreateDiscoveryClient(workspace VirtualWorkspace, targetWorkspace string) (discovery.DiscoveryInterface, error) { + virtualConfig, err := createVirtualConfig(workspace, targetWorkspace) if err != nil { return nil, err } @@ -111,9 +158,9 @@ func (v *VirtualWorkspaceManager) CreateDiscoveryClient(workspace VirtualWorkspa return discoveryClient, nil } -// CreateRESTConfig creates a REST config for the virtual workspace (for REST mappers) -func (v *VirtualWorkspaceManager) CreateRESTConfig(workspace VirtualWorkspace) (*rest.Config, error) { - return createVirtualConfig(workspace) +// CreateRESTConfig creates a REST config for the virtual workspace with a specific target workspace +func (v *VirtualWorkspaceManager) CreateRESTConfig(workspace VirtualWorkspace, targetWorkspace string) (*rest.Config, error) { + return createVirtualConfig(workspace, targetWorkspace) } // LoadConfig loads the virtual workspaces configuration from a file @@ -135,6 +182,13 @@ func (v *VirtualWorkspaceManager) LoadConfig(configPath string) (*VirtualWorkspa return nil, fmt.Errorf("failed to parse virtual workspaces config: %w", err) } + // Validate all virtual workspaces + for i, workspace := range config.VirtualWorkspaces { + if err := workspace.Validate(); err != nil { + return nil, fmt.Errorf("validation failed for virtual workspace at index %d: %w", i, err) + } + } + return &config, nil } @@ -188,6 +242,8 @@ func (r *VirtualWorkspaceReconciler) ReconcileConfig(ctx context.Context, config if err := r.processVirtualWorkspace(ctx, workspace); err != nil { r.log.Error().Err(err).Str("workspace", name).Msg("failed to process virtual workspace") + // Don't fail the entire reconciliation if one workspace fails + // This allows other workspaces to be processed and the listener to continue running continue } } @@ -210,7 +266,8 @@ func (r *VirtualWorkspaceReconciler) ReconcileConfig(ctx context.Context, config return nil } -// processVirtualWorkspace generates schema for a single virtual workspace +// processVirtualWorkspace generates a generic schema for a virtual workspace +// The schema will be workspace-agnostic and the actual workspace will be resolved at request time func (r *VirtualWorkspaceReconciler) processVirtualWorkspace(ctx context.Context, workspace VirtualWorkspace) error { workspacePath := r.virtualWSManager.GetWorkspacePath(workspace) @@ -218,18 +275,25 @@ func (r *VirtualWorkspaceReconciler) processVirtualWorkspace(ctx context.Context Str("workspace", workspace.Name). Str("url", workspace.URL). Str("path", workspacePath). - Msg("generating schema for virtual workspace") + Msg("generating generic schema for virtual workspace") + + // Use a default workspace for schema generation - but the gateway will override it at request time + defaultWorkspace := resolveDefaultWorkspace(r.virtualWSManager.appCfg) - // Create discovery client for the virtual workspace - discoveryClient, err := r.virtualWSManager.CreateDiscoveryClient(workspace) + // Create discovery client for the virtual workspace with default workspace + discoveryClient, err := r.virtualWSManager.CreateDiscoveryClient(workspace, defaultWorkspace) if err != nil { + r.log.Warn().Err(err). + Str("workspace", workspace.Name). + Str("url", workspace.URL). + Msg("failed to create discovery client for virtual workspace, will retry later") return fmt.Errorf("failed to create discovery client: %w", err) } r.log.Debug().Str("workspace", workspace.Name).Str("url", workspace.URL).Msg("created discovery client for virtual workspace") - // Create REST config and mapper for the virtual workspace - virtualConfig, err := r.virtualWSManager.CreateRESTConfig(workspace) + // Create REST config and mapper for the virtual workspace with default workspace + virtualConfig, err := r.virtualWSManager.CreateRESTConfig(workspace, defaultWorkspace) if err != nil { return fmt.Errorf("failed to create REST config: %w", err) } @@ -244,13 +308,14 @@ func (r *VirtualWorkspaceReconciler) processVirtualWorkspace(ctx context.Context return fmt.Errorf("failed to create REST mapper for virtual workspace: %w", err) } - // Use shared schema generation logic + // Use shared schema generation logic with generic virtual workspace URL + // The actual workspace will be resolved at request time by the roundtripper schemaWithMetadata, err := generateSchemaWithMetadata( SchemaGenerationParams{ ClusterPath: workspacePath, DiscoveryClient: discoveryClient, RESTMapper: restMapper, - HostOverride: workspace.URL, // Use virtual workspace URL as host override + HostOverride: workspace.URL, // Use base virtual workspace URL without specific workspace }, r.apiSchemaResolver, r.log, @@ -268,7 +333,7 @@ func (r *VirtualWorkspaceReconciler) processVirtualWorkspace(ctx context.Context Str("workspace", workspace.Name). Str("path", workspacePath). Int("schemaSize", len(schemaWithMetadata)). - Msg("successfully generated schema for virtual workspace") + Msg("successfully generated generic schema for virtual workspace") return nil } diff --git a/listener/reconciler/kcp/virtual_workspace_test.go b/listener/reconciler/kcp/virtual_workspace_test.go index ece12dd..884897a 100644 --- a/listener/reconciler/kcp/virtual_workspace_test.go +++ b/listener/reconciler/kcp/virtual_workspace_test.go @@ -77,7 +77,7 @@ func TestVirtualWorkspaceManager_GetWorkspacePath(t *testing.T) { prefix: "virtual-workspace", workspace: VirtualWorkspace{ Name: "test-workspace", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectedPath: "virtual-workspace/test-workspace", }, @@ -86,7 +86,7 @@ func TestVirtualWorkspaceManager_GetWorkspacePath(t *testing.T) { prefix: "vw", workspace: VirtualWorkspace{ Name: "test-workspace_123.domain", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectedPath: "vw/test-workspace_123.domain", }, @@ -95,7 +95,7 @@ func TestVirtualWorkspaceManager_GetWorkspacePath(t *testing.T) { prefix: "", workspace: VirtualWorkspace{ Name: "test-workspace", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectedPath: "/test-workspace", }, @@ -125,7 +125,7 @@ func TestCreateVirtualConfig(t *testing.T) { name: "valid_workspace_without_kubeconfig", workspace: VirtualWorkspace{ Name: "test-workspace", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectError: false, }, @@ -168,7 +168,7 @@ func TestCreateVirtualConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - config, err := createVirtualConfig(tt.workspace) + config, err := createVirtualConfig(tt.workspace, "root:orgs:default") if tt.expectError { assert.Error(t, err) @@ -179,7 +179,7 @@ func TestCreateVirtualConfig(t *testing.T) { } else { assert.NoError(t, err) assert.NotNil(t, config) - assert.Equal(t, tt.workspace.URL+"/clusters/root", config.Host) + assert.Equal(t, tt.workspace.URL+"/clusters/root:orgs:default", config.Host) if tt.workspace.Kubeconfig == "" { assert.True(t, config.TLSClientConfig.Insecure) assert.Equal(t, "kubernetes-graphql-gateway-listener", config.UserAgent) @@ -223,10 +223,10 @@ users: Kubeconfig: kubeconfigPath, } - config, err := createVirtualConfig(workspace) + config, err := createVirtualConfig(workspace, "root:orgs:default") assert.NoError(t, err) assert.NotNil(t, config) - assert.Equal(t, workspace.URL+"/clusters/root", config.Host) + assert.Equal(t, workspace.URL+"/clusters/root:orgs:default", config.Host) assert.Equal(t, "test-token", config.BearerToken) } @@ -240,7 +240,7 @@ func TestVirtualWorkspaceManager_CreateDiscoveryClient(t *testing.T) { name: "valid_workspace", workspace: VirtualWorkspace{ Name: "test-workspace", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectError: false, }, @@ -302,7 +302,7 @@ users: appCfg := config.Config{} manager := NewVirtualWorkspaceManager(appCfg) - client, err := manager.CreateDiscoveryClient(tt.workspace) + client, err := manager.CreateDiscoveryClient(tt.workspace, "root:orgs:default") if tt.expectError { assert.Error(t, err) @@ -325,7 +325,7 @@ func TestVirtualWorkspaceManager_CreateRESTConfig(t *testing.T) { name: "valid_workspace", workspace: VirtualWorkspace{ Name: "test-workspace", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectError: false, }, @@ -387,7 +387,7 @@ users: appCfg := config.Config{} manager := NewVirtualWorkspaceManager(appCfg) - config, err := manager.CreateRESTConfig(tt.workspace) + config, err := manager.CreateRESTConfig(tt.workspace, "root:orgs:default") if tt.expectError { assert.Error(t, err) @@ -395,7 +395,7 @@ users: } else { assert.NoError(t, err) assert.NotNil(t, config) - assert.Equal(t, tt.workspace.URL+"/clusters/root", config.Host) + assert.Equal(t, tt.workspace.URL+"/clusters/root:orgs:default", config.Host) } }) } @@ -427,7 +427,7 @@ func TestVirtualWorkspaceManager_LoadConfig(t *testing.T) { configContent: ` virtualWorkspaces: - name: "test-workspace" - url: "https://example.com" + url: "https://example.com/services/apiexport/root/configmaps-view" `, expectError: false, expectedCount: 1, @@ -438,10 +438,9 @@ virtualWorkspaces: configContent: ` virtualWorkspaces: - name: "workspace1" - url: "https://example.com" + url: "https://example.com/services/apiexport/root/configmaps-view" - name: "workspace2" url: "https://example.org" - kubeconfig: "/path/to/kubeconfig" `, expectError: false, expectedCount: 2, @@ -452,7 +451,7 @@ virtualWorkspaces: configContent: ` virtualWorkspaces: - name: "test-workspace" - url: "https://example.com" + url: "https://example.com/services/apiexport/root/configmaps-view" invalid yaml content `, expectError: true, @@ -547,7 +546,7 @@ users: if tt.expectedCount == 2 { assert.Equal(t, "workspace1", config.VirtualWorkspaces[0].Name) assert.Equal(t, "workspace2", config.VirtualWorkspaces[1].Name) - assert.Equal(t, "/path/to/kubeconfig", config.VirtualWorkspaces[1].Kubeconfig) + assert.Equal(t, "", config.VirtualWorkspaces[1].Kubeconfig) } } }) @@ -584,7 +583,7 @@ func TestVirtualWorkspaceReconciler_ReconcileConfig_Simple(t *testing.T) { initialWorkspaces: make(map[string]VirtualWorkspace), newConfig: &VirtualWorkspacesConfig{ VirtualWorkspaces: []VirtualWorkspace{ - {Name: "new-ws", URL: "https://example.com"}, + {Name: "new-ws", URL: "https://example.com/services/apiexport/root/configmaps-view"}, }, }, expectCurrentCount: 1, @@ -661,7 +660,7 @@ func TestVirtualWorkspaceReconciler_ProcessVirtualWorkspace(t *testing.T) { name: "successful_processing", workspace: VirtualWorkspace{ Name: "test-ws", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, expectError: true, // Expected due to kubeconfig dependency in metadata injection expectedWriteCalls: 0, // Won't reach write due to metadata injection failure @@ -671,7 +670,7 @@ func TestVirtualWorkspaceReconciler_ProcessVirtualWorkspace(t *testing.T) { name: "io_write_error", workspace: VirtualWorkspace{ Name: "test-ws", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, ioWriteError: errors.New("write failed"), expectError: true, // Expected due to kubeconfig dependency in metadata injection @@ -682,7 +681,7 @@ func TestVirtualWorkspaceReconciler_ProcessVirtualWorkspace(t *testing.T) { name: "api_resolve_error", workspace: VirtualWorkspace{ Name: "test-ws", - URL: "https://example.com", + URL: "https://example.com/services/apiexport/root/configmaps-view", }, apiResolveError: errors.New("resolve failed"), expectError: true, @@ -705,6 +704,8 @@ func TestVirtualWorkspaceReconciler_ProcessVirtualWorkspace(t *testing.T) { appCfg := config.Config{} appCfg.Url.VirtualWorkspacePrefix = "virtual-workspace" + appCfg.Url.DefaultKcpWorkspace = "root" + appCfg.Url.KcpWorkspacePattern = "root:orgs:{org}" manager := NewVirtualWorkspaceManager(appCfg) @@ -804,3 +805,139 @@ func TestVirtualWorkspaceReconciler_RemoveVirtualWorkspace(t *testing.T) { }) } } + +func TestVirtualWorkspace_Validate(t *testing.T) { + tests := []struct { + name string + workspace VirtualWorkspace + expectError bool + errorMsg string + }{ + { + name: "valid_workspace", + workspace: VirtualWorkspace{ + Name: "test-workspace", + URL: "https://example.com/services/apiexport/root/configmaps-view", + }, + expectError: false, + }, + { + name: "empty_name", + workspace: VirtualWorkspace{ + Name: "", + URL: "https://example.com/services/apiexport/root/configmaps-view", + }, + expectError: true, + errorMsg: "virtual workspace name cannot be empty", + }, + { + name: "empty_url", + workspace: VirtualWorkspace{ + Name: "test-workspace", + URL: "", + }, + expectError: true, + errorMsg: "virtual workspace URL cannot be empty", + }, + { + name: "invalid_apiexport_url", + workspace: VirtualWorkspace{ + Name: "test-workspace", + URL: "https://example.com/services/apiexport/incomplete", + }, + expectError: true, + errorMsg: "invalid APIExport URL format", + }, + { + name: "nonexistent_kubeconfig", + workspace: VirtualWorkspace{ + Name: "test-workspace", + URL: "https://example.com/services/apiexport/root/configmaps-view", + Kubeconfig: "/nonexistent/kubeconfig", + }, + expectError: true, + errorMsg: "kubeconfig file not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.workspace.Validate() + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestVirtualWorkspaceManager_LoadConfig_WithValidation(t *testing.T) { + tests := []struct { + name string + configYAML string + expectError bool + errorMsg string + }{ + { + name: "valid_config", + configYAML: ` +virtualWorkspaces: +- name: test-workspace + url: https://example.com/services/apiexport/root/configmaps-view +`, + expectError: false, + }, + { + name: "invalid_workspace_name", + configYAML: ` +virtualWorkspaces: +- name: "" + url: https://example.com/services/apiexport/root/configmaps-view +`, + expectError: true, + errorMsg: "validation failed for virtual workspace at index 0", + }, + { + name: "invalid_apiexport_url", + configYAML: ` +virtualWorkspaces: +- name: test-workspace + url: https://example.com/services/apiexport/incomplete +`, + expectError: true, + errorMsg: "invalid APIExport URL format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary config file + tempFile, err := os.CreateTemp("", "test-config-*.yaml") + require.NoError(t, err) + defer os.Remove(tempFile.Name()) + + _, err = tempFile.WriteString(tt.configYAML) + require.NoError(t, err) + tempFile.Close() + + // Test loading config + appCfg := config.Config{} + manager := NewVirtualWorkspaceManager(appCfg) + + config, err := manager.LoadConfig(tempFile.Name()) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, config) + } else { + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Len(t, config.VirtualWorkspaces, 1) + } + }) + } +} diff --git a/listener/reconciler/types.go b/listener/reconciler/types.go index a7632f4..6074d68 100644 --- a/listener/reconciler/types.go +++ b/listener/reconciler/types.go @@ -9,8 +9,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// CustomReconciler defines the interface that all reconcilers must implement -type CustomReconciler interface { +// ControllerProvider defines the interface for components that provide controller-runtime managers +// and can set up controllers. This includes both actual reconcilers and manager/coordinator components. +type ControllerProvider interface { Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) SetupWithManager(mgr ctrl.Manager) error GetManager() ctrl.Manager diff --git a/tests/gateway_test/testdata/crd/core.platform-mesh.io_accounts.yaml b/tests/gateway_test/testdata/crd/core.platform-mesh.io_accounts.yaml index 8842274..b10939a 100644 --- a/tests/gateway_test/testdata/crd/core.platform-mesh.io_accounts.yaml +++ b/tests/gateway_test/testdata/crd/core.platform-mesh.io_accounts.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.14.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: accounts.core.platform-mesh.io spec: group: core.platform-mesh.io @@ -110,16 +110,8 @@ spec: properties: conditions: items: - description: "Condition contains details for one aspect of the current - state of this API Resource.\n---\nThis struct is intended for - direct use as an array at the field path .status.conditions. For - example,\n\n\n\ttype FooStatus struct{\n\t // Represents the - observations of a foo's current state.\n\t // Known .status.conditions.type - are: \"Available\", \"Progressing\", and \"Degraded\"\n\t // - +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t - \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\" - patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t - \ // other fields\n\t}" + description: Condition contains details for one aspect of the current + state of this API Resource. properties: lastTransitionTime: description: |- @@ -160,12 +152,7 @@ spec: - Unknown type: string type: - description: |- - type of condition in CamelCase or in foo.example.com/CamelCase. - --- - Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be - useful (see .node.status.conditions), the ability to deconflict is important. - The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + description: type of condition in CamelCase or in foo.example.com/CamelCase. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string