From 337591f08c191abe99c97b17485c0526fbcc4d75 Mon Sep 17 00:00:00 2001 From: Till <253026766+philtk79@users.noreply.github.com> Date: Tue, 19 May 2026 18:08:57 +0200 Subject: [PATCH 1/2] Add feature for preset rbac kubeconfig generation Signed-off-by: Till <253026766+philtk79@users.noreply.github.com> --- api/v1alpha1/platformmesh_types.go | 6 + cmd/operator.go | 2 +- .../core.platform-mesh.io_platformmeshes.yaml | 14 + internal/controller/controller_test.go | 12 +- .../controller/platformmesh_controller.go | 9 +- pkg/rbacpresets/loader.go | 239 +++++++++++ pkg/rbacpresets/loader_test.go | 165 ++++++++ pkg/rbacpresets/providers/sample.yaml | 57 +++ pkg/rbacpresets/types.go | 61 +++ pkg/subroutines/deployment_helpers_test.go | 2 +- pkg/subroutines/deployment_test.go | 2 +- pkg/subroutines/providersecret.go | 36 +- pkg/subroutines/providersecret_test.go | 28 +- pkg/subroutines/resource/subroutine_test.go | 12 +- pkg/subroutines/scoped_provider_preset.go | 234 +++++++++++ .../scoped_provider_preset_test.go | 332 +++++++++++++++ test/e2e/kind/helpers.go | 84 ++++ test/e2e/kind/kind_preset_kubeconfig_test.go | 384 ++++++++++++++++++ test/e2e/kind/kind_scoped_kubeconfig_test.go | 34 +- test/e2e/kind/suite_kind_test.go | 45 +- .../e2e-path-raw-path.yaml | 45 ++ .../kcp-preset-fixtures/e2e-raw-path.yaml | 44 ++ .../e2e-workspace-cluster.yaml | 44 ++ .../e2e-wtvw-workspacetype.yaml | 7 + .../yaml/kcp-preset-fixtures/e2e-wtvw.yaml | 46 +++ 25 files changed, 1872 insertions(+), 72 deletions(-) create mode 100644 pkg/rbacpresets/loader.go create mode 100644 pkg/rbacpresets/loader_test.go create mode 100644 pkg/rbacpresets/providers/sample.yaml create mode 100644 pkg/rbacpresets/types.go create mode 100644 pkg/subroutines/scoped_provider_preset.go create mode 100644 pkg/subroutines/scoped_provider_preset_test.go create mode 100644 test/e2e/kind/kind_preset_kubeconfig_test.go create mode 100644 test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml create mode 100644 test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml create mode 100644 test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml create mode 100644 test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw-workspacetype.yaml create mode 100644 test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml diff --git a/api/v1alpha1/platformmesh_types.go b/api/v1alpha1/platformmesh_types.go index c222b91b..ae6310b3 100644 --- a/api/v1alpha1/platformmesh_types.go +++ b/api/v1alpha1/platformmesh_types.go @@ -148,6 +148,12 @@ type ProviderConnection struct { // Scoped mode requires exactly one of endpointSliceName (virtual workspace server from slice) or apiExportName (workspace server for Path). // +optional AdminAuth *bool `json:"adminAuth,omitempty"` + // ProviderRBACPreset selects an in-tree preset shipped with the operator. + // When set, PMO writes a scoped kubeconfig whose server URL shape and RBAC + // come from the preset instead of being derived from an APIExport. Mutually + // exclusive with APIExportName and EndpointSliceName. + // +optional + ProviderRBACPreset *string `json:"providerRBACPreset,omitempty"` } // PlatformMeshStatus defines the observed state of PlatformMesh diff --git a/cmd/operator.go b/cmd/operator.go index 4db17944..aac24a08 100644 --- a/cmd/operator.go +++ b/cmd/operator.go @@ -152,7 +152,7 @@ func RunController(_ *cobra.Command, _ []string) { // coverage-ignore } imageVersionStore := subroutines.NewImageVersionStore() - pmReconciler, err := controller.NewPlatformMeshReconciler(mgr, &operatorCfg, defaultCfg, operatorCfg.WorkspaceDir, clientInfra, imageVersionStore) + pmReconciler, err := controller.NewPlatformMeshReconciler(mgr, &operatorCfg, defaultCfg, operatorCfg.WorkspaceDir, clientInfra, imageVersionStore, nil) if err != nil { setupLog.Error(err, "unable to create PlatformMesh reconciler") os.Exit(1) diff --git a/config/crd/core.platform-mesh.io_platformmeshes.yaml b/config/crd/core.platform-mesh.io_platformmeshes.yaml index 1cc76366..28419524 100644 --- a/config/crd/core.platform-mesh.io_platformmeshes.yaml +++ b/config/crd/core.platform-mesh.io_platformmeshes.yaml @@ -141,6 +141,13 @@ spec: type: string path: type: string + providerRBACPreset: + description: |- + ProviderRBACPreset selects an in-tree preset shipped with the operator. + When set, PMO writes a scoped kubeconfig whose server URL shape and RBAC + come from the preset instead of being derived from an APIExport. Mutually + exclusive with APIExportName and EndpointSliceName. + type: string rawPath: type: string secret: @@ -191,6 +198,13 @@ spec: type: string path: type: string + providerRBACPreset: + description: |- + ProviderRBACPreset selects an in-tree preset shipped with the operator. + When set, PMO writes a scoped kubeconfig whose server URL shape and RBAC + come from the preset instead of being derived from an APIExport. Mutually + exclusive with APIExportName and EndpointSliceName. + type: string rawPath: type: string secret: diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index 75d6bdbd..f55a6d6d 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -386,7 +386,7 @@ func (s *NewPlatformMeshReconcilerTestSuite) Test_allSubroutinesDisabled_returns } commonCfg := &pmconfig.CommonServiceConfig{} - r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, subroutines.NewImageVersionStore()) + r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, subroutines.NewImageVersionStore(), nil) s.Require().NoError(err) s.NotNil(r) s.NotNil(r.lifecycle) @@ -403,7 +403,7 @@ func (s *NewPlatformMeshReconcilerTestSuite) Test_deploymentSubroutineEnabled_re } commonCfg := &pmconfig.CommonServiceConfig{} - r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, subroutines.NewImageVersionStore()) + r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, subroutines.NewImageVersionStore(), nil) s.Require().NoError(err) s.NotNil(r) s.NotNil(r.lifecycle) @@ -419,7 +419,7 @@ func (s *NewPlatformMeshReconcilerTestSuite) Test_kcpSetupSubroutineEnabled_retu } commonCfg := &pmconfig.CommonServiceConfig{} - r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil) + r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil, nil) s.Require().NoError(err) s.NotNil(r) s.NotNil(r.lifecycle) @@ -435,7 +435,7 @@ func (s *NewPlatformMeshReconcilerTestSuite) Test_waitSubroutineEnabled_returnsV } commonCfg := &pmconfig.CommonServiceConfig{} - r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil) + r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil, nil) s.Require().NoError(err) s.NotNil(r) s.NotNil(r.lifecycle) @@ -451,7 +451,7 @@ func (s *NewPlatformMeshReconcilerTestSuite) Test_providerSecretSubroutineEnable } commonCfg := &pmconfig.CommonServiceConfig{} - r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil) + r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil, nil) s.Require().NoError(err) s.NotNil(r) s.NotNil(r.lifecycle) @@ -467,7 +467,7 @@ func (s *NewPlatformMeshReconcilerTestSuite) Test_featureTogglesSubroutineEnable } commonCfg := &pmconfig.CommonServiceConfig{} - r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil) + r, err := NewPlatformMeshReconciler(mgr, cfg, commonCfg, "/tmp", fakeClient, nil, nil) s.Require().NoError(err) s.NotNil(r) s.NotNil(r.lifecycle) diff --git a/internal/controller/platformmesh_controller.go b/internal/controller/platformmesh_controller.go index f2e4db96..056c3320 100644 --- a/internal/controller/platformmesh_controller.go +++ b/internal/controller/platformmesh_controller.go @@ -42,6 +42,7 @@ import ( corev1alpha1 "github.com/platform-mesh/platform-mesh-operator/api/v1alpha1" "github.com/platform-mesh/platform-mesh-operator/internal/config" "github.com/platform-mesh/platform-mesh-operator/internal/metrics" + "github.com/platform-mesh/platform-mesh-operator/pkg/rbacpresets" pmsubs "github.com/platform-mesh/platform-mesh-operator/pkg/subroutines" ) @@ -127,7 +128,11 @@ func (r *PlatformMeshReconciler) mapConfigMapToPlatformMesh(ctx context.Context, return requests } -func NewPlatformMeshReconciler(mgr mcmanager.Manager, cfg *config.OperatorConfig, commonCfg *pmconfig.CommonServiceConfig, dir string, clientInfra client.Client, imageVersionStore *pmsubs.ImageVersionStore) (*PlatformMeshReconciler, error) { +func NewPlatformMeshReconciler(mgr mcmanager.Manager, cfg *config.OperatorConfig, commonCfg *pmconfig.CommonServiceConfig, dir string, clientInfra client.Client, imageVersionStore *pmsubs.ImageVersionStore, presetLoader *rbacpresets.Loader, +) (*PlatformMeshReconciler, error) { + if presetLoader == nil { + presetLoader = rbacpresets.NewLoader(rbacpresets.EmbeddedProvidersFS()) + } kcpUrl := fmt.Sprintf("https://%s-front-proxy.%s:%s", cfg.KCP.FrontProxyName, cfg.KCP.Namespace, cfg.KCP.FrontProxyPort) if cfg.KCP.Url != "" { kcpUrl = cfg.KCP.Url @@ -145,7 +150,7 @@ func NewPlatformMeshReconciler(mgr mcmanager.Manager, cfg *config.OperatorConfig subs = append(subs, pmsubs.NewKcpsetupSubroutine(localCl, &pmsubs.Helper{}, cfg, dir+"/manifests/kcp", kcpUrl)) } if cfg.Subroutines.ProviderSecret.Enabled { - subs = append(subs, pmsubs.NewProviderSecretSubroutine(localCl, &pmsubs.Helper{}, pmsubs.DefaultHelmGetter{}, kcpUrl)) + subs = append(subs, pmsubs.NewProviderSecretSubroutine(localCl, &pmsubs.Helper{}, pmsubs.DefaultHelmGetter{}, kcpUrl, presetLoader)) } if cfg.Subroutines.FeatureToggles.Enabled { subs = append(subs, pmsubs.NewFeatureToggleSubroutine(localCl, &pmsubs.Helper{}, cfg, kcpUrl)) diff --git a/pkg/rbacpresets/loader.go b/pkg/rbacpresets/loader.go new file mode 100644 index 00000000..e74a95e3 --- /dev/null +++ b/pkg/rbacpresets/loader.go @@ -0,0 +1,239 @@ +package rbacpresets + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + "text/template" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +//go:embed providers/*.yaml +var providerPresetFilesEmbedded embed.FS + +// EmbeddedProvidersFS returns the embedded production preset files (providers/*.yaml). +func EmbeddedProvidersFS() fs.FS { + return providerPresetFilesEmbedded +} + +// Loader reads provider RBAC preset YAML from an io/fs.FS (expected layout: providers/.yaml). +type Loader struct { + FS fs.FS +} + +// NewLoader returns a Loader that reads presets from f. f must not be nil. +func NewLoader(f fs.FS) *Loader { + if f == nil { + panic("rbacpresets: NewLoader: fs.FS is nil") + } + return &Loader{FS: f} +} + +// MergePresetFS returns an fs.FS that resolves paths from overlay first, then base. +func MergePresetFS(base, overlay fs.FS) fs.FS { + return mergedPresetFS{base: base, overlay: overlay} +} + +type mergedPresetFS struct { + base, overlay fs.FS +} + +func (m mergedPresetFS) Open(name string) (fs.File, error) { + f, err := m.overlay.Open(name) + if err == nil { + return f, nil + } + return m.base.Open(name) +} + +var allowedManifestKinds = map[string]struct{}{ + "ServiceAccount": {}, + "ClusterRole": {}, + "ClusterRoleBinding": {}, + "RoleBinding": {}, +} + +// LoadPreset reads providers/.yaml from l.FS and renders the preset. +func (l *Loader) LoadPreset(name string, data PresetTemplateData) (*RenderedPreset, error) { + presetName := strings.TrimSpace(name) + if presetName == "" { + return nil, fmt.Errorf("preset name is empty") + } + if strings.Contains(presetName, "/") || strings.Contains(presetName, "\\") || strings.Contains(presetName, "..") { + return nil, fmt.Errorf("invalid preset name %q", name) + } + raw, err := fs.ReadFile(l.FS, filepath.ToSlash(filepath.Join("providers", presetName+".yaml"))) + if err != nil { + return nil, fmt.Errorf("load provider RBAC preset %q: %w", presetName, err) + } + return RenderPreset(presetName, raw, data) +} + +func RenderPreset(name string, raw []byte, data PresetTemplateData) (*RenderedPreset, error) { + if data.Suffix == "" { + data.Suffix = name + } + header, err := renderPresetHeader(name, raw, data) + if err != nil { + return nil, err + } + if data.ProviderPath == "" { + data.ProviderPath = header.Spec.ServiceAccountWorkspace + } + if data.SAName == "" { + data.SAName = header.Spec.ServiceAccountName + } + if data.SAName == "" { + data.SAName = "platform-mesh-provider-" + data.Suffix + } + + renderedBytes, err := executeTemplate(name, raw, data) + if err != nil { + return nil, err + } + + docs, err := parseRenderedDocs(renderedBytes) + if err != nil { + return nil, err + } + var preset *ProviderRBACPreset + grouped := map[string][]unstructured.Unstructured{} + for i := range docs { + obj := docs[i] + if obj.GetKind() == "" { + continue + } + if obj.GetAPIVersion() == GroupVersion && obj.GetKind() == KindProviderRBACPreset { + if preset != nil { + return nil, fmt.Errorf("preset %q contains multiple %s documents", name, KindProviderRBACPreset) + } + var current ProviderRBACPreset + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, ¤t); err != nil { + return nil, fmt.Errorf("decode %s header in preset %q: %w", KindProviderRBACPreset, name, err) + } + preset = ¤t + continue + } + if _, ok := allowedManifestKinds[obj.GetKind()]; !ok { + return nil, fmt.Errorf("preset %q contains unsupported manifest kind %q", name, obj.GetKind()) + } + workspace := strings.TrimSpace(obj.GetAnnotations()[AnnotationWorkspace]) + if workspace == "" && preset != nil { + workspace = strings.TrimSpace(preset.Spec.ServiceAccountWorkspace) + } + if workspace == "" { + workspace = strings.TrimSpace(header.Spec.ServiceAccountWorkspace) + } + if workspace == "" { + return nil, fmt.Errorf("preset %q manifest %s/%s has no %s annotation and no serviceAccountWorkspace default", name, obj.GetKind(), obj.GetName(), AnnotationWorkspace) + } + stripWorkspaceAnnotation(&obj) + grouped[workspace] = append(grouped[workspace], obj) + } + if preset == nil { + return nil, fmt.Errorf("preset %q missing %s header document", name, KindProviderRBACPreset) + } + if preset.Spec.ServiceAccountWorkspace == "" { + preset.Spec.ServiceAccountWorkspace = header.Spec.ServiceAccountWorkspace + } + if preset.Spec.ServiceAccountWorkspace == "" { + preset.Spec.ServiceAccountWorkspace = data.ProviderPath + } + if preset.Spec.ServiceAccountName == "" { + preset.Spec.ServiceAccountName = data.SAName + } + + workspaces := make([]string, 0, len(grouped)) + for workspace := range grouped { + workspaces = append(workspaces, workspace) + } + sort.Strings(workspaces) + byWorkspace := make([]WorkspaceManifests, 0, len(workspaces)) + for _, workspace := range workspaces { + byWorkspace = append(byWorkspace, WorkspaceManifests{ + Workspace: workspace, + Manifests: grouped[workspace], + }) + } + return &RenderedPreset{ + Spec: preset.Spec, + ByWorkspace: byWorkspace, + }, nil +} + +func renderPresetHeader(name string, raw []byte, data PresetTemplateData) (*ProviderRBACPreset, error) { + renderedBytes, err := executeTemplate(name+"-header", raw, data) + if err != nil { + return nil, err + } + docs, err := parseRenderedDocs(renderedBytes) + if err != nil { + return nil, err + } + for i := range docs { + obj := docs[i] + if obj.GetAPIVersion() == GroupVersion && obj.GetKind() == KindProviderRBACPreset { + var preset ProviderRBACPreset + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &preset); err != nil { + return nil, fmt.Errorf("decode %s header in preset %q: %w", KindProviderRBACPreset, name, err) + } + if preset.Spec.ServiceAccountWorkspace == "" { + preset.Spec.ServiceAccountWorkspace = data.ProviderPath + } + return &preset, nil + } + } + return nil, fmt.Errorf("preset %q missing %s header document", name, KindProviderRBACPreset) +} + +func executeTemplate(name string, raw []byte, data PresetTemplateData) ([]byte, error) { + tmpl, err := template.New(name).Option("missingkey=error").Parse(string(raw)) + if err != nil { + return nil, fmt.Errorf("parse preset template %q: %w", name, err) + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, data); err != nil { + return nil, fmt.Errorf("execute preset template %q: %w", name, err) + } + return rendered.Bytes(), nil +} + +func parseRenderedDocs(rendered []byte) ([]unstructured.Unstructured, error) { + rawDocs := strings.Split(string(rendered), "\n---") + docs := make([]unstructured.Unstructured, 0, len(rawDocs)) + for _, rawDoc := range rawDocs { + rawDoc = strings.TrimSpace(rawDoc) + if rawDoc == "" { + continue + } + var objMap map[string]interface{} + if err := yaml.Unmarshal([]byte(rawDoc), &objMap); err != nil { + return nil, fmt.Errorf("unmarshal preset manifest: %w", err) + } + if len(objMap) == 0 { + continue + } + docs = append(docs, unstructured.Unstructured{Object: objMap}) + } + return docs, nil +} + +func stripWorkspaceAnnotation(obj *unstructured.Unstructured) { + annotations := obj.GetAnnotations() + if len(annotations) == 0 { + return + } + delete(annotations, AnnotationWorkspace) + if len(annotations) == 0 { + obj.SetAnnotations(nil) + return + } + obj.SetAnnotations(annotations) +} diff --git a/pkg/rbacpresets/loader_test.go b/pkg/rbacpresets/loader_test.go new file mode 100644 index 00000000..e3515a78 --- /dev/null +++ b/pkg/rbacpresets/loader_test.go @@ -0,0 +1,165 @@ +package rbacpresets + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderPresetGroupsTemplatedManifestsByWorkspace(t *testing.T) { + t.Parallel() + + raw := []byte(`apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: "{{ .ProviderPath }}" + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}-extra" + annotations: + rbacpresets.platform-mesh.io/workspace: root +subjects: [] +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: anything +`) + + preset, err := RenderPreset("test", raw, PresetTemplateData{ + ProviderPath: "root:platform-mesh-system", + Suffix: "init-agent-kubeconfig", + }) + require.NoError(t, err) + require.Equal(t, ServerTargetWorkspaceCluster, preset.Spec.ServerTarget.Type) + require.Equal(t, "root:platform-mesh-system", preset.Spec.ServiceAccountWorkspace) + require.Equal(t, "platform-mesh-provider-init-agent-kubeconfig", preset.Spec.ServiceAccountName) + require.Len(t, preset.ByWorkspace, 2) + + require.Equal(t, "root", preset.ByWorkspace[0].Workspace) + require.Equal(t, "ClusterRoleBinding", preset.ByWorkspace[0].Manifests[0].GetKind()) + require.NotContains(t, preset.ByWorkspace[0].Manifests[0].GetAnnotations(), AnnotationWorkspace) + + require.Equal(t, "root:platform-mesh-system", preset.ByWorkspace[1].Workspace) + require.Equal(t, "ServiceAccount", preset.ByWorkspace[1].Manifests[0].GetKind()) + require.Equal(t, "platform-mesh-provider-init-agent-kubeconfig", preset.ByWorkspace[1].Manifests[0].GetName()) + require.NotContains(t, preset.ByWorkspace[1].Manifests[0].GetAnnotations(), AnnotationWorkspace) +} + +func TestLoadPresetLoadsEmbeddedPreset(t *testing.T) { + t.Parallel() + + preset, err := NewLoader(EmbeddedProvidersFS()).LoadPreset("sample", PresetTemplateData{ + ProviderPath: "root:platform-mesh-system", + Suffix: "sample-kubeconfig", + }) + require.NoError(t, err) + require.Equal(t, ServerTargetWorkspaceCluster, preset.Spec.ServerTarget.Type) + require.Equal(t, "platform-mesh-provider-sample-kubeconfig", preset.Spec.ServiceAccountName) + require.Len(t, preset.ByWorkspace, 1) + require.Len(t, preset.ByWorkspace[0].Manifests, 3) +} + +func TestRenderPresetErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantErr string + }{ + { + name: "missing header", + raw: `apiVersion: v1 +kind: ServiceAccount +metadata: + name: test +`, + wantErr: "missing ProviderRBACPreset header document", + }, + { + name: "multiple headers", + raw: `apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: one +spec: + serverTarget: + type: workspaceCluster +--- +apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: two +spec: + serverTarget: + type: workspaceCluster +`, + wantErr: "multiple ProviderRBACPreset documents", + }, + { + name: "unsupported kind", + raw: `apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: root +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + wantErr: "unsupported manifest kind", + }, + { + name: "missing workspace", + raw: `apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test + namespace: default +`, + wantErr: "has no rbacpresets.platform-mesh.io/workspace annotation", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := RenderPreset("test", []byte(tt.raw), PresetTemplateData{}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +func TestLoadPresetRejectsUnsafeNames(t *testing.T) { + t.Parallel() + + _, err := NewLoader(EmbeddedProvidersFS()).LoadPreset("../init-agent", PresetTemplateData{}) + require.ErrorContains(t, err, "invalid preset name") +} diff --git a/pkg/rbacpresets/providers/sample.yaml b/pkg/rbacpresets/providers/sample.yaml new file mode 100644 index 00000000..d6dc1d5a --- /dev/null +++ b/pkg/rbacpresets/providers/sample.yaml @@ -0,0 +1,57 @@ +# Minimal reference preset: workspace-cluster server URL, SA + ClusterRole + ClusterRoleBinding. +# Copy and extend for operator-specific presets +apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: sample +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: "{{ .ProviderPath }}" + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +rules: + - apiGroups: ["core.platform-mesh.io"] + resources: ["contentconfigurations"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apis.kcp.io"] + resources: ["apiexportendpointslices"] + verbs: ["get", "list", "watch"] + - nonResourceURLs: + - "/api" + - "/api/*" + - "/apis" + - "/apis/*" + - "/clusters/*" + - "/services" + - "/services/*" + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +subjects: + - kind: ServiceAccount + name: "{{ .SAName }}" + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "{{ .SAName }}" diff --git a/pkg/rbacpresets/types.go b/pkg/rbacpresets/types.go new file mode 100644 index 00000000..72eb0af7 --- /dev/null +++ b/pkg/rbacpresets/types.go @@ -0,0 +1,61 @@ +package rbacpresets + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + GroupVersion = "rbacpresets.platform-mesh.io/v1alpha1" + KindProviderRBACPreset = "ProviderRBACPreset" + AnnotationWorkspace = "rbacpresets.platform-mesh.io/workspace" +) + +type ServerTargetType string + +const ( + ServerTargetWorkspaceCluster ServerTargetType = "workspaceCluster" + ServerTargetRawPath ServerTargetType = "rawPath" + ServerTargetWorkspaceTypeVirtualWorkspace ServerTargetType = "workspaceTypeVirtualWorkspace" + ServerTargetPathRawPath ServerTargetType = "pathRawPath" +) + +type ProviderRBACPreset struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec ProviderRBACPresetSpec `json:"spec"` +} + +type ProviderRBACPresetSpec struct { + ServerTarget ServerTarget `json:"serverTarget"` + // Defaulted to the provider connection path when empty. + ServiceAccountWorkspace string `json:"serviceAccountWorkspace,omitempty"` + // Defaulted to "platform-mesh-provider-" when empty. + ServiceAccountName string `json:"serviceAccountName,omitempty"` +} + +type ServerTarget struct { + Type ServerTargetType `json:"type"` + // For rawPath: optional preset-declared rawPath that overrides pc.RawPath when set. + RawPath string `json:"rawPath,omitempty"` + // For workspaceTypeVirtualWorkspace. + WorkspaceTypeName string `json:"workspaceTypeName,omitempty"` + WorkspaceTypePath string `json:"workspaceTypePath,omitempty"` +} + +type PresetTemplateData struct { + ProviderPath string + RawPath string + SAName string + Suffix string +} + +type RenderedPreset struct { + Spec ProviderRBACPresetSpec + ByWorkspace []WorkspaceManifests +} + +type WorkspaceManifests struct { + Workspace string + Manifests []unstructured.Unstructured +} diff --git a/pkg/subroutines/deployment_helpers_test.go b/pkg/subroutines/deployment_helpers_test.go index 40e796bf..86c2f618 100644 --- a/pkg/subroutines/deployment_helpers_test.go +++ b/pkg/subroutines/deployment_helpers_test.go @@ -670,7 +670,7 @@ func (s *DeploymentHelpersTestSuite) Test_mergeImageVersionsIntoHelmReleaseValue tests := []struct { name string isUnsuspended bool - specSuspend *bool // nil = field absent from template + specSuspend *bool // nil = field absent from template expectSuspend interface{} // nil = key absent }{ { diff --git a/pkg/subroutines/deployment_test.go b/pkg/subroutines/deployment_test.go index 497c8bea..2f692031 100644 --- a/pkg/subroutines/deployment_test.go +++ b/pkg/subroutines/deployment_test.go @@ -171,7 +171,7 @@ func (s *DeploymentProcessTestSuite) newOperatorConfig() config.OperatorConfig { }, Subroutines: config.SubroutinesConfig{ Deployment: config.DeploymentSubroutineConfig{ - Enabled: true, + Enabled: true, EnableIstio: false, }, }, diff --git a/pkg/subroutines/providersecret.go b/pkg/subroutines/providersecret.go index 0ce8e83e..f0f8d1a2 100644 --- a/pkg/subroutines/providersecret.go +++ b/pkg/subroutines/providersecret.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "path" + "strings" "time" pmconfig "github.com/platform-mesh/golang-commons/config" @@ -29,6 +30,7 @@ import ( corev1alpha1 "github.com/platform-mesh/platform-mesh-operator/api/v1alpha1" "github.com/platform-mesh/platform-mesh-operator/internal/config" + "github.com/platform-mesh/platform-mesh-operator/pkg/rbacpresets" "github.com/platform-mesh/platform-mesh-operator/internal/metrics" ) @@ -50,21 +52,27 @@ func NewProviderSecretSubroutine( helper KcpHelper, helm HelmGetter, kcpUrl string, + presetLoader *rbacpresets.Loader, ) *ProvidersecretSubroutine { + if presetLoader == nil { + presetLoader = rbacpresets.NewLoader(rbacpresets.EmbeddedProvidersFS()) + } sub := &ProvidersecretSubroutine{ - client: client, - kcpUrl: kcpUrl, - kcpHelper: helper, - helm: helm, + client: client, + kcpUrl: kcpUrl, + kcpHelper: helper, + helm: helm, + presetLoader: presetLoader, } return sub } type ProvidersecretSubroutine struct { - client client.Client - kcpHelper KcpHelper - kcpUrl string - helm HelmGetter + client client.Client + kcpHelper KcpHelper + kcpUrl string + helm HelmGetter + presetLoader *rbacpresets.Loader } const ( @@ -178,6 +186,18 @@ func (r *ProvidersecretSubroutine) HandleProviderConnection( log := logger.LoadLoggerFromContext(ctx) operatorCfg := pmconfig.LoadConfigFromContext(ctx).(config.OperatorConfig) + preset := strings.TrimSpace(ptr.Deref(pc.ProviderRBACPreset, "")) + if preset != "" { + if ptr.Deref(pc.APIExportName, "") != "" || ptr.Deref(pc.EndpointSliceName, "") != "" { + return subroutines.OK(), fmt.Errorf("providerRBACPreset is mutually exclusive with apiExportName and endpointSliceName") + } + if err := writeProviderPresetKubeconfigToSecret(ctx, r.presetLoader, r.client, r.kcpHelper, cfg, instance, pc); err != nil { + log.Error().Err(err).Str("secret", pc.Secret).Str("preset", preset).Msg("Failed to write preset-based provider kubeconfig") + return subroutines.OK(), err + } + return subroutines.OK(), nil + } + if !ptr.Deref(pc.AdminAuth, false) { if err := writeScopedKubeconfigToSecret(ctx, r.client, r.kcpHelper, cfg, instance, pc); err != nil { log.Error().Err(err).Str("secret", pc.Secret).Msg("Failed to write scoped provider kubeconfig") diff --git a/pkg/subroutines/providersecret_test.go b/pkg/subroutines/providersecret_test.go index b029e88b..c5b92cb4 100644 --- a/pkg/subroutines/providersecret_test.go +++ b/pkg/subroutines/providersecret_test.go @@ -118,7 +118,7 @@ func (suite *ProvidersecretTestSuite) SetupTest() { suite.clientMock.EXPECT().Scheme().Return(suite.scheme).Maybe() - suite.testObj = NewProviderSecretSubroutine(suite.clientMock, &Helper{}, fakeHelm{ready: true}, "") + suite.testObj = NewProviderSecretSubroutine(suite.clientMock, &Helper{}, fakeHelm{ready: true}, "", nil) } func (suite *ProvidersecretTestSuite) TearDownTest() { @@ -290,7 +290,7 @@ func (s *ProvidersecretTestSuite) TestProcess() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) operatorCfg := config.OperatorConfig{ KCP: config.OperatorConfig{}.KCP, @@ -386,7 +386,7 @@ func (s *ProvidersecretTestSuite) TestWrongScheme() { ).Once() // s.testObj.kcpHelper = mockedKcpHelper - s.testObj = NewProviderSecretSubroutine(mockK8sClient, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(mockK8sClient, mockedKcpHelper, fakeHelm{ready: true}, "", nil) operatorCfg := config.OperatorConfig{ KCP: config.OperatorConfig{}.KCP, @@ -549,7 +549,7 @@ func (s *ProvidersecretTestSuite) TestErrorCreatingSecret() { ).Once() // Run - s.testObj = NewProviderSecretSubroutine(mockClient, mockedKcpHelper, fakeHelm{ready: true}, "example.com") + s.testObj = NewProviderSecretSubroutine(mockClient, mockedKcpHelper, fakeHelm{ready: true}, "example.com", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -659,7 +659,7 @@ func (s *ProvidersecretTestSuite) TestFailedBuilidingKubeconfig() { ).Once() // s.testObj.kcpHelper = mockedKcpHelper - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -777,7 +777,7 @@ func (s *ProvidersecretTestSuite) TestGetName() { func (suite *ProvidersecretTestSuite) TestConstructor() { helper := &Helper{} - suite.testObj = NewProviderSecretSubroutine(suite.clientMock, helper, fakeHelm{ready: true}, "") + suite.testObj = NewProviderSecretSubroutine(suite.clientMock, helper, fakeHelm{ready: true}, "", nil) } func (s *ProvidersecretTestSuite) TestFinalize() { @@ -861,7 +861,7 @@ func (s *ProvidersecretTestSuite) TestInvalidKubeconfig() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1049,7 +1049,7 @@ func (s *ProvidersecretTestSuite) TestErrorCreatingKCPClient() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1146,7 +1146,7 @@ func (s *ProvidersecretTestSuite) TestErrorGettingAPIExportEndpointSlice() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1252,7 +1252,7 @@ func (s *ProvidersecretTestSuite) TestEmptyAPIExportEndpoints() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1359,7 +1359,7 @@ func (s *ProvidersecretTestSuite) TestInvalidEndpointURL() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1480,7 +1480,7 @@ func (s *ProvidersecretTestSuite) TestContextNotFoundInKubeconfig() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1620,7 +1620,7 @@ func (s *ProvidersecretTestSuite) TestClusterNotFoundInKubeconfig() { }, ).Once() - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "", nil) // Add the missing operator config context operatorCfg := config.OperatorConfig{ @@ -1868,7 +1868,7 @@ func (s *ProvidersecretTestSuite) TestHandleProviderConnections() { } // Run test - s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "example.com") + s.testObj = NewProviderSecretSubroutine(s.clientMock, mockedKcpHelper, fakeHelm{ready: true}, "example.com", nil) ctx := context.WithValue(context.Background(), keys.LoggerCtxKey, s.log) ctx = context.WithValue(ctx, keys.ConfigCtxKey, opCfg) diff --git a/pkg/subroutines/resource/subroutine_test.go b/pkg/subroutines/resource/subroutine_test.go index 7495bde7..78439d9b 100644 --- a/pkg/subroutines/resource/subroutine_test.go +++ b/pkg/subroutines/resource/subroutine_test.go @@ -901,14 +901,14 @@ func (s *ResourceTestSuite) Test_updateArgoCDApplication_AlreadyUpToDate() { "apiVersion": "delivery.ocm.software/v1alpha1", "kind": "Resource", "metadata": map[string]interface{}{ - "name": "keycloak-chart", - "namespace": "platform-mesh-system", + "name": "keycloak-chart", + "namespace": "platform-mesh-system", "annotations": map[string]interface{}{"artifact": "chart", "repo": "helm"}, }, "status": map[string]interface{}{ "resource": map[string]interface{}{ "version": "25.2.3", - "access": map[string]interface{}{"type": "helmChart", "helmRepository": "https://charts.bitnami.com/bitnami"}, + "access": map[string]interface{}{"type": "helmChart", "helmRepository": "https://charts.bitnami.com/bitnami"}, }, }, "spec": map[string]interface{}{}, @@ -948,8 +948,8 @@ func (s *ResourceTestSuite) Test_updateArgoCDApplicationHelmValues() { "apiVersion": "delivery.ocm.software/v1alpha1", "kind": "Resource", "metadata": map[string]interface{}{ - "name": "kcp-image", - "namespace": "platform-mesh-system", + "name": "kcp-image", + "namespace": "platform-mesh-system", "annotations": map[string]interface{}{"artifact": "image", "repo": "oci", "path": "kcp.image.tag"}, }, "status": map[string]interface{}{ @@ -996,7 +996,7 @@ func (s *ResourceTestSuite) Test_resolveArgoCDSource_OCI() { "status": map[string]interface{}{ "resource": map[string]interface{}{ "version": "1.2.3", - "access": map[string]interface{}{"imageReference": "oci://registry.example.com/charts/mychart:1.2.3@sha256:abc"}, + "access": map[string]interface{}{"imageReference": "oci://registry.example.com/charts/mychart:1.2.3@sha256:abc"}, }, }, }, diff --git a/pkg/subroutines/scoped_provider_preset.go b/pkg/subroutines/scoped_provider_preset.go new file mode 100644 index 00000000..079b4a15 --- /dev/null +++ b/pkg/subroutines/scoped_provider_preset.go @@ -0,0 +1,234 @@ +package subroutines + +import ( + "context" + "fmt" + "net/url" + "strings" + + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + pmconfig "github.com/platform-mesh/golang-commons/config" + "github.com/platform-mesh/golang-commons/errors" + "github.com/platform-mesh/platform-mesh-operator/internal/config" + "github.com/platform-mesh/platform-mesh-operator/pkg/rbacpresets" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + corev1alpha1 "github.com/platform-mesh/platform-mesh-operator/api/v1alpha1" +) + +func writeProviderPresetKubeconfigToSecret( + ctx context.Context, + presetLoader *rbacpresets.Loader, + k8sClient client.Client, + kcpHelper KcpHelper, + cfg *rest.Config, + instance *corev1alpha1.PlatformMesh, + pc corev1alpha1.ProviderConnection, +) error { + operatorCfg := pmconfig.LoadConfigFromContext(ctx).(config.OperatorConfig) + rawPath := strings.TrimSpace(ptr.Deref(pc.RawPath, "")) + presetName := strings.TrimSpace(ptr.Deref(pc.ProviderRBACPreset, "")) + rendered, err := presetLoader.LoadPreset(presetName, rbacpresets.PresetTemplateData{ + ProviderPath: strings.TrimSpace(pc.Path), + RawPath: rawPath, + Suffix: pc.Secret, + }) + if err != nil { + return err + } + if rendered.Spec.ServiceAccountWorkspace == "" { + return fmt.Errorf("preset %q did not define a ServiceAccount workspace", presetName) + } + if rendered.Spec.ServiceAccountName == "" { + return fmt.Errorf("preset %q did not define a ServiceAccount name", presetName) + } + + serverURL, err := buildPresetServerURL(ctx, kcpHelper, cfg, operatorCfg, instance, pc, rendered.Spec.ServerTarget) + if err != nil { + return err + } + if err := applyPresetManifests(ctx, kcpHelper, cfg, rendered.ByWorkspace); err != nil { + return err + } + + saWorkspaceClient, err := kcpHelper.NewKcpClient(rest.CopyConfig(cfg), rendered.Spec.ServiceAccountWorkspace) + if err != nil { + return errors.Wrap(err, "kcp client for preset ServiceAccount workspace") + } + token, err := createTokenForSA(ctx, saWorkspaceClient, defaultScopedSANamespace, rendered.Spec.ServiceAccountName, defaultTokenExpirationSeconds) + if err != nil { + return errors.Wrap(err, "create token for preset ServiceAccount") + } + + caData := cfg.TLSClientConfig.CAData + if caData == nil { + caData = []byte{} + } + caData = AppendRootShardCAPEMIfMissing(ctx, k8sClient, &operatorCfg, caData) + kubeconfigBytes, err := clientcmd.Write(*buildScopedKubeconfig(serverURL, token, caData)) + if err != nil { + return errors.Wrap(err, "write preset kubeconfig") + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: pc.Secret, Namespace: ptr.Deref(pc.Namespace, operatorCfg.KCP.Namespace)}, + } + _, err = controllerutil.CreateOrUpdate(ctx, k8sClient, secret, func() error { + secret.Data = map[string][]byte{"kubeconfig": kubeconfigBytes} + return nil + }) + if err != nil { + return errors.Wrap(err, "write preset provider secret") + } + return nil +} + +func buildPresetServerURL( + ctx context.Context, + kcpHelper KcpHelper, + cfg *rest.Config, + operatorCfg config.OperatorConfig, + instance *corev1alpha1.PlatformMesh, + pc corev1alpha1.ProviderConnection, + target rbacpresets.ServerTarget, +) (string, error) { + switch target.Type { + case rbacpresets.ServerTargetWorkspaceCluster: + if strings.TrimSpace(pc.Path) == "" { + return "", fmt.Errorf("workspaceCluster preset target requires provider connection path") + } + return createScopedKubeconfigURLForAPIExportName(operatorCfg, instance, strings.TrimSpace(pc.Path), pc.External) + case rbacpresets.ServerTargetRawPath: + rawPath := strings.TrimSpace(target.RawPath) + if rawPath == "" { + rawPath = strings.TrimSpace(ptr.Deref(pc.RawPath, "")) + } + if rawPath == "" { + return "", fmt.Errorf("rawPath preset target requires serverTarget.rawPath or provider connection rawPath") + } + return joinPresetHostPath(operatorCfg, instance, pc.External, rawPath) + case rbacpresets.ServerTargetPathRawPath: + if strings.TrimSpace(pc.Path) == "" { + return "", fmt.Errorf("pathRawPath preset target requires provider connection path") + } + rawPath := strings.TrimSpace(target.RawPath) + if rawPath == "" { + rawPath = strings.TrimSpace(ptr.Deref(pc.RawPath, "")) + } + if rawPath == "" { + return "", fmt.Errorf("pathRawPath preset target requires serverTarget.rawPath or provider connection rawPath") + } + return joinPresetHostPath(operatorCfg, instance, pc.External, rawPath) + case rbacpresets.ServerTargetWorkspaceTypeVirtualWorkspace: + workspaceTypeName := strings.TrimSpace(target.WorkspaceTypeName) + if workspaceTypeName == "" { + return "", fmt.Errorf("workspaceTypeVirtualWorkspace preset target requires workspaceTypeName") + } + workspaceTypePath := strings.TrimSpace(target.WorkspaceTypePath) + if workspaceTypePath == "" { + workspaceTypePath = strings.TrimSpace(pc.Path) + } + if workspaceTypePath == "" { + return "", fmt.Errorf("workspaceTypeVirtualWorkspace preset target requires workspaceTypePath or provider connection path") + } + kcpClient, err := kcpHelper.NewKcpClient(rest.CopyConfig(cfg), workspaceTypePath) + if err != nil { + return "", errors.Wrap(err, "kcp client for WorkspaceType virtual workspace") + } + wt := &kcptenancyv1alpha.WorkspaceType{} + if err := kcpClient.Get(ctx, types.NamespacedName{Name: workspaceTypeName}, wt); err != nil { + return "", fmt.Errorf("get WorkspaceType %s in workspace %s: %w", workspaceTypeName, workspaceTypePath, err) + } + if len(wt.Status.VirtualWorkspaces) == 0 || strings.TrimSpace(wt.Status.VirtualWorkspaces[0].URL) == "" { + return "", fmt.Errorf("WorkspaceType %s in workspace %s has no virtual workspace URL", workspaceTypeName, workspaceTypePath) + } + return rewriteScopedVirtualWorkspaceURLToFrontProxy(wt.Status.VirtualWorkspaces[0].URL, operatorCfg, instance, pc.External) + default: + return "", fmt.Errorf("unsupported preset server target type %q", target.Type) + } +} + +func joinPresetHostPath(operatorCfg config.OperatorConfig, instance *corev1alpha1.PlatformMesh, external bool, rawPath string) (string, error) { + hostPort, err := scopedProviderHostPort(operatorCfg, instance, external) + if err != nil { + return "", err + } + out, err := url.JoinPath(hostPort, rawPath) + if err != nil { + return "", errors.Wrap(err, "build preset server URL") + } + return out, nil +} + +func scopedProviderHostPort(operatorCfg config.OperatorConfig, instance *corev1alpha1.PlatformMesh, external bool) (string, error) { + if external { + if instance.Spec.Exposure == nil { + return "", fmt.Errorf("provider connection with external: true requires spec.exposure") + } + return fmt.Sprintf("https://kcp.api.%s:%d", instance.Spec.Exposure.BaseDomain, instance.Spec.Exposure.Port), nil + } + return fmt.Sprintf("https://%s-front-proxy.%s:%s", operatorCfg.KCP.FrontProxyName, operatorCfg.KCP.Namespace, operatorCfg.KCP.FrontProxyPort), nil +} + +func applyPresetManifests(ctx context.Context, kcpHelper KcpHelper, cfg *rest.Config, manifestsByWorkspace []rbacpresets.WorkspaceManifests) error { + for _, workspaceManifests := range manifestsByWorkspace { + workspace := strings.TrimSpace(workspaceManifests.Workspace) + if workspace == "" { + return fmt.Errorf("preset manifest workspace is empty") + } + kcpClient, err := kcpHelper.NewKcpClient(rest.CopyConfig(cfg), workspace) + if err != nil { + return errors.Wrap(err, "kcp client for preset workspace %s", workspace) + } + if err := ensureScopedNamespaceExists(ctx, kcpClient, defaultScopedSANamespace); err != nil { + return errors.Wrap(err, "ensure namespace %s for preset workspace %s", defaultScopedSANamespace, workspace) + } + for i := range workspaceManifests.Manifests { + if err := createOrUpdatePresetManifest(ctx, kcpClient, &workspaceManifests.Manifests[i]); err != nil { + return errors.Wrap(err, "apply preset manifest in workspace %s", workspace) + } + } + } + return nil +} + +func createOrUpdatePresetManifest(ctx context.Context, kcpClient client.Client, manifest *unstructured.Unstructured) error { + if manifest == nil || manifest.Object == nil { + return nil + } + desired := manifest.DeepCopy() + if desired.GetName() == "" { + return fmt.Errorf("manifest %s has empty name", desired.GetKind()) + } + defaultPresetManifestNamespace(desired) + + current := &unstructured.Unstructured{} + current.SetGroupVersionKind(desired.GroupVersionKind()) + key := client.ObjectKey{Name: desired.GetName(), Namespace: desired.GetNamespace()} + if err := kcpClient.Get(ctx, key, current); err != nil { + if !kerrors.IsNotFound(err) { + return err + } + return kcpClient.Create(ctx, desired) + } + desired.SetResourceVersion(current.GetResourceVersion()) + return kcpClient.Update(ctx, desired) +} + +func defaultPresetManifestNamespace(obj *unstructured.Unstructured) { + switch obj.GetKind() { + case "ServiceAccount", "RoleBinding": + if obj.GetNamespace() == "" { + obj.SetNamespace(defaultScopedSANamespace) + } + } +} diff --git a/pkg/subroutines/scoped_provider_preset_test.go b/pkg/subroutines/scoped_provider_preset_test.go new file mode 100644 index 00000000..b5670aa5 --- /dev/null +++ b/pkg/subroutines/scoped_provider_preset_test.go @@ -0,0 +1,332 @@ +package subroutines + +import ( + "context" + "fmt" + "testing" + + kcptenancyv1alpha "github.com/kcp-dev/kcp/sdk/apis/tenancy/v1alpha1" + "github.com/platform-mesh/golang-commons/context/keys" + "github.com/platform-mesh/golang-commons/logger" + corev1alpha1 "github.com/platform-mesh/platform-mesh-operator/api/v1alpha1" + "github.com/platform-mesh/platform-mesh-operator/internal/config" + "github.com/platform-mesh/platform-mesh-operator/pkg/rbacpresets" + "github.com/stretchr/testify/require" + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type presetTestKcpHelper struct { + clients map[string]client.Client +} + +func (h presetTestKcpHelper) NewKcpClient(_ *rest.Config, workspacePath string) (client.Client, error) { + if cl, ok := h.clients[workspacePath]; ok { + return cl, nil + } + return nil, fmt.Errorf("unexpected workspace %s", workspacePath) +} + +type tokenClient struct { + client.Client + token string +} + +func (c tokenClient) SubResource(subResource string) client.SubResourceClient { + if subResource == "token" { + return tokenSubResource{token: c.token} + } + return c.Client.SubResource(subResource) +} + +type tokenSubResource struct { + token string +} + +func (s tokenSubResource) Get(context.Context, client.Object, client.Object, ...client.SubResourceGetOption) error { + return nil +} + +func (s tokenSubResource) Create(_ context.Context, _ client.Object, subResource client.Object, _ ...client.SubResourceCreateOption) error { + tokenRequest, ok := subResource.(*authv1.TokenRequest) + if !ok { + return fmt.Errorf("unexpected token subresource type %T", subResource) + } + tokenRequest.Status.Token = s.token + return nil +} + +func (s tokenSubResource) Update(context.Context, client.Object, ...client.SubResourceUpdateOption) error { + return nil +} + +func (s tokenSubResource) Patch(context.Context, client.Object, client.Patch, ...client.SubResourcePatchOption) error { + return nil +} + +func (s tokenSubResource) Apply(context.Context, runtime.ApplyConfiguration, ...client.SubResourceApplyOption) error { + return nil +} + +func TestBuildPresetServerURL(t *testing.T) { + t.Parallel() + + operatorCfg := presetTestOperatorConfig() + instance := &corev1alpha1.PlatformMesh{ + Spec: corev1alpha1.PlatformMeshSpec{ + Exposure: &corev1alpha1.ExposureConfig{ + BaseDomain: "example.test", + Port: 443, + }, + }, + } + cfg := &rest.Config{Host: "https://root.kcp.test", TLSClientConfig: rest.TLSClientConfig{CAData: []byte("ca")}} + + workspaceTypeClient := fake.NewClientBuilder(). + WithScheme(presetTestScheme(t)). + WithObjects(&kcptenancyv1alpha.WorkspaceType{ + ObjectMeta: metav1.ObjectMeta{Name: "org"}, + Status: kcptenancyv1alpha.WorkspaceTypeStatus{ + VirtualWorkspaces: []kcptenancyv1alpha.VirtualWorkspace{ + {URL: "https://shard.example.test/services/workspaces/org"}, + }, + }, + }). + Build() + helper := presetTestKcpHelper{clients: map[string]client.Client{"root": workspaceTypeClient}} + + tests := []struct { + name string + pc corev1alpha1.ProviderConnection + target rbacpresets.ServerTarget + want string + }{ + { + name: "workspace cluster", + pc: corev1alpha1.ProviderConnection{ + Path: "root:platform-mesh-system", + }, + target: rbacpresets.ServerTarget{Type: rbacpresets.ServerTargetWorkspaceCluster}, + want: "https://frontproxy-front-proxy.platform-mesh-system:8443/clusters/root:platform-mesh-system", + }, + { + name: "raw path", + pc: corev1alpha1.ProviderConnection{}, + target: rbacpresets.ServerTarget{Type: rbacpresets.ServerTargetRawPath, RawPath: "/services/marketplace"}, + want: "https://frontproxy-front-proxy.platform-mesh-system:8443/services/marketplace", + }, + { + name: "path raw path", + pc: corev1alpha1.ProviderConnection{ + Path: "root:orgs", + RawPath: ptr.To("/services/contentconfigurations"), + }, + target: rbacpresets.ServerTarget{Type: rbacpresets.ServerTargetPathRawPath}, + want: "https://frontproxy-front-proxy.platform-mesh-system:8443/services/contentconfigurations", + }, + { + name: "workspace type virtual workspace", + pc: corev1alpha1.ProviderConnection{ + Path: "root", + }, + target: rbacpresets.ServerTarget{ + Type: rbacpresets.ServerTargetWorkspaceTypeVirtualWorkspace, + WorkspaceTypeName: "org", + WorkspaceTypePath: "root", + }, + want: "https://frontproxy-front-proxy.platform-mesh-system:8443/services/workspaces/org", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := buildPresetServerURL(context.Background(), helper, cfg, operatorCfg, instance, tt.pc, tt.target) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestApplyPresetManifestsCreatesObjectsInAnnotatedWorkspaces(t *testing.T) { + t.Parallel() + + scheme := presetTestScheme(t) + pmSystemClient := fake.NewClientBuilder().WithScheme(scheme).Build() + rootClient := fake.NewClientBuilder().WithScheme(scheme).Build() + rendered, err := rbacpresets.RenderPreset("test", []byte(`apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: root:platform-mesh-system + serviceAccountName: platform-mesh-provider-test +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: platform-mesh-provider-test + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: root:platform-mesh-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: platform-mesh-provider-test-extra + annotations: + rbacpresets.platform-mesh.io/workspace: root +subjects: + - kind: ServiceAccount + name: platform-mesh-provider-test + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:kcp:workspace:access +`), rbacpresets.PresetTemplateData{}) + require.NoError(t, err) + + helper := presetTestKcpHelper{clients: map[string]client.Client{ + "root:platform-mesh-system": pmSystemClient, + "root": rootClient, + }} + err = applyPresetManifests(context.Background(), helper, &rest.Config{}, rendered.ByWorkspace) + require.NoError(t, err) + + var sa corev1.ServiceAccount + require.NoError(t, pmSystemClient.Get(context.Background(), client.ObjectKey{Name: "platform-mesh-provider-test", Namespace: "default"}, &sa)) + var crb rbacv1.ClusterRoleBinding + require.NoError(t, rootClient.Get(context.Background(), client.ObjectKey{Name: "platform-mesh-provider-test-extra"}, &crb)) + require.Empty(t, crb.GetAnnotations()) +} + +func TestWriteProviderPresetKubeconfigToSecret(t *testing.T) { + t.Parallel() + + scheme := presetTestScheme(t) + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + kcpClient := tokenClient{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + token: "preset-token", + } + ctx := presetTestContext() + instance := &corev1alpha1.PlatformMesh{} + pc := corev1alpha1.ProviderConnection{ + Path: "root:platform-mesh-system", + Secret: "sample-kubeconfig", + ProviderRBACPreset: ptr.To("sample"), + } + cfg := &rest.Config{Host: "https://root.kcp.test", TLSClientConfig: rest.TLSClientConfig{CAData: []byte("ca")}} + helper := presetTestKcpHelper{clients: map[string]client.Client{ + "root:platform-mesh-system": kcpClient, + }} + + err := writeProviderPresetKubeconfigToSecret(ctx, rbacpresets.NewLoader(rbacpresets.EmbeddedProvidersFS()), k8sClient, helper, cfg, instance, pc) + require.NoError(t, err) + + var secret corev1.Secret + require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: "sample-kubeconfig", Namespace: "platform-mesh-system"}, &secret)) + kubeconfig, err := clientcmd.Load(secret.Data["kubeconfig"]) + require.NoError(t, err) + cluster := kubeconfig.Clusters[kubeconfig.Contexts[kubeconfig.CurrentContext].Cluster] + require.Equal(t, "https://frontproxy-front-proxy.platform-mesh-system:8443/clusters/root:platform-mesh-system", cluster.Server) + authInfo := kubeconfig.AuthInfos[kubeconfig.Contexts[kubeconfig.CurrentContext].AuthInfo] + require.Equal(t, "preset-token", authInfo.Token) +} + +func TestHandleProviderConnectionRejectsPresetWithAPIExportSource(t *testing.T) { + t.Parallel() + + subroutine := &ProvidersecretSubroutine{} + ctx := presetTestContext() + _, err := subroutine.HandleProviderConnection(ctx, &corev1alpha1.PlatformMesh{}, corev1alpha1.ProviderConnection{ + Secret: "bad", + ProviderRBACPreset: ptr.To("init-agent"), + APIExportName: ptr.To("core.platform-mesh.io"), + }, &rest.Config{}) + require.ErrorContains(t, err, "providerRBACPreset is mutually exclusive") +} + +func presetTestContext() context.Context { + logCfg := logger.DefaultConfig() + logCfg.Name = "preset-test" + log, _ := logger.New(logCfg) + ctx := context.WithValue(context.Background(), keys.ConfigCtxKey, presetTestOperatorConfig()) + ctx = context.WithValue(ctx, keys.LoggerCtxKey, log) + return ctx +} + +func presetTestOperatorConfig() config.OperatorConfig { + return config.OperatorConfig{ + KCP: config.KCPConfig{ + Namespace: "platform-mesh-system", + FrontProxyName: "frontproxy", + FrontProxyPort: "8443", + RootShardName: "rootshard", + }, + } +} + +func presetTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, authv1.AddToScheme(scheme)) + require.NoError(t, rbacv1.AddToScheme(scheme)) + require.NoError(t, kcptenancyv1alpha.AddToScheme(scheme)) + return scheme +} + +func TestCreateOrUpdatePresetManifestUpdatesExistingObjects(t *testing.T) { + t.Parallel() + + scheme := presetTestScheme(t) + kcpClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "preset-role"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get"}}}, + }).Build() + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "preset-role", + }, + "rules": []interface{}{ + map[string]interface{}{ + "apiGroups": []interface{}{""}, + "resources": []interface{}{"pods"}, + "verbs": []interface{}{"list"}, + }, + }, + }} + + err := createOrUpdatePresetManifest(context.Background(), kcpClient, obj) + require.NoError(t, err) + + var role rbacv1.ClusterRole + require.NoError(t, kcpClient.Get(context.Background(), client.ObjectKey{Name: "preset-role"}, &role)) + require.Equal(t, []string{"list"}, role.Rules[0].Verbs) +} + +func TestCreateOrUpdatePresetManifestIgnoresAlreadyExistingNamespace(t *testing.T) { + t.Parallel() + + scheme := presetTestScheme(t) + kcpClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: defaultScopedSANamespace}, + }).Build() + err := ensureScopedNamespaceExists(context.Background(), kcpClient, defaultScopedSANamespace) + require.NoError(t, err) +} diff --git a/test/e2e/kind/helpers.go b/test/e2e/kind/helpers.go index d3063ea9..f0d9a817 100644 --- a/test/e2e/kind/helpers.go +++ b/test/e2e/kind/helpers.go @@ -6,6 +6,8 @@ import ( "fmt" "html/template" "os" + "os/exec" + goruntime "runtime" "strings" "time" @@ -18,6 +20,9 @@ import ( "sigs.k8s.io/yaml" ) +// e2eScopedKubeconfigProvider1Path matches kind_scoped_kubeconfig_test.go fixtures (provider1 workspace cluster). +const e2eScopedKubeconfigProvider1Path = "root:providers:provider1" + func dynamicClientForKubeconfig(kubeconfigBytes []byte) (dynamic.Interface, error) { cfg, err := clientcmd.RESTConfigFromKubeConfig(kubeconfigBytes) if err != nil { @@ -109,3 +114,82 @@ func ReplaceTemplate(templateData map[string]string, templateBytes []byte) ([]by } return result.Bytes(), nil } + +// runKubectlAuthCanI runs `kubectl auth can-i ` with a kubeconfig backed by kubeconfigBytes +// (after normalizeScopedKubeconfigServerForLocalRun). Returns whether kubectl answered "yes". +func runKubectlAuthCanI(kubeconfigBytes []byte, verb, resource string) (bool, error) { + normalizedKubeconfigBytes, err := normalizeScopedKubeconfigServerForLocalRun(kubeconfigBytes) + if err != nil { + return false, err + } + tmp, err := os.CreateTemp("", "preset-kubeconfig-auth-can-i-*.yaml") + if err != nil { + return false, err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(normalizedKubeconfigBytes); err != nil { + _ = tmp.Close() + return false, err + } + if err := tmp.Close(); err != nil { + return false, err + } + args := []string{"--kubeconfig", tmp.Name(), "auth", "can-i", verb, resource} + cmd := exec.Command("kubectl", args...) + env := os.Environ() + if goruntime.GOOS == "darwin" { + env = append(env, "DOCKER_HOST=unix:///var/run/docker.sock") + } else { + env = append(env, "DOCKER_HOST=unix:///run/docker.sock") + } + cmd.Env = env + out, err := cmd.CombinedOutput() + raw := string(out) + var lastLine string + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Warning:") { + continue + } + lastLine = line + } + switch lastLine { + case "yes": + return true, nil + case "no": + return false, nil + } + if err != nil { + return false, fmt.Errorf("kubectl auth can-i: %w, output: %s", err, strings.TrimSpace(raw)) + } + return false, fmt.Errorf("kubectl auth can-i: unexpected output: %q", strings.TrimSpace(raw)) +} + +// normalizeScopedKubeconfigServerForLocalRun handles scoped e2e cases. +// This is test-only behavior for host-run kubectl in local/CI e2e, not generic production kubeconfig rewriting. +func normalizeScopedKubeconfigServerForLocalRun(kubeconfigBytes []byte) ([]byte, error) { + cfg, err := clientcmd.Load(kubeconfigBytes) + if err != nil { + return nil, err + } + + currentContext := cfg.Contexts[cfg.CurrentContext] + cluster := cfg.Clusters[currentContext.Cluster] + + server := cluster.Server + + // In-cluster front-proxy DNS is not resolvable from host-run kubectl. + server = strings.Replace(server, "frontproxy-front-proxy.platform-mesh-system:8443", "localhost:8443", 1) + + // provider1: virtual workspace URL from endpoint slice is flaky for create/get in host-run kubectl. + if strings.Contains(server, "/services/apiexport/") { + server = "https://localhost:8443/clusters/" + e2eScopedKubeconfigProvider1Path + } + + cluster.Server = server + out, err := clientcmd.Write(*cfg) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/test/e2e/kind/kind_preset_kubeconfig_test.go b/test/e2e/kind/kind_preset_kubeconfig_test.go new file mode 100644 index 00000000..2b056ea9 --- /dev/null +++ b/test/e2e/kind/kind_preset_kubeconfig_test.go @@ -0,0 +1,384 @@ +package e2e + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "testing/fstest" + "time" + + corev1alpha1 "github.com/platform-mesh/platform-mesh-operator/api/v1alpha1" + "github.com/platform-mesh/platform-mesh-operator/pkg/rbacpresets" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Kind e2e tests for provider RBAC presets (PlatformMesh.spec.kcp.extraProviderConnections[].providerRBACPreset). +// Each case merges a test-only preset from yaml/kcp-preset-fixtures into the suite's rbacpresets.Loader.FS, patches one +// extra provider connection, waits for the operator-written kubeconfig Secret, then asserts cluster.server; it also checks +// ServiceAccount / ClusterRole / ClusterRoleBinding in the expected workspace, and where useful kubectl auth can-i. +// TestX_* runs after TestScoped* lexicographically. +const ( + // Shared by preset tests: on-disk fixtures and front-proxy host used in expected cluster.server values. + e2ePresetFixtureDir = "../../../test/e2e/kind/yaml/kcp-preset-fixtures" + e2ePresetFrontProxyBase = "https://frontproxy-front-proxy.platform-mesh-system:8443" + + // TestX_Preset01KubeconfigPrereq — seeds WorkspaceType so Preset05 can read status.virtualWorkspaces[0].URL. + e2ePresetWorkspaceTypeName = "e2e-preset-target" + e2ePresetWorkspaceTypeYAML = "e2e-wtvw-workspacetype.yaml" + + // TestX_Preset02KubeconfigWorkspaceCluster — providerRBACPreset e2e-workspace-cluster (serverTarget workspaceCluster). + e2ePresetInitAgentSecretName = "kind-e2e-preset-init-agent-kubeconfig" + e2ePresetWorkspaceClusterPreset = "e2e-workspace-cluster" + e2ePresetWorkspaceClusterFixture = "e2e-workspace-cluster.yaml" + + // TestX_Preset03KubeconfigRawPath — providerRBACPreset e2e-raw-path (serverTarget rawPath). + e2ePresetMarketplaceSecretName = "kind-e2e-preset-marketplace-kubeconfig" + e2ePresetRawPathPreset = "e2e-raw-path" + e2ePresetRawPathFixture = "e2e-raw-path.yaml" + + // TestX_Preset04KubeconfigPathRawPath — providerRBACPreset e2e-path-raw-path (serverTarget pathRawPath). + e2ePresetPortalSecretName = "kind-e2e-preset-portal-kubeconfig" + e2ePresetPathRawPathPreset = "e2e-path-raw-path" + e2ePresetPathRawPathFixture = "e2e-path-raw-path.yaml" + + // TestX_Preset05KubeconfigWorkspaceTypeVW — providerRBACPreset e2e-wtvw (workspaceTypeVirtualWorkspace; needs Preset01). + e2ePresetWTVWSecretName = "kind-e2e-preset-wtvw-kubeconfig" + e2ePresetWTVWPreset = "e2e-wtvw" + e2ePresetWTVWFixture = "e2e-wtvw.yaml" +) + +// e2ePresetFixtureFiles maps preset name to fixture basename (ProviderRBACPreset YAML only). +var e2ePresetFixtureFiles = map[string]string{ + e2ePresetWorkspaceClusterPreset: e2ePresetWorkspaceClusterFixture, + e2ePresetRawPathPreset: e2ePresetRawPathFixture, + e2ePresetPathRawPathPreset: e2ePresetPathRawPathFixture, + e2ePresetWTVWPreset: e2ePresetWTVWFixture, +} + +// buildE2EPresetOverlayFS loads all Kind e2e preset fixtures into providers/.yaml for the in-process operator. +// Required at operator start because the Kind cluster is reused and PlatformMesh may still reference e2e presets from a prior run. +func buildE2EPresetOverlayFS() (fstest.MapFS, error) { + overlay := fstest.MapFS{} + for presetName, fixtureBasename := range e2ePresetFixtureFiles { + path := filepath.Join(e2ePresetFixtureDir, fixtureBasename) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read preset fixture %s: %w", path, err) + } + mapKey := filepath.ToSlash(filepath.Join("providers", presetName+".yaml")) + overlay[mapKey] = &fstest.MapFile{Data: data} + } + return overlay, nil +} + +// OverlayPresetLoaderWithFixtureFile merges fixtureBasename from kcp-preset-fixtures into the Kind suite's preset loader at +// providers/.yaml (must match Loader.LoadPreset and PlatformMesh providerRBACPreset). +// +// Layers are intentionally left in place across tests: each case patches PlatformMesh with an extra preset connection that +// remains for the lifetime of the suite. Restoring FS on teardown would make the reconciler retry LoadPreset against the CR +// and fail (e.g. "e2e-workspace-cluster" missing after Preset02 cleanup while Preset03 runs). +func (s *KindTestSuite) OverlayPresetLoaderWithFixtureFile(presetName, fixtureBasename string) { + s.Require().NotNil(s.presetLoader, "presetLoader is set when the in-process operator starts") + prevFS := s.presetLoader.FS + path := filepath.Join(e2ePresetFixtureDir, fixtureBasename) + data, err := os.ReadFile(path) + s.Require().NoError(err, "read preset fixture %s", path) + mapKey := filepath.ToSlash(filepath.Join("providers", presetName+".yaml")) + s.presetLoader.FS = rbacpresets.MergePresetFS(prevFS, fstest.MapFS{ + mapKey: &fstest.MapFile{Data: data}, + }) +} + +// TestX_Preset01KubeconfigPrereq seeds kcp with a WorkspaceType used only for the workspaceTypeVirtualWorkspace preset case: +// it applies e2e-wtvw-workspacetype.yaml in workspace root and blocks until .status.virtualWorkspaces[0].URL is set, +// which Preset05 needs to build the expected kubeconfig server URL. +func (s *KindTestSuite) TestX_Preset01KubeconfigPrereq() { + s.logger.Info().Str("kind_e2e", "TestX_Preset01KubeconfigPrereq").Msg("start") + ctx := context.Background() + rootClient, err := s.kcpClientForWorkspace(ctx, "root") + s.Require().NoError(err, "kcp client for root (preset WT fixture)") + path := filepath.Join(e2ePresetFixtureDir, e2ePresetWorkspaceTypeYAML) + s.Require().NoError( + ApplyManifestFromFile(ctx, path, rootClient, make(map[string]string)), + "apply preset WorkspaceType fixture", + ) + _ = s.AwaitWorkspaceTypeVirtualWorkspaceURL(ctx, "root", e2ePresetWorkspaceTypeName) + s.logger.Info().Str("kind_e2e", "TestX_Preset01KubeconfigPrereq").Msg("done") +} + +// TestX_Preset02KubeconfigWorkspaceCluster covers providerRBACPreset "e2e-workspace-cluster" (fixture YAML, serverTarget workspaceCluster): +// scoped kubeconfig for root:platform-mesh-system, server is the workspace cluster URL on the front proxy; +// checks SA/RBAC objects in that workspace and kubectl auth can-i list inittargets.initialization.kcp.io. +func (s *KindTestSuite) TestX_Preset02KubeconfigWorkspaceCluster() { + s.logger.Info().Str("kind_e2e", "TestX_Preset02KubeconfigWorkspaceCluster").Msg("start") + ctx := context.TODO() + s.scopedWaitPlatformMeshReady(ctx) + s.OverlayPresetLoaderWithFixtureFile(e2ePresetWorkspaceClusterPreset, e2ePresetWorkspaceClusterFixture) + s.SetPlatformMeshProviderConnectionBySecretName(ctx, corev1alpha1.ProviderConnection{ + Path: "root:platform-mesh-system", + Secret: e2ePresetInitAgentSecretName, + ProviderRBACPreset: ptr.To(e2ePresetWorkspaceClusterPreset), + AdminAuth: ptr.To(false), + }) + s.AwaitProviderKubeconfigSecret(ctx, e2ePresetInitAgentSecretName) + sec := s.ReadProviderKubeconfigSecretOrFail(ctx, e2ePresetInitAgentSecretName) + kcfg := sec.Data["kubeconfig"] + wantServer := e2ePresetFrontProxyBase + "/clusters/root:platform-mesh-system" + s.Require().Equal(wantServer, CurrentContextClusterServer(s.T(), kcfg), "e2e-workspace-cluster preset server URL") + + saName := "platform-mesh-provider-" + e2ePresetInitAgentSecretName + s.RequireProviderIdentityRBACInWorkspace(ctx, "root:platform-mesh-system", saName) + + ok, err := runKubectlAuthCanI(kcfg, "list", "inittargets.initialization.kcp.io") + s.Require().NoError(err) + s.Require().True(ok, "scoped preset identity must list inittargets") + s.logger.Info().Str("kind_e2e", "TestX_Preset02KubeconfigWorkspaceCluster").Msg("done") +} + +// TestX_Preset03KubeconfigRawPath covers providerRBACPreset "e2e-raw-path" (fixture YAML, serverTarget rawPath): +// kubeconfig server is /services/marketplace on the front proxy (no connection Path); verifies SA and RBAC in root:platform-mesh-system. +func (s *KindTestSuite) TestX_Preset03KubeconfigRawPath() { + s.logger.Info().Str("kind_e2e", "TestX_Preset03KubeconfigRawPath").Msg("start") + ctx := context.TODO() + s.scopedWaitPlatformMeshReady(ctx) + s.OverlayPresetLoaderWithFixtureFile(e2ePresetRawPathPreset, e2ePresetRawPathFixture) + s.SetPlatformMeshProviderConnectionBySecretName(ctx, corev1alpha1.ProviderConnection{ + Secret: e2ePresetMarketplaceSecretName, + ProviderRBACPreset: ptr.To(e2ePresetRawPathPreset), + AdminAuth: ptr.To(false), + }) + s.AwaitProviderKubeconfigSecret(ctx, e2ePresetMarketplaceSecretName) + sec := s.ReadProviderKubeconfigSecretOrFail(ctx, e2ePresetMarketplaceSecretName) + kcfg := sec.Data["kubeconfig"] + wantServer, err := url.JoinPath(e2ePresetFrontProxyBase, "/services/marketplace") + s.Require().NoError(err) + s.Require().Equal(wantServer, CurrentContextClusterServer(s.T(), kcfg), "e2e-raw-path preset server URL") + + saName := "platform-mesh-provider-" + e2ePresetMarketplaceSecretName + s.RequireProviderIdentityRBACInWorkspace(ctx, "root:platform-mesh-system", saName) + s.logger.Info().Str("kind_e2e", "TestX_Preset03KubeconfigRawPath").Msg("done") +} + +// TestX_Preset04KubeconfigPathRawPath covers providerRBACPreset "e2e-path-raw-path" (fixture YAML, serverTarget pathRawPath): connection Path +// root:orgs, server ends with /services/contentconfigurations; checks SA/RBAC in root:orgs; prefers kubectl auth can-i +// list contentconfigurations.core.platform-mesh.io, otherwise asserts the ClusterRole rules if that API is unavailable. +func (s *KindTestSuite) TestX_Preset04KubeconfigPathRawPath() { + s.logger.Info().Str("kind_e2e", "TestX_Preset04KubeconfigPathRawPath").Msg("start") + ctx := context.TODO() + s.scopedWaitPlatformMeshReady(ctx) + s.OverlayPresetLoaderWithFixtureFile(e2ePresetPathRawPathPreset, e2ePresetPathRawPathFixture) + s.SetPlatformMeshProviderConnectionBySecretName(ctx, corev1alpha1.ProviderConnection{ + Path: "root:orgs", + Secret: e2ePresetPortalSecretName, + ProviderRBACPreset: ptr.To(e2ePresetPathRawPathPreset), + AdminAuth: ptr.To(false), + }) + s.AwaitProviderKubeconfigSecret(ctx, e2ePresetPortalSecretName) + sec := s.ReadProviderKubeconfigSecretOrFail(ctx, e2ePresetPortalSecretName) + kcfg := sec.Data["kubeconfig"] + wantServer, err := url.JoinPath(e2ePresetFrontProxyBase, "/services/contentconfigurations") + s.Require().NoError(err) + s.Require().Equal(wantServer, CurrentContextClusterServer(s.T(), kcfg), "e2e-path-raw-path preset server URL") + + saName := "platform-mesh-provider-" + e2ePresetPortalSecretName + s.RequireProviderIdentityRBACInWorkspace(ctx, "root:orgs", saName) + + ok, err := runKubectlAuthCanI(kcfg, "list", "contentconfigurations.core.platform-mesh.io") + if err == nil && ok { + s.logger.Info().Msg("e2e-path-raw-path preset: auth can-i list contentconfigurations ok") + } else { + s.logger.Warn().Err(err).Bool("allowed", ok).Msg("e2e-path-raw-path preset: auth can-i skipped or failed; asserting ClusterRole rules") + cl, err2 := s.kcpClientForWorkspace(ctx, "root:orgs") + s.Require().NoError(err2) + var cr rbacv1.ClusterRole + s.Require().NoError(cl.Get(ctx, client.ObjectKey{Name: saName}, &cr)) + var found bool + for _, r := range cr.Rules { + for _, g := range r.APIGroups { + if g == "core.platform-mesh.io" { + for _, res := range r.Resources { + if res == "contentconfigurations" { + found = true + break + } + } + } + } + } + s.Require().True(found, "e2e-path-raw-path ClusterRole must include core.platform-mesh.io/contentconfigurations") + } + s.logger.Info().Str("kind_e2e", "TestX_Preset04KubeconfigPathRawPath").Msg("done") +} + +// TestX_Preset05KubeconfigWorkspaceTypeVW covers workspaceTypeVirtualWorkspace using fixture preset "e2e-wtvw" +// (fixture merged into the suite preset loader; layers persist across Kind preset tests alongside PlatformMesh CR updates). +// Requires Preset01: connection Path root, kubeconfig server must match front-proxy host + path from the WorkspaceType VW URL; +// asserts SA/RBAC in root. +func (s *KindTestSuite) TestX_Preset05KubeconfigWorkspaceTypeVW() { + s.logger.Info().Str("kind_e2e", "TestX_Preset05KubeconfigWorkspaceTypeVW").Msg("start") + ctx := context.TODO() + s.scopedWaitPlatformMeshReady(ctx) + + s.OverlayPresetLoaderWithFixtureFile(e2ePresetWTVWPreset, e2ePresetWTVWFixture) + + vwURL := s.AwaitWorkspaceTypeVirtualWorkspaceURL(ctx, "root", e2ePresetWorkspaceTypeName) + wantServer, err := KubeconfigServerURLForFrontProxyAndVWPath(vwURL) + s.Require().NoError(err) + + s.SetPlatformMeshProviderConnectionBySecretName(ctx, corev1alpha1.ProviderConnection{ + Path: "root", + Secret: e2ePresetWTVWSecretName, + ProviderRBACPreset: ptr.To(e2ePresetWTVWPreset), + AdminAuth: ptr.To(false), + }) + s.AwaitProviderKubeconfigSecret(ctx, e2ePresetWTVWSecretName) + sec := s.ReadProviderKubeconfigSecretOrFail(ctx, e2ePresetWTVWSecretName) + kcfg := sec.Data["kubeconfig"] + s.Require().Equal(wantServer, CurrentContextClusterServer(s.T(), kcfg), "workspaceTypeVirtualWorkspace preset server URL") + + saName := "platform-mesh-provider-" + e2ePresetWTVWSecretName + s.RequireProviderIdentityRBACInWorkspace(ctx, "root", saName) + s.logger.Info().Str("kind_e2e", "TestX_Preset05KubeconfigWorkspaceTypeVW").Msg("done") +} + +func CurrentContextClusterServer(t requireInterface, kubeconfigBytes []byte) string { + t.Helper() + cfg, err := clientcmd.Load(kubeconfigBytes) + if err != nil { + t.Fatalf("parse kubeconfig: %v", err) + return "" + } + cur := cfg.Contexts[cfg.CurrentContext] + if cur == nil { + t.Fatalf("missing context %q", cfg.CurrentContext) + return "" + } + cluster := cfg.Clusters[cur.Cluster] + if cluster == nil { + t.Fatalf("missing cluster %q", cur.Cluster) + return "" + } + return cluster.Server +} + +type requireInterface interface { + Helper() + Fatalf(format string, args ...interface{}) +} + +func (s *KindTestSuite) SetPlatformMeshProviderConnectionBySecretName(ctx context.Context, d corev1alpha1.ProviderConnection) { + pm := &corev1alpha1.PlatformMesh{} + err := s.client.Get(ctx, client.ObjectKey{ + Name: e2ePlatformMeshName, + Namespace: e2ePlatformMeshNamespace, + }, pm) + s.Require().NoError(err, "get PlatformMesh for preset e2e provider connection") + + currentBySecret := make(map[string]int, len(pm.Spec.Kcp.ExtraProviderConnections)) + for i, pc := range pm.Spec.Kcp.ExtraProviderConnections { + currentBySecret[pc.Secret] = i + } + + if idx, ok := currentBySecret[d.Secret]; ok { + if providerConnectionEquivalent(pm.Spec.Kcp.ExtraProviderConnections[idx], d) { + s.logger.Info().Str("secret", d.Secret).Msg("preset e2e: provider connection already desired") + return + } + pm.Spec.Kcp.ExtraProviderConnections[idx] = d + } else { + pm.Spec.Kcp.ExtraProviderConnections = append(pm.Spec.Kcp.ExtraProviderConnections, d) + } + + s.Require().NoError(s.client.Update(ctx, pm), "update PlatformMesh preset e2e extraProviderConnections") + s.logger.Info().Str("secret", d.Secret).Msg("preset e2e: provider connection updated") +} + +func (s *KindTestSuite) AwaitProviderKubeconfigSecret(ctx context.Context, secretName string) { + s.Eventually(func() bool { + sec := &corev1.Secret{} + if err := s.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: e2ePlatformMeshNamespace}, sec); err != nil { + s.logger.Info().Str("secret", secretName).Msg("preset e2e: provider secret not yet present") + return false + } + if len(sec.Data["kubeconfig"]) == 0 { + s.logger.Info().Str("secret", secretName).Msg("preset e2e: provider secret kubeconfig empty") + return false + } + return true + }, 6*time.Minute, 10*time.Second, "preset provider secret %s/%s not ready", e2ePlatformMeshNamespace, secretName) +} + +func (s *KindTestSuite) ReadProviderKubeconfigSecretOrFail(ctx context.Context, secretName string) *corev1.Secret { + sec := &corev1.Secret{} + err := s.client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: e2ePlatformMeshNamespace}, sec) + s.Require().NoError(err, "get preset provider secret %s/%s", e2ePlatformMeshNamespace, secretName) + s.Require().NotEmpty(sec.Data["kubeconfig"]) + return sec +} + +func (s *KindTestSuite) RequireProviderIdentityRBACInWorkspace(ctx context.Context, workspacePath, saName string) { + cl, err := s.kcpClientForWorkspace(ctx, workspacePath) + s.Require().NoError(err, "kcp client for workspace %s", workspacePath) + var sa corev1.ServiceAccount + s.Require().NoError(cl.Get(ctx, client.ObjectKey{Name: saName, Namespace: "default"}, &sa)) + var cr rbacv1.ClusterRole + s.Require().NoError(cl.Get(ctx, client.ObjectKey{Name: saName}, &cr)) + var crb rbacv1.ClusterRoleBinding + s.Require().NoError(cl.Get(ctx, client.ObjectKey{Name: saName}, &crb)) +} + +func (s *KindTestSuite) AwaitWorkspaceTypeVirtualWorkspaceURL(ctx context.Context, workspacePath, wtName string) string { + cl, err := s.kcpClientForWorkspace(ctx, workspacePath) + s.Require().NoError(err) + var got string + s.Eventually(func() bool { + u := &unstructured.Unstructured{} + u.SetAPIVersion("tenancy.kcp.io/v1alpha1") + u.SetKind("WorkspaceType") + if err := cl.Get(ctx, client.ObjectKey{Name: wtName}, u); err != nil { + return false + } + vws, found, _ := unstructured.NestedSlice(u.Object, "status", "virtualWorkspaces") + if !found || len(vws) == 0 { + return false + } + first, ok := vws[0].(map[string]interface{}) + if !ok { + return false + } + raw, ok, _ := unstructured.NestedString(first, "url") + if !ok || strings.TrimSpace(raw) == "" { + return false + } + got = raw + return true + }, 6*time.Minute, 10*time.Second, "WorkspaceType %q in %s missing status.virtualWorkspaces URL", wtName, workspacePath) + return got +} + +func KubeconfigServerURLForFrontProxyAndVWPath(vwStatusURL string) (string, error) { + u, err := url.Parse(vwStatusURL) + if err != nil { + return "", err + } + if u.Path == "" || u.Path == "/" { + return "", fmt.Errorf("virtual workspace URL %q has no path", vwStatusURL) + } + out, err := url.JoinPath(e2ePresetFrontProxyBase, u.Path) + if err != nil { + return "", err + } + out = strings.TrimSuffix(out, "/") + if u.RawQuery != "" { + return out + "?" + u.RawQuery, nil + } + return out, nil +} diff --git a/test/e2e/kind/kind_scoped_kubeconfig_test.go b/test/e2e/kind/kind_scoped_kubeconfig_test.go index 3b83c700..3f302e21 100644 --- a/test/e2e/kind/kind_scoped_kubeconfig_test.go +++ b/test/e2e/kind/kind_scoped_kubeconfig_test.go @@ -42,7 +42,6 @@ const ( // Match test/e2e/kind/yaml/platform-mesh-resource/platform-mesh.yaml extraProviderConnections[].path; // ProvidersecretSubroutine creates scoped SA + ClusterRole + ClusterRoleBinding in each provider workspace. - e2eScopedKubeconfigProvider1Path = "root:providers:provider1" e2eScopedKubeconfigProvider2Path = "root:providers:provider2" e2eKcpProviderWorkspacesYAMLDir = "../../../test/e2e/kind/yaml/kcp-provider-workspaces" @@ -138,7 +137,8 @@ func providerConnectionEquivalent(a, b corev1alpha1.ProviderConnection) bool { ptr.Deref(a.APIExportName, "") == ptr.Deref(b.APIExportName, "") && ptr.Deref(a.RawPath, "") == ptr.Deref(b.RawPath, "") && ptr.Deref(a.Namespace, "") == ptr.Deref(b.Namespace, "") && - ptr.Deref(a.AdminAuth, false) == ptr.Deref(b.AdminAuth, false) + ptr.Deref(a.AdminAuth, false) == ptr.Deref(b.AdminAuth, false) && + ptr.Deref(a.ProviderRBACPreset, "") == ptr.Deref(b.ProviderRBACPreset, "") } func (s *KindTestSuite) waitScopedProviderConnectionSecretsReady(ctx context.Context) { @@ -541,36 +541,6 @@ func (s *KindTestSuite) runKubectlWithRawKubeconfig(kubeconfigBytes []byte, kube return runCommand("kubectl", args...) } -// normalizeScopedKubeconfigServerForLocalRun handles scoped e2e cases. -// This is test-only behavior for host-run kubectl in local/CI e2e, not generic production kubeconfig rewriting. -func normalizeScopedKubeconfigServerForLocalRun(kubeconfigBytes []byte) ([]byte, error) { - cfg, err := clientcmd.Load(kubeconfigBytes) - if err != nil { - return nil, err - } - - currentContext := cfg.Contexts[cfg.CurrentContext] - cluster := cfg.Clusters[currentContext.Cluster] - - server := cluster.Server - - // provider2: in-cluster front-proxy DNS is not resolvable from host-run kubectl. - server = strings.Replace(server, "frontproxy-front-proxy.platform-mesh-system:8443", "localhost:8443", 1) - - // provider1: virtual workspace URL from endpoint slice is flaky for create/get in host-run kubectl. - // For this fixed fixture, use the concrete provider1 workspace cluster URL. - if strings.Contains(server, "/services/apiexport/") { - server = "https://localhost:8443/clusters/" + e2eScopedKubeconfigProvider1Path - } - - cluster.Server = server - out, err := clientcmd.Write(*cfg) - if err != nil { - return nil, err - } - return out, nil -} - // logWorkspaceObservedAfterApply helps CI debug: confirms whether the Workspace object is visible right after SSA apply. func (s *KindTestSuite) logWorkspaceObservedAfterApply(ctx context.Context, cl client.Client, workspaceName string) { ws := &unstructured.Unstructured{} diff --git a/test/e2e/kind/suite_kind_test.go b/test/e2e/kind/suite_kind_test.go index 8729a853..0d65bbfa 100644 --- a/test/e2e/kind/suite_kind_test.go +++ b/test/e2e/kind/suite_kind_test.go @@ -36,11 +36,13 @@ import ( providersv1alpha1 "github.com/platform-mesh/platform-mesh-operator/api/providers/v1alpha1" "github.com/platform-mesh/platform-mesh-operator/api/v1alpha1" "github.com/platform-mesh/platform-mesh-operator/pkg/kapply" + "github.com/platform-mesh/platform-mesh-operator/pkg/rbacpresets" fluxcdv2 "github.com/fluxcd/helm-controller/api/v2" fluxcdv1 "github.com/fluxcd/source-controller/api/v1beta2" pmconfig "github.com/platform-mesh/golang-commons/config" "k8s.io/client-go/rest" + "k8s.io/utils/ptr" "github.com/platform-mesh/platform-mesh-operator/internal/config" "github.com/platform-mesh/platform-mesh-operator/internal/controller" @@ -58,7 +60,7 @@ type KindTestSuite struct { logger *logger.Logger containerRuntime string - + presetLoader *rbacpresets.Loader cancel context.CancelFunc } @@ -508,11 +510,44 @@ func (s *KindTestSuite) SetupSuite() { s.T().FailNow() } + if err := s.stripStaleE2EPresetProviderConnections(ctx); err != nil { + s.FailNow("Failed to strip stale e2e preset provider connections from PlatformMesh", err) + } + // Run the PlatformMesh operator s.logger.Info().Msg("starting PlatformMesh operator...") s.runPlatformMeshOperator(ctx) } +// stripStaleE2EPresetProviderConnections removes extraProviderConnections left on a reused Kind cluster from prior suite runs. +// Server-side apply of platform-mesh.yaml does not clear fields patched by tests (different field managers). +func (s *KindTestSuite) stripStaleE2EPresetProviderConnections(ctx context.Context) error { + pm := &v1alpha1.PlatformMesh{} + if err := s.client.Get(ctx, client.ObjectKey{ + Name: "platform-mesh", + Namespace: "platform-mesh-system", + }, pm); err != nil { + return err + } + orig := pm.Spec.Kcp.ExtraProviderConnections + filtered := make([]v1alpha1.ProviderConnection, 0, len(orig)) + var removed int + for _, pc := range orig { + preset := strings.TrimSpace(ptr.Deref(pc.ProviderRBACPreset, "")) + if preset != "" && strings.HasPrefix(preset, "e2e-") { + removed++ + continue + } + filtered = append(filtered, pc) + } + if removed == 0 { + return nil + } + pm.Spec.Kcp.ExtraProviderConnections = filtered + s.logger.Info().Int("removed", removed).Msg("stripped stale e2e preset extraProviderConnections from PlatformMesh") + return s.client.Update(ctx, pm) +} + func (s *KindTestSuite) waitForCRDEstablished(ctx context.Context, crdName string, timeout time.Duration) error { return wait.PollUntilContextTimeout(ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { crd := &apiextensionsv1.CustomResourceDefinition{} @@ -629,6 +664,14 @@ func (s *KindTestSuite) runPlatformMeshOperator(ctx context.Context) { } imageVersionStore := subroutines.NewImageVersionStore() + + e2eOverlay, err := buildE2EPresetOverlayFS() + if err != nil { + s.logger.Error().Err(err).Msg("Failed to load Kind e2e preset fixtures") + return + } + s.presetLoader = rbacpresets.NewLoader(rbacpresets.MergePresetFS(rbacpresets.EmbeddedProvidersFS(), e2eOverlay)) + pmReconciler, err := controller.NewPlatformMeshReconciler(mgr, &appConfig, commonConfig, "../../../", mgr.GetLocalManager().GetClient(), imageVersionStore) if err != nil { s.logger.Error().Err(err).Msg("Failed to create PlatformMesh reconciler") diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml new file mode 100644 index 00000000..526371a6 --- /dev/null +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml @@ -0,0 +1,45 @@ +# E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. +apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: e2e-path-raw-path +spec: + serverTarget: + type: pathRawPath + rawPath: /services/contentconfigurations + serviceAccountWorkspace: "{{ .ProviderPath }}" + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +rules: + - apiGroups: ["core.platform-mesh.io"] + resources: ["contentconfigurations"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +subjects: + - kind: ServiceAccount + name: "{{ .SAName }}" + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "{{ .SAName }}" diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml new file mode 100644 index 00000000..82f978eb --- /dev/null +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml @@ -0,0 +1,44 @@ +# E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. +apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: e2e-raw-path +spec: + serverTarget: + type: rawPath + rawPath: /services/marketplace + serviceAccountWorkspace: root:platform-mesh-system + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: root:platform-mesh-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: root:platform-mesh-system +rules: + - nonResourceURLs: ["/services", "/services/*"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: root:platform-mesh-system +subjects: + - kind: ServiceAccount + name: "{{ .SAName }}" + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "{{ .SAName }}" diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml new file mode 100644 index 00000000..63c24821 --- /dev/null +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml @@ -0,0 +1,44 @@ +# E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. +apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: e2e-workspace-cluster +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: "{{ .ProviderPath }}" + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +rules: + - apiGroups: ["initialization.kcp.io"] + resources: ["inittargets"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +subjects: + - kind: ServiceAccount + name: "{{ .SAName }}" + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "{{ .SAName }}" diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw-workspacetype.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw-workspacetype.yaml new file mode 100644 index 00000000..debf6b09 --- /dev/null +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw-workspacetype.yaml @@ -0,0 +1,7 @@ +# E2E-only WorkspaceType fixture for workspaceTypeVirtualWorkspace preset tests. +apiVersion: tenancy.kcp.io/v1alpha1 +kind: WorkspaceType +metadata: + name: e2e-preset-target +spec: + initializer: true diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml new file mode 100644 index 00000000..89be9f09 --- /dev/null +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml @@ -0,0 +1,46 @@ +# E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. +apiVersion: rbacpresets.platform-mesh.io/v1alpha1 +kind: ProviderRBACPreset +metadata: + name: e2e-wtvw +spec: + serverTarget: + type: workspaceTypeVirtualWorkspace + workspaceTypeName: e2e-preset-target + workspaceTypePath: root + serviceAccountWorkspace: root + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: root +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: root +rules: + - apiGroups: ["tenancy.kcp.io"] + resources: ["workspaces"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}" + annotations: + rbacpresets.platform-mesh.io/workspace: root +subjects: + - kind: ServiceAccount + name: "{{ .SAName }}" + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "{{ .SAName }}" From 8e1c03e402a26bb0bcb05dc45dbd6508b75ec417 Mon Sep 17 00:00:00 2001 From: Till <253026766+philtk79@users.noreply.github.com> Date: Mon, 25 May 2026 11:29:55 +0200 Subject: [PATCH 2/2] Clean up and added annotations Signed-off-by: Till <253026766+philtk79@users.noreply.github.com> --- pkg/rbacpresets/loader.go | 176 -------------- pkg/rbacpresets/loader_test.go | 137 ----------- pkg/rbacpresets/preset.go | 52 ++++ pkg/rbacpresets/providers/sample.yaml | 1 - pkg/rbacpresets/render.go | 228 ++++++++++++++++++ pkg/rbacpresets/render_test.go | 139 +++++++++++ pkg/rbacpresets/types.go | 61 ----- pkg/subroutines/scoped_provider_preset.go | 20 +- .../scoped_provider_preset_test.go | 16 +- .../e2e-path-raw-path.yaml | 1 - .../kcp-preset-fixtures/e2e-raw-path.yaml | 1 - .../e2e-workspace-cluster.yaml | 1 - .../yaml/kcp-preset-fixtures/e2e-wtvw.yaml | 1 - 13 files changed, 447 insertions(+), 387 deletions(-) create mode 100644 pkg/rbacpresets/preset.go create mode 100644 pkg/rbacpresets/render.go create mode 100644 pkg/rbacpresets/render_test.go delete mode 100644 pkg/rbacpresets/types.go diff --git a/pkg/rbacpresets/loader.go b/pkg/rbacpresets/loader.go index e74a95e3..5a98992a 100644 --- a/pkg/rbacpresets/loader.go +++ b/pkg/rbacpresets/loader.go @@ -1,18 +1,11 @@ package rbacpresets import ( - "bytes" "embed" "fmt" "io/fs" "path/filepath" - "sort" "strings" - "text/template" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/yaml" ) //go:embed providers/*.yaml @@ -53,13 +46,6 @@ func (m mergedPresetFS) Open(name string) (fs.File, error) { return m.base.Open(name) } -var allowedManifestKinds = map[string]struct{}{ - "ServiceAccount": {}, - "ClusterRole": {}, - "ClusterRoleBinding": {}, - "RoleBinding": {}, -} - // LoadPreset reads providers/.yaml from l.FS and renders the preset. func (l *Loader) LoadPreset(name string, data PresetTemplateData) (*RenderedPreset, error) { presetName := strings.TrimSpace(name) @@ -75,165 +61,3 @@ func (l *Loader) LoadPreset(name string, data PresetTemplateData) (*RenderedPres } return RenderPreset(presetName, raw, data) } - -func RenderPreset(name string, raw []byte, data PresetTemplateData) (*RenderedPreset, error) { - if data.Suffix == "" { - data.Suffix = name - } - header, err := renderPresetHeader(name, raw, data) - if err != nil { - return nil, err - } - if data.ProviderPath == "" { - data.ProviderPath = header.Spec.ServiceAccountWorkspace - } - if data.SAName == "" { - data.SAName = header.Spec.ServiceAccountName - } - if data.SAName == "" { - data.SAName = "platform-mesh-provider-" + data.Suffix - } - - renderedBytes, err := executeTemplate(name, raw, data) - if err != nil { - return nil, err - } - - docs, err := parseRenderedDocs(renderedBytes) - if err != nil { - return nil, err - } - var preset *ProviderRBACPreset - grouped := map[string][]unstructured.Unstructured{} - for i := range docs { - obj := docs[i] - if obj.GetKind() == "" { - continue - } - if obj.GetAPIVersion() == GroupVersion && obj.GetKind() == KindProviderRBACPreset { - if preset != nil { - return nil, fmt.Errorf("preset %q contains multiple %s documents", name, KindProviderRBACPreset) - } - var current ProviderRBACPreset - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, ¤t); err != nil { - return nil, fmt.Errorf("decode %s header in preset %q: %w", KindProviderRBACPreset, name, err) - } - preset = ¤t - continue - } - if _, ok := allowedManifestKinds[obj.GetKind()]; !ok { - return nil, fmt.Errorf("preset %q contains unsupported manifest kind %q", name, obj.GetKind()) - } - workspace := strings.TrimSpace(obj.GetAnnotations()[AnnotationWorkspace]) - if workspace == "" && preset != nil { - workspace = strings.TrimSpace(preset.Spec.ServiceAccountWorkspace) - } - if workspace == "" { - workspace = strings.TrimSpace(header.Spec.ServiceAccountWorkspace) - } - if workspace == "" { - return nil, fmt.Errorf("preset %q manifest %s/%s has no %s annotation and no serviceAccountWorkspace default", name, obj.GetKind(), obj.GetName(), AnnotationWorkspace) - } - stripWorkspaceAnnotation(&obj) - grouped[workspace] = append(grouped[workspace], obj) - } - if preset == nil { - return nil, fmt.Errorf("preset %q missing %s header document", name, KindProviderRBACPreset) - } - if preset.Spec.ServiceAccountWorkspace == "" { - preset.Spec.ServiceAccountWorkspace = header.Spec.ServiceAccountWorkspace - } - if preset.Spec.ServiceAccountWorkspace == "" { - preset.Spec.ServiceAccountWorkspace = data.ProviderPath - } - if preset.Spec.ServiceAccountName == "" { - preset.Spec.ServiceAccountName = data.SAName - } - - workspaces := make([]string, 0, len(grouped)) - for workspace := range grouped { - workspaces = append(workspaces, workspace) - } - sort.Strings(workspaces) - byWorkspace := make([]WorkspaceManifests, 0, len(workspaces)) - for _, workspace := range workspaces { - byWorkspace = append(byWorkspace, WorkspaceManifests{ - Workspace: workspace, - Manifests: grouped[workspace], - }) - } - return &RenderedPreset{ - Spec: preset.Spec, - ByWorkspace: byWorkspace, - }, nil -} - -func renderPresetHeader(name string, raw []byte, data PresetTemplateData) (*ProviderRBACPreset, error) { - renderedBytes, err := executeTemplate(name+"-header", raw, data) - if err != nil { - return nil, err - } - docs, err := parseRenderedDocs(renderedBytes) - if err != nil { - return nil, err - } - for i := range docs { - obj := docs[i] - if obj.GetAPIVersion() == GroupVersion && obj.GetKind() == KindProviderRBACPreset { - var preset ProviderRBACPreset - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &preset); err != nil { - return nil, fmt.Errorf("decode %s header in preset %q: %w", KindProviderRBACPreset, name, err) - } - if preset.Spec.ServiceAccountWorkspace == "" { - preset.Spec.ServiceAccountWorkspace = data.ProviderPath - } - return &preset, nil - } - } - return nil, fmt.Errorf("preset %q missing %s header document", name, KindProviderRBACPreset) -} - -func executeTemplate(name string, raw []byte, data PresetTemplateData) ([]byte, error) { - tmpl, err := template.New(name).Option("missingkey=error").Parse(string(raw)) - if err != nil { - return nil, fmt.Errorf("parse preset template %q: %w", name, err) - } - var rendered bytes.Buffer - if err := tmpl.Execute(&rendered, data); err != nil { - return nil, fmt.Errorf("execute preset template %q: %w", name, err) - } - return rendered.Bytes(), nil -} - -func parseRenderedDocs(rendered []byte) ([]unstructured.Unstructured, error) { - rawDocs := strings.Split(string(rendered), "\n---") - docs := make([]unstructured.Unstructured, 0, len(rawDocs)) - for _, rawDoc := range rawDocs { - rawDoc = strings.TrimSpace(rawDoc) - if rawDoc == "" { - continue - } - var objMap map[string]interface{} - if err := yaml.Unmarshal([]byte(rawDoc), &objMap); err != nil { - return nil, fmt.Errorf("unmarshal preset manifest: %w", err) - } - if len(objMap) == 0 { - continue - } - docs = append(docs, unstructured.Unstructured{Object: objMap}) - } - return docs, nil -} - -func stripWorkspaceAnnotation(obj *unstructured.Unstructured) { - annotations := obj.GetAnnotations() - if len(annotations) == 0 { - return - } - delete(annotations, AnnotationWorkspace) - if len(annotations) == 0 { - obj.SetAnnotations(nil) - return - } - obj.SetAnnotations(annotations) -} diff --git a/pkg/rbacpresets/loader_test.go b/pkg/rbacpresets/loader_test.go index e3515a78..e3d44a57 100644 --- a/pkg/rbacpresets/loader_test.go +++ b/pkg/rbacpresets/loader_test.go @@ -6,60 +6,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestRenderPresetGroupsTemplatedManifestsByWorkspace(t *testing.T) { - t.Parallel() - - raw := []byte(`apiVersion: rbacpresets.platform-mesh.io/v1alpha1 -kind: ProviderRBACPreset -metadata: - name: test -spec: - serverTarget: - type: workspaceCluster - serviceAccountWorkspace: "{{ .ProviderPath }}" - serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: "{{ .SAName }}" - namespace: default - annotations: - rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: "{{ .SAName }}-extra" - annotations: - rbacpresets.platform-mesh.io/workspace: root -subjects: [] -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: anything -`) - - preset, err := RenderPreset("test", raw, PresetTemplateData{ - ProviderPath: "root:platform-mesh-system", - Suffix: "init-agent-kubeconfig", - }) - require.NoError(t, err) - require.Equal(t, ServerTargetWorkspaceCluster, preset.Spec.ServerTarget.Type) - require.Equal(t, "root:platform-mesh-system", preset.Spec.ServiceAccountWorkspace) - require.Equal(t, "platform-mesh-provider-init-agent-kubeconfig", preset.Spec.ServiceAccountName) - require.Len(t, preset.ByWorkspace, 2) - - require.Equal(t, "root", preset.ByWorkspace[0].Workspace) - require.Equal(t, "ClusterRoleBinding", preset.ByWorkspace[0].Manifests[0].GetKind()) - require.NotContains(t, preset.ByWorkspace[0].Manifests[0].GetAnnotations(), AnnotationWorkspace) - - require.Equal(t, "root:platform-mesh-system", preset.ByWorkspace[1].Workspace) - require.Equal(t, "ServiceAccount", preset.ByWorkspace[1].Manifests[0].GetKind()) - require.Equal(t, "platform-mesh-provider-init-agent-kubeconfig", preset.ByWorkspace[1].Manifests[0].GetName()) - require.NotContains(t, preset.ByWorkspace[1].Manifests[0].GetAnnotations(), AnnotationWorkspace) -} - func TestLoadPresetLoadsEmbeddedPreset(t *testing.T) { t.Parallel() @@ -74,89 +20,6 @@ func TestLoadPresetLoadsEmbeddedPreset(t *testing.T) { require.Len(t, preset.ByWorkspace[0].Manifests, 3) } -func TestRenderPresetErrors(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - raw string - wantErr string - }{ - { - name: "missing header", - raw: `apiVersion: v1 -kind: ServiceAccount -metadata: - name: test -`, - wantErr: "missing ProviderRBACPreset header document", - }, - { - name: "multiple headers", - raw: `apiVersion: rbacpresets.platform-mesh.io/v1alpha1 -kind: ProviderRBACPreset -metadata: - name: one -spec: - serverTarget: - type: workspaceCluster ---- -apiVersion: rbacpresets.platform-mesh.io/v1alpha1 -kind: ProviderRBACPreset -metadata: - name: two -spec: - serverTarget: - type: workspaceCluster -`, - wantErr: "multiple ProviderRBACPreset documents", - }, - { - name: "unsupported kind", - raw: `apiVersion: rbacpresets.platform-mesh.io/v1alpha1 -kind: ProviderRBACPreset -metadata: - name: test -spec: - serverTarget: - type: workspaceCluster - serviceAccountWorkspace: root ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: test -`, - wantErr: "unsupported manifest kind", - }, - { - name: "missing workspace", - raw: `apiVersion: rbacpresets.platform-mesh.io/v1alpha1 -kind: ProviderRBACPreset -metadata: - name: test -spec: - serverTarget: - type: workspaceCluster ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: test - namespace: default -`, - wantErr: "has no rbacpresets.platform-mesh.io/workspace annotation", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := RenderPreset("test", []byte(tt.raw), PresetTemplateData{}) - require.ErrorContains(t, err, tt.wantErr) - }) - } -} - func TestLoadPresetRejectsUnsafeNames(t *testing.T) { t.Parallel() diff --git a/pkg/rbacpresets/preset.go b/pkg/rbacpresets/preset.go new file mode 100644 index 00000000..35aad61b --- /dev/null +++ b/pkg/rbacpresets/preset.go @@ -0,0 +1,52 @@ +// Package rbacpresets defines the in-tree provider RBAC preset format. Preset +// files use a ProviderRBACPreset header document (kind only) followed by RBAC +// manifests; this is not a Kubernetes CRD and is not registered with the +// controller-runtime scheme. ProviderConnection.providerRBACPreset (in +// api/v1alpha1) references a preset by name only. +package rbacpresets + +const ( + KindProviderRBACPreset = "ProviderRBACPreset" + AnnotationWorkspace = "rbacpresets.platform-mesh.io/workspace" + LabelPreset = "rbacpresets.platform-mesh.io/preset" + LabelProviderSecret = "rbacpresets.platform-mesh.io/provider-secret" + LabelManagedBy = "app.kubernetes.io/managed-by" + ManagedByPlatformMesh = "platform-mesh-operator" +) + +type ServerTargetType string + +const ( + ServerTargetWorkspaceCluster ServerTargetType = "workspaceCluster" + ServerTargetRawPath ServerTargetType = "rawPath" + ServerTargetWorkspaceTypeVirtualWorkspace ServerTargetType = "workspaceTypeVirtualWorkspace" + ServerTargetPathRawPath ServerTargetType = "pathRawPath" +) + +type ProviderRBACPresetSpec struct { + ServerTarget ServerTarget `yaml:"serverTarget" json:"serverTarget"` + // Defaulted to the provider connection path when empty. + ServiceAccountWorkspace string `yaml:"serviceAccountWorkspace,omitempty" json:"serviceAccountWorkspace,omitempty"` + // Defaulted to "platform-mesh-provider-" when empty. + ServiceAccountName string `yaml:"serviceAccountName,omitempty" json:"serviceAccountName,omitempty"` +} + +type ServerTarget struct { + Type ServerTargetType `yaml:"type" json:"type"` + // For rawPath: optional preset-declared rawPath that overrides pc.RawPath when set. + RawPath string `yaml:"rawPath,omitempty" json:"rawPath,omitempty"` + // For workspaceTypeVirtualWorkspace. + WorkspaceTypeName string `yaml:"workspaceTypeName,omitempty" json:"workspaceTypeName,omitempty"` + WorkspaceTypePath string `yaml:"workspaceTypePath,omitempty" json:"workspaceTypePath,omitempty"` +} + +type presetDocument struct { + Spec ProviderRBACPresetSpec `yaml:"spec"` +} + +var allowedManifestKinds = map[string]struct{}{ + "ServiceAccount": {}, + "ClusterRole": {}, + "ClusterRoleBinding": {}, + "RoleBinding": {}, +} diff --git a/pkg/rbacpresets/providers/sample.yaml b/pkg/rbacpresets/providers/sample.yaml index d6dc1d5a..08706371 100644 --- a/pkg/rbacpresets/providers/sample.yaml +++ b/pkg/rbacpresets/providers/sample.yaml @@ -1,6 +1,5 @@ # Minimal reference preset: workspace-cluster server URL, SA + ClusterRole + ClusterRoleBinding. # Copy and extend for operator-specific presets -apiVersion: rbacpresets.platform-mesh.io/v1alpha1 kind: ProviderRBACPreset metadata: name: sample diff --git a/pkg/rbacpresets/render.go b/pkg/rbacpresets/render.go new file mode 100644 index 00000000..c9606240 --- /dev/null +++ b/pkg/rbacpresets/render.go @@ -0,0 +1,228 @@ +package rbacpresets + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/template" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +type PresetTemplateData struct { + ProviderPath string + RawPath string + SAName string + Suffix string +} + +type RenderedPreset struct { + Spec ProviderRBACPresetSpec + ByWorkspace []WorkspaceManifests +} + +type WorkspaceManifests struct { + Workspace string + Manifests []unstructured.Unstructured +} + +func RenderPreset(name string, raw []byte, data PresetTemplateData) (*RenderedPreset, error) { + if data.Suffix == "" { + data.Suffix = name + } + + renderedBytes, err := executeTemplate(name, raw, data) + if err != nil { + return nil, err + } + + headerSpec, err := extractHeaderSpec(name, renderedBytes, data.ProviderPath) + if err != nil { + return nil, err + } + + resolved := resolveTemplateData(data, headerSpec) + if templateDataChanged(data, resolved) { + renderedBytes, err = executeTemplate(name, raw, resolved) + if err != nil { + return nil, err + } + } + data = resolved + + return groupRenderedPreset(name, renderedBytes, data, headerSpec) +} + +func resolveTemplateData(data PresetTemplateData, headerSpec ProviderRBACPresetSpec) PresetTemplateData { + resolved := data + if resolved.ProviderPath == "" { + resolved.ProviderPath = headerSpec.ServiceAccountWorkspace + } + if resolved.SAName == "" { + resolved.SAName = headerSpec.ServiceAccountName + } + if resolved.SAName == "" { + resolved.SAName = "platform-mesh-provider-" + resolved.Suffix + } + return resolved +} + +func templateDataChanged(before, after PresetTemplateData) bool { + return before.ProviderPath != after.ProviderPath || before.SAName != after.SAName +} + +func extractHeaderSpec(name string, rendered []byte, providerPath string) (ProviderRBACPresetSpec, error) { + for _, rawDoc := range splitRenderedDocs(rendered) { + if !isPresetHeaderDoc(rawDoc) { + continue + } + spec, err := decodePresetSpec(rawDoc) + if err != nil { + return ProviderRBACPresetSpec{}, fmt.Errorf("decode %s header in preset %q: %w", KindProviderRBACPreset, name, err) + } + if spec.ServiceAccountWorkspace == "" { + spec.ServiceAccountWorkspace = providerPath + } + return spec, nil + } + return ProviderRBACPresetSpec{}, fmt.Errorf("preset %q missing %s header document", name, KindProviderRBACPreset) +} + +func groupRenderedPreset(name string, renderedBytes []byte, data PresetTemplateData, headerSpec ProviderRBACPresetSpec) (*RenderedPreset, error) { + rawDocs := splitRenderedDocs(renderedBytes) + var presetSpec *ProviderRBACPresetSpec + grouped := map[string][]unstructured.Unstructured{} + for _, rawDoc := range rawDocs { + if isPresetHeaderDoc(rawDoc) { + if presetSpec != nil { + return nil, fmt.Errorf("preset %q contains multiple %s documents", name, KindProviderRBACPreset) + } + spec, err := decodePresetSpec(rawDoc) + if err != nil { + return nil, fmt.Errorf("decode %s header in preset %q: %w", KindProviderRBACPreset, name, err) + } + presetSpec = &spec + continue + } + obj, err := parseManifestDoc(rawDoc) + if err != nil { + return nil, err + } + if obj.GetKind() == "" { + continue + } + if _, ok := allowedManifestKinds[obj.GetKind()]; !ok { + return nil, fmt.Errorf("preset %q contains unsupported manifest kind %q", name, obj.GetKind()) + } + workspace := strings.TrimSpace(obj.GetAnnotations()[AnnotationWorkspace]) + if workspace == "" && presetSpec != nil { + workspace = strings.TrimSpace(presetSpec.ServiceAccountWorkspace) + } + if workspace == "" { + workspace = strings.TrimSpace(headerSpec.ServiceAccountWorkspace) + } + if workspace == "" { + return nil, fmt.Errorf("preset %q manifest %s/%s has no %s annotation and no serviceAccountWorkspace default", name, obj.GetKind(), obj.GetName(), AnnotationWorkspace) + } + stripWorkspaceAnnotation(&obj) + grouped[workspace] = append(grouped[workspace], obj) + } + if presetSpec == nil { + return nil, fmt.Errorf("preset %q missing %s header document", name, KindProviderRBACPreset) + } + if presetSpec.ServiceAccountWorkspace == "" { + presetSpec.ServiceAccountWorkspace = headerSpec.ServiceAccountWorkspace + } + if presetSpec.ServiceAccountWorkspace == "" { + presetSpec.ServiceAccountWorkspace = data.ProviderPath + } + if presetSpec.ServiceAccountName == "" { + presetSpec.ServiceAccountName = data.SAName + } + + workspaces := make([]string, 0, len(grouped)) + for workspace := range grouped { + workspaces = append(workspaces, workspace) + } + sort.Strings(workspaces) + byWorkspace := make([]WorkspaceManifests, 0, len(workspaces)) + for _, workspace := range workspaces { + byWorkspace = append(byWorkspace, WorkspaceManifests{ + Workspace: workspace, + Manifests: grouped[workspace], + }) + } + return &RenderedPreset{ + Spec: *presetSpec, + ByWorkspace: byWorkspace, + }, nil +} + +func decodePresetSpec(rawDoc []byte) (ProviderRBACPresetSpec, error) { + var doc presetDocument + if err := yaml.Unmarshal(rawDoc, &doc); err != nil { + return ProviderRBACPresetSpec{}, err + } + return doc.Spec, nil +} + +func isPresetHeaderDoc(rawDoc []byte) bool { + var header struct { + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(rawDoc, &header); err != nil { + return false + } + return header.Kind == KindProviderRBACPreset +} + +func executeTemplate(name string, raw []byte, data PresetTemplateData) ([]byte, error) { + tmpl, err := template.New(name).Option("missingkey=error").Parse(string(raw)) + if err != nil { + return nil, fmt.Errorf("parse preset template %q: %w", name, err) + } + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, data); err != nil { + return nil, fmt.Errorf("execute preset template %q: %w", name, err) + } + return rendered.Bytes(), nil +} + +func splitRenderedDocs(rendered []byte) [][]byte { + rawDocs := strings.Split(string(rendered), "\n---") + docs := make([][]byte, 0, len(rawDocs)) + for _, rawDoc := range rawDocs { + rawDoc = strings.TrimSpace(rawDoc) + if rawDoc == "" { + continue + } + docs = append(docs, []byte(rawDoc)) + } + return docs +} + +func parseManifestDoc(rawDoc []byte) (unstructured.Unstructured, error) { + var objMap map[string]interface{} + if err := yaml.Unmarshal(rawDoc, &objMap); err != nil { + return unstructured.Unstructured{}, fmt.Errorf("unmarshal preset manifest: %w", err) + } + if len(objMap) == 0 { + return unstructured.Unstructured{}, nil + } + return unstructured.Unstructured{Object: objMap}, nil +} + +func stripWorkspaceAnnotation(obj *unstructured.Unstructured) { + annotations := obj.GetAnnotations() + if len(annotations) == 0 { + return + } + delete(annotations, AnnotationWorkspace) + if len(annotations) == 0 { + obj.SetAnnotations(nil) + return + } + obj.SetAnnotations(annotations) +} diff --git a/pkg/rbacpresets/render_test.go b/pkg/rbacpresets/render_test.go new file mode 100644 index 00000000..ce1f817b --- /dev/null +++ b/pkg/rbacpresets/render_test.go @@ -0,0 +1,139 @@ +package rbacpresets + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderPresetGroupsTemplatedManifestsByWorkspace(t *testing.T) { + t.Parallel() + + raw := []byte(`kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: "{{ .ProviderPath }}" + serviceAccountName: "platform-mesh-provider-{{ .Suffix }}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "{{ .SAName }}" + namespace: default + annotations: + rbacpresets.platform-mesh.io/workspace: "{{ .ProviderPath }}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "{{ .SAName }}-extra" + annotations: + rbacpresets.platform-mesh.io/workspace: root +subjects: [] +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: anything +`) + + preset, err := RenderPreset("test", raw, PresetTemplateData{ + ProviderPath: "root:platform-mesh-system", + Suffix: "init-agent-kubeconfig", + }) + require.NoError(t, err) + require.Equal(t, ServerTargetWorkspaceCluster, preset.Spec.ServerTarget.Type) + require.Equal(t, "root:platform-mesh-system", preset.Spec.ServiceAccountWorkspace) + require.Equal(t, "platform-mesh-provider-init-agent-kubeconfig", preset.Spec.ServiceAccountName) + require.Len(t, preset.ByWorkspace, 2) + + require.Equal(t, "root", preset.ByWorkspace[0].Workspace) + require.Equal(t, "ClusterRoleBinding", preset.ByWorkspace[0].Manifests[0].GetKind()) + require.NotContains(t, preset.ByWorkspace[0].Manifests[0].GetAnnotations(), AnnotationWorkspace) + + require.Equal(t, "root:platform-mesh-system", preset.ByWorkspace[1].Workspace) + require.Equal(t, "ServiceAccount", preset.ByWorkspace[1].Manifests[0].GetKind()) + require.Equal(t, "platform-mesh-provider-init-agent-kubeconfig", preset.ByWorkspace[1].Manifests[0].GetName()) + require.NotContains(t, preset.ByWorkspace[1].Manifests[0].GetAnnotations(), AnnotationWorkspace) +} + +func TestRenderPresetErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantErr string + }{ + { + name: "missing header", + raw: `apiVersion: v1 +kind: ServiceAccount +metadata: + name: test +`, + wantErr: "missing ProviderRBACPreset header document", + }, + { + name: "multiple headers", + raw: `kind: ProviderRBACPreset +metadata: + name: one +spec: + serverTarget: + type: workspaceCluster +--- +kind: ProviderRBACPreset +metadata: + name: two +spec: + serverTarget: + type: workspaceCluster +`, + wantErr: "multiple ProviderRBACPreset documents", + }, + { + name: "unsupported kind", + raw: `kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster + serviceAccountWorkspace: root +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + wantErr: "unsupported manifest kind", + }, + { + name: "missing workspace", + raw: `kind: ProviderRBACPreset +metadata: + name: test +spec: + serverTarget: + type: workspaceCluster +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test + namespace: default +`, + wantErr: "has no rbacpresets.platform-mesh.io/workspace annotation", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := RenderPreset("test", []byte(tt.raw), PresetTemplateData{}) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/pkg/rbacpresets/types.go b/pkg/rbacpresets/types.go deleted file mode 100644 index 72eb0af7..00000000 --- a/pkg/rbacpresets/types.go +++ /dev/null @@ -1,61 +0,0 @@ -package rbacpresets - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -const ( - GroupVersion = "rbacpresets.platform-mesh.io/v1alpha1" - KindProviderRBACPreset = "ProviderRBACPreset" - AnnotationWorkspace = "rbacpresets.platform-mesh.io/workspace" -) - -type ServerTargetType string - -const ( - ServerTargetWorkspaceCluster ServerTargetType = "workspaceCluster" - ServerTargetRawPath ServerTargetType = "rawPath" - ServerTargetWorkspaceTypeVirtualWorkspace ServerTargetType = "workspaceTypeVirtualWorkspace" - ServerTargetPathRawPath ServerTargetType = "pathRawPath" -) - -type ProviderRBACPreset struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - Spec ProviderRBACPresetSpec `json:"spec"` -} - -type ProviderRBACPresetSpec struct { - ServerTarget ServerTarget `json:"serverTarget"` - // Defaulted to the provider connection path when empty. - ServiceAccountWorkspace string `json:"serviceAccountWorkspace,omitempty"` - // Defaulted to "platform-mesh-provider-" when empty. - ServiceAccountName string `json:"serviceAccountName,omitempty"` -} - -type ServerTarget struct { - Type ServerTargetType `json:"type"` - // For rawPath: optional preset-declared rawPath that overrides pc.RawPath when set. - RawPath string `json:"rawPath,omitempty"` - // For workspaceTypeVirtualWorkspace. - WorkspaceTypeName string `json:"workspaceTypeName,omitempty"` - WorkspaceTypePath string `json:"workspaceTypePath,omitempty"` -} - -type PresetTemplateData struct { - ProviderPath string - RawPath string - SAName string - Suffix string -} - -type RenderedPreset struct { - Spec ProviderRBACPresetSpec - ByWorkspace []WorkspaceManifests -} - -type WorkspaceManifests struct { - Workspace string - Manifests []unstructured.Unstructured -} diff --git a/pkg/subroutines/scoped_provider_preset.go b/pkg/subroutines/scoped_provider_preset.go index 079b4a15..7915bfeb 100644 --- a/pkg/subroutines/scoped_provider_preset.go +++ b/pkg/subroutines/scoped_provider_preset.go @@ -56,7 +56,7 @@ func writeProviderPresetKubeconfigToSecret( if err != nil { return err } - if err := applyPresetManifests(ctx, kcpHelper, cfg, rendered.ByWorkspace); err != nil { + if err := applyPresetManifests(ctx, kcpHelper, cfg, rendered.ByWorkspace, presetName, pc.Secret); err != nil { return err } @@ -179,7 +179,7 @@ func scopedProviderHostPort(operatorCfg config.OperatorConfig, instance *corev1a return fmt.Sprintf("https://%s-front-proxy.%s:%s", operatorCfg.KCP.FrontProxyName, operatorCfg.KCP.Namespace, operatorCfg.KCP.FrontProxyPort), nil } -func applyPresetManifests(ctx context.Context, kcpHelper KcpHelper, cfg *rest.Config, manifestsByWorkspace []rbacpresets.WorkspaceManifests) error { +func applyPresetManifests(ctx context.Context, kcpHelper KcpHelper, cfg *rest.Config, manifestsByWorkspace []rbacpresets.WorkspaceManifests, presetName, providerSecret string) error { for _, workspaceManifests := range manifestsByWorkspace { workspace := strings.TrimSpace(workspaceManifests.Workspace) if workspace == "" { @@ -193,7 +193,7 @@ func applyPresetManifests(ctx context.Context, kcpHelper KcpHelper, cfg *rest.Co return errors.Wrap(err, "ensure namespace %s for preset workspace %s", defaultScopedSANamespace, workspace) } for i := range workspaceManifests.Manifests { - if err := createOrUpdatePresetManifest(ctx, kcpClient, &workspaceManifests.Manifests[i]); err != nil { + if err := createOrUpdatePresetManifest(ctx, kcpClient, &workspaceManifests.Manifests[i], presetName, providerSecret); err != nil { return errors.Wrap(err, "apply preset manifest in workspace %s", workspace) } } @@ -201,7 +201,7 @@ func applyPresetManifests(ctx context.Context, kcpHelper KcpHelper, cfg *rest.Co return nil } -func createOrUpdatePresetManifest(ctx context.Context, kcpClient client.Client, manifest *unstructured.Unstructured) error { +func createOrUpdatePresetManifest(ctx context.Context, kcpClient client.Client, manifest *unstructured.Unstructured, presetName, providerSecret string) error { if manifest == nil || manifest.Object == nil { return nil } @@ -209,6 +209,7 @@ func createOrUpdatePresetManifest(ctx context.Context, kcpClient client.Client, if desired.GetName() == "" { return fmt.Errorf("manifest %s has empty name", desired.GetKind()) } + labelPresetManifest(desired, presetName, providerSecret) defaultPresetManifestNamespace(desired) current := &unstructured.Unstructured{} @@ -224,6 +225,17 @@ func createOrUpdatePresetManifest(ctx context.Context, kcpClient client.Client, return kcpClient.Update(ctx, desired) } +func labelPresetManifest(obj *unstructured.Unstructured, presetName, providerSecret string) { + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[rbacpresets.LabelManagedBy] = rbacpresets.ManagedByPlatformMesh + labels[rbacpresets.LabelPreset] = presetName + labels[rbacpresets.LabelProviderSecret] = providerSecret + obj.SetLabels(labels) +} + func defaultPresetManifestNamespace(obj *unstructured.Unstructured) { switch obj.GetKind() { case "ServiceAccount", "RoleBinding": diff --git a/pkg/subroutines/scoped_provider_preset_test.go b/pkg/subroutines/scoped_provider_preset_test.go index b5670aa5..dc464dd1 100644 --- a/pkg/subroutines/scoped_provider_preset_test.go +++ b/pkg/subroutines/scoped_provider_preset_test.go @@ -163,8 +163,7 @@ func TestApplyPresetManifestsCreatesObjectsInAnnotatedWorkspaces(t *testing.T) { scheme := presetTestScheme(t) pmSystemClient := fake.NewClientBuilder().WithScheme(scheme).Build() rootClient := fake.NewClientBuilder().WithScheme(scheme).Build() - rendered, err := rbacpresets.RenderPreset("test", []byte(`apiVersion: rbacpresets.platform-mesh.io/v1alpha1 -kind: ProviderRBACPreset + rendered, err := rbacpresets.RenderPreset("test", []byte(`kind: ProviderRBACPreset metadata: name: test spec: @@ -202,13 +201,19 @@ roleRef: "root:platform-mesh-system": pmSystemClient, "root": rootClient, }} - err = applyPresetManifests(context.Background(), helper, &rest.Config{}, rendered.ByWorkspace) + err = applyPresetManifests(context.Background(), helper, &rest.Config{}, rendered.ByWorkspace, "test", "test-kubeconfig") require.NoError(t, err) var sa corev1.ServiceAccount require.NoError(t, pmSystemClient.Get(context.Background(), client.ObjectKey{Name: "platform-mesh-provider-test", Namespace: "default"}, &sa)) + require.Equal(t, rbacpresets.ManagedByPlatformMesh, sa.Labels[rbacpresets.LabelManagedBy]) + require.Equal(t, "test", sa.Labels[rbacpresets.LabelPreset]) + require.Equal(t, "test-kubeconfig", sa.Labels[rbacpresets.LabelProviderSecret]) var crb rbacv1.ClusterRoleBinding require.NoError(t, rootClient.Get(context.Background(), client.ObjectKey{Name: "platform-mesh-provider-test-extra"}, &crb)) + require.Equal(t, rbacpresets.ManagedByPlatformMesh, crb.Labels[rbacpresets.LabelManagedBy]) + require.Equal(t, "test", crb.Labels[rbacpresets.LabelPreset]) + require.Equal(t, "test-kubeconfig", crb.Labels[rbacpresets.LabelProviderSecret]) require.Empty(t, crb.GetAnnotations()) } @@ -312,12 +317,15 @@ func TestCreateOrUpdatePresetManifestUpdatesExistingObjects(t *testing.T) { }, }} - err := createOrUpdatePresetManifest(context.Background(), kcpClient, obj) + err := createOrUpdatePresetManifest(context.Background(), kcpClient, obj, "test", "test-kubeconfig") require.NoError(t, err) var role rbacv1.ClusterRole require.NoError(t, kcpClient.Get(context.Background(), client.ObjectKey{Name: "preset-role"}, &role)) require.Equal(t, []string{"list"}, role.Rules[0].Verbs) + require.Equal(t, rbacpresets.ManagedByPlatformMesh, role.Labels[rbacpresets.LabelManagedBy]) + require.Equal(t, "test", role.Labels[rbacpresets.LabelPreset]) + require.Equal(t, "test-kubeconfig", role.Labels[rbacpresets.LabelProviderSecret]) } func TestCreateOrUpdatePresetManifestIgnoresAlreadyExistingNamespace(t *testing.T) { diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml index 526371a6..148562eb 100644 --- a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-path-raw-path.yaml @@ -1,5 +1,4 @@ # E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. -apiVersion: rbacpresets.platform-mesh.io/v1alpha1 kind: ProviderRBACPreset metadata: name: e2e-path-raw-path diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml index 82f978eb..f6ebd40f 100644 --- a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-raw-path.yaml @@ -1,5 +1,4 @@ # E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. -apiVersion: rbacpresets.platform-mesh.io/v1alpha1 kind: ProviderRBACPreset metadata: name: e2e-raw-path diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml index 63c24821..1a88fded 100644 --- a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-workspace-cluster.yaml @@ -1,5 +1,4 @@ # E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. -apiVersion: rbacpresets.platform-mesh.io/v1alpha1 kind: ProviderRBACPreset metadata: name: e2e-workspace-cluster diff --git a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml index 89be9f09..f6467a6b 100644 --- a/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml +++ b/test/e2e/kind/yaml/kcp-preset-fixtures/e2e-wtvw.yaml @@ -1,5 +1,4 @@ # E2E-only preset fixture (kind tests). Not shipped in pkg/rbacpresets/providers. -apiVersion: rbacpresets.platform-mesh.io/v1alpha1 kind: ProviderRBACPreset metadata: name: e2e-wtvw