Skip to content

Feat: virtual workspace #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
Jul 24, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6aa4dac
feat: virtual ws support
vertex451 Jul 15, 2025
22fe7a4
Merge branch 'main' of github.com:openmfp/kubernetes-graphql-gateway …
vertex451 Jul 16, 2025
e386bf1
/{virtual-ws-name}/clusters/{cluster-name}/api/v1
vertex451 Jul 17, 2025
4f370c6
virtual ws works in the gateway
vertex451 Jul 18, 2025
d964753
normal ws works
vertex451 Jul 18, 2025
5c33b08
pass kubeconfig via metadata
vertex451 Jul 18, 2025
fba3b4e
first iteration of improvements
vertex451 Jul 21, 2025
57d3b7c
tests for extractClusterName
vertex451 Jul 21, 2025
9f12a03
added pattern matching
vertex451 Jul 21, 2025
0d2e5cc
removed overlaped tests cases
vertex451 Jul 21, 2025
7f20b65
imporved kcp/apibinding_controller.go
vertex451 Jul 21, 2025
e711fd7
moved metadata injector to common package
vertex451 Jul 21, 2025
8d3644b
improved virtual worksapce code
vertex451 Jul 22, 2025
51fe783
moved url params to the config
vertex451 Jul 22, 2025
164a3e6
fix test
vertex451 Jul 22, 2025
861a3e0
pass context to watcher from the very top
vertex451 Jul 22, 2025
0b4d447
removed Close() method
vertex451 Jul 22, 2025
8936333
fixed tests
vertex451 Jul 22, 2025
28a19c5
more coverage
vertex451 Jul 22, 2025
fe6fdff
watcher test
vertex451 Jul 22, 2025
baa7339
listener/reconciler/kcp/virtual_workspace_test.go
vertex451 Jul 22, 2025
513d338
fix tests
vertex451 Jul 22, 2025
969a712
linter
vertex451 Jul 22, 2025
8bf176f
adjusted sleep time
vertex451 Jul 22, 2025
acf4135
one more fix
vertex451 Jul 22, 2025
97ac6b8
fixed tests
vertex451 Jul 22, 2025
e5ee282
removed redundnat test
vertex451 Jul 22, 2025
675663a
increse coverage
vertex451 Jul 22, 2025
d123a8d
linter
vertex451 Jul 22, 2025
bc4a0c4
more tests
vertex451 Jul 23, 2025
9001fa7
tests for listener/reconciler/clusteraccess/metadata_injector_test.go
vertex451 Jul 23, 2025
cd170fe
fixed unnecessary extract auth data call in inject metadata
vertex451 Jul 23, 2025
2a02c2f
pass ctx from top
vertex451 Jul 23, 2025
4f3cd2c
extractAuthDataForMetadata test
vertex451 Jul 23, 2025
55ca379
reduced complexity at metadata_injector
vertex451 Jul 23, 2025
141ce82
return error if no watching path
vertex451 Jul 23, 2025
a2815de
adjust tests
vertex451 Jul 23, 2025
7cc1136
WalkDir
vertex451 Jul 23, 2025
2c4f965
used constants for timeout
vertex451 Jul 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions cmd/listener.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,28 @@ var listenCmd = &cobra.Command{
// Create the appropriate reconciler based on configuration
var reconcilerInstance reconciler.CustomReconciler
if appCfg.EnableKcp {
reconcilerInstance, err = kcp.NewKCPReconciler(appCfg, reconcilerOpts, log)
kcpReconciler, err := kcp.NewKCPReconciler(appCfg, reconcilerOpts, log)
if err != nil {
log.Error().Err(err).Msg("unable to create KCP reconciler")
os.Exit(1)
}

// 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.Error().Err(err).Msg("failed to start virtual workspace watching")
}
}()
}

reconcilerInstance = kcpReconciler
} else {
reconcilerInstance, err = clusteraccess.CreateMultiClusterReconciler(appCfg, reconcilerOpts, log)
}
if err != nil {
log.Error().Err(err).Msg("unable to create reconciler")
os.Exit(1)
if err != nil {
log.Error().Err(err).Msg("unable to create cluster access reconciler")
os.Exit(1)
}
}

// Setup reconciler with its own manager and start everything
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func initConfig() {
// Listener
v.SetDefault("listener-apiexport-workspace", ":root")
v.SetDefault("listener-apiexport-name", "kcp.io")
v.SetDefault("virtual-workspaces-config-path", "./bin/virtual-workspaces/config.yaml")

// Gateway
v.SetDefault("gateway-port", "8080")
Expand Down
2 changes: 1 addition & 1 deletion common/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type Config struct {
IntrospectionAuthentication bool `mapstructure:"introspection-authentication"`

Listener struct {
// Listener fields will be added here
VirtualWorkspacesConfigPath string `mapstructure:"virtual-workspaces-config-path"`
} `mapstructure:",squash"`

Gateway struct {
Expand Down
113 changes: 113 additions & 0 deletions docs/virtual-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Virtual Workspaces

Virtual workspaces allow the listener to connect to external KCP workspaces or API exports without them being part of the main KCP cluster hierarchy. This enables accessing remote services and APIs through the GraphQL gateway.

## Configuration

Virtual workspaces are configured through a YAML configuration file that is mounted to the listener. The path to this file is specified using the `virtual-workspaces-config-path` configuration option.

### Configuration File Format

```yaml
virtualWorkspaces:
- name: example
url: https://192.168.1.118:6443/services/apiexport/root/configmaps-view
- name: another-service
url: https://your-kcp-server:6443/services/apiexport/root/your-export
```

### Configuration Options

- `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

## Environment Variables

Set the configuration path using:

```bash
export VIRTUAL_WORKSPACES_CONFIG_PATH="/etc/config/virtual-workspaces.yaml"
```

Or use the default path: `/etc/config/virtual-workspaces.yaml`

## URL Pattern

Virtual workspaces are accessible through the gateway using the following URL pattern:

```
/kubernetes-graphql-gateway/virtualworkspace/{name}/query
```

For example:
- Normal workspace: `/kubernetes-graphql-gateway/root:abc:abc/query`
- Virtual workspace: `/kubernetes-graphql-gateway/virtualworkspace/example/query`

## 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
- Stores the schema in a file at `virtualworkspace/{name}`
3. **Gateway Integration**: The gateway watches the schema files and exposes virtual workspaces as GraphQL endpoints

## File System Layout

Schema files for virtual workspaces are stored in the definitions directory with the following structure:

```
./bin/definitions/
├── root:workspace1:workspace2 # Regular KCP workspace
├── root:workspace3 # Regular KCP workspace
└── virtualworkspace/
├── example # Virtual workspace schema
└── another-service # Virtual workspace schema
```

## Example Usage

1. Create a configuration file:

```yaml
# /etc/config/virtual-workspaces.yaml
virtualWorkspaces:
- name: configmaps-view
url: https://192.168.1.118:6443/services/apiexport/root/configmaps-view
```

2. Start the listener with the configuration:

```bash
export VIRTUAL_WORKSPACES_CONFIG_PATH="/etc/config/virtual-workspaces.yaml"
export KUBECONFIG=/path/to/your/kcp/admin.kubeconfig
go run main.go listener
```

3. The virtual workspace will be available at:
- GraphQL endpoint: `/kubernetes-graphql-gateway/virtualworkspace/configmaps-view/query`

## Updating Configuration

The configuration file is watched for changes. When the file is modified:
- New virtual workspaces are automatically discovered and schema files generated
- Updated URLs trigger schema regeneration
- Removed virtual workspaces have their schema files deleted

## Troubleshooting

### Common Issues

1. **Invalid URL Format**: Ensure URLs are properly formatted and accessible
2. **Network Connectivity**: Verify the listener can reach the virtual workspace URLs
3. **Authentication**: Virtual workspaces use the same authentication as the base KCP connection

### Logs

Check listener logs for virtual workspace processing:

```bash
# Look for log entries with virtual workspace information
kubectl logs <listener-pod> | grep "virtual workspace"
```
16 changes: 12 additions & 4 deletions gateway/manager/roundtripper/roundtripper.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package roundtripper

import (
"net/http"
"strings"

"github.com/golang-jwt/jwt/v5"
"github.com/openmfp/golang-commons/logger"
"k8s.io/client-go/transport"
"net/http"
"strings"

"github.com/openmfp/kubernetes-graphql-gateway/common/config"
)
Expand Down Expand Up @@ -35,10 +36,11 @@ func NewUnauthorizedRoundTripper() http.RoundTripper {
}

func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.log.Debug().
rt.log.Info().
Str("req.Host", req.Host).
Str("req.URL.Host", req.URL.Host).
Str("path", req.URL.Path).
Str("method", req.Method).
Bool("localDev", rt.appCfg.LocalDevelopment).
Bool("shouldImpersonate", rt.appCfg.Gateway.ShouldImpersonate).
Str("usernameClaim", rt.appCfg.Gateway.UsernameClaim).
Msg("RoundTripper processing request")
Expand Down Expand Up @@ -126,6 +128,12 @@ func isDiscoveryRequest(req *http.Request) bool {
parts = parts[2:]
}

// Handle virtual workspace prefixes: /services/<service>/clusters/<workspace>/api or /services/<service>/clusters/<workspace>/apis
if len(parts) >= 5 && parts[0] == "services" && parts[2] == "clusters" {
// Remove /services/<service>/clusters/<workspace> prefix
parts = parts[4:]
}

switch {
case len(parts) == 1 && (parts[0] == "api" || parts[0] == "apis"):
return true // /api or /apis (root groups)
Expand Down
62 changes: 28 additions & 34 deletions gateway/manager/targetcluster/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import (
"fmt"
"net/http"
"os"
"strings"

"github.com/go-openapi/spec"
"github.com/openmfp/golang-commons/logger"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/kcp"

"github.com/openmfp/kubernetes-graphql-gateway/common/auth"
appConfig "github.com/openmfp/kubernetes-graphql-gateway/common/config"
"github.com/openmfp/kubernetes-graphql-gateway/gateway/resolver"
"github.com/openmfp/kubernetes-graphql-gateway/gateway/schema"
kcputil "github.com/openmfp/kubernetes-graphql-gateway/listener/reconciler/kcp"
)

// FileData represents the data extracted from a schema file
Expand Down Expand Up @@ -99,38 +98,20 @@ func (tc *TargetCluster) connect(appCfg appConfig.Config, metadata *ClusterMetad
var config *rest.Config
var err error

// In multicluster mode, we MUST have metadata to connect
if appCfg.EnableKcp {
tc.log.Info().
Str("cluster", tc.name).
Bool("enableKcp", appCfg.EnableKcp).
Bool("localDevelopment", appCfg.LocalDevelopment).
Msg("Using standard config for connection (single cluster, KCP mode, or local development)")

config, err = ctrl.GetConfig()
if err != nil {
return fmt.Errorf("failed to get Kubernetes config: %w", err)
}

// For KCP mode, modify the config to point to the specific workspace
config, err = kcputil.ConfigForKCPCluster(tc.name, config)
if err != nil {
return fmt.Errorf("failed to configure KCP workspace: %w", err)
}
} else { // clusterAccess path
if metadata == nil {
return fmt.Errorf("multicluster mode requires cluster metadata in schema file")
}
// All workspaces (both virtual and normal) now use metadata from schema files
if metadata == nil {
return fmt.Errorf("cluster %s requires cluster metadata in schema file", tc.name)
}

tc.log.Info().
Str("cluster", tc.name).
Str("host", metadata.Host).
Msg("Using cluster metadata for connection (multicluster mode)")
tc.log.Info().
Str("cluster", tc.name).
Str("host", metadata.Host).
Bool("isVirtualWorkspace", strings.HasPrefix(tc.name, "virtual-workspace/")).
Msg("Using cluster metadata from schema file for connection")

config, err = buildConfigFromMetadata(metadata, tc.log)
if err != nil {
return fmt.Errorf("failed to build config from metadata: %w", err)
}
config, err = buildConfigFromMetadata(metadata, tc.log)
if err != nil {
return fmt.Errorf("failed to build config from metadata: %w", err)
}

// Apply round tripper
Expand Down Expand Up @@ -222,13 +203,26 @@ func (tc *TargetCluster) GetConfig() *rest.Config {

// GetEndpoint returns the HTTP endpoint for this cluster's GraphQL API
func (tc *TargetCluster) GetEndpoint(appCfg appConfig.Config) string {
// tc.name already contains the correct path format:
// - For virtual workspaces: "virtual-workspace/{name}" (requires additional /{kcpWorkspace} parameter in actual URL)
// - For regular workspaces: "{workspace-name}"
path := tc.name

if appCfg.LocalDevelopment {
return fmt.Sprintf("http://localhost:%s/%s/graphql", appCfg.Gateway.Port, path)
endpoint := fmt.Sprintf("http://localhost:%s/%s", appCfg.Gateway.Port, path)
// For virtual workspaces, indicate that additional parameter is needed
if strings.HasPrefix(path, "virtual-workspace/") {
endpoint += "/{kcpWorkspace}"
}
return endpoint + "/graphql"
}

return fmt.Sprintf("/%s/graphql", path)
endpoint := fmt.Sprintf("/%s", path)
// For virtual workspaces, indicate that additional parameter is needed
if strings.HasPrefix(path, "virtual-workspace/") {
endpoint += "/{kcpWorkspace}"
}
return endpoint + "/graphql"
}

// ServeHTTP handles HTTP requests for this cluster
Expand Down
9 changes: 8 additions & 1 deletion gateway/manager/targetcluster/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,14 @@ 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 {
r = r.WithContext(kontext.WithCluster(r.Context(), logicalcluster.Name(workspace)))
// 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
kcpWorkspaceName := workspace
if kcpWorkspace, ok := r.Context().Value(kcpWorkspaceKey).(string); ok && kcpWorkspace != "" {
kcpWorkspaceName = kcpWorkspace
}
r = r.WithContext(kontext.WithCluster(r.Context(), logicalcluster.Name(kcpWorkspaceName)))
}
return r.WithContext(context.WithValue(r.Context(), roundtripper.TokenKey{}, token))
}
Expand Down
Loading
Loading