Skip to content
10 changes: 3 additions & 7 deletions common/auth/metadata_injector.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
// MetadataInjectionConfig contains configuration for metadata injection
type MetadataInjectionConfig struct {
Host string
Path string
Auth *gatewayv1alpha1.AuthConfig
CA *gatewayv1alpha1.CAConfig
HostOverride string // For virtual workspaces
Expand Down Expand Up @@ -57,7 +56,6 @@ func (m *MetadataInjector) InjectClusterMetadata(ctx context.Context, schemaJSON
// Create cluster metadata
metadata := map[string]any{
"host": host,
"path": config.Path,
}

// Add auth data if configured
Expand All @@ -84,7 +82,7 @@ func (m *MetadataInjector) InjectClusterMetadata(ctx context.Context, schemaJSON
m.tryExtractKubeconfigCA(ctx, config.Auth, metadata)
}

return m.finalizeSchemaInjection(schemaData, metadata, host, config.Path, config.CA != nil || config.Auth != nil)
return m.finalizeSchemaInjection(schemaData, metadata, host, config.CA != nil || config.Auth != nil)
}

// InjectKCPMetadataFromEnv injects KCP metadata using kubeconfig from environment
Expand Down Expand Up @@ -114,7 +112,6 @@ func (m *MetadataInjector) InjectKCPMetadataFromEnv(schemaJSON []byte, clusterPa
// Create cluster metadata with environment kubeconfig
metadata := map[string]any{
"host": host,
"path": clusterPath,
"auth": map[string]any{
"type": "kubeconfig",
"kubeconfig": base64.StdEncoding.EncodeToString(kubeconfigData),
Expand All @@ -129,7 +126,7 @@ func (m *MetadataInjector) InjectKCPMetadataFromEnv(schemaJSON []byte, clusterPa
}
}

return m.finalizeSchemaInjection(schemaData, metadata, host, clusterPath, caData != nil)
return m.finalizeSchemaInjection(schemaData, metadata, host, caData != nil)
}

// extractAuthDataForMetadata extracts auth data from AuthConfig for metadata injection
Expand Down Expand Up @@ -436,7 +433,7 @@ func (m *MetadataInjector) determineKCPHost(kubeconfigHost, override, clusterPat
}

// finalizeSchemaInjection finalizes the schema injection process
func (m *MetadataInjector) finalizeSchemaInjection(schemaData map[string]any, metadata map[string]any, host, path string, hasCA bool) ([]byte, error) {
func (m *MetadataInjector) finalizeSchemaInjection(schemaData map[string]any, metadata map[string]any, host string, hasCA bool) ([]byte, error) {
// Inject the metadata into the schema
schemaData["x-cluster-metadata"] = metadata

Expand All @@ -448,7 +445,6 @@ func (m *MetadataInjector) finalizeSchemaInjection(schemaData map[string]any, me

m.log.Info().
Str("host", host).
Str("path", path).
Bool("hasCA", hasCA).
Msg("successfully injected cluster metadata into schema")

Expand Down
18 changes: 0 additions & 18 deletions common/auth/metadata_injector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,6 @@ users:
require.True(t, exists, "host should be present")
assert.Equal(t, tt.expectedHost, host)

// Verify path
path, exists := metadataMap["path"]
require.True(t, exists, "path should be present")
assert.Equal(t, tt.clusterPath, path)

// Verify auth
auth, exists := metadataMap["auth"]
require.True(t, exists, "auth should be present")
Expand Down Expand Up @@ -198,7 +193,6 @@ func TestInjectClusterMetadata(t *testing.T) {
schemaJSON []byte
config MetadataInjectionConfig
expectedHost string
expectedPath string
expectError bool
}{
{
Expand All @@ -217,10 +211,8 @@ func TestInjectClusterMetadata(t *testing.T) {
}`),
config: MetadataInjectionConfig{
Host: "https://test-cluster.example.com:6443",
Path: "test-cluster",
},
expectedHost: "https://test-cluster.example.com:6443",
expectedPath: "test-cluster",
expectError: false,
},
{
Expand All @@ -234,11 +226,9 @@ func TestInjectClusterMetadata(t *testing.T) {
}`),
config: MetadataInjectionConfig{
Host: "https://original.example.com:6443",
Path: "virtual-workspace/test",
HostOverride: "https://override.example.com:6443/services/test",
},
expectedHost: "https://override.example.com:6443/services/test",
expectedPath: "virtual-workspace/test",
expectError: false,
},
{
Expand All @@ -252,10 +242,8 @@ func TestInjectClusterMetadata(t *testing.T) {
}`),
config: MetadataInjectionConfig{
Host: "https://kcp.example.com:6443/services/apiexport/some/path",
Path: "test-workspace",
},
expectedHost: "https://kcp.example.com:6443", // Should be stripped
expectedPath: "test-workspace",
expectError: false,
},
{
Expand All @@ -267,7 +255,6 @@ func TestInjectClusterMetadata(t *testing.T) {
}`),
config: MetadataInjectionConfig{
Host: "https://test.example.com:6443",
Path: "test",
},
expectError: true,
},
Expand Down Expand Up @@ -302,11 +289,6 @@ func TestInjectClusterMetadata(t *testing.T) {
host, exists := metadataMap["host"]
require.True(t, exists, "host should be present")
assert.Equal(t, tt.expectedHost, host)

// Verify path
path, exists := metadataMap["path"]
require.True(t, exists, "path should be present")
assert.Equal(t, tt.expectedPath, path)
})
}
}
Expand Down
15 changes: 9 additions & 6 deletions docs/clusteraccess.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ kind: ClusterAccess
metadata:
name: my-target-cluster
spec:
path: my-target-cluster # Used as schema filename
# Optional: path overrides the output schema filename. If omitted, metadata.name is used.
# Note: This value is not used by the gateway at runtime.
path: my-target-cluster
host: https://my-cluster-api-server:6443
auth:
kubeconfigSecretRef:
Expand Down Expand Up @@ -68,14 +70,13 @@ The listener:

Generated schema files contain:

```json
```
{
"definitions": {
// ... Kubernetes API definitions

},
"x-cluster-metadata": {
"host": "https://my-cluster-api-server:6443",
"path": "my-target-cluster",
"auth": {
"type": "kubeconfig",
"kubeconfig": "base64-encoded-kubeconfig"
Expand All @@ -87,6 +88,7 @@ Generated schema files contain:
}
```


### 4. Gateway Usage

When running the gateway in ClusterAccess mode:
Expand All @@ -99,12 +101,13 @@ export GATEWAY_SHOULD_IMPERSONATE=false
The gateway:
- Watches the definitions directory for schema files
- For each schema file:
- Reads the `x-cluster-metadata` section
- Reads the `x-cluster-metadata` section (only `host`, `auth`, `ca` are used)
- Creates a `rest.Config` using the embedded connection info
- Establishes a Kubernetes client connection to the target cluster
- Serves GraphQL API at `/{cluster-name}/graphql`
- **Does NOT require KUBECONFIG** - all connection info comes from schema files


## Troubleshooting

### Schema files not generated
Expand All @@ -120,4 +123,4 @@ The gateway:
### Connection errors
- Verify target cluster URLs are accessible
- Check CA certificates are correct
- Validate authentication credentials have required permissions
- Validate authentication credentials have required permissions
39 changes: 39 additions & 0 deletions docs/listener.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,45 @@ Contains reconciliation logic for different operational modes:
- Generates schema files with embedded cluster connection metadata
- Injects `x-cluster-metadata` into schema files for gateway consumption

## Custom OpenAPI properties used by the project

The project uses two categories of OpenAPI extensions:

- Kubernetes-standard extensions (consumed during schema building):
- `x-kubernetes-group-version-kind`
- `x-kubernetes-categories`
- `x-kubernetes-scope`
These are provided by kube-openapi and are not defined by this project.

- Project-defined extension (produced by the listener and consumed by the gateway):
- `x-cluster-metadata`: carries connection information for the target cluster.

### `x-cluster-metadata`

The listener injects `x-cluster-metadata` into each schema file so the gateway can establish a connection to the referenced cluster without any external configuration.

Structure (minimal shape used by the gateway):

```
"x-cluster-metadata": {
"host": "https://<api-server>",
"auth": { /* one of: token | kubeconfig | clientCert */ },
"ca": { "data": "<base64-PEM>" }
}
```

Notes:
- The `host` field is required.
- Exactly one authentication method should be provided under `auth`:
- `{"type":"token","token":"<base64>"}`
- `{"type":"kubeconfig","kubeconfig":"<base64>"}`
- `{"type":"clientCert","certData":"<base64>","keyData":"<base64>"}`
- The `ca.data` field is optional but recommended; if omitted and `auth` contains a kubeconfig, the listener attempts to extract the CA from that kubeconfig automatically.

Why we keep it simple:
- All information needed to connect is either intrinsic to the target cluster (host, CA) or already available via selected auth material. We avoid injecting duplicate or derivable data.
- We do not replicate routing information in metadata; routing is defined by where the file is stored and how the gateway is addressed.

#### KCP Reconciler (`reconciler/kcp/`)
- Watches APIBinding resources in KCP workspaces
- Discovers virtual workspaces and their API resources
Expand Down
1 change: 0 additions & 1 deletion gateway/manager/targetcluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ type FileData struct {
// ClusterMetadata represents the cluster connection metadata stored in schema files
type ClusterMetadata struct {
Host string `json:"host"`
Path string `json:"path,omitempty"`
Auth *AuthMetadata `json:"auth,omitempty"`
CA *CAMetadata `json:"ca,omitempty"`
}
Expand Down
7 changes: 0 additions & 7 deletions listener/reconciler/clusteraccess/metadata_injector.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,9 @@ import (
)

func injectClusterMetadata(ctx context.Context, schemaJSON []byte, clusterAccess gatewayv1alpha1.ClusterAccess, k8sClient client.Client, log *logger.Logger) ([]byte, error) {
// Determine the path
path := clusterAccess.Spec.Path
if path == "" {
path = clusterAccess.GetName()
}

// Create metadata injection config
config := auth.MetadataInjectionConfig{
Host: clusterAccess.Spec.Host,
Path: path,
Auth: clusterAccess.Spec.Auth,
CA: clusterAccess.Spec.CA,
}
Expand Down
56 changes: 0 additions & 56 deletions listener/reconciler/clusteraccess/metadata_injector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://test-cluster.example.com",
"path": "test-cluster",
},
wantErr: false,
},
Expand All @@ -55,7 +54,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://test-cluster.example.com",
"path": "custom-path",
},
wantErr: false,
},
Expand Down Expand Up @@ -83,7 +81,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://example.com",
"path": "",
},
wantErr: false,
},
Expand All @@ -100,7 +97,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://example.com",
"path": "",
},
wantErr: false,
},
Expand All @@ -116,7 +112,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "",
"path": "no-host-cluster",
},
wantErr: false,
},
Expand All @@ -133,7 +128,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://special.example.com",
"path": "special/chars_path.test",
},
wantErr: false,
},
Expand All @@ -149,7 +143,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://minimal.example.com",
"path": "minimal",
},
wantErr: false,
},
Expand All @@ -166,7 +159,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://example.com",
"path": "very/long/path/with/multiple/segments",
},
wantErr: false,
},
Expand All @@ -182,7 +174,6 @@ func TestInjectClusterMetadata(t *testing.T) {
mockSetup: func(m *mocks.MockClient) {},
wantMetadata: map[string]any{
"host": "https://unicode.example.com",
"path": "üñíçødé-cluster",
},
wantErr: false,
},
Expand Down Expand Up @@ -251,50 +242,3 @@ func TestInjectClusterMetadata(t *testing.T) {
})
}
}

func TestInjectClusterMetadata_PathLogic(t *testing.T) {
mockLogger, err := logger.New(logger.DefaultConfig())
require.NoError(t, err)
mockClient := mocks.NewMockClient(t)
schemaJSON := []byte(`{"openapi": "3.0.0", "info": {"title": "Test"}}`)

t.Run("path_precedence_custom_over_name", func(t *testing.T) {
clusterAccess := gatewayv1alpha1.ClusterAccess{
ObjectMeta: metav1.ObjectMeta{Name: "cluster-name"},
Spec: gatewayv1alpha1.ClusterAccessSpec{
Host: "https://test.example.com",
Path: "custom-path",
},
}

result, err := clusteraccess.InjectClusterMetadata(t.Context(), schemaJSON, clusterAccess, mockClient, mockLogger)
require.NoError(t, err)

var resultData map[string]any
err = json.Unmarshal(result, &resultData)
require.NoError(t, err)

metadata := resultData["x-cluster-metadata"].(map[string]any)
assert.Equal(t, "custom-path", metadata["path"])
})

t.Run("fallback_to_name_when_path_empty", func(t *testing.T) {
clusterAccess := gatewayv1alpha1.ClusterAccess{
ObjectMeta: metav1.ObjectMeta{Name: "fallback-name"},
Spec: gatewayv1alpha1.ClusterAccessSpec{
Host: "https://test.example.com",
Path: "",
},
}

result, err := clusteraccess.InjectClusterMetadata(t.Context(), schemaJSON, clusterAccess, mockClient, mockLogger)
require.NoError(t, err)

var resultData map[string]any
err = json.Unmarshal(result, &resultData)
require.NoError(t, err)

metadata := resultData["x-cluster-metadata"].(map[string]any)
assert.Equal(t, "fallback-name", metadata["path"])
})
}
Loading
Loading