From 951b7653f23470b38fa2639c9ff7243f541475e2 Mon Sep 17 00:00:00 2001 From: Igor Fominykh Date: Thu, 2 Apr 2026 17:32:47 +0200 Subject: [PATCH] feat: support virtual workspace URL path in --config-workspace Allow --config-workspace to accept a direct URL path (starting with /) in addition to logical cluster paths. This enables init-agent to watch InitTarget/InitTemplate resources via an APIExport virtual workspace, which aggregates resources from multiple provider workspaces. When the value starts with /, it is used as a direct URL path appended to the base KCP host. Otherwise, existing behavior is preserved (appending /clusters/). Ref: #18 Signed-off-by: Igor Fominykh --- cmd/init-agent/main.go | 12 +++- cmd/init-agent/options.go | 8 +-- internal/kcp/rest.go | 12 +++- internal/kcp/rest_test.go | 147 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 internal/kcp/rest_test.go diff --git a/cmd/init-agent/main.go b/cmd/init-agent/main.go index 5b7a01a..598e378 100644 --- a/cmd/init-agent/main.go +++ b/cmd/init-agent/main.go @@ -21,6 +21,7 @@ import ( "flag" "fmt" golog "log" + "strings" "github.com/go-logr/zapr" "github.com/spf13/pflag" @@ -145,7 +146,15 @@ func setupManager(ctx context.Context, cfg *rest.Config, opts *Options) (manager return nil, fmt.Errorf("failed to register local scheme %s: %w", initializationv1alpha1.SchemeGroupVersion, err) } - cfg = kcp.RetargetRestConfig(cfg, logicalcluster.Name(opts.ConfigWorkspace)) + // Preserve the base config for leader election before retargeting, + // because virtual workspace URLs do not support coordination/v1 leases. + leaderElectionCfg := rest.CopyConfig(cfg) + + if strings.HasPrefix(opts.ConfigWorkspace, "/") { + cfg = kcp.RetargetRestConfigToPath(cfg, opts.ConfigWorkspace) + } else { + cfg = kcp.RetargetRestConfig(cfg, logicalcluster.Name(opts.ConfigWorkspace)) + } return manager.New(cfg, manager.Options{ Scheme: scheme, @@ -156,6 +165,7 @@ func setupManager(ctx context.Context, cfg *rest.Config, opts *Options) (manager LeaderElection: opts.EnableLeaderElection, LeaderElectionID: "init-agent.kcp.io", LeaderElectionNamespace: opts.LeaderElectionNamespace, + LeaderElectionConfig: leaderElectionCfg, HealthProbeBindAddress: opts.HealthAddr, }) } diff --git a/cmd/init-agent/options.go b/cmd/init-agent/options.go index cb4a2f5..59b2059 100644 --- a/cmd/init-agent/options.go +++ b/cmd/init-agent/options.go @@ -34,9 +34,9 @@ type Options struct { // work. // KubeconfigFile string - // ConfigWorkspace is the kcp workspace (either a path or a cluster name) - // where the InitTarget and InitTemplate objects live that should be processed - // by this init-agent. + // ConfigWorkspace is the kcp workspace (either a path, a cluster name, or a + // URL path starting with / for virtual workspaces) where the InitTarget and + // InitTemplate objects live that should be processed by this init-agent. ConfigWorkspace string // Whether or not to perform leader election (requires permissions to @@ -67,7 +67,7 @@ func NewOptions() *Options { func (o *Options) AddFlags(flags *pflag.FlagSet) { o.LogOptions.AddPFlags(flags) - flags.StringVar(&o.ConfigWorkspace, "config-workspace", o.ConfigWorkspace, "kcp workspace or cluster where the InitTargets live that should be processed") + flags.StringVar(&o.ConfigWorkspace, "config-workspace", o.ConfigWorkspace, "kcp workspace, cluster, or URL path (starting with /) where the InitTargets live that should be processed") flags.StringVar(&o.InitTargetSelectorString, "init-target-selector", o.InitTargetSelectorString, "restrict to only process InitTargets matching this label selector (optional)") flags.BoolVar(&o.EnableLeaderElection, "enable-leader-election", o.EnableLeaderElection, "whether to perform leader election") flags.StringVar(&o.LeaderElectionNamespace, "leader-election-namespace", o.LeaderElectionNamespace, "Kubernetes namespace for the leader election lease") diff --git a/internal/kcp/rest.go b/internal/kcp/rest.go index 3ea5900..dbc55c4 100644 --- a/internal/kcp/rest.go +++ b/internal/kcp/rest.go @@ -34,9 +34,19 @@ func RetargetRestConfig(cfg *rest.Config, cluster logicalcluster.Name) *rest.Con return stripped } +// RetargetRestConfigToPath retargets the rest config to a direct URL path +// (e.g., a virtual workspace path like /services/apiexport/...). +// The path is appended directly to the stripped host URL. +func RetargetRestConfigToPath(cfg *rest.Config, path string) *rest.Config { + stripped := StripCluster(cfg) + stripped.Host = strings.TrimRight(stripped.Host, "/") + path + return stripped +} + func StripCluster(cfg *rest.Config) *rest.Config { clone := rest.CopyConfig(cfg) - clone.Host = strings.TrimRight(clusterFinder.ReplaceAllString(cfg.Host, ""), "/") + host := strings.TrimRight(cfg.Host, "/") + clone.Host = strings.TrimRight(clusterFinder.ReplaceAllString(host, ""), "/") return clone } diff --git a/internal/kcp/rest_test.go b/internal/kcp/rest_test.go new file mode 100644 index 0000000..658e14f --- /dev/null +++ b/internal/kcp/rest_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2026 The kcp Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kcp + +import ( + "testing" + + "github.com/kcp-dev/logicalcluster/v3" + + "k8s.io/client-go/rest" +) + +func TestRetargetRestConfig(t *testing.T) { + tests := []struct { + name string + host string + cluster string + expected string + }{ + { + name: "simple cluster path", + host: "https://kcp.example.com", + cluster: "root:my-workspace", + expected: "https://kcp.example.com/clusters/root:my-workspace", + }, + { + name: "host with existing cluster path", + host: "https://kcp.example.com/clusters/root:old", + cluster: "root:new", + expected: "https://kcp.example.com/clusters/root:new", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &rest.Config{Host: tt.host} + result := RetargetRestConfig(cfg, logicalcluster.Name(tt.cluster)) + if result.Host != tt.expected { + t.Errorf("RetargetRestConfig() host = %q, want %q", result.Host, tt.expected) + } + }) + } +} + +func TestRetargetRestConfigToPath(t *testing.T) { + tests := []struct { + name string + host string + path string + expected string + }{ + { + name: "virtual workspace path", + host: "https://kcp.example.com", + path: "/services/apiexport/root:platform-mesh-system/initialization.kcp.io", + expected: "https://kcp.example.com/services/apiexport/root:platform-mesh-system/initialization.kcp.io", + }, + { + name: "host with trailing slash", + host: "https://kcp.example.com/", + path: "/services/apiexport/root:init/init.kcp.io", + expected: "https://kcp.example.com/services/apiexport/root:init/init.kcp.io", + }, + { + name: "host with existing cluster path", + host: "https://kcp.example.com/clusters/root:old", + path: "/services/apiexport/root:system/my-export", + expected: "https://kcp.example.com/services/apiexport/root:system/my-export", + }, + { + name: "host with existing cluster path and trailing slash", + host: "https://kcp.example.com/clusters/root:old/", + path: "/services/apiexport/root:system/my-export", + expected: "https://kcp.example.com/services/apiexport/root:system/my-export", + }, + { + name: "root path only", + host: "https://kcp.example.com", + path: "/", + expected: "https://kcp.example.com/", + }, + { + name: "path with double slash", + host: "https://kcp.example.com/", + path: "//services/test", + expected: "https://kcp.example.com//services/test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &rest.Config{Host: tt.host} + result := RetargetRestConfigToPath(cfg, tt.path) + if result.Host != tt.expected { + t.Errorf("RetargetRestConfigToPath() host = %q, want %q", result.Host, tt.expected) + } + }) + } +} + +func TestStripCluster(t *testing.T) { + tests := []struct { + name string + host string + expected string + }{ + { + name: "with cluster path", + host: "https://kcp.example.com/clusters/root:my-workspace", + expected: "https://kcp.example.com", + }, + { + name: "without cluster path", + host: "https://kcp.example.com", + expected: "https://kcp.example.com", + }, + { + name: "with trailing slash", + host: "https://kcp.example.com/clusters/root:ws/", + expected: "https://kcp.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &rest.Config{Host: tt.host} + result := StripCluster(cfg) + if result.Host != tt.expected { + t.Errorf("StripCluster() host = %q, want %q", result.Host, tt.expected) + } + }) + } +}