From 57ae12f1c9e37a821fbcba5328701e5fa5f8e541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 21 May 2025 11:47:05 +0200 Subject: [PATCH 01/19] chore: correct metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- api/v1beta2/capsuleconfiguration_types.go | 2 + api/v1beta2/tenantresource_namespaced.go | 3 + api/v1beta2/zz_generated.deepcopy.go | 15 ++++ ...sule.clastix.io_capsuleconfigurations.yaml | 2 +- ...sule.clastix.io_globaltenantresources.yaml | 7 +- .../capsule.clastix.io_tenantresources.yaml | 7 +- .../crds/capsule.clastix.io_tenants.yaml | 2 +- controllers/config/manager.go | 7 +- controllers/resources/global.go | 20 ++++- controllers/resources/manager.go | 40 +++++++++ controllers/resources/processor.go | 10 ++- pkg/api/sa_client.go | 28 +++++++ pkg/configuration/client.go | 82 +++++++++++++++---- pkg/configuration/configuration.go | 4 + pkg/configuration/utils.go | 28 +++++++ tmp/claims.yaml | 25 ++++++ tmp/deploy.yaml | 29 +++++++ tmp/ppools.yaml | 34 ++++++++ tmp/tnt.yaml | 24 ++++++ 19 files changed, 343 insertions(+), 26 deletions(-) create mode 100644 controllers/resources/manager.go create mode 100644 pkg/api/sa_client.go create mode 100644 pkg/configuration/utils.go create mode 100644 tmp/claims.yaml create mode 100644 tmp/deploy.yaml create mode 100644 tmp/ppools.yaml create mode 100644 tmp/tnt.yaml diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index c12734570..bf71393a3 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -31,6 +31,8 @@ type CapsuleConfigurationSpec struct { // when not using an already provided CA and certificate, or when these are managed externally with Vault, or cert-manager. // +kubebuilder:default=true EnableTLSReconciler bool `json:"enableTLSReconciler"` //nolint:tagliatelle + // Define Kubernetes-Client Configurations + ServiceAccountClient *api.ServiceAccountClient `json:"serviceAccountClient,omitempty"` } type NodeMetadata struct { diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index 766cd7581..655f0c32b 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -22,6 +22,9 @@ type TenantResourceSpec struct { PruningOnDelete *bool `json:"pruningOnDelete,omitempty"` // Defines the rules to select targeting Namespace, along with the objects that must be replicated. Resources []ResourceSpec `json:"resources"` + // Local ServiceAccount which will perform all the actions defined in the TenantResource + // You must provide permissions accordingly to that ServiceAccount + ServiceAccountName string `json:"serivceAccountName,omitempty"` } type ResourceSpec struct { diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 79ab4abaa..f354d9c8d 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -529,6 +529,21 @@ func (in *ResourceSpec) DeepCopy() *ResourceSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountImpersonation) DeepCopyInto(out *ServiceAccountImpersonation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountImpersonation. +func (in *ServiceAccountImpersonation) DeepCopy() *ServiceAccountImpersonation { + if in == nil { + return nil + } + out := new(ServiceAccountImpersonation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tenant) DeepCopyInto(out *Tenant) { *out = *in diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index e3e8b2ed1..0c22d8212 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.18.0 name: capsuleconfigurations.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index f9c7ed29d..f6281fbbb 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.18.0 name: globaltenantresources.capsule.clastix.io spec: group: capsule.clastix.io @@ -199,6 +199,11 @@ spec: Define the period of time upon a second reconciliation must be invoked. Keep in mind that any change to the manifests will trigger a new reconciliation. type: string + serivceAccountName: + description: |- + Local ServiceAccount which will perform all the actions defined in the TenantResource + You must provide permissions accordingly to that ServiceAccount + type: string tenantSelector: description: Defines the Tenant selector used target the tenants on which resources must be propagated. diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 2c9a0be77..bfa3371fe 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.18.0 name: tenantresources.capsule.clastix.io spec: group: capsule.clastix.io @@ -201,6 +201,11 @@ spec: Define the period of time upon a second reconciliation must be invoked. Keep in mind that any change to the manifests will trigger a new reconciliation. type: string + serivceAccountName: + description: |- + Local ServiceAccount which will perform all the actions defined in the TenantResource + You must provide permissions accordingly to that ServiceAccount + type: string required: - resources - resyncPeriod diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 44b2b2a5f..fbb84ae8a 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.18.0 name: tenants.capsule.clastix.io spec: group: capsule.clastix.io diff --git a/controllers/config/manager.go b/controllers/config/manager.go index cf565f9ea..79622ca10 100644 --- a/controllers/config/manager.go +++ b/controllers/config/manager.go @@ -8,6 +8,7 @@ import ( "github.com/go-logr/logr" "github.com/pkg/errors" + "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -18,13 +19,15 @@ import ( ) type Manager struct { - client client.Client + client client.Client + restClient *rest.Config Log logr.Logger } func (c *Manager) SetupWithManager(mgr ctrl.Manager, configurationName string) error { c.client = mgr.GetClient() + c.restClient = mgr.GetConfig() return ctrl.NewControllerManagedBy(mgr). For(&capsulev1beta2.CapsuleConfiguration{}, utils.NamesMatchingPredicate(configurationName)). @@ -34,7 +37,7 @@ func (c *Manager) SetupWithManager(mgr ctrl.Manager, configurationName string) e func (c *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) { c.Log.Info("CapsuleConfiguration reconciliation started", "request.name", request.Name) - cfg := configuration.NewCapsuleConfiguration(ctx, c.client, request.Name) + cfg := configuration.NewCapsuleConfiguration(ctx, c.client, c.restClient, request.Name) // Validating the Capsule Configuration options if _, err = cfg.ProtectedNamespaceRegexp(); err != nil { panic(errors.Wrap(err, "Invalid configuration for protected Namespace regex")) diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 737782114..cd8622c41 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -22,11 +22,25 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/configuration" ) -type Global struct { - client client.Client - processor Processor +type globalResourceController struct { + client client.Client + processor Processor + Configuration configuration.Configuration +} + +func (r *Global) SetupWithManager(mgr ctrl.Manager) error { + r.client = mgr.GetClient() + r.processor = Processor{ + client: mgr.GetClient(), + } + + return ctrl.NewControllerManagedBy(mgr). + For(&capsulev1beta2.GlobalTenantResource{}). + Watches(&capsulev1beta2.Tenant{}, handler.EnqueueRequestsFromMapFunc(r.enqueueRequestFromTenant)). + Complete(r) } func (r *Global) enqueueRequestFromTenant(ctx context.Context, object client.Object) (reqs []reconcile.Request) { diff --git a/controllers/resources/manager.go b/controllers/resources/manager.go new file mode 100644 index 000000000..9f7617629 --- /dev/null +++ b/controllers/resources/manager.go @@ -0,0 +1,40 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "fmt" + + "github.com/go-logr/logr" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/projectcapsule/capsule/pkg/metrics" +) + +func Add( + log logr.Logger, + mgr manager.Manager, + recorder record.EventRecorder, +) (err error) { + if err = (&globalResourceController{ + Client: mgr.GetClient(), + log: log.WithName("Pools"), + recorder: recorder, + metrics: metrics.MustMakeResourcePoolRecorder(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create pool controller: %w", err) + } + + if err = (&resourceClaimController{ + Client: mgr.GetClient(), + log: log.WithName("Claims"), + recorder: recorder, + metrics: metrics.MustMakeClaimRecorder(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create claim controller: %w", err) + } + + return nil +} diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 10726f69b..6bc30f466 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -80,7 +80,15 @@ func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set } //nolint:gocognit -func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant, allowCrossNamespaceSelection bool, tenantLabel string, resourceIndex int, spec capsulev1beta2.ResourceSpec) ([]string, error) { +func (r *Processor) HandleSection( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + allowCrossNamespaceSelection bool, + tenantLabel string, + resourceIndex int, + spec capsulev1beta2.ResourceSpec, +) ([]string, error) { log := ctrllog.FromContext(ctx) var err error diff --git a/pkg/api/sa_client.go b/pkg/api/sa_client.go new file mode 100644 index 000000000..35ba11ec2 --- /dev/null +++ b/pkg/api/sa_client.go @@ -0,0 +1,28 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package api + +type ServiceAccountClient struct { + // Kubernetes API Endpoint to use for impersonation + Endpoint string `json:"endpoint,omitempty"` + + // Namespace where the CA certificate secret is located + CASecretNamespace string `json:"caSecretNamespace,omitempty"` + + // Name of the secret containing the CA certificate + CASecretName string `json:"caSecretName,omitempty"` + + // Key in the secret that holds the CA certificate (e.g., "ca.crt") + // +kubebuilder:default=ca.crt + CASecretKey string `json:"caSecretKey,omitempty"` + + // If true, TLS certificate verification is skipped (not recommended for production) + // +kubebuilder:default=false + SkipTLSVerify bool `json:"skipTlsVerify,omitempty"` + + // Default ServiceAccount for namespaced resources (TenantResource) + // When defined, users are required to use this ServiceAccount within the namespace + // where they deploy the resource, unless they explicitly provide their own. + DefaultNamespaced string `json:"defaultServiceAccount,omitempty"` +} diff --git a/pkg/configuration/client.go b/pkg/configuration/client.go index b829c971c..d40c30cce 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -5,11 +5,14 @@ package configuration import ( "context" + "fmt" + "os" "regexp" "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -20,27 +23,32 @@ import ( // using a closure that provides the desired configuration. type capsuleConfiguration struct { retrievalFn func() *capsulev1beta2.CapsuleConfiguration + rest *rest.Config + client client.Client } -func NewCapsuleConfiguration(ctx context.Context, client client.Client, name string) Configuration { - return &capsuleConfiguration{retrievalFn: func() *capsulev1beta2.CapsuleConfiguration { - config := &capsulev1beta2.CapsuleConfiguration{} - - if err := client.Get(ctx, types.NamespacedName{Name: name}, config); err != nil { - if apierrors.IsNotFound(err) { - return &capsulev1beta2.CapsuleConfiguration{ - Spec: capsulev1beta2.CapsuleConfigurationSpec{ - UserGroups: []string{"projectcapsule.dev"}, - ForceTenantPrefix: false, - ProtectedNamespaceRegexpString: "", - }, +func NewCapsuleConfiguration(ctx context.Context, client client.Client, rest *rest.Config, name string) Configuration { + return &capsuleConfiguration{ + client: client, + rest: rest, + retrievalFn: func() *capsulev1beta2.CapsuleConfiguration { + config := &capsulev1beta2.CapsuleConfiguration{} + + if err := client.Get(ctx, types.NamespacedName{Name: name}, config); err != nil { + if apierrors.IsNotFound(err) { + return &capsulev1beta2.CapsuleConfiguration{ + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + UserGroups: []string{"projectcapsule.dev"}, + ForceTenantPrefix: false, + ProtectedNamespaceRegexpString: "", + }, + } } + panic(errors.Wrap(err, "Cannot retrieve Capsule configuration with name "+name)) } - panic(errors.Wrap(err, "Cannot retrieve Capsule configuration with name "+name)) - } - return config - }} + return config + }} } func (c *capsuleConfiguration) ProtectedNamespaceRegexp() (*regexp.Regexp, error) { @@ -100,3 +108,45 @@ func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.Forbid return &c.retrievalFn().Spec.NodeMetadata.ForbiddenAnnotations } + +func (c *capsuleConfiguration) ServiceAccountClientProperties() *capsuleapi.ServiceAccountClient { + if c.retrievalFn().Spec.ServiceAccountClient == nil { + return nil + } + + return c.retrievalFn().Spec.ServiceAccountClient +} + +func (c *capsuleConfiguration) ServiceAccountClient(ctx context.Context) (client *rest.Config, err error) { + props := c.ServiceAccountClientProperties() + + client = c.rest + + if props == nil { + return + } + + if props.Endpoint != "" { + client.Host = c.rest.Host + } + + if props.SkipTLSVerify { + client.TLSClientConfig.Insecure = true + } else { + if props.CASecretName != "" { + namespace := props.CASecretNamespace + if namespace == "" { + namespace = os.Getenv("NAMESPACE") + } + + caData, err := fetchCACertFromSecret(ctx, c.client, namespace, props.CASecretName, props.CASecretKey) + if err != nil { + return nil, fmt.Errorf("could not fetch CA cert: %w", err) + } + + client.TLSClientConfig.CAData = caData + } + } + + return client, nil +} diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index e75f71d65..da11cd3a9 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -4,9 +4,11 @@ package configuration import ( + "context" "regexp" capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "k8s.io/client-go/rest" ) const ( @@ -26,4 +28,6 @@ type Configuration interface { UserGroups() []string ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec + ServiceAccountClientProperties() *capsuleapi.ServiceAccountClient + ServiceAccountClient(context.Context) (*rest.Config, error) } diff --git a/pkg/configuration/utils.go b/pkg/configuration/utils.go new file mode 100644 index 000000000..b65548026 --- /dev/null +++ b/pkg/configuration/utils.go @@ -0,0 +1,28 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package configuration + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func fetchCACertFromSecret(ctx context.Context, k8sClient client.Client, namespace, secretName, secretCaKey string) ([]byte, error) { + var secret corev1.Secret + key := client.ObjectKey{Namespace: namespace, Name: secretName} + + if err := k8sClient.Get(ctx, key, &secret); err != nil { + return nil, fmt.Errorf("unable to fetch CA secret %s/%s: %w", namespace, secretName, err) + } + + data, ok := secret.Data[secretCaKey] + if !ok { + return nil, fmt.Errorf("secret %s/%s does not contain key '%s'", namespace, secretName, secretCaKey) + } + + return data, nil +} diff --git a/tmp/claims.yaml b/tmp/claims.yaml new file mode 100644 index 000000000..d75f94e18 --- /dev/null +++ b/tmp/claims.yaml @@ -0,0 +1,25 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: ResourcePoolClaim +metadata: + name: compute + namespace: migration-dev +spec: + pool: "migration-compute" + claim: + requests.cpu: 375m + requests.memory: 384Mi + limits.cpu: 375m + limits.memory: 384Mi +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: ResourcePoolClaim +metadata: + name: pods + namespace: migration-dev +spec: + pool: "migration-size" + claim: + pods: "3" + + diff --git a/tmp/deploy.yaml b/tmp/deploy.yaml new file mode 100644 index 000000000..1702aa440 --- /dev/null +++ b/tmp/deploy.yaml @@ -0,0 +1,29 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 + resources: + requests: + cpu: 0.125 + memory: 128Mi + limits: + cpu: 0.125 + memory: 128Mi + diff --git a/tmp/ppools.yaml b/tmp/ppools.yaml new file mode 100644 index 000000000..3a080c752 --- /dev/null +++ b/tmp/ppools.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: ResourcePool +metadata: + name: migration-compute +spec: + config: + defaultsZero: true + orderedQueue: false + selectors: + - matchLabels: + capsule.clastix.io/tenant: migration + quota: + hard: + limits.cpu: "2" + limits.memory: 2Gi + requests.cpu: "2" + requests.memory: 2Gi +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: ResourcePool +metadata: + name: migration-size +spec: + config: + defaultsZero: true + selectors: + - matchLabels: + capsule.clastix.io/tenant: migration + quota: + hard: + pods: "7" + + diff --git a/tmp/tnt.yaml b/tmp/tnt.yaml new file mode 100644 index 000000000..1c1bb4479 --- /dev/null +++ b/tmp/tnt.yaml @@ -0,0 +1,24 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + labels: + kubernetes.io/metadata.name: migration + name: migration +spec: + owners: + - clusterRoles: + - admin + - capsule-namespace-deleter + kind: User + name: alice + preventDeletion: false + resourceQuotas: + items: + - hard: + limits.cpu: "2" + limits.memory: 2Gi + requests.cpu: "2" + requests.memory: 2Gi + - hard: + pods: "7" + scope: Tenant From cde0bd42cd247bc0042bfcb5de9b09d88000c14e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Thu, 22 May 2025 09:26:40 +0200 Subject: [PATCH 02/19] chore: correct metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- api/v1beta2/zz_generated.deepcopy.go | 20 +-- ...sule.clastix.io_capsuleconfigurations.yaml | 29 +++++ .../mutatingwebhookconfiguration.yaml | 30 +++++ .../validatingwebhookconfiguration.yaml | 17 +-- charts/capsule/values.yaml | 27 ++++ controllers/resources/global.go | 42 ++++--- controllers/resources/manager.go | 23 ++-- controllers/resources/namespaced.go | 119 ++++++++++++++++-- controllers/resources/processor.go | 25 ++-- go.mod | 1 + main.go | 20 +-- pkg/api/sa_client.go | 7 +- pkg/utils/serviceaccount.go | 67 ++++++++++ pkg/webhook/route/tenantresource.go | 40 ++++++ pkg/webhook/route/tenantresource_objs.go | 24 ---- .../tenantresource/namespaced_mutating.go | 88 +++++++++++++ .../{objects.go => objects_validating.go} | 16 +-- tmp/claims.yaml | 2 - tmp/deploy.yaml | 2 +- tmp/ppools.yaml | 2 - 20 files changed, 481 insertions(+), 120 deletions(-) create mode 100644 pkg/utils/serviceaccount.go create mode 100644 pkg/webhook/route/tenantresource.go delete mode 100644 pkg/webhook/route/tenantresource_objs.go create mode 100644 pkg/webhook/tenantresource/namespaced_mutating.go rename pkg/webhook/tenantresource/{objects.go => objects_validating.go} (78%) diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index f9402eb5d..c85e3963d 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -127,6 +127,11 @@ func (in *CapsuleConfigurationSpec) DeepCopyInto(out *CapsuleConfigurationSpec) *out = new(NodeMetadata) (*in).DeepCopyInto(*out) } + if in.ServiceAccountClient != nil { + in, out := &in.ServiceAccountClient, &out.ServiceAccountClient + *out = new(api.ServiceAccountClient) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfigurationSpec. @@ -556,21 +561,6 @@ func (in *ResourceSpec) DeepCopy() *ResourceSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ServiceAccountImpersonation) DeepCopyInto(out *ServiceAccountImpersonation) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountImpersonation. -func (in *ServiceAccountImpersonation) DeepCopy() *ServiceAccountImpersonation { - if in == nil { - return nil - } - out := new(ServiceAccountImpersonation) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tenant) DeepCopyInto(out *Tenant) { *out = *in diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 0c22d8212..3945617c0 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -117,6 +117,35 @@ spec: description: Disallow creation of namespaces, whose name matches this regexp type: string + serviceAccountClient: + description: Define Kubernetes-Client Configurations + properties: + caSecretKey: + default: ca.crt + description: Key in the secret that holds the CA certificate (e.g., + "ca.crt") + type: string + caSecretName: + description: Name of the secret containing the CA certificate + type: string + caSecretNamespace: + description: Namespace where the CA certificate secret is located + type: string + defaultServiceAccount: + description: |- + Default ServiceAccount for namespaced resources (TenantResource) + When defined, users are required to use this ServiceAccount within the namespace + where they deploy the resource, unless they explicitly provide their own. + type: string + endpoint: + description: Kubernetes API Endpoint to use for impersonation + type: string + skipTlsVerify: + default: false + description: If true, TLS certificate verification is skipped + (not recommended for production) + type: boolean + type: object userGroups: default: - capsule.clastix.io diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 600a58322..e923489b0 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -136,4 +136,34 @@ webhooks: sideEffects: NoneOnDryRun timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} {{- end }} + {{- with .Values.webhooks.hooks.tenantResources.namespaced.mutation }} +- admissionReviewVersions: + - v1 + clientConfig: + {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/namespaced/mutating" "ctx" $) | nindent 4 }} + failurePolicy: {{ .failurePolicy }} + name: namespaced.resource-objects.tenant.projectcapsule.dev + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + reinvocationPolicy: {{ .reinvocationPolicy }} + rules: + - apiGroups: + - capsule.clastix.io + apiVersions: + - v1beta2 + operations: + - UPDATE + - CREATE + resources: + - 'tenantresources' + scope: Namespaced + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} + {{- end }} {{- end }} diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 5f737c810..9866feb21 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -245,21 +245,22 @@ webhooks: sideEffects: None timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} -{{- with .Values.webhooks.hooks.tenantResourceObjects }} + +{{- with (mergeOverwrite .Values.webhooks.hooks.tenantResources.namespaced.mutation .Values.webhooks.hooks.tenantResourceObjects) }} - admissionReviewVersions: - v1 clientConfig: - {{- include "capsule.webhooks.service" (dict "path" "/tenantresource-objects" "ctx" $) | nindent 4 }} + {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/objects/validating" "ctx" $) | nindent 4 }} failurePolicy: {{ .failurePolicy }} name: resource-objects.tenant.projectcapsule.dev + {{- with .namespaceSelector }} namespaceSelector: - matchExpressions: - - key: capsule.clastix.io/tenant - operator: Exists + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .objectSelector }} objectSelector: - matchExpressions: - - key: capsule.clastix.io/resources - operator: Exists + {{- toYaml . | nindent 4 }} + {{- end }} rules: - apiGroups: - '*' diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 6ea0b1bcd..3284b2f6a 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -323,6 +323,33 @@ webhooks: operator: Exists tenants: failurePolicy: Fail + tenantResources: + objects: + validation: + namespaceSelector: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists + objectSelector: + matchExpressions: + - key: capsule.clastix.io/resources + operator: Exists + failurePolicy: Fail + namespaced: + mutation: + namespaceSelector: + matchExpressions: + - key: capsule.clastix.io/tenant + operator: Exists + objectSelector: {} + failurePolicy: Fail + reinvocationPolicy: Never + + + + + + tenantResourceObjects: failurePolicy: Fail services: diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 31ccbf38c..712d759aa 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -7,6 +7,7 @@ import ( "context" "errors" + "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,11 +28,12 @@ import ( type globalResourceController struct { client client.Client + log logr.Logger processor Processor - Configuration configuration.Configuration + configuration configuration.Configuration } -func (r *Global) SetupWithManager(mgr ctrl.Manager) error { +func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager) error { r.client = mgr.GetClient() r.processor = Processor{ client: mgr.GetClient(), @@ -43,19 +45,7 @@ func (r *Global) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *Global) SetupWithManager(mgr ctrl.Manager) error { - r.client = mgr.GetClient() - r.processor = Processor{ - client: mgr.GetClient(), - } - - return ctrl.NewControllerManagedBy(mgr). - For(&capsulev1beta2.GlobalTenantResource{}). - Watches(&capsulev1beta2.Tenant{}, handler.EnqueueRequestsFromMapFunc(r.enqueueRequestFromTenant)). - Complete(r) -} - -func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { +func (r *globalResourceController) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { var err error log := ctrllog.FromContext(ctx) @@ -95,7 +85,7 @@ func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reco return r.reconcileNormal(ctx, tntResource) } -func (r *Global) enqueueRequestFromTenant(ctx context.Context, object client.Object) (reqs []reconcile.Request) { +func (r *globalResourceController) enqueueRequestFromTenant(ctx context.Context, object client.Object) (reqs []reconcile.Request) { tnt := object.(*capsulev1beta2.Tenant) //nolint:forcetypeassert resList := capsulev1beta2.GlobalTenantResourceList{} @@ -129,7 +119,7 @@ func (r *Global) enqueueRequestFromTenant(ctx context.Context, object client.Obj return reqs } -func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta2.GlobalTenantResource) (reconcile.Result, error) { +func (r *globalResourceController) reconcileNormal(ctx context.Context, tntResource *capsulev1beta2.GlobalTenantResource) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { @@ -148,6 +138,7 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta return reconcile.Result{}, err } + // Use Controller Client. tntList := capsulev1beta2.TenantList{} if err = r.client.List(ctx, &tntList, &client.MatchingLabelsSelector{Selector: tntSelector}); err != nil { log.Error(err, "cannot list Tenants matching the provided selector") @@ -162,6 +153,21 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta // the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. processedItems := sets.NewString() + saClient := r.client + if tntResource.Spec.ServiceAccountName != "" { + re, err := r.configuration.ServiceAccountClient(ctx) + if err != nil { + log.Error(err, "failed to load impersonated rest client") + + return reconcile.Result{}, err + } + + utils.Ser + + } + + r.configuration.ServiceAccountClient(ctx) + for index, resource := range tntResource.Spec.Resources { tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) if labelErr != nil { @@ -207,7 +213,7 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } -func (r *Global) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.GlobalTenantResource) (reconcile.Result, error) { +func (r *globalResourceController) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.GlobalTenantResource) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { diff --git a/controllers/resources/manager.go b/controllers/resources/manager.go index 9f7617629..26c5da732 100644 --- a/controllers/resources/manager.go +++ b/controllers/resources/manager.go @@ -7,33 +7,28 @@ import ( "fmt" "github.com/go-logr/logr" - "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/manager" - "github.com/projectcapsule/capsule/pkg/metrics" + "github.com/projectcapsule/capsule/pkg/configuration" ) func Add( log logr.Logger, mgr manager.Manager, - recorder record.EventRecorder, + configuration configuration.Configuration, ) (err error) { if err = (&globalResourceController{ - Client: mgr.GetClient(), - log: log.WithName("Pools"), - recorder: recorder, - metrics: metrics.MustMakeResourcePoolRecorder(), + log: log.WithName("Global"), + configuration: configuration, }).SetupWithManager(mgr); err != nil { - return fmt.Errorf("unable to create pool controller: %w", err) + return fmt.Errorf("unable to create global controller: %w", err) } - if err = (&resourceClaimController{ - Client: mgr.GetClient(), - log: log.WithName("Claims"), - recorder: recorder, - metrics: metrics.MustMakeClaimRecorder(), + if err = (&namespacedResourceController{ + log: log.WithName("Namespaced"), + configuration: configuration, }).SetupWithManager(mgr); err != nil { - return fmt.Errorf("unable to create claim controller: %w", err) + return fmt.Errorf("unable to create namespaced controller: %w", err) } return nil diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 96d32649b..f0fc6408f 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -7,6 +7,7 @@ import ( "context" "errors" + "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/fields" @@ -19,14 +20,18 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/utils" ) -type Namespaced struct { - client client.Client - processor Processor +type namespacedResourceController struct { + client client.Client + log logr.Logger + processor Processor + configuration configuration.Configuration } -func (r *Namespaced) SetupWithManager(mgr ctrl.Manager) error { +func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager) error { r.client = mgr.GetClient() r.processor = Processor{ client: mgr.GetClient(), @@ -37,7 +42,7 @@ func (r *Namespaced) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { +func (r *namespacedResourceController) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) log.Info("start processing") @@ -66,6 +71,16 @@ func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) ( } }() + // Load Client + c, err := r.loadClient(ctx, log, tntResource) + if err != nil { + return reconcile.Result{}, gherrors.Wrap(err, "failed to load serviceaccount client") + } + if c == nil { + log.V(3).Info("received empty client for serviceaccount") + return reconcile.Result{}, nil + } + // Handle deleted TenantResource if !tntResource.DeletionTimestamp.IsZero() { return r.reconcileDelete(ctx, tntResource) @@ -75,13 +90,27 @@ func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) ( return r.reconcileNormal(ctx, tntResource) } -func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { +func (r *namespacedResourceController) reconcileNormal(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { controllerutil.AddFinalizer(tntResource, finalizer) } + // Add ServiceAccount if required, Retriggers reconcile + // This is done in the background, Everything else should be handeled at admission + cfg := r.configuration.ServiceAccountClientProperties() + if cfg != nil { + if cfg.TenantDefaultServiceAccount != "" && tntResource.Spec.ServiceAccountName != "" { + tntResource.Spec.ServiceAccountName = utils.NamespacedServiceAccountName(cfg.TenantDefaultServiceAccount, tntResource.Namespace) + + log.V(5).Info("adding default serviceAccount '%s'", cfg.TenantDefaultServiceAccount) + + // Trigger new reconcile with ServiceAccount + return reconcile.Result{}, nil + } + } + // Adding the default value for the status if tntResource.Status.ProcessedItems == nil { tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0) @@ -102,6 +131,30 @@ func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1 return reconcile.Result{}, nil } + // Load impersonation client + saClient := r.client + if tntResource.Spec.ServiceAccountName != "" { + re, err := r.configuration.ServiceAccountClient(ctx) + if err != nil { + log.Error(err, "failed to load impersonated rest client") + + return reconcile.Result{}, err + } + + //utils.NamespacedServiceAccountName() + // + saClient, err = utils.ImpersonatedKubernetesClientForServiceAccount( + re, + r.client.Scheme(), + utils.NamespacedServiceAccountName(tntResource.Spec.ServiceAccountName, tntResource.Namespace), + ) + if err != nil { + log.Error(err, "failed to create impersonated client") + + return reconcile.Result{}, err + } + } + // A TenantResource is made of several Resource sections, each one with specific options: // the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. processedItems := sets.NewString() @@ -117,7 +170,7 @@ func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1 var err error for index, resource := range tntResource.Spec.Resources { - items, sectionErr := r.processor.HandleSection(ctx, tl.Items[0], false, tenantLabel, index, resource) + items, sectionErr := r.processor.HandleSection(ctx, saClient, tl.Items[0], false, tenantLabel, index, resource) if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. @@ -133,7 +186,7 @@ func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1 return reconcile.Result{}, err } - if r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) { + if r.processor.HandlePruning(ctx, saClient, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) { tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) for _, item := range processedItems.List() { @@ -148,11 +201,11 @@ func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1 return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } -func (r *Namespaced) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { +func (r *namespacedResourceController) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { - r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), nil) + r.processor.HandlePruning(ctx, r.client, tntResource.Status.ProcessedItems.AsSet(), nil) } controllerutil.RemoveFinalizer(tntResource, finalizer) @@ -161,3 +214,49 @@ func (r *Namespaced) reconcileDelete(ctx context.Context, tntResource *capsulev1 return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } + +func (r *namespacedResourceController) loadClient( + ctx context.Context, + log logr.Logger, + tntResource *capsulev1beta2.TenantResource, +) (*client.Client, error) { + // Add ServiceAccount if required, Retriggers reconcile + // This is done in the background, Everything else should be handeled at admission + cfg := r.configuration.ServiceAccountClientProperties() + if cfg != nil { + if cfg.TenantDefaultServiceAccount != "" && tntResource.Spec.ServiceAccountName != "" { + tntResource.Spec.ServiceAccountName = utils.NamespacedServiceAccountName(cfg.TenantDefaultServiceAccount, tntResource.Namespace) + + log.V(5).Info("adding default serviceAccount '%s'", cfg.TenantDefaultServiceAccount) + + // Trigger new reconcile with ServiceAccount + return nil, nil + } + } + + // Load impersonation client + saClient := r.client + if tntResource.Spec.ServiceAccountName != "" { + re, err := r.configuration.ServiceAccountClient(ctx) + if err != nil { + log.Error(err, "failed to load impersonated rest client") + + return nil, err + } + + //utils.NamespacedServiceAccountName() + // + saClient, err = utils.ImpersonatedKubernetesClientForServiceAccount( + re, + r.client.Scheme(), + utils.NamespacedServiceAccountName(tntResource.Spec.ServiceAccountName, tntResource.Namespace), + ) + if err != nil { + log.Error(err, "failed to create impersonated client") + + return nil, err + } + } + + return &saClient, nil +} diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 814237d0a..644c55702 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -43,7 +43,12 @@ func prepareAdditionalMetadata(m map[string]string) map[string]string { return m } -func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set[string]) (updateStatus bool) { +func (r *Processor) HandlePruning( + ctx context.Context, + c client.Client, + current, + desired sets.Set[string], +) (updateStatus bool) { log := ctrllog.FromContext(ctx) diff := current.Difference(desired) @@ -70,7 +75,7 @@ func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set obj.SetName(or.Name) obj.SetGroupVersionKind(schema.FromAPIVersionAndKind(or.APIVersion, or.Kind)) - if err := r.client.Delete(ctx, &obj); err != nil { + if err := c.Delete(ctx, &obj); err != nil { if apierr.IsNotFound(err) { // Object may have been already deleted, we can ignore this error continue @@ -90,7 +95,7 @@ func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set //nolint:gocognit func (r *Processor) HandleSection( ctx context.Context, - c client.Client, + serviceAccountClient client.Client, tnt capsulev1beta2.Tenant, allowCrossNamespaceSelection bool, tenantLabel string, @@ -178,7 +183,7 @@ func (r *Processor) HandleSection( objs := unstructured.UnstructuredList{} objs.SetGroupVersionKind(schema.FromAPIVersionAndKind(item.APIVersion, fmt.Sprintf("%sList", item.Kind))) - if clientErr := r.client.List(ctx, &objs, client.InNamespace(item.Namespace), client.MatchingLabelsSelector{Selector: itemSelector}); clientErr != nil { + if clientErr := serviceAccountClient.List(ctx, &objs, client.InNamespace(item.Namespace), client.MatchingLabelsSelector{Selector: itemSelector}); clientErr != nil { log.Error(clientErr, "cannot retrieve object for namespacedItem", keysAndValues...) syncErr = errors.Join(syncErr, clientErr) @@ -208,7 +213,7 @@ func (r *Processor) HandleSection( kv := keysAndValues kv = append(kv, "resource", fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetNamespace())) - if opErr := r.createOrUpdate(ctx, &obj, objLabels, objAnnotations); opErr != nil { + if opErr := r.createOrUpdate(ctx, serviceAccountClient, &obj, objLabels, objAnnotations); opErr != nil { log.Error(opErr, "unable to sync namespacedItems", kv...) errorsChan <- opErr @@ -267,7 +272,7 @@ func (r *Processor) HandleSection( obj.SetNamespace(ns.Name) - if rawErr := r.createOrUpdate(ctx, &obj, objLabels, objAnnotations); rawErr != nil { + if rawErr := r.createOrUpdate(ctx, serviceAccountClient, &obj, objLabels, objAnnotations); rawErr != nil { log.Info("unable to sync rawItem", keysAndValues...) // In case of error processing an item in one of any selected Namespaces, storing it to report it lately // to the upper call to ensure a partial sync that will be fixed by a subsequent reconciliation. @@ -292,7 +297,13 @@ func (r *Processor) HandleSection( // createOrUpdate replicates the provided unstructured object to all the provided Namespaces: // this function mimics the CreateOrUpdate, by retrieving the object to understand if it must be created or updated, // along adding the additional metadata, if required. -func (r *Processor) createOrUpdate(ctx context.Context, obj *unstructured.Unstructured, labels map[string]string, annotations map[string]string) (err error) { +func (r *Processor) createOrUpdate( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, + labels map[string]string, + annotations map[string]string, +) (err error) { actual, desired := &unstructured.Unstructured{}, obj.DeepCopy() actual.SetAPIVersion(desired.GetAPIVersion()) diff --git a/go.mod b/go.mod index 36e0960b1..4dbfdba90 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( k8s.io/api v0.33.1 k8s.io/apiextensions-apiserver v0.33.1 k8s.io/apimachinery v0.33.1 + k8s.io/apiserver v0.33.1 k8s.io/client-go v0.33.1 k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 sigs.k8s.io/cluster-api v1.10.2 diff --git a/main.go b/main.go index fc7e9331d..8313a33e1 100644 --- a/main.go +++ b/main.go @@ -153,7 +153,7 @@ func main() { ctx := ctrl.SetupSignalHandler() - cfg := configuration.NewCapsuleConfiguration(ctx, manager.GetClient(), configurationName) + cfg := configuration.NewCapsuleConfiguration(ctx, manager.GetClient(), manager.GetConfig(), configurationName) directClient, err := client.New(ctrl.GetConfigOrDie(), client.Options{ Scheme: manager.GetScheme(), @@ -164,7 +164,7 @@ func main() { os.Exit(1) } - directCfg := configuration.NewCapsuleConfiguration(ctx, directClient, configurationName) + directCfg := configuration.NewCapsuleConfiguration(ctx, directClient, manager.GetConfig(), configurationName) if directCfg.EnableTLSConfiguration() { tlsReconciler := &tlscontroller.Reconciler{ @@ -227,7 +227,8 @@ func main() { route.Ingress(ingress.Class(cfg, kubeVersion), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()), route.PVC(pvc.Validating(), pvc.PersistentVolumeReuse()), route.Service(service.Handler()), - route.TenantResourceObjects(utils.InCapsuleGroups(cfg, tntresource.WriteOpsHandler())), + route.TenantResourceNamespacedMutation(tntresource.NamespacedMutatingHandler(cfg)), + route.TenantResourceObjectsValidation(utils.InCapsuleGroups(cfg, tntresource.ObjectsValidatingHandler())), route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())), route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()), route.Cordoning(tenant.CordoningHandler(cfg)), @@ -294,13 +295,12 @@ func main() { os.Exit(1) } - if err = (&resources.Global{}).SetupWithManager(manager); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "resources.Global") - os.Exit(1) - } - - if err = (&resources.Namespaced{}).SetupWithManager(manager); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "resources.Namespaced") + if err := resources.Add( + ctrl.Log.WithName("controllers").WithName("TenantResources"), + manager, + cfg, + ); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "tenantresources") os.Exit(1) } diff --git a/pkg/api/sa_client.go b/pkg/api/sa_client.go index 35ba11ec2..ebf51d45e 100644 --- a/pkg/api/sa_client.go +++ b/pkg/api/sa_client.go @@ -21,8 +21,13 @@ type ServiceAccountClient struct { // +kubebuilder:default=false SkipTLSVerify bool `json:"skipTlsVerify,omitempty"` + // Default ServiceAccount for namespaced resources (GlobalTenantResource) + // When defined, users are required to use this ServiceAccount anywhere in the cluster + // unless they explicitly provide their own. + GlobalDefaultServiceAccount string `json:"globalDefaultServiceAccount,omitempty"` + // Default ServiceAccount for namespaced resources (TenantResource) // When defined, users are required to use this ServiceAccount within the namespace // where they deploy the resource, unless they explicitly provide their own. - DefaultNamespaced string `json:"defaultServiceAccount,omitempty"` + TenantDefaultServiceAccount string `json:"tenantDefaultServiceAccount,omitempty"` } diff --git a/pkg/utils/serviceaccount.go b/pkg/utils/serviceaccount.go new file mode 100644 index 000000000..83ea082d3 --- /dev/null +++ b/pkg/utils/serviceaccount.go @@ -0,0 +1,67 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/authentication/serviceaccount" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Returns a namespaced serviceaccount name +func PrivilegedServiceAccountName(name string) (string, error) { + if _, _, err := serviceaccount.SplitUsername(name); err != nil { + return "", err + } + + return name, nil +} + +// Returns a namespaced serviceaccount name +func NamespacedServiceAccountName(name string, namespace string) string { + sanitized := strings.ReplaceAll(name, ":", "") + + return fmt.Sprintf("system:serviceaccount:%s:%s", namespace, sanitized) +} + +// Gather all groups for a ServiceAccount +func ServiceAccountGroups(sa string) (groups []string, err error) { + if namespace, _, err := serviceaccount.SplitUsername(sa); err == nil { + groups = append(groups, fmt.Sprintf("%s%s", serviceaccount.ServiceAccountGroupPrefix, namespace)) + groups = append(groups, serviceaccount.AllServiceAccountsGroup) + groups = append(groups, user.AllAuthenticated) + } + + return +} + +// ImpersonatedKubernetesClientForServiceAccount returns a controller-runtime client.Client that impersonates a given ServiceAccount. +func ImpersonatedKubernetesClientForServiceAccount( + base *rest.Config, + scheme *runtime.Scheme, + serviceAccountName string, +) (client.Client, error) { + groups, err := ServiceAccountGroups(serviceAccountName) + if err != nil { + return nil, fmt.Errorf("failed to get service account groups: %w", err) + } + + impersonated := rest.CopyConfig(base) + impersonated.Impersonate = rest.ImpersonationConfig{ + UserName: serviceAccountName, + Groups: groups, + } + + k8sClient, err := client.New(impersonated, client.Options{Scheme: scheme}) + if err != nil { + return nil, fmt.Errorf("failed to create impersonated client: %w", err) + } + + return k8sClient, nil +} diff --git a/pkg/webhook/route/tenantresource.go b/pkg/webhook/route/tenantresource.go new file mode 100644 index 000000000..394e8f74f --- /dev/null +++ b/pkg/webhook/route/tenantresource.go @@ -0,0 +1,40 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package route + +import ( + capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" +) + +type tntResourceObjsValidation struct { + handlers []capsulewebhook.Handler +} + +func TenantResourceObjectsValidation(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook { + return &tntResourceObjsValidation{handlers: handlers} +} + +func (t tntResourceObjsValidation) GetPath() string { + return "/tenantresource/objects/validating" +} + +func (t tntResourceObjsValidation) GetHandlers() []capsulewebhook.Handler { + return t.handlers +} + +type tntResourcenamespaceMutation struct { + handlers []capsulewebhook.Handler +} + +func TenantResourceNamespacedMutation(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook { + return &tntResourcenamespaceMutation{handlers: handlers} +} + +func (t tntResourcenamespaceMutation) GetPath() string { + return "/tenantresource/namespaced/mutating" +} + +func (t tntResourcenamespaceMutation) GetHandlers() []capsulewebhook.Handler { + return t.handlers +} diff --git a/pkg/webhook/route/tenantresource_objs.go b/pkg/webhook/route/tenantresource_objs.go deleted file mode 100644 index 647d9bfd3..000000000 --- a/pkg/webhook/route/tenantresource_objs.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2020-2023 Project Capsule Authors. -// SPDX-License-Identifier: Apache-2.0 - -package route - -import ( - capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" -) - -type tntResourceObjs struct { - handlers []capsulewebhook.Handler -} - -func TenantResourceObjects(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook { - return &tntResourceObjs{handlers: handlers} -} - -func (t tntResourceObjs) GetPath() string { - return "/tenantresource-objects" -} - -func (t tntResourceObjs) GetHandlers() []capsulewebhook.Handler { - return t.handlers -} diff --git a/pkg/webhook/tenantresource/namespaced_mutating.go b/pkg/webhook/tenantresource/namespaced_mutating.go new file mode 100644 index 000000000..afb94741a --- /dev/null +++ b/pkg/webhook/tenantresource/namespaced_mutating.go @@ -0,0 +1,88 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package tenantresource + +import ( + "context" + "encoding/json" + "net/http" + + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/configuration" + caputils "github.com/projectcapsule/capsule/pkg/utils" + capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" + "github.com/projectcapsule/capsule/pkg/webhook/utils" +) + +type namespacedMutatingHandler struct { + cfg configuration.Configuration +} + +func NamespacedMutatingHandler(cfg configuration.Configuration) capsulewebhook.Handler { + return &namespacedMutatingHandler{} +} + +func (h *namespacedMutatingHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *namespacedMutatingHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return h.handler(ctx, client, req, decoder, recorder) + } +} + +func (h *namespacedMutatingHandler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return h.handler(ctx, client, req, decoder, recorder) + } +} + +func (h *namespacedMutatingHandler) handler(ctx context.Context, clt client.Client, req admission.Request, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response { + resource := &capsulev1beta2.TenantResource{} + if err := decoder.Decode(req, resource); err != nil { + return utils.ErroredResponse(err) + } + + changed := h.handleServiceAccount(resource) + if !changed { + return nil + } + + // Marshal Manifest + marshaled, err := json.Marshal(resource) + if err != nil { + response := admission.Errored(http.StatusInternalServerError, err) + + return &response + } + response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled) + + return &response +} + +func (h *namespacedMutatingHandler) handleServiceAccount(resource *capsulev1beta2.TenantResource) (changed bool) { + changed = false + + if resource.Spec.ServiceAccountName != "" { + return + } + + cfg := h.cfg.ServiceAccountClientProperties() + if cfg == nil || cfg.TenantDefaultServiceAccount != "" { + return + } + + changed = true + + resource.Spec.ServiceAccountName = caputils.NamespacedServiceAccountName(cfg.TenantDefaultServiceAccount, resource.Namespace) + + return +} diff --git a/pkg/webhook/tenantresource/objects.go b/pkg/webhook/tenantresource/objects_validating.go similarity index 78% rename from pkg/webhook/tenantresource/objects.go rename to pkg/webhook/tenantresource/objects_validating.go index 42c0735a5..86e6a1319 100644 --- a/pkg/webhook/tenantresource/objects.go +++ b/pkg/webhook/tenantresource/objects_validating.go @@ -1,7 +1,7 @@ // Copyright 2020-2023 Project Capsule Authors. // SPDX-License-Identifier: Apache-2.0 -package tenant +package tenantresource import ( "context" @@ -20,31 +20,31 @@ import ( "github.com/projectcapsule/capsule/pkg/webhook/utils" ) -type cordoningHandler struct{} +type objectsValidatingHandler struct{} -func WriteOpsHandler() capsulewebhook.Handler { - return &cordoningHandler{} +func ObjectsValidatingHandler() capsulewebhook.Handler { + return &objectsValidatingHandler{} } -func (h *cordoningHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { +func (h *objectsValidatingHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { return func(context.Context, admission.Request) *admission.Response { return nil } } -func (h *cordoningHandler) OnDelete(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { +func (h *objectsValidatingHandler) OnDelete(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handler(ctx, client, req, recorder) } } -func (h *cordoningHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { +func (h *objectsValidatingHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handler(ctx, client, req, recorder) } } -func (h *cordoningHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder) *admission.Response { +func (h *objectsValidatingHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder) *admission.Response { tntList := &capsulev1beta2.TenantList{} if err := clt.List(ctx, tntList, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(".status.namespaces", req.Namespace)}); err != nil { diff --git a/tmp/claims.yaml b/tmp/claims.yaml index d75f94e18..88fbac59e 100644 --- a/tmp/claims.yaml +++ b/tmp/claims.yaml @@ -21,5 +21,3 @@ spec: pool: "migration-size" claim: pods: "3" - - diff --git a/tmp/deploy.yaml b/tmp/deploy.yaml index 1702aa440..81804dfe7 100644 --- a/tmp/deploy.yaml +++ b/tmp/deploy.yaml @@ -26,4 +26,4 @@ spec: limits: cpu: 0.125 memory: 128Mi - + diff --git a/tmp/ppools.yaml b/tmp/ppools.yaml index 3a080c752..416fbbfea 100644 --- a/tmp/ppools.yaml +++ b/tmp/ppools.yaml @@ -30,5 +30,3 @@ spec: quota: hard: pods: "7" - - From 06321fc2775ec8d035bd1c1d6087957f43d2103d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 11 Jun 2025 10:35:35 +0200 Subject: [PATCH 03/19] chore: implement impersonation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- controllers/resources/global.go | 44 +++++++++++++++++++++++ controllers/resources/namespaced.go | 46 ++++++++++++++++++++++++ e2e/tenantresource_impersonation_test.go | 36 +++++++++++++++++++ pkg/utils/serviceaccount_test.go | 2 +- 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 01fa6bd32..6fccb72fc 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -6,6 +6,7 @@ package resources import ( "context" "errors" + "reflect" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" @@ -16,10 +17,13 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -45,6 +49,46 @@ func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&capsulev1beta2.GlobalTenantResource{}). Watches(&capsulev1beta2.Tenant{}, handler.EnqueueRequestsFromMapFunc(r.enqueueRequestFromTenant)). + Watches( + &capsulev1beta2.CapsuleConfiguration{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { + var list capsulev1beta2.GlobalTenantResourceList + if err := r.client.List(ctx, &list); err != nil { + r.log.Error(err, "unable to list GlobalTenantResources") + + return nil + } + + var requests []reconcile.Request + for _, s := range list.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: s.Name, + Namespace: s.Namespace, + }, + }) + } + + return requests + }), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldObj, okOld := e.ObjectOld.(*capsulev1beta2.CapsuleConfiguration) + newObj, okNew := e.ObjectNew.(*capsulev1beta2.CapsuleConfiguration) + if !okOld || !okNew { + return false + } + + return !reflect.DeepEqual(oldObj.Spec.ServiceAccountClient, newObj.Spec.ServiceAccountClient) + }, + DeleteFunc: func(event.DeleteEvent) bool { + return false + }, + }), + ). Complete(r) } diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 754eaf46e..921806504 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -6,17 +6,23 @@ package resources import ( "context" "errors" + "reflect" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -41,6 +47,46 @@ func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager) error return ctrl.NewControllerManagedBy(mgr). For(&capsulev1beta2.TenantResource{}). + Watches( + &capsulev1beta2.CapsuleConfiguration{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { + var list capsulev1beta2.TenantResourceList + if err := r.client.List(ctx, &list); err != nil { + r.log.Error(err, "unable to list TenantResources") + + return nil + } + + var requests []reconcile.Request + for _, s := range list.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: s.Name, + Namespace: s.Namespace, + }, + }) + } + + return requests + }), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldObj, okOld := e.ObjectOld.(*capsulev1beta2.CapsuleConfiguration) + newObj, okNew := e.ObjectNew.(*capsulev1beta2.CapsuleConfiguration) + if !okOld || !okNew { + return false + } + + return !reflect.DeepEqual(oldObj.Spec.ServiceAccountClient, newObj.Spec.ServiceAccountClient) + }, + DeleteFunc: func(event.DeleteEvent) bool { + return false + }, + }), + ). Complete(r) } diff --git a/e2e/tenantresource_impersonation_test.go b/e2e/tenantresource_impersonation_test.go index fb7d89e5c..05b74e215 100644 --- a/e2e/tenantresource_impersonation_test.go +++ b/e2e/tenantresource_impersonation_test.go @@ -22,6 +22,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/meta" ) var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource"), func() { @@ -279,6 +280,41 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) }) + By("verify status (Verify ServiceAccount Names)", func() { + err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(tr), tr) + Expect(err).Should(Succeed()) + + Expect(tr.Status.Condition.Status).To(Equal(metav1.ConditionFalse)) + Expect(tr.Status.Condition.Type).To(Equal(meta.ReadyCondition)) + Expect(tr.Status.Condition.Reason).To(Equal(meta.FailedReason)) + + found := true + for _, ns := range solarNs { + for _, name := range []string{"raw-secret-1", "raw-secret-2", "raw-secret-3"} { + foundInner := false + for _, status := range tr.Status.ProcessedItems { + if status.Kind == "Secret" && + status.Name == name && + status.Namespace == ns && + status.Type == meta.ReplicationCondition && + status.Status == metav1.ConditionFalse { + foundInner = true + break + } + } + if !foundInner { + found = false + break + } + } + if !found { + break + } + } + + Expect(found).To(BeTrue()) + }) + for _, ns := range solarNs { By(fmt.Sprintf("waiting for replicated resources in %s Namespace", ns), func() { Eventually(func() []corev1.Secret { diff --git a/pkg/utils/serviceaccount_test.go b/pkg/utils/serviceaccount_test.go index 2da1f2f45..c3012e34f 100644 --- a/pkg/utils/serviceaccount_test.go +++ b/pkg/utils/serviceaccount_test.go @@ -35,7 +35,7 @@ func TestSanitizeServiceAccountProp(t *testing.T) { } func TestImpersonatedKubernetesClientForServiceAccount(t *testing.T) { - reference := api.ServiceAccountReference{ + reference := &api.ServiceAccountReference{ Name: "account", Namespace: "namespace", } From e51b8fecb13e93b70eaf007be034d5e77ddea38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Mon, 16 Jun 2025 13:49:55 +0200 Subject: [PATCH 04/19] chore: update helm-schema version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- .../mutatingwebhookconfiguration.yaml | 28 +++++++- .../validatingwebhookconfiguration.yaml | 4 +- charts/capsule/values.yaml | 13 ++-- cmd/main.go | 1 + controllers/resources/namespaced.go | 18 +++-- controllers/resources/processor.go | 13 ++-- controllers/resources/utils.go | 23 +++--- e2e/tenantresource_impersonation_test.go | 36 +++++++--- pkg/webhook/route/tenantresource.go | 16 +++++ pkg/webhook/tenantresource/global_mutating.go | 71 +++++++++++++++++++ 10 files changed, 179 insertions(+), 44 deletions(-) create mode 100644 pkg/webhook/tenantresource/global_mutating.go diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 75a267924..08872fe34 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -2,7 +2,7 @@ apiVersion: admissionregistration.k8s.io/v1 kind: MutatingWebhookConfiguration metadata: - name: {{ include "capsule.fullname" . }}-mutating-webhook-configuration + name: {{ include "capsule.fullname" . }}-webhook labels: {{- include "capsule.labels" . | nindent 4 }} annotations: @@ -166,6 +166,32 @@ webhooks: sideEffects: None timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} {{- end }} + {{- with .Values.webhooks.hooks.tenantResources.global.mutation }} +- admissionReviewVersions: + - v1 + clientConfig: + {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/global/mutating" "ctx" $) | nindent 4 }} + failurePolicy: {{ .failurePolicy }} + name: global.resource-objects.tenant.projectcapsule.dev + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + reinvocationPolicy: {{ .reinvocationPolicy }} + rules: + - apiGroups: + - capsule.clastix.io + apiVersions: + - v1beta2 + operations: + - UPDATE + - CREATE + resources: + - 'globaltenantresources' + scope: '*' + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} + {{- end }} {{- with .Values.webhooks.hooks.resourcepools.pools }} - admissionReviewVersions: - v1 diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 806ed8cd8..802dd9ff9 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -2,7 +2,7 @@ apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingWebhookConfiguration metadata: - name: {{ include "capsule.fullname" . }}-validating-webhook-configuration + name: {{ include "capsule.fullname" . }}-webhook labels: {{- include "capsule.labels" . | nindent 4 }} annotations: @@ -246,7 +246,7 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} -{{- with (mergeOverwrite .Values.webhooks.hooks.tenantResources.namespaced.mutation .Values.webhooks.hooks.tenantResourceObjects) }} +{{- with (mergeOverwrite (default dict .Values.webhooks.hooks.tenantResources.namespaced.mutation) (default dict .Values.webhooks.hooks.tenantResourceObjects)) }} - admissionReviewVersions: - v1 clientConfig: diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 74f6aa66c..6129e0286 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -372,14 +372,11 @@ webhooks: objectSelector: {} failurePolicy: Fail reinvocationPolicy: Never - - - - - - - tenantResourceObjects: - failurePolicy: Fail + global: + mutation: + objectSelector: {} + failurePolicy: Fail + reinvocationPolicy: Never services: failurePolicy: Fail namespaceSelector: diff --git a/cmd/main.go b/cmd/main.go index ac697eb80..aa1de91a7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -232,6 +232,7 @@ func main() { route.PVC(pvc.Validating(), pvc.PersistentVolumeReuse()), route.Service(service.Handler()), route.TenantResourceNamespacedMutation(tntresource.NamespacedMutatingHandler(cfg)), + route.TenantResourceGlobalMutation(tntresource.GlobalMutatingHandler(cfg)), route.TenantResourceObjectsValidation(utils.InCapsuleGroups(cfg, tntresource.ObjectsValidatingHandler())), route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())), route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()), diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 921806504..9603cf935 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -199,30 +199,29 @@ func (r *namespacedResourceController) reconcileNormal( }() // new empty error - var err error + var itemErrors error for index, resource := range tntResource.Spec.Resources { items, sectionErr := r.processor.HandleSection(ctx, c, tl.Items[0], false, tenantLabel, index, resource) if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. - err = errors.Join(err, sectionErr) + itemErrors = errors.Join(itemErrors, sectionErr) } processedItems.Insert(items...) } - if err != nil { - log.Error(err, "unable to replicate the requested resources") - - return reconcile.Result{}, err + if itemErrors != nil { + return reconcile.Result{}, nil } failedItems, err := r.processor.HandlePruning( ctx, c, tntResource.Status.ProcessedItems.AsSet(), - sets.Set[string](processedItems)) + sets.Set[string](processedItems), + ) if len(failedItems) > 0 { tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) @@ -252,6 +251,8 @@ func (r *namespacedResourceController) reconcileDelete( if *tntResource.Spec.PruningOnDelete { failedItems, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), nil) if len(failedItems) > 0 { + log.V(5).Info("failed items", "amount", len(failedItems), "items", failedItems) + tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) for _, item := range failedItems { @@ -259,6 +260,9 @@ func (r *namespacedResourceController) reconcileDelete( tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) } } + + log.V(5).Info("new status", "status", tntResource.Status.ProcessedItems) + } if len(failedItems) > 0 || err != nil { diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index e515cce33..d8bfe79ff 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -69,6 +69,8 @@ func (r *Processor) HandlePruning( for item := range diff { or := capsulev1beta2.ObjectReferenceStatus{} if sectionErr := or.ParseFromString(item); sectionErr != nil { + processed.Insert(or.String()) + log.Error(sectionErr, "unable to parse resource to prune", "resource", item) continue @@ -79,6 +81,8 @@ func (r *Processor) HandlePruning( obj.SetName(or.Name) obj.SetGroupVersionKind(schema.FromAPIVersionAndKind(or.APIVersion, or.Kind)) + log.V(5).Info("pruning", "resource", obj.GroupVersionKind(), "name", obj.GetName(), "namespace", obj.GetNamespace()) + if sectionErr := c.Delete(ctx, &obj); err != sectionErr { if apierr.IsNotFound(sectionErr) { // Object may have been already deleted, we can ignore this error @@ -88,15 +92,14 @@ func (r *Processor) HandlePruning( or.Status = metav1.ConditionFalse or.Message = sectionErr.Error() or.Type = meta.PruningCondition - - log.Error(err, "unable to prune resource", "resource", item) + processed.Insert(or.String()) err = errors.Join(sectionErr) - } - processed.Insert(or.String()) + continue + } - log.Info("resource has been pruned", "resource", item) + log.V(5).Info("resource has been pruned", "resource", item) } return processed.List(), nil diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go index e940c6ac7..6c30e2522 100644 --- a/controllers/resources/utils.go +++ b/controllers/resources/utils.go @@ -57,28 +57,29 @@ func SetTenantResourceServiceAccount( ) (changed bool) { changed = false + // If name is empty, remove the whole reference if resource.Spec.ServiceAccount == nil || resource.Spec.ServiceAccount.Name == "" { - if !setTenantDefaultResourceServiceAccount(config, resource) { + // If a default is configured, apply it + if setTenantDefaultResourceServiceAccount(config, resource) { + changed = true + } else { + // Remove invalid ServiceAccount reference + if resource.Spec.ServiceAccount != nil { + resource.Spec.ServiceAccount = nil + changed = true + } return } - - changed = true } - // Always sanitize the Name field (strip any colons, etc.) + // Sanitize the Name sanitizedName := caputils.SanitizeServiceAccountProp(resource.Spec.ServiceAccount.Name.String()) if resource.Spec.ServiceAccount.Name.String() != sanitizedName { resource.Spec.ServiceAccount.Name = api.Name(sanitizedName) changed = true } - if resource.Spec.ServiceAccount.Name == "" && resource.Spec.ServiceAccount.Namespace != "" { - resource.Spec.ServiceAccount = nil - changed = true - - return - } - + // Always set the namespace to match the resource sanitizedNS := caputils.SanitizeServiceAccountProp(resource.Namespace) if resource.Spec.ServiceAccount.Namespace.String() != sanitizedNS { resource.Spec.ServiceAccount.Namespace = api.Name(sanitizedNS) diff --git a/e2e/tenantresource_impersonation_test.go b/e2e/tenantresource_impersonation_test.go index 05b74e215..a918c46f0 100644 --- a/e2e/tenantresource_impersonation_test.go +++ b/e2e/tenantresource_impersonation_test.go @@ -25,7 +25,10 @@ import ( "github.com/projectcapsule/capsule/pkg/meta" ) -var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource"), func() { +var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource", "config"), func() { + originConfig := &capsulev1beta2.CapsuleConfiguration{} + testConfig := &capsulev1beta2.CapsuleConfiguration{} + solar := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ Name: "tenantresource-imp-config", @@ -174,6 +177,10 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource } JustBeforeEach(func() { + // Save the current state of the argoaddon configuration + Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed()) + testConfig = originConfig.DeepCopy() + EventuallyCreation(func() error { return k8sClient.Create(context.TODO(), solar) }).Should(Succeed()) @@ -186,6 +193,18 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource JustAfterEach(func() { Expect(k8sClient.Delete(context.TODO(), crossNamespaceItem)).Should(Succeed()) _ = k8sClient.Delete(context.TODO(), solar) + + // Restore Configuration + Eventually(func() error { + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originConfig.Name}, testConfig); err != nil { + return err + } + + // Apply the initial configuration from originConfig to testConfig + testConfig.Spec = originConfig.Spec + return k8sClient.Update(context.Background(), testConfig) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) It("should replicate resources to all Tenant Namespaces", func() { @@ -245,7 +264,9 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource EventuallyCreation(func() error { return k8sClient.Update(context.TODO(), t) - }).ShouldNot(Succeed()) + }).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount).To(BeNil()) t.Spec.ServiceAccount = &api.ServiceAccountReference{ Name: "", @@ -255,11 +276,8 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource return k8sClient.Update(context.TODO(), t) }).Should(Succeed()) - err = k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) - Expect(err).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) Expect(t.Spec.ServiceAccount).To(BeNil()) - - t.SetResourceVersion("") t.Spec.ServiceAccount = &api.ServiceAccountReference{ Name: "system:serviceaccount:kube-system:replication-account", } @@ -274,15 +292,13 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource return k8sClient.Update(context.TODO(), t) }).Should(Succeed()) - err = k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) - Expect(err).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("replication-account")) Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) }) By("verify status (Verify ServiceAccount Names)", func() { - err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(tr), tr) - Expect(err).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(tr), tr)).Should(Succeed()) Expect(tr.Status.Condition.Status).To(Equal(metav1.ConditionFalse)) Expect(tr.Status.Condition.Type).To(Equal(meta.ReadyCondition)) diff --git a/pkg/webhook/route/tenantresource.go b/pkg/webhook/route/tenantresource.go index 394e8f74f..1b2eaf4e1 100644 --- a/pkg/webhook/route/tenantresource.go +++ b/pkg/webhook/route/tenantresource.go @@ -38,3 +38,19 @@ func (t tntResourcenamespaceMutation) GetPath() string { func (t tntResourcenamespaceMutation) GetHandlers() []capsulewebhook.Handler { return t.handlers } + +type tntResourceglobalMutation struct { + handlers []capsulewebhook.Handler +} + +func TenantResourceGlobalMutation(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook { + return &tntResourceglobalMutation{handlers: handlers} +} + +func (t tntResourceglobalMutation) GetPath() string { + return "/tenantresource/global/mutating" +} + +func (t tntResourceglobalMutation) GetHandlers() []capsulewebhook.Handler { + return t.handlers +} diff --git a/pkg/webhook/tenantresource/global_mutating.go b/pkg/webhook/tenantresource/global_mutating.go new file mode 100644 index 000000000..5caee93c0 --- /dev/null +++ b/pkg/webhook/tenantresource/global_mutating.go @@ -0,0 +1,71 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package tenantresource + +import ( + "context" + "encoding/json" + "net/http" + + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/controllers/resources" + "github.com/projectcapsule/capsule/pkg/configuration" + capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" + "github.com/projectcapsule/capsule/pkg/webhook/utils" +) + +type globalMutatingHandler struct { + configuration configuration.Configuration +} + +func GlobalMutatingHandler(configuration configuration.Configuration) capsulewebhook.Handler { + return &globalMutatingHandler{ + configuration: configuration, + } +} + +func (h *globalMutatingHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *globalMutatingHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return h.handler(req, decoder) + } +} + +func (h *globalMutatingHandler) OnUpdate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + return h.handler(req, decoder) + } +} + +func (h *globalMutatingHandler) handler(req admission.Request, decoder admission.Decoder) *admission.Response { + resource := &capsulev1beta2.GlobalTenantResource{} + if err := decoder.Decode(req, resource); err != nil { + return utils.ErroredResponse(err) + } + + changed := resources.SetGlobalTenantResourceServiceAccount(h.configuration, resource) + if !changed { + return nil + } + + // Marshal Manifest + marshaled, err := json.Marshal(resource) + if err != nil { + response := admission.Errored(http.StatusInternalServerError, err) + + return &response + } + response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled) + + return &response +} From 501847da8cb181ce23dd80ce32f1ae3629ea70a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Thu, 10 Jul 2025 09:11:14 +0200 Subject: [PATCH 05/19] chore: progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- charts/capsule/ci/proxy-values.yaml | 2 - ...sule.clastix.io_capsuleconfigurations.yaml | 14 +- controllers/resources/global.go | 2 +- controllers/resources/namespaced.go | 4 +- controllers/resources/utils.go | 36 +- e2e/tenantresource_impersonation_test.go | 404 ++++++++++++++---- e2e/tenantresource_test.go | 2 +- pkg/api/serviceaccount_config.go | 10 +- pkg/metrics/globaltenantresource_recorder.go | 19 +- pkg/metrics/tenantresource_recorder.go | 20 +- test.yaml | 34 ++ 11 files changed, 430 insertions(+), 117 deletions(-) create mode 100644 test.yaml diff --git a/charts/capsule/ci/proxy-values.yaml b/charts/capsule/ci/proxy-values.yaml index c657eb947..465ea9749 100644 --- a/charts/capsule/ci/proxy-values.yaml +++ b/charts/capsule/ci/proxy-values.yaml @@ -3,8 +3,6 @@ proxy: manager: options: useProxyForServiceAccountClient: true - serviceAccountClient: - skipTlsVerify: true resources: requests: cpu: 200m diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index ffb7560cd..528a21ba7 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -136,9 +136,19 @@ spec: type: string globalDefaultServiceAccount: description: |- - Default ServiceAccount for namespaced resources (GlobalTenantResource) + Default ServiceAccount for global resources (GlobalTenantResource) When defined, users are required to use this ServiceAccount anywhere in the cluster unless they explicitly provide their own. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + globalDefaultServiceAccountNamespace: + description: |- + Default ServiceAccount for global resources (GlobalTenantResource) + When defined, users are required to use this ServiceAccount anywhere in the cluster + unless they explicitly provide their own. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string skipTlsVerify: default: false @@ -150,6 +160,8 @@ spec: Default ServiceAccount for namespaced resources (TenantResource) When defined, users are required to use this ServiceAccount within the namespace where they deploy the resource, unless they explicitly provide their own. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string type: object userGroups: diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 6fccb72fc..2b3e0c320 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -104,7 +104,7 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc if apierrors.IsNotFound(err) { log.V(3).Info("Request object not found, could have been deleted after reconcile request") - r.metrics.DeleteMetric(request.Name) + r.metrics.DeleteMetrics(request.Name) return reconcile.Result{}, nil } diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 9603cf935..4b30c77f6 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -100,7 +100,7 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re if apierrors.IsNotFound(err) { log.V(3).Info("Request object not found, could have been deleted after reconcile request") - r.metrics.DeleteMetric(request.Name) + r.metrics.DeleteMetrics(request.Name, request.Namespace) return reconcile.Result{}, nil } @@ -209,6 +209,8 @@ func (r *namespacedResourceController) reconcileNormal( itemErrors = errors.Join(itemErrors, sectionErr) } + log.Info("replicate items", "amount", len(items)) + processedItems.Insert(items...) } diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go index 6c30e2522..31955d164 100644 --- a/controllers/resources/utils.go +++ b/controllers/resources/utils.go @@ -30,7 +30,7 @@ func SetGlobalTenantResourceServiceAccount( return } - resource.Spec.ServiceAccount.Name = api.Name(caputils.SanitizeServiceAccountProp(cfg.TenantDefaultServiceAccount)) + resource.Spec.ServiceAccount.Name = api.Name(caputils.SanitizeServiceAccountProp(cfg.TenantDefaultServiceAccount.String())) changed = true } @@ -107,8 +107,40 @@ func setTenantDefaultResourceServiceAccount( } resource.Spec.ServiceAccount.Name = api.Name( - caputils.SanitizeServiceAccountProp(cfg.TenantDefaultServiceAccount), + caputils.SanitizeServiceAccountProp(cfg.TenantDefaultServiceAccount.String()), ) return true } + +func setGlobalTenantDefaultResourceServiceAccount( + config configuration.Configuration, + resource *capsulev1beta2.GlobalTenantResource, +) (changed bool) { + cfg := config.ServiceAccountClientProperties() + if cfg == nil { + return false + } + + if cfg.GlobalDefaultServiceAccount == "" && cfg.GlobalDefaultServiceAccountNamespace == "" { + return false + } + + if resource.Spec.ServiceAccount == nil { + resource.Spec.ServiceAccount = &api.ServiceAccountReference{} + } + + if cfg.GlobalDefaultServiceAccount == "" { + resource.Spec.ServiceAccount.Name = api.Name( + caputils.SanitizeServiceAccountProp(cfg.GlobalDefaultServiceAccount.String()), + ) + } + + if cfg.GlobalDefaultServiceAccountNamespace == "" { + resource.Spec.ServiceAccount.Namespace = api.Name( + caputils.SanitizeServiceAccountProp(cfg.GlobalDefaultServiceAccountNamespace.String()), + ) + } + + return true +} diff --git a/e2e/tenantresource_impersonation_test.go b/e2e/tenantresource_impersonation_test.go index a918c46f0..b2c966c51 100644 --- a/e2e/tenantresource_impersonation_test.go +++ b/e2e/tenantresource_impersonation_test.go @@ -12,6 +12,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" @@ -25,13 +26,17 @@ import ( "github.com/projectcapsule/capsule/pkg/meta" ) +var ( + suiteLabelValue = "e2e-tenantresource-impersonation" +) + var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource", "config"), func() { originConfig := &capsulev1beta2.CapsuleConfiguration{} testConfig := &capsulev1beta2.CapsuleConfiguration{} solar := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ - Name: "tenantresource-imp-config", + Name: "tenantresource-imp", }, Spec: capsulev1beta2.TenantSpec{ Owners: capsulev1beta2.OwnerListSpec{ @@ -84,7 +89,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource { NamespaceSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ - "replicate": "true", + "replicate": "tenantresource-imp", }, }, NamespacedItems: []capsulev1beta2.ObjectReference{ @@ -96,7 +101,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource }, Selector: metav1.LabelSelector{ MatchLabels: map[string]string{ - "replicate": "tenantresource-imp", + "replicate": "true", }, }, }, @@ -176,16 +181,19 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource }, } + solarNs := []string{"tenantresource-imp-one", "tenantresource-imp-two", "tenantresource-imp-three"} + JustBeforeEach(func() { - // Save the current state of the argoaddon configuration Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed()) testConfig = originConfig.DeepCopy() EventuallyCreation(func() error { + solar.ResourceVersion = "" return k8sClient.Create(context.TODO(), solar) }).Should(Succeed()) EventuallyCreation(func() error { + crossNamespaceItem.ResourceVersion = "" return k8sClient.Create(context.TODO(), crossNamespaceItem) }).Should(Succeed()) }) @@ -205,23 +213,179 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource return k8sClient.Update(context.Background(), testConfig) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + Eventually(func() error { + poolList := &rbacv1.RoleBindingList{} + labelSelector := client.MatchingLabels{"e2e-test": suiteLabelValue} + if err := k8sClient.List(context.TODO(), poolList, labelSelector); err != nil { + return err + } + + for _, pool := range poolList.Items { + if err := k8sClient.Delete(context.TODO(), &pool); err != nil { + return err + } + } + + return nil + }, "30s", "5s").Should(Succeed()) + + Eventually(func() error { + poolList := &corev1.ServiceAccountList{} + labelSelector := client.MatchingLabels{"e2e-test": suiteLabelValue} + if err := k8sClient.List(context.TODO(), poolList, labelSelector); err != nil { + return err + } + + for _, pool := range poolList.Items { + if err := k8sClient.Delete(context.TODO(), &pool); err != nil { + return err + } + } + + return nil + }, "30s", "5s").Should(Succeed()) + }) - It("should replicate resources to all Tenant Namespaces", func() { - solarNs := []string{"tenantresource-imp-one", "tenantresource-imp-two", "tenantresource-imp-three"} + It("Impersonation from ServiceAccount (From Config)", func() { + By("Verifying CapsuleConfiguration Influence", func() { + + NamespaceCreation(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "tenantresource-imp-config"}}, solar.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) + + t := tr.DeepCopy() + t.Namespace = "tenantresource-imp-config" + + Expect(k8sClient.Create(context.TODO(), t)).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + + Expect(t.Spec.ServiceAccount).To(BeNil()) + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "system:serviceaccount:kube-system:replication-account", + } + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("replication-account")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = nil + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount).To(BeNil()) + + testConfig.Spec.ServiceAccountClient = &api.ServiceAccountClient{ + TenantDefaultServiceAccount: "default-gitops", + } + Expect(k8sClient.Update(context.TODO(), testConfig)).Should(Succeed()) + + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("default-gitops")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(testConfig), testConfig) + testConfig.Spec.ServiceAccountClient = &api.ServiceAccountClient{ + TenantDefaultServiceAccount: "illegal:name", + } + return k8sClient.Update(context.TODO(), testConfig) + }).ShouldNot(Succeed()) + + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "system:serviceaccount:kube-system:custom-account", + Namespace: "kube-system", + } + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("custom-account")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = nil + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("default-gitops")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(testConfig), testConfig) + testConfig.Spec.ServiceAccountClient = &api.ServiceAccountClient{ + TenantDefaultServiceAccount: "", + } + return k8sClient.Update(context.TODO(), testConfig) + }).Should(Succeed()) + + // It's still going to be the default, as we are not tracking the relation between default from the config + // and the TenantResource. + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("default-gitops")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = nil + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + + }) + }) + + It("should replicate resources to all Tenant Namespaces", func() { By("creating solar Namespaces", func() { for _, ns := range append(solarNs, "tenantresource-imp-system") { NamespaceCreation(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}, solar.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed()) } - }) - By("labelling Namespaces", func() { - for _, name := range solarNs { + // Create the ServiceAccount in tenantresource-imp-system + adminSA := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-gitops", + Namespace: tr.GetNamespace(), + Labels: map[string]string{ + "e2e-test": suiteLabelValue, + }, + }, + } + Expect(k8sClient.Create(context.TODO(), adminSA)).To(Succeed()) + + for _, name := range append(solarNs, tr.GetNamespace()) { + role := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenantresource-imp-binding", + Labels: map[string]string{ + "e2e-test": suiteLabelValue, + }, + Namespace: name, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: "default-gitops", + Namespace: tr.GetNamespace(), + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + Name: "admin", + APIGroup: "rbac.authorization.k8s.io", + }, + } + EventuallyWithOffset(1, func() error { ns := corev1.Namespace{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: name}, &ns)).Should(Succeed()) + Expect(k8sClient.Create(context.TODO(), role)).To(Succeed()) + labels := ns.GetLabels() if labels == nil { return fmt.Errorf("missing labels") @@ -240,80 +404,136 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource }).Should(Succeed()) }) - By("creating the TenantResource (Verify ServiceAccount Names)", func() { + By("verifying ServiceAccount Names", func() { t := tr.DeepCopy() - t.Spec.ServiceAccount = &api.ServiceAccountReference{ - Name: "privileged-account", - Namespace: "kube-system", - } - EventuallyCreation(func() error { + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "", + Namespace: "kube-system:", + } return k8sClient.Create(context.TODO(), t) }).Should(Succeed()) - - err := k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) - Expect(err).Should(Succeed()) - Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("privileged-account")) - Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) - - t.Spec.ServiceAccount = &api.ServiceAccountReference{ - Name: "", - Namespace: "kube-system:", - } + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount).To(BeNil()) EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "", + Namespace: "kube-system", + } return k8sClient.Update(context.TODO(), t) }).Should(Succeed()) - Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) Expect(t.Spec.ServiceAccount).To(BeNil()) - t.Spec.ServiceAccount = &api.ServiceAccountReference{ - Name: "", - Namespace: "kube-system", - } EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "privileged-account", + Namespace: "kube-system", + } return k8sClient.Update(context.TODO(), t) }).Should(Succeed()) Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) - Expect(t.Spec.ServiceAccount).To(BeNil()) - t.Spec.ServiceAccount = &api.ServiceAccountReference{ - Name: "system:serviceaccount:kube-system:replication-account", - } + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("privileged-account")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "system:serviceaccount:kube-system:replication-3-account", + } return k8sClient.Update(context.TODO(), t) - }).ShouldNot(Succeed()) + }).Should(Succeed()) + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("replication-3-account")) + Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) - t.Spec.ServiceAccount = &api.ServiceAccountReference{ - Name: "replication-account", - } EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "replication-account", + } return k8sClient.Update(context.TODO(), t) }).Should(Succeed()) - Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) Expect(t.Spec.ServiceAccount.Name.String()).To(Equal("replication-account")) Expect(t.Spec.ServiceAccount.Namespace.String()).To(Equal(t.Namespace)) + + By("verify status (Verify ServiceAccount Names)", func() { + t := tr.DeepCopy() + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) + + Expect(t.Status.Condition.Status).To(Equal(metav1.ConditionFalse)) + Expect(t.Status.Condition.Type).To(Equal(meta.ReadyCondition)) + Expect(t.Status.Condition.Reason).To(Equal(meta.FailedReason)) + + found := true + for _, ns := range solarNs { + for _, name := range []string{"raw-secret-1", "raw-secret-2", "raw-secret-3"} { + foundInner := false + for _, status := range t.Status.ProcessedItems { + if status.Kind == "Secret" && + status.Name == name && + status.Namespace == ns && + status.Type == meta.ReplicationCondition && + status.Status == metav1.ConditionFalse { + foundInner = true + break + } + } + if !foundInner { + found = false + break + } + } + if !found { + break + } + } + + Expect(found).To(BeTrue()) + }) + + EventuallyCreation(func() error { + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + t.Spec.ServiceAccount = nil + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + + Expect(k8sClient.Delete(context.TODO(), t)).Should(Succeed()) }) - By("verify status (Verify ServiceAccount Names)", func() { - Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(tr), tr)).Should(Succeed()) + By("Recreating Object", func() { + t := tr.DeepCopy() + + EventuallyCreation(func() error { + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "default-gitops", + } + + return k8sClient.Create(context.TODO(), t) + }).Should(Succeed()) + + Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t)).Should(Succeed()) - Expect(tr.Status.Condition.Status).To(Equal(metav1.ConditionFalse)) - Expect(tr.Status.Condition.Type).To(Equal(meta.ReadyCondition)) - Expect(tr.Status.Condition.Reason).To(Equal(meta.FailedReason)) + Expect(t.Status.Condition.Status).To(Equal(metav1.ConditionTrue)) + Expect(t.Status.Condition.Type).To(Equal(meta.ReadyCondition)) + Expect(t.Status.Condition.Reason).To(Equal(meta.SucceededReason)) found := true for _, ns := range solarNs { for _, name := range []string{"raw-secret-1", "raw-secret-2", "raw-secret-3"} { foundInner := false - for _, status := range tr.Status.ProcessedItems { + for _, status := range t.Status.ProcessedItems { if status.Kind == "Secret" && status.Name == name && status.Namespace == ns && status.Type == meta.ReplicationCondition && - status.Status == metav1.ConditionFalse { + status.Status == metav1.ConditionTrue { foundInner = true break } @@ -329,47 +549,61 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource } Expect(found).To(BeTrue()) - }) - for _, ns := range solarNs { - By(fmt.Sprintf("waiting for replicated resources in %s Namespace", ns), func() { - Eventually(func() []corev1.Secret { - r, err := labels.NewRequirement("labels.energy.io", selection.DoubleEquals, []string{"replicate"}) - if err != nil { - return nil - } + for _, ns := range solarNs { + By(fmt.Sprintf("waiting for replicated resources in %s Namespace", ns), func() { + Eventually(func() []corev1.Secret { + r, err := labels.NewRequirement("labels.energy.io", selection.DoubleEquals, []string{"replicate"}) + if err != nil { + return nil + } - secrets := corev1.SecretList{} - err = k8sClient.List(context.TODO(), &secrets, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*r), Namespace: ns}) - if err != nil { - return nil - } + secrets := corev1.SecretList{} + err = k8sClient.List(context.TODO(), &secrets, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*r), Namespace: ns}) + if err != nil { + return nil + } - return secrets.Items - }, defaultTimeoutInterval, defaultPollInterval).Should(HaveLen(4)) - }) + return secrets.Items + }, defaultTimeoutInterval, defaultPollInterval).Should(HaveLen(4)) + }) - By(fmt.Sprintf("ensuring raw items are templated in %s Namespace", ns), func() { - for _, name := range []string{"raw-secret-1", "raw-secret-2", "raw-secret-3"} { - secret := corev1.Secret{} - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: name, Namespace: ns}, &secret)).ToNot(HaveOccurred()) + By(fmt.Sprintf("ensuring raw items are templated in %s Namespace", ns), func() { + for _, name := range []string{"raw-secret-1", "raw-secret-2", "raw-secret-3"} { + secret := corev1.Secret{} + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: name, Namespace: ns}, &secret)).ToNot(HaveOccurred()) - Expect(secret.Data).To(HaveKey(solar.Name)) - Expect(secret.Data).To(HaveKey(ns)) - } - }) - } + Expect(secret.Data).To(HaveKey(solar.Name)) + Expect(secret.Data).To(HaveKey(ns)) + } + }) + } + + Expect(k8sClient.Delete(context.TODO(), tr)).Should(Succeed()) + + }) - By("using a Namespace selector", func() { - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tr.GetName(), Namespace: "solar-system"}, tr)).ToNot(HaveOccurred()) + By("using a Namespace selector ()", func() { + t := tr.DeepCopy() - tr.Spec.Resources[0].NamespaceSelector = &metav1.LabelSelector{ + t.Spec.Resources[0].NamespaceSelector = &metav1.LabelSelector{ MatchLabels: map[string]string{ - "kubernetes.io/metadata.name": "solar-three", + "kubernetes.io/metadata.name": "tenantresource-imp-three", }, } - Expect(k8sClient.Update(context.TODO(), tr)).ToNot(HaveOccurred()) + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "replication-account", + } + + EventuallyCreation(func() error { + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "default-gitops", + } + + return k8sClient.Create(context.TODO(), t) + + }).Should(Succeed()) checkFn := func(ns string) func() []corev1.Secret { return func() []corev1.Secret { @@ -388,17 +622,15 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource } } - for _, ns := range []string{"solar-one", "solar-two"} { + for _, ns := range []string{"tenantresource-imp-one", "tenantresource-imp-two", "tenantresource-imp-three"} { Eventually(checkFn(ns), defaultTimeoutInterval, defaultPollInterval).Should(HaveLen(0)) } - - Eventually(checkFn("solar-three"), defaultTimeoutInterval, defaultPollInterval).Should(HaveLen(4)) }) By("checking if replicated object have annotations and labels", func() { for _, name := range []string{"dummy-secret", "raw-secret-1", "raw-secret-2", "raw-secret-3"} { secret := corev1.Secret{} - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: name, Namespace: "solar-three"}, &secret)).ToNot(HaveOccurred()) + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: name, Namespace: "tenantresource-imp-three"}, &secret)).ToNot(HaveOccurred()) for k, v := range tr.Spec.Resources[0].AdditionalMetadata.Labels { _, err := HaveKeyWithValue(k, v).Match(secret.GetLabels()) @@ -424,7 +656,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource cs := ownerClient(solar.Spec.Owners[0]) Consistently(func() error { - return cs.CoreV1().Secrets("solar-three").Delete(context.TODO(), name, metav1.DeleteOptions{}) + return cs.CoreV1().Secrets("tenantresource-imp-three").Delete(context.TODO(), name, metav1.DeleteOptions{}) }, 10*time.Second, time.Second).Should(HaveOccurred()) } }) @@ -434,7 +666,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource cs := ownerClient(solar.Spec.Owners[0]) Consistently(func() error { - secret, err := cs.CoreV1().Secrets("solar-three").Get(context.TODO(), name, metav1.GetOptions{}) + secret, err := cs.CoreV1().Secrets("tenantresource-imp-three").Get(context.TODO(), name, metav1.GetOptions{}) if err != nil { return err } @@ -442,7 +674,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource secret.SetLabels(nil) secret.SetAnnotations(nil) - _, err = cs.CoreV1().Secrets("solar-three").Update(context.TODO(), secret, metav1.UpdateOptions{}) + _, err = cs.CoreV1().Secrets("tenantresource-imp-three").Update(context.TODO(), secret, metav1.UpdateOptions{}) return err }, 10*time.Second, time.Second).Should(HaveOccurred()) @@ -450,7 +682,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource }) By("checking that cross-namespace objects are not replicated", func() { - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tr.GetName(), Namespace: "solar-system"}, tr)).ToNot(HaveOccurred()) + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tr.GetName(), Namespace: "tenantresource-imp-system"}, tr)).ToNot(HaveOccurred()) tr.Spec.Resources[0].NamespacedItems = append(tr.Spec.Resources[0].NamespacedItems, capsulev1beta2.ObjectReference{ ObjectReferenceAbstract: capsulev1beta2.ObjectReferenceAbstract{ Kind: crossNamespaceItem.Kind, @@ -471,7 +703,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource }) By("checking pruning is deleted", func() { - Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tr.GetName(), Namespace: "solar-system"}, tr)).ToNot(HaveOccurred()) + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tr.GetName(), Namespace: "tenantresource-imp-system"}, tr)).ToNot(HaveOccurred()) Expect(*tr.Spec.PruningOnDelete).Should(BeTrue()) tr.Spec.PruningOnDelete = ptr.To(false) @@ -489,7 +721,7 @@ var _ = Describe("Using Impersonation on TenantResources", Label("tenantresource Consistently(func() []corev1.Secret { secrets := corev1.SecretList{} - err = k8sClient.List(context.TODO(), &secrets, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*r), Namespace: "solar-three"}) + err = k8sClient.List(context.TODO(), &secrets, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*r), Namespace: "tenantresource-imp-three"}) Expect(err).ToNot(HaveOccurred()) return secrets.Items diff --git a/e2e/tenantresource_test.go b/e2e/tenantresource_test.go index c24f57de6..2aa6482ac 100644 --- a/e2e/tenantresource_test.go +++ b/e2e/tenantresource_test.go @@ -24,7 +24,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api" ) -var _ = Describe("Creating a TenantResource object", Label("tenantresource"), func() { +var _ = Describe("Creating a TenantResource object", Label("tenantresource2"), func() { solar := &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ Name: "energy-solar", diff --git a/pkg/api/serviceaccount_config.go b/pkg/api/serviceaccount_config.go index f1eaba537..28a27f7b5 100644 --- a/pkg/api/serviceaccount_config.go +++ b/pkg/api/serviceaccount_config.go @@ -17,12 +17,16 @@ type ServiceAccountClient struct { // If true, TLS certificate verification is skipped (not recommended for production) // +kubebuilder:default=false SkipTLSVerify bool `json:"skipTlsVerify,omitempty"` - // Default ServiceAccount for namespaced resources (GlobalTenantResource) + // Default ServiceAccount for global resources (GlobalTenantResource) // When defined, users are required to use this ServiceAccount anywhere in the cluster // unless they explicitly provide their own. - GlobalDefaultServiceAccount string `json:"globalDefaultServiceAccount,omitempty"` + GlobalDefaultServiceAccount Name `json:"globalDefaultServiceAccount,omitempty"` + // Default ServiceAccount for global resources (GlobalTenantResource) + // When defined, users are required to use this ServiceAccount anywhere in the cluster + // unless they explicitly provide their own. + GlobalDefaultServiceAccountNamespace Name `json:"globalDefaultServiceAccountNamespace,omitempty"` // Default ServiceAccount for namespaced resources (TenantResource) // When defined, users are required to use this ServiceAccount within the namespace // where they deploy the resource, unless they explicitly provide their own. - TenantDefaultServiceAccount string `json:"tenantDefaultServiceAccount,omitempty"` + TenantDefaultServiceAccount Name `json:"tenantDefaultServiceAccount,omitempty"` } diff --git a/pkg/metrics/globaltenantresource_recorder.go b/pkg/metrics/globaltenantresource_recorder.go index 7762a4f1b..067d13f01 100644 --- a/pkg/metrics/globaltenantresource_recorder.go +++ b/pkg/metrics/globaltenantresource_recorder.go @@ -5,10 +5,10 @@ package metrics import ( "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/meta" ) type GlobalTenantResourceRecorder struct { @@ -30,7 +30,7 @@ func NewGlobalTenantResourceRecorder() *GlobalTenantResourceRecorder { Name: "global_resource_condition", Help: "The current condition status of a global tenant resource.", }, - []string{"name", "condition", "status", "reason"}, + []string{"name", "condition", "status"}, ), } } @@ -43,24 +43,23 @@ func (r *GlobalTenantResourceRecorder) Collectors() []prometheus.Collector { // RecordCondition records the condition as given for the ref. func (r *GlobalTenantResourceRecorder) RecordCondition(resource *capsulev1beta2.GlobalTenantResource) { - for _, status := range []string{meta.ReadyCondition} { + for _, status := range []metav1.ConditionStatus{metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown} { var value float64 - if status == resource.Status.Condition.Type { + if status == resource.Status.Condition.Status { value = 1 } r.resourceConditionGauge.WithLabelValues( resource.Name, - status, + resource.Status.Condition.Type, string(resource.Status.Condition.Status), - resource.Status.Condition.Reason, ).Set(value) } } // DeleteCondition deletes the condition metrics for the ref. -func (r *GlobalTenantResourceRecorder) DeleteMetric(resourceName string) { - for _, status := range []string{meta.ReadyCondition} { - r.resourceConditionGauge.DeleteLabelValues(resourceName, status) - } +func (r *GlobalTenantResourceRecorder) DeleteMetrics(resourceName string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": resourceName, + }) } diff --git a/pkg/metrics/tenantresource_recorder.go b/pkg/metrics/tenantresource_recorder.go index cf4cec89a..caec5db93 100644 --- a/pkg/metrics/tenantresource_recorder.go +++ b/pkg/metrics/tenantresource_recorder.go @@ -5,10 +5,10 @@ package metrics import ( "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/meta" ) type TenantResourceRecorder struct { @@ -30,7 +30,7 @@ func NewTenantResourceRecorder() *TenantResourceRecorder { Name: "resource_condition", Help: "The current condition status of a tenant resource.", }, - []string{"name", "target_namespace", "condition", "status", "reason"}, + []string{"name", "target_namespace", "condition", "status"}, ), } } @@ -43,25 +43,25 @@ func (r *TenantResourceRecorder) Collectors() []prometheus.Collector { // RecordCondition records the condition as given for the ref. func (r *TenantResourceRecorder) RecordCondition(resource *capsulev1beta2.TenantResource) { - for _, status := range []string{meta.ReadyCondition} { + for _, status := range []metav1.ConditionStatus{metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown} { var value float64 - if status == resource.Status.Condition.Type { + if status == resource.Status.Condition.Status { value = 1 } r.resourceConditionGauge.WithLabelValues( resource.Name, resource.Namespace, - status, + resource.Status.Condition.Type, string(resource.Status.Condition.Status), - resource.Status.Condition.Reason, ).Set(value) } } // DeleteCondition deletes the condition metrics for the ref. -func (r *TenantResourceRecorder) DeleteMetric(resourceName string) { - for _, status := range []string{meta.ReadyCondition} { - r.resourceConditionGauge.DeleteLabelValues(resourceName, status) - } +func (r *TenantResourceRecorder) DeleteMetrics(resourceName string, resourceNamespace string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": resourceName, + "target_namespace": resourceNamespace, + }) } diff --git a/test.yaml b/test.yaml new file mode 100644 index 000000000..6e8beb801 --- /dev/null +++ b/test.yaml @@ -0,0 +1,34 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: renewable-pull-secrets +spec: + tenantSelector: + matchLabels: + energy: renewable + resyncPeriod: 5s + resources: + - templates: + resources: + - index: "sec" + apiVersion: v1 + kind: Secret + items: + - | + --- + apiVersion: v1 + kind: Secret + metadata: + name: "some-secret-bruv" + stringData: + username: "some-username" + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: "the-context" + data: + context: | + {{ . | toYaml | nindent 4}} + + From 15c6977baa8398e236de7b684d85d3d2fbd9b350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Thu, 14 Aug 2025 13:19:26 +0200 Subject: [PATCH 06/19] feat: add error interval for secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- Makefile | 1 + .../templates/configuration-default.yaml | 2 +- charts/capsule/values.yaml | 2 + controllers/resources/utils_test.go | 79 ------------------- 4 files changed, 4 insertions(+), 80 deletions(-) delete mode 100644 controllers/resources/utils_test.go diff --git a/Makefile b/Makefile index 08fa7c0d8..a1fd9b63b 100644 --- a/Makefile +++ b/Makefile @@ -146,6 +146,7 @@ dev-setup: --create-namespace \ --set 'crds.install=true' \ --set 'crds.exclusive=true'\ + --set 'crds.createConfig=true'\ --set "webhooks.exclusive=true"\ --set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \ diff --git a/charts/capsule/templates/configuration-default.yaml b/charts/capsule/templates/configuration-default.yaml index 95266bea2..25790a897 100644 --- a/charts/capsule/templates/configuration-default.yaml +++ b/charts/capsule/templates/configuration-default.yaml @@ -1,4 +1,4 @@ -{{- if not $.Values.crds.exclusive }} +{{- if and (not $.Values.crds.exclusive) (not $.Values.crds.createConfig) }} apiVersion: capsule.clastix.io/v1beta2 kind: CapsuleConfiguration metadata: diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 6129e0286..decab6229 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -57,6 +57,8 @@ crds: install: true # -- Only install the CRDs, no other primitives exclusive: false + # -- Create additionally CapsuleConfiguration even if CRDs are exclusive + createConfig: false # -- Extra Labels for CRDs labels: {} # -- Extra Annotations for CRDs diff --git a/controllers/resources/utils_test.go b/controllers/resources/utils_test.go deleted file mode 100644 index 63b3a7021..000000000 --- a/controllers/resources/utils_test.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2020-2023 Project Capsule Authors. -// SPDX-License-Identifier: Apache-2.0 - -package resources - -import ( - "os" - "testing" - - capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" - "github.com/stretchr/testify/assert" -) - -type mockConfig struct { - props *api.ServiceAccountClient -} - -func (m *mockConfig) ServiceAccountClientProperties() *api.ServiceAccountClient { - return m.props -} - -func TestSetGlobalTenantResourceServiceAccount(t *testing.T) { - // Clear env to avoid side effects - _ = os.Unsetenv("NAMESPACE") - - t.Run("Should sanitize malformed name", func(t *testing.T) { - resource := &capsulev1beta2.GlobalTenantResource{} - resource.Spec.ServiceAccount.Name = "invalid:name" - - cfg := &mockConfig{props: &api.ServiceAccountClient{}} - - changed := SetGlobalTenantResourceServiceAccount(cfg, resource) - assert.True(t, changed) - assert.Equal(t, "name", resource.Spec.ServiceAccount.Name.String()) - }) - - t.Run("Should not change if everything is valid", func(t *testing.T) { - resource := &capsulev1beta2.GlobalTenantResource{} - resource.Spec.ServiceAccount.Name = "valid" - resource.Spec.ServiceAccount.Namespace = "validns" - - changed := SetGlobalTenantResourceServiceAccount(&mockConfig{}, resource) - assert.False(t, changed) - }) - - t.Run("Should set default namespace from env if empty", func(t *testing.T) { - _ = os.Setenv("NAMESPACE", "myns") - resource := &capsulev1beta2.GlobalTenantResource{} - resource.Spec.ServiceAccount.Name = "sa" - - changed := SetGlobalTenantResourceServiceAccount(&mockConfig{}, resource) - assert.True(t, changed) - assert.Equal(t, "myns", resource.Spec.ServiceAccount.Namespace.String()) - }) -} - -func TestSetTenantResourceServiceAccount(t *testing.T) { - t.Run("Should sanitize name and set namespace from resource", func(t *testing.T) { - resource := &capsulev1beta2.TenantResource{} - resource.Spec.ServiceAccount.Name = "some:sa" - resource.Namespace = "tenant:ns" - - changed := SetTenantResourceServiceAccount(&mockConfig{}, resource) - assert.True(t, changed) - assert.Equal(t, "sa", resource.Spec.ServiceAccount.Name.String()) - assert.Equal(t, "tenantns", resource.Spec.ServiceAccount.Namespace.String()) - }) - - t.Run("Should not change if all values already valid", func(t *testing.T) { - resource := &capsulev1beta2.TenantResource{} - resource.Spec.ServiceAccount.Name = "sa" - resource.Spec.ServiceAccount.Namespace = "ns" - resource.Namespace = "ns" - - changed := SetTenantResourceServiceAccount(&mockConfig{}, resource) - assert.False(t, changed) - }) -} From 2fa719434351bb624ee7fa6015ff5adca82c91b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Thu, 14 Aug 2025 14:25:59 +0200 Subject: [PATCH 07/19] feat(config): add ignore user groups property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- charts/capsule/templates/configuration-default.yaml | 2 +- charts/capsule/templates/mutatingwebhookconfiguration.yaml | 2 +- charts/capsule/templates/validatingwebhookconfiguration.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/capsule/templates/configuration-default.yaml b/charts/capsule/templates/configuration-default.yaml index 25790a897..7aece45ea 100644 --- a/charts/capsule/templates/configuration-default.yaml +++ b/charts/capsule/templates/configuration-default.yaml @@ -1,4 +1,4 @@ -{{- if and (not $.Values.crds.exclusive) (not $.Values.crds.createConfig) }} +{{- if or (not $.Values.crds.exclusive) ($.Values.crds.createConfig) }} apiVersion: capsule.clastix.io/v1beta2 kind: CapsuleConfiguration metadata: diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 08872fe34..f0737335e 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -2,7 +2,7 @@ apiVersion: admissionregistration.k8s.io/v1 kind: MutatingWebhookConfiguration metadata: - name: {{ include "capsule.fullname" . }}-webhook + name: {{ include "capsule.fullname" . }}-mutating-webhook-configuration labels: {{- include "capsule.labels" . | nindent 4 }} annotations: diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 802dd9ff9..5d5f9ab36 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -2,7 +2,7 @@ apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingWebhookConfiguration metadata: - name: {{ include "capsule.fullname" . }}-webhook + name: {{ include "capsule.fullname" . }}-validating-webhook-configuration labels: {{- include "capsule.labels" . | nindent 4 }} annotations: From 2bad17426240316ae63a1d346fdc88288662c442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Mon, 18 Aug 2025 12:28:08 +0200 Subject: [PATCH 08/19] fix: regenerate manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- e2e/globaltenantresource_test.go | 7 +++++++ e2e/tenantresource_test.go | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/e2e/globaltenantresource_test.go b/e2e/globaltenantresource_test.go index 25ac38954..e6edd946b 100644 --- a/e2e/globaltenantresource_test.go +++ b/e2e/globaltenantresource_test.go @@ -197,6 +197,13 @@ var _ = Describe("Creating a GlobalTenantResource object", func() { } }) + By("verify labels/annotations are not redirect to TenantResource", func() { + trVerify := &capsulev1beta2.GlobalTenantResource{} + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: gtr.GetName(), Namespace: gtr.GetNamespace()}, trVerify)).ToNot(HaveOccurred()) + + Expect(trVerify.Spec.Resources[0].AdditionalMetadata).To(Equal(gtr.Spec.Resources[0].AdditionalMetadata)) + }) + for _, ns := range append(solarNs, windNs...) { By(fmt.Sprintf("waiting for replicated resources in %s Namespace", ns), func() { Eventually(func() []corev1.Secret { diff --git a/e2e/tenantresource_test.go b/e2e/tenantresource_test.go index 2aa6482ac..f92bcac55 100644 --- a/e2e/tenantresource_test.go +++ b/e2e/tenantresource_test.go @@ -226,6 +226,13 @@ var _ = Describe("Creating a TenantResource object", Label("tenantresource2"), f }).Should(Succeed()) }) + By("verify labels/annotations are not redirect to TenantResource", func() { + trVerify := &capsulev1beta2.TenantResource{} + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tr.GetName(), Namespace: tr.GetNamespace()}, trVerify)).ToNot(HaveOccurred()) + + Expect(trVerify.Spec.Resources[0].AdditionalMetadata).To(Equal(tr.Spec.Resources[0].AdditionalMetadata)) + }) + for _, ns := range solarNs { By(fmt.Sprintf("waiting for replicated resources in %s Namespace", ns), func() { Eventually(func() []corev1.Secret { From 18fe577cd0bb025c5b45925a80977e50810e6e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 20 Aug 2025 20:17:30 +0200 Subject: [PATCH 09/19] little progress --- api/v1beta2/tenantresource_global.go | 17 ++++++++++ controllers/resources/processor.go | 46 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index 29eb0076a..4c3ec5853 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -10,8 +10,25 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ) +// +kubebuilder:validation:Enum=Namespace;Tenant +type GlobalTenantResourceScope string + +func (p GlobalTenantResourceScope) String() string { + return string(p) +} + +const ( + GlobalTenantResourceScopeNamespace GlobalTenantResourceScope = "Namespace" + GlobalTenantResourceScopeTenant GlobalTenantResourceScope = "Tenant" +) + // GlobalTenantResourceSpec defines the desired state of GlobalTenantResource. type GlobalTenantResourceSpec struct { + // Resource Scope, Can either be + // - Tenant: Create Resources for each tenant in selected Tenants + // - Namespace: Create Resources for each namespace in selected Tenants + // +kubebuilder:default:=Namespace + Scope GlobalTenantResourceScope `json:"scope"` // Defines the Tenant selector used target the tenants on which resources must be propagated. TenantSelector metav1.LabelSelector `json:"tenantSelector,omitempty"` TenantResourceSpec `json:",inline"` diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index d8bfe79ff..7c1edd6ff 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -9,6 +9,7 @@ import ( "fmt" "sync" + "github.com/rs/zerolog/log" "github.com/valyala/fasttemplate" corev1 "k8s.io/api/core/v1" apierr "k8s.io/apimachinery/pkg/api/errors" @@ -105,6 +106,51 @@ func (r *Processor) HandlePruning( return processed.List(), nil } +//nolint:gocognit +func (r *Processor) HandleNamespaceSection( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + allowCrossNamespaceSelection bool, + tenantLabel string, + resourceIndex int, + spec capsulev1beta2.ResourceSpec, +) ([]string, error) { + + var err error + // Creating Namespace selector + var selector labels.Selector + + if spec.NamespaceSelector != nil { + selector, err = metav1.LabelSelectorAsSelector(spec.NamespaceSelector) + if err != nil { + log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication", "index", resourceIndex) + + return nil, err + } + } else { + selector = labels.NewSelector() + } + // Resources can be replicated only on Namespaces belonging to the same Global: + // preventing a boundary cross by enforcing the selection. + tntRequirement, err := labels.NewRequirement(tenantLabel, selection.Equals, []string{tnt.GetName()}) + if err != nil { + log.Error(err, "unable to create requirement for Namespace filtering and resource replication", "index", resourceIndex) + + return nil, err + } + + selector = selector.Add(*tntRequirement) + // Selecting the targeted Namespace according to the TenantResource specification. + namespaces := corev1.NamespaceList{} + if err = r.client.List(ctx, &namespaces, client.MatchingLabelsSelector{Selector: selector}); err != nil { + log.Error(err, "cannot retrieve Namespaces for resource", "index", resourceIndex) + + return nil, err + } + +} + //nolint:gocognit func (r *Processor) HandleSection( ctx context.Context, From 316d88690bdaff7f1e6122bdc707171b05762286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Mon, 25 Aug 2025 11:02:27 +0200 Subject: [PATCH 10/19] feat(docs): improve setup and ecosystem --- controllers/resources/processor.go | 45 ++++++++++-------------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 7c1edd6ff..987c39673 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -149,6 +149,19 @@ func (r *Processor) HandleNamespaceSection( return nil, err } + for _, ns := range namespaces.Items { + r.HandleSection( + ctx, + c, + tnt, + allowCrossNamespaceSelection, + tenantLabel, + resourceIndex, + spec, + ns + ) + } + } //nolint:gocognit @@ -160,40 +173,10 @@ func (r *Processor) HandleSection( tenantLabel string, resourceIndex int, spec capsulev1beta2.ResourceSpec, + ns *corev1.Namespace ) ([]string, error) { log := ctrllog.FromContext(ctx) - var err error - // Creating Namespace selector - var selector labels.Selector - - if spec.NamespaceSelector != nil { - selector, err = metav1.LabelSelectorAsSelector(spec.NamespaceSelector) - if err != nil { - log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication", "index", resourceIndex) - - return nil, err - } - } else { - selector = labels.NewSelector() - } - // Resources can be replicated only on Namespaces belonging to the same Global: - // preventing a boundary cross by enforcing the selection. - tntRequirement, err := labels.NewRequirement(tenantLabel, selection.Equals, []string{tnt.GetName()}) - if err != nil { - log.Error(err, "unable to create requirement for Namespace filtering and resource replication", "index", resourceIndex) - - return nil, err - } - - selector = selector.Add(*tntRequirement) - // Selecting the targeted Namespace according to the TenantResource specification. - namespaces := corev1.NamespaceList{} - if err = r.client.List(ctx, &namespaces, client.MatchingLabelsSelector{Selector: selector}); err != nil { - log.Error(err, "cannot retrieve Namespaces for resource", "index", resourceIndex) - - return nil, err - } // Generating additional metadata objAnnotations, objLabels := map[string]string{}, map[string]string{} From 837090059e5dff2d63a093c64a305a88264db214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Mon, 25 Aug 2025 15:47:04 +0200 Subject: [PATCH 11/19] feat(docs): improve setup and ecosystem --- ...psule.clastix.io_globaltenantresources.yaml | 18 ++++++++++++++++++ .../capsule.clastix.io_resourcepoolclaims.yaml | 10 ++++++++-- .../crds/capsule.clastix.io_resourcepools.yaml | 10 ++++++++-- .../capsule.clastix.io_tenantresources.yaml | 6 ++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index f65949e2e..119581680 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -211,6 +211,17 @@ spec: Define the period of time upon a second reconciliation must be invoked. Keep in mind that any change to the manifests will trigger a new reconciliation. type: string + scope: + default: Namespace + description: |- + Resource Scope, Can either be + - Cluster: Just once per cluster + - Tenant: Create Resources for each tenant in selected Tenants + - Namespace: Create Resources for each namespace in selected Tenants + enum: + - Namespace + - Tenant + type: string serviceAccount: description: |- Local ServiceAccount which will perform all the actions defined in the TenantResource @@ -277,6 +288,7 @@ spec: required: - resources - resyncPeriod + - scope type: object status: description: GlobalTenantResourceStatus defines the observed state of @@ -363,6 +375,12 @@ spec: Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ type: string + scope: + description: Resource Scope + enum: + - Namespace + - Tenant + type: string status: description: status of the condition, one of True, False, Unknown. enum: diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml index 9de2d79f5..6da2c51c7 100644 --- a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml +++ b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml @@ -137,15 +137,21 @@ spec: description: Reference to the GlobalQuota being claimed from properties: name: - description: Name + description: Name of The Resource maxLength: 253 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string namespace: - description: Namespace + description: Namespace of The Resource maxLength: 253 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + scope: + description: Scope of The Resource + enum: + - Namespace + - Tenant + type: string uid: description: UID of the tracked Tenant to pin point tracking type: string diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml index 617f4c2d5..365cc5530 100644 --- a/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml +++ b/charts/capsule/crds/capsule.clastix.io_resourcepools.yaml @@ -275,15 +275,21 @@ spec: description: Claimed resources type: object name: - description: Name + description: Name of The Resource maxLength: 253 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string namespace: - description: Namespace + description: Namespace of The Resource maxLength: 253 pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string + scope: + description: Scope of The Resource + enum: + - Namespace + - Tenant + type: string uid: description: UID of the tracked Tenant to pin point tracking type: string diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 05c727523..869684671 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -317,6 +317,12 @@ spec: Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ type: string + scope: + description: Resource Scope + enum: + - Namespace + - Tenant + type: string status: description: status of the condition, one of True, False, Unknown. enum: From 25e7473b8f95f4498edd249af81d3b373effad4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Tue, 26 Aug 2025 15:34:08 +0200 Subject: [PATCH 12/19] feat(docs): improve setup and ecosystem --- go.mod | 26 ++++++++--- go.sum | 88 ++++++++++++++++++++----------------- pkg/utils/serviceaccount.go | 6 +-- 3 files changed, 70 insertions(+), 50 deletions(-) diff --git a/go.mod b/go.mod index b67ebab25..8f9b1cd61 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.24.6 require ( github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.25.1 - github.com/onsi/gomega v1.38.1 + github.com/onsi/gomega v1.38.2 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.0 github.com/spf13/pflag v1.0.7 @@ -16,11 +16,11 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.0 golang.org/x/sync v0.16.0 - k8s.io/api v0.33.4 + k8s.io/api v0.34.0-rc.1 k8s.io/apiextensions-apiserver v0.33.4 - k8s.io/apimachinery v0.33.4 + k8s.io/apimachinery v0.34.0-rc.1 k8s.io/apiserver v0.33.4 - k8s.io/client-go v0.33.4 + k8s.io/client-go v0.34.0-rc.1 k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d sigs.k8s.io/cluster-api v1.11.0 sigs.k8s.io/controller-runtime v0.21.0 @@ -29,8 +29,10 @@ require ( require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect @@ -45,26 +47,34 @@ require ( github.com/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.35.0 // indirect @@ -73,12 +83,16 @@ require ( golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.36.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect diff --git a/go.sum b/go.sum index 6f08094b7..bc719aff0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= -cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -11,14 +11,14 @@ github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lpr github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0= @@ -63,8 +63,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= -github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -77,9 +77,8 @@ github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pI github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -109,12 +108,16 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.25.1 h1:Fwp6crTREKM+oA6Cz4MsO8RhKQzs2/gOIVOUscMAfZY= github.com/onsi/ginkgo/v2 v2.25.1/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk= github.com/onsi/gomega v1.38.1 h1:FaLA8GlcpXDwsb7m0h2A9ew2aTk3vnZMlzFgg5tz/pk= github.com/onsi/gomega v1.38.1/go.mod h1:LfcV8wZLvwcYRwPiJysphKAEsmcFnLMK/9c+PjvlX8g= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -142,8 +145,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -165,22 +168,22 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= -go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -198,8 +201,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= -golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -240,13 +243,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.71.3 h1:iEhneYTxOruJyZAxdAv8Y0iRZvsc5M6KoW7UA0/7jn0= -google.golang.org/grpc v1.71.3/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -261,14 +263,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk= k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc= +k8s.io/api v0.34.0-rc.1 h1:S4iMsAUFx9YBgUqBCrD2N9JrL2m85JdRVtfwVi4O3t4= +k8s.io/api v0.34.0-rc.1/go.mod h1:bntA2P5s25PXa3bIe29bG45qv3JvSJkfyf1MYMV0V1c= k8s.io/apiextensions-apiserver v0.33.4 h1:rtq5SeXiDbXmSwxsF0MLe2Mtv3SwprA6wp+5qh/CrOU= k8s.io/apiextensions-apiserver v0.33.4/go.mod h1:mWXcZQkQV1GQyxeIjYApuqsn/081hhXPZwZ2URuJeSs= k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s= k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apimachinery v0.34.0-rc.1 h1:2GL0UZ8BHpUoCFI2jaqoyTMvH9/upz7jfsRBnlHIzVM= +k8s.io/apimachinery v0.34.0-rc.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.33.4 h1:6N0TEVA6kASUS3owYDIFJjUH6lgN8ogQmzZvaFFj1/Y= k8s.io/apiserver v0.33.4/go.mod h1:8ODgXMnOoSPLMUg1aAzMFx+7wTJM+URil+INjbTZCok= k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw= k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY= +k8s.io/client-go v0.34.0-rc.1 h1:xrjjcJOOgyXrDkxHvEPRH2rFYF6CmMOZCMFV24UYUv8= +k8s.io/client-go v0.34.0-rc.1/go.mod h1:A1rEDyNrSFIXQ1T9pAmIGLujjUlv/CrXYAApVf4Nl1w= k8s.io/cluster-bootstrap v0.33.3 h1:u2NTxJ5CFSBFXaDxLQoOWMly8eni31psVso+caq6uwI= k8s.io/cluster-bootstrap v0.33.3/go.mod h1:p970f8u8jf273zyQ5raD8WUu2XyAl0SAWOY82o7i/ds= k8s.io/component-base v0.33.4 h1:Jvb/aw/tl3pfgnJ0E0qPuYLT0NwdYs1VXXYQmSuxJGY= @@ -279,8 +287,8 @@ k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 h1:liMHz39T5dJO1aOKHLvwaC k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/cluster-api v1.11.0 h1:4ZqKxjhdP3F/vvHMd675rGsDrT/siggnFPt5eKQ8nkI= sigs.k8s.io/cluster-api v1.11.0/go.mod h1:gGmNlHrtJe3z0YV3J6JRy5Rwh9SfzokjQaS+Fv3DBPE= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= diff --git a/pkg/utils/serviceaccount.go b/pkg/utils/serviceaccount.go index 59ac17821..3de77f283 100644 --- a/pkg/utils/serviceaccount.go +++ b/pkg/utils/serviceaccount.go @@ -36,10 +36,8 @@ func ImpersonatedKubernetesClientForServiceAccount( } impersonated := rest.CopyConfig(base) - impersonated.Impersonate = rest.ImpersonationConfig{ - UserName: reference.GetFullName(), - Groups: groups, - } + impersonated.Impersonate.UserName = reference.GetFullName() + impersonated.Impersonate.Groups = groups k8sClient, err := client.New(impersonated, client.Options{Scheme: scheme}) if err != nil { From 5fd0b1c4f323ea00903b6053b570253251ded518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Sat, 30 Aug 2025 02:01:24 +0200 Subject: [PATCH 13/19] fix: correct base64 translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- api/v1beta2/tenantresource_types.go | 1 - .../crds/capsule.clastix.io_tenants.yaml | 16 ++--- .../mutatingwebhookconfiguration.yaml | 58 +------------------ .../validatingwebhookconfiguration.yaml | 16 +---- controllers/resources/global.go | 28 +++++++-- controllers/resources/namespaced.go | 2 +- controllers/resources/processor.go | 5 +- controllers/resources/utils.go | 45 +++++++------- global-scope.yaml | 20 +++++++ 9 files changed, 77 insertions(+), 114 deletions(-) create mode 100644 global-scope.yaml diff --git a/api/v1beta2/tenantresource_types.go b/api/v1beta2/tenantresource_types.go index 26493bb95..112c0cd4a 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -29,7 +29,6 @@ type ObjectReferenceStatus struct { // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names Name string `json:"name"` // Tenant of the referent. - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ Tenant string `json:"tenant,omitempty"` // status of the condition, one of True, False, Unknown. // +required diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 39df0c857..a4c3526e8 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -700,11 +700,11 @@ spec: podSelector: description: |- podSelector selects the pods to which this NetworkPolicy object applies. - The array of ingress rules is applied to any pods selected by this field. + The array of rules is applied to any pods selected by this field. An empty + selector matches all pods in the policy's namespace. Multiple network policies can select the same set of pods. In this case, the ingress rules for each are combined additively. - This field is NOT optional and follows standard label selector semantics. - An empty podSelector matches all pods in this namespace. + This field is optional. If it is not specified, it defaults to an empty selector. properties: matchExpressions: description: matchExpressions is a list of label selector @@ -768,8 +768,6 @@ spec: type: string type: array x-kubernetes-list-type: atomic - required: - - podSelector type: object type: array type: object @@ -1928,11 +1926,11 @@ spec: podSelector: description: |- podSelector selects the pods to which this NetworkPolicy object applies. - The array of ingress rules is applied to any pods selected by this field. + The array of rules is applied to any pods selected by this field. An empty + selector matches all pods in the policy's namespace. Multiple network policies can select the same set of pods. In this case, the ingress rules for each are combined additively. - This field is NOT optional and follows standard label selector semantics. - An empty podSelector matches all pods in this namespace. + This field is optional. If it is not specified, it defaults to an empty selector. properties: matchExpressions: description: matchExpressions is a list of label selector @@ -1996,8 +1994,6 @@ spec: type: string type: array x-kubernetes-list-type: atomic - required: - - podSelector type: object type: array type: object diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index 5dda70f87..76f01ddf1 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -196,62 +196,6 @@ webhooks: scope: '*' sideEffects: NoneOnDryRun timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} -{{- end }} - {{- with .Values.webhooks.hooks.tenantResources.namespaced.mutation }} -- admissionReviewVersions: - - v1 - clientConfig: - {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/namespaced/mutating" "ctx" $) | nindent 4 }} - failurePolicy: {{ .failurePolicy }} - name: namespaced.resource-objects.tenant.projectcapsule.dev - {{- with .namespaceSelector }} - namespaceSelector: - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .objectSelector }} - objectSelector: - {{- toYaml . | nindent 4 }} - {{- end }} - reinvocationPolicy: {{ .reinvocationPolicy }} - rules: - - apiGroups: - - capsule.clastix.io - apiVersions: - - v1beta2 - operations: - - UPDATE - - CREATE - resources: - - 'tenantresources' - scope: Namespaced - sideEffects: None - timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} - {{- end }} - {{- with .Values.webhooks.hooks.tenantResources.global.mutation }} -- admissionReviewVersions: - - v1 - clientConfig: - {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/global/mutating" "ctx" $) | nindent 4 }} - failurePolicy: {{ .failurePolicy }} - name: global.resource-objects.tenant.projectcapsule.dev - {{- with .objectSelector }} - objectSelector: - {{- toYaml . | nindent 4 }} - {{- end }} - reinvocationPolicy: {{ .reinvocationPolicy }} - rules: - - apiGroups: - - capsule.clastix.io - apiVersions: - - v1beta2 - operations: - - UPDATE - - CREATE - resources: - - 'globaltenantresources' - scope: '*' - sideEffects: None - timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} {{- with .Values.webhooks.hooks.resourcepools.pools }} @@ -330,4 +274,4 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} -{{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 5c313c757..8fd52dc76 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -339,26 +339,15 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} -<<<<<<< HEAD - -{{- with (mergeOverwrite (default dict .Values.webhooks.hooks.tenantResources.namespaced.mutation) (default dict .Values.webhooks.hooks.tenantResourceObjects)) }} -- admissionReviewVersions: - - v1 - clientConfig: - {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/objects/validating" "ctx" $) | nindent 4 }} - failurePolicy: {{ .failurePolicy }} - name: resource-objects.tenant.projectcapsule.dev -======= {{- with .Values.webhooks.hooks.tenantResourceObjects }} {{- if .enabled }} - name: resource-objects.tenant.projectcapsule.dev admissionReviewVersions: - v1 clientConfig: - {{- include "capsule.webhooks.service" (dict "path" "/tenantresource-objects" "ctx" $) | nindent 4 }} + {{- include "capsule.webhooks.service" (dict "path" "/tenantresource/objects/validating" "ctx" $) | nindent 4 }} failurePolicy: {{ .failurePolicy }} matchPolicy: {{ .matchPolicy }} ->>>>>>> 6bee346d43f6439fe78f01c11ba002ed38dd4a41 {{- with .namespaceSelector }} namespaceSelector: {{- toYaml . | nindent 4 }} @@ -367,13 +356,10 @@ webhooks: objectSelector: {{- toYaml . | nindent 4 }} {{- end }} -<<<<<<< HEAD -======= {{- with .matchConditions }} matchConditions: {{- toYaml . | nindent 4 }} {{- end }} ->>>>>>> 6bee346d43f6439fe78f01c11ba002ed38dd4a41 rules: - apiGroups: - '*' diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 4c82c375c..fadc0d15a 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -132,8 +132,10 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc if err != nil { return reconcile.Result{}, gherrors.Wrap(err, "failed to load serviceaccount client") } + if c == nil { - log.V(3).Info("received empty client for serviceaccount") + log.V(5).Info("received empty client for serviceaccount") + return reconcile.Result{}, nil } @@ -218,6 +220,22 @@ func (r *globalResourceController) reconcileNormal( // the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. processedItems := sets.NewString() + // Always post the processed items, as they allow users to track errors + defer func() { + tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + + for _, item := range processedItems.List() { + or := capsulev1beta2.ObjectReferenceStatus{} + if err := or.ParseFromString(item); err == nil { + tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + } else { + log.Error(err, "failed to parse processed item", "item", item) + } + } + }() + + var itemErrors error + for index, resource := range tntResource.Spec.Resources { tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) if labelErr != nil { @@ -233,10 +251,12 @@ func (r *globalResourceController) reconcileNormal( if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. - err = errors.Join(err, sectionErr) - } else { - processedItems.Insert(items...) + itemErrors = errors.Join(itemErrors, sectionErr) } + + log.Info("replicate items", "amount", len(items)) + + processedItems.Insert(items...) } } diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 5f24fe34b..3cde7e699 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -289,7 +289,7 @@ func (r *namespacedResourceController) loadClient( // Add ServiceAccount if required, Retriggers reconcile // This is done in the background, Everything else should be handeled at admission if changed := SetTenantResourceServiceAccount(r.configuration, tntResource); changed { - log.V(5).Info("adding default serviceAccount '%s'", tntResource.Spec.ServiceAccount.GetFullName()) + log.V(5).Info("adding default serviceAccount", "serviceaccount", tntResource.Spec.ServiceAccount.GetFullName()) return nil, nil } diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 3881a54e9..85b3eeed0 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -229,7 +229,7 @@ func (r *Processor) handleSection( codecFactory := serializer.NewCodecFactory(r.client.Scheme()) for nsIndex, item := range spec.NamespacedItems { - keysAndValues := []any{"index", nsIndex, "namespace", item.Namespace} + keysAndValues := []any{"index", nsIndex, "namespace", item.Namespace, "tenant", tnt.GetName()} // A TenantResource is created by a TenantOwner, and potentially, they could point to a resource in a non-owned // Namespace: this must be blocked by checking it this is the case. if !allowCrossNamespaceSelection && !tntNamespaces.Has(item.Namespace) { @@ -289,6 +289,7 @@ func (r *Processor) handleSection( replicatedItem.APIVersion = obj.GetAPIVersion() replicatedItem.Type = meta.ReplicationCondition replicatedItem.Scope = scope + replicatedItem.Tenant = tnt.GetName() if ns != nil { replicatedItem.Namespace = ns.Name @@ -361,10 +362,10 @@ func (r *Processor) handleSection( replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} replicatedItem.Name = obj.GetName() replicatedItem.Kind = obj.GetKind() - replicatedItem.Namespace = ns.Name replicatedItem.APIVersion = obj.GetAPIVersion() replicatedItem.Type = meta.ReplicationCondition replicatedItem.Scope = scope + replicatedItem.Tenant = tnt.GetName() if ns != nil { replicatedItem.Namespace = ns.Name diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go index 31955d164..9872e32c2 100644 --- a/controllers/resources/utils.go +++ b/controllers/resources/utils.go @@ -4,8 +4,6 @@ package resources import ( - "os" - capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/configuration" @@ -16,36 +14,34 @@ func SetGlobalTenantResourceServiceAccount( config configuration.Configuration, resource *capsulev1beta2.GlobalTenantResource, ) (changed bool) { - changed = false - name := caputils.SanitizeServiceAccountProp(resource.Spec.ServiceAccount.Name.String()) - if resource.Spec.ServiceAccount.Name != "" && resource.Spec.ServiceAccount.Name.String() != name { - resource.Spec.ServiceAccount.Name = api.Name(name) - changed = true - } + // If name is empty, remove the whole reference + if resource.Spec.ServiceAccount == nil || resource.Spec.ServiceAccount.Name == "" { + // If a default is configured, apply it + if setGlobalTenantDefaultResourceServiceAccount(config, resource) { + changed = true + } else { + if resource.Spec.ServiceAccount != nil { + resource.Spec.ServiceAccount = nil + changed = true + } - if resource.Spec.ServiceAccount.Name.String() == "" { - cfg := config.ServiceAccountClientProperties() - if cfg == nil || cfg.TenantDefaultServiceAccount != "" { return } + } - resource.Spec.ServiceAccount.Name = api.Name(caputils.SanitizeServiceAccountProp(cfg.TenantDefaultServiceAccount.String())) + // Sanitize the Name + sanitizedName := caputils.SanitizeServiceAccountProp(resource.Spec.ServiceAccount.Name.String()) + if resource.Spec.ServiceAccount.Name.String() != sanitizedName { + resource.Spec.ServiceAccount.Name = api.Name(sanitizedName) changed = true } - if resource.Spec.ServiceAccount.Namespace == "" { - dflt := caputils.SanitizeServiceAccountProp(os.Getenv("NAMESPACE")) - if resource.Spec.ServiceAccount.Namespace.String() != dflt { - resource.Spec.ServiceAccount.Namespace = api.Name(dflt) - changed = true - } - } else { - ns := caputils.SanitizeServiceAccountProp(resource.Spec.ServiceAccount.Namespace.String()) - if resource.Spec.ServiceAccount.Namespace.String() != ns { - resource.Spec.ServiceAccount.Namespace = api.Name(ns) - changed = true - } + // Always set the namespace to match the resource + sanitizedNS := caputils.SanitizeServiceAccountProp(resource.Namespace) + if resource.Spec.ServiceAccount.Namespace.String() != sanitizedNS { + resource.Spec.ServiceAccount.Namespace = api.Name(sanitizedNS) + changed = true } return @@ -68,6 +64,7 @@ func SetTenantResourceServiceAccount( resource.Spec.ServiceAccount = nil changed = true } + return } } diff --git a/global-scope.yaml b/global-scope.yaml new file mode 100644 index 000000000..6ae6ebaa8 --- /dev/null +++ b/global-scope.yaml @@ -0,0 +1,20 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: global-scope +spec: + tenantSelector: + matchLabels: + energy: renewable + scope: Tenant + resyncPeriod: 5s + resources: + - rawItems: + - apiVersion: v1 + kind: Secret + metadata: + name: "some-secret-bruv" + namespace: "solar-test" + stringData: + username: "some-username" + From a06956f2b4378c902c4b4368d2c97050b369e703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 8 Oct 2025 14:10:06 +0200 Subject: [PATCH 14/19] chore: only consider 0.10 --- api/v1beta2/tenantresource_global.go | 1 - ...sule.clastix.io_globaltenantresources.yaml | 1 - controllers/resources/global.go | 3 +- controllers/resources/namespaced.go | 3 +- controllers/resources/processor.go | 6 +- pkg/api/condition.go | 30 -------- pkg/api/condition_test.go | 76 ------------------- pkg/api/status.go | 1 - pkg/api/zz_generated.deepcopy.go | 16 ---- pkg/metrics/globaltenantresource_recorder.go | 37 ++++++--- pkg/metrics/tenantresource_recorder.go | 39 +++++++--- tnt-1.yaml | 28 +++++++ 12 files changed, 91 insertions(+), 150 deletions(-) delete mode 100644 pkg/api/condition.go delete mode 100644 pkg/api/condition_test.go create mode 100644 tnt-1.yaml diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index 85ab89c5a..b73d6e953 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -14,7 +14,6 @@ import ( // GlobalTenantResourceSpec defines the desired state of GlobalTenantResource. type GlobalTenantResourceSpec struct { // Resource Scope, Can either be - // - Cluster: Just once per cluster // - Tenant: Create Resources for each tenant in selected Tenants // - Namespace: Create Resources for each namespace in selected Tenants // +kubebuilder:default:=Namespace diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 2564d8a9d..941eb0c2d 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -215,7 +215,6 @@ spec: default: Namespace description: |- Resource Scope, Can either be - - Cluster: Just once per cluster - Tenant: Create Resources for each tenant in selected Tenants - Namespace: Create Resources for each namespace in selected Tenants enum: diff --git a/controllers/resources/global.go b/controllers/resources/global.go index fadc0d15a..ebc43d183 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -118,8 +118,7 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc } defer func() { - r.metrics.RecordCondition(tntResource) - tntResource.SetCondition() + r.metrics.RecordConditions(tntResource) if e := patchHelper.Patch(ctx, tntResource); e != nil { if err == nil { diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 3cde7e699..faffbbce9 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -115,9 +115,8 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re } defer func() { - r.metrics.RecordCondition(tntResource) + r.metrics.RecordConditions(tntResource) - tntResource.SetCondition() if e := patchHelper.Patch(ctx, tntResource); e != nil { if err == nil { err = gherrors.Wrap(e, "failed to patch TenantResource") diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 85b3eeed0..602148091 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -98,7 +98,7 @@ func (r *Processor) HandlePruning( or.Status = metav1.ConditionFalse or.Message = sectionErr.Error() - or.Type = meta.PruningCondition + or.Type = meta.ReadyCondition processed.Insert(or.String()) err = errors.Join(sectionErr) @@ -287,7 +287,7 @@ func (r *Processor) handleSection( replicatedItem.Name = obj.GetName() replicatedItem.Kind = obj.GetKind() replicatedItem.APIVersion = obj.GetAPIVersion() - replicatedItem.Type = meta.ReplicationCondition + replicatedItem.Type = meta.ReadyCondition replicatedItem.Scope = scope replicatedItem.Tenant = tnt.GetName() @@ -363,7 +363,7 @@ func (r *Processor) handleSection( replicatedItem.Name = obj.GetName() replicatedItem.Kind = obj.GetKind() replicatedItem.APIVersion = obj.GetAPIVersion() - replicatedItem.Type = meta.ReplicationCondition + replicatedItem.Type = meta.ReadyCondition replicatedItem.Scope = scope replicatedItem.Tenant = tnt.GetName() diff --git a/pkg/api/condition.go b/pkg/api/condition.go deleted file mode 100644 index e33168a24..000000000 --- a/pkg/api/condition.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2020-2023 Project Capsule Authors. -// SPDX-License-Identifier: Apache-2.0 - -package api - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +kubebuilder:object:generate=true -type Condition metav1.Condition - -// Disregards fields like LastTransitionTime and Version, which are not relevant for the API. -func (c *Condition) UpdateCondition(condition metav1.Condition) (updated bool) { - if condition.Type == c.Type && - condition.Status == c.Status && - condition.Reason == c.Reason && - condition.Message == c.Message { - return false - } - - c.Type = condition.Type - c.Status = condition.Status - c.Reason = condition.Reason - c.Message = condition.Message - c.ObservedGeneration = condition.ObservedGeneration - c.LastTransitionTime = condition.LastTransitionTime - - return true -} diff --git a/pkg/api/condition_test.go b/pkg/api/condition_test.go deleted file mode 100644 index 6582855ef..000000000 --- a/pkg/api/condition_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2020-2023 Project Capsule Authors. -// SPDX-License-Identifier: Apache-2.0 - -package api - -import ( - "testing" - - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestUpdateCondition(t *testing.T) { - now := metav1.Now() - - t.Run("no update when all relevant fields match", func(t *testing.T) { - c := &Condition{ - Type: "Ready", - Status: "True", - Reason: "Success", - Message: "All good", - } - - updated := c.UpdateCondition(metav1.Condition{ - Type: "Ready", - Status: "True", - Reason: "Success", - Message: "All good", - LastTransitionTime: now, - }) - - assert.False(t, updated) - }) - - t.Run("update occurs on message change", func(t *testing.T) { - c := &Condition{ - Type: "Ready", - Status: "True", - Reason: "Success", - Message: "Old message", - } - - updated := c.UpdateCondition(metav1.Condition{ - Type: "Ready", - Status: "True", - Reason: "Success", - Message: "New message", - LastTransitionTime: now, - }) - - assert.True(t, updated) - assert.Equal(t, "New message", c.Message) - }) - - t.Run("update occurs on status change", func(t *testing.T) { - c := &Condition{ - Type: "Ready", - Status: "False", - Reason: "Pending", - Message: "Not ready yet", - } - - updated := c.UpdateCondition(metav1.Condition{ - Type: "Ready", - Status: "True", - Reason: "Success", - Message: "Ready", - LastTransitionTime: now, - }) - - assert.True(t, updated) - assert.Equal(t, "True", string(c.Status)) - assert.Equal(t, "Success", c.Reason) - assert.Equal(t, "Ready", c.Message) - }) -} diff --git a/pkg/api/status.go b/pkg/api/status.go index 54999bd1d..3b243974e 100644 --- a/pkg/api/status.go +++ b/pkg/api/status.go @@ -8,7 +8,6 @@ import k8stypes "k8s.io/apimachinery/pkg/types" const ( ResourceScopeNamespace ResourceScope = "Namespace" ResourceScopeTenant ResourceScope = "Tenant" - ResourceScopeCluster ResourceScope = "Cluster" ) // +kubebuilder:validation:Enum=Namespace;Tenant diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 0fe4d8ae7..8fcbb96cb 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -147,22 +147,6 @@ func (in *AllowedServices) DeepCopy() *AllowedServices { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *Condition) DeepCopyInto(out *Condition) { - *out = *in - in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition. -func (in *Condition) DeepCopy() *Condition { - if in == nil { - return nil - } - out := new(Condition) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DefaultAllowedListSpec) DeepCopyInto(out *DefaultAllowedListSpec) { *out = *in diff --git a/pkg/metrics/globaltenantresource_recorder.go b/pkg/metrics/globaltenantresource_recorder.go index 7d4d964d0..2fb955ccf 100644 --- a/pkg/metrics/globaltenantresource_recorder.go +++ b/pkg/metrics/globaltenantresource_recorder.go @@ -5,6 +5,7 @@ package metrics import ( "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -30,7 +31,7 @@ func NewGlobalTenantResourceRecorder() *GlobalTenantResourceRecorder { Name: "global_resource_condition", Help: "The current condition status of a global tenant resource.", }, - []string{"name", "condition", "status"}, + []string{"name", "condition"}, ), } } @@ -41,25 +42,43 @@ func (r *GlobalTenantResourceRecorder) Collectors() []prometheus.Collector { } } -// RecordCondition records the condition as given for the ref. func (r *GlobalTenantResourceRecorder) RecordConditions(resource *capsulev1beta2.GlobalTenantResource) { - for _, status := range []meta.ConditionStatus{meta.ReadyCondition} { + for _, status := range []string{meta.ReadyCondition} { var value float64 - if status == resource.Status.Condition.Status { + + cond := resource.Status.Conditions.GetConditionByType(status) + if cond == nil { + r.DeleteConditionMetricByType(resource.GetName(), status) + + continue + } + + if cond.Status == metav1.ConditionTrue { value = 1 } - r.resourceConditionGauge.WithLabelValues( - resource.Name, - resource.Status.Condition.Type, - string(resource.Status.Condition.Status), - ).Set(value) + r.resourceConditionGauge.WithLabelValues(resource.GetName(), resource.GetNamespace(), status).Set(value) } } +func (r *GlobalTenantResourceRecorder) DeleteConditionMetrics(name string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": name, + }) +} + +func (r *GlobalTenantResourceRecorder) DeleteConditionMetricByType(name string, condition string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": name, + "condition": condition, + }) +} + // DeleteCondition deletes the condition metrics for the ref. func (r *GlobalTenantResourceRecorder) DeleteMetrics(resourceName string) { r.resourceConditionGauge.DeletePartialMatch(map[string]string{ "name": resourceName, }) + + r.DeleteConditionMetrics(resourceName) } diff --git a/pkg/metrics/tenantresource_recorder.go b/pkg/metrics/tenantresource_recorder.go index 76d2ae97e..601ccc4a9 100644 --- a/pkg/metrics/tenantresource_recorder.go +++ b/pkg/metrics/tenantresource_recorder.go @@ -5,6 +5,7 @@ package metrics import ( "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" @@ -30,7 +31,7 @@ func NewTenantResourceRecorder() *TenantResourceRecorder { Name: "resource_condition", Help: "The current condition status of a tenant resource.", }, - []string{"tenant", "target_namespace", "condition", "status"}, + []string{"name", "target_namespace", "condition"}, ), } } @@ -42,26 +43,46 @@ func (r *TenantResourceRecorder) Collectors() []prometheus.Collector { } // RecordCondition records the condition as given for the ref. -func (r *TenantResourceRecorder) RecordCondition(resource *capsulev1beta2.TenantResource) { +func (r *TenantResourceRecorder) RecordConditions(resource *capsulev1beta2.TenantResource) { for _, status := range []string{meta.ReadyCondition} { var value float64 - if status == resource.Status.Condition.Status { + + cond := resource.Status.Conditions.GetConditionByType(status) + if cond == nil { + r.DeleteConditionMetricByType(resource.GetName(), resource.GetNamespace(), status) + + continue + } + + if cond.Status == metav1.ConditionTrue { value = 1 } - r.resourceConditionGauge.WithLabelValues( - resource.Name, - resource.Namespace, - resource.Status.Condition.Type, - string(resource.Status.Condition.Status), - ).Set(value) + r.resourceConditionGauge.WithLabelValues(resource.GetName(), resource.GetNamespace(), status).Set(value) } } +func (r *TenantResourceRecorder) DeleteConditionMetrics(name string, namespace string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": name, + "target_namespace": namespace, + }) +} + +func (r *TenantResourceRecorder) DeleteConditionMetricByType(name string, namespace string, condition string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": name, + "target_namespace": namespace, + "condition": condition, + }) +} + // DeleteCondition deletes the condition metrics for the ref. func (r *TenantResourceRecorder) DeleteMetrics(resourceName string, resourceNamespace string) { r.resourceConditionGauge.DeletePartialMatch(map[string]string{ "name": resourceName, "target_namespace": resourceNamespace, }) + + r.DeleteConditionMetrics(resourceName, resourceNamespace) } diff --git a/tnt-1.yaml b/tnt-1.yaml new file mode 100644 index 000000000..7e47c21f4 --- /dev/null +++ b/tnt-1.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: green +spec: + owners: + - name: alice + kind: User +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: solar +spec: + owners: + - name: alice + kind: User +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: wind +spec: + owners: + - name: alice + kind: User + From 7dea2f8ec0f52ae45dd134ee5fc49b57214dcb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Mon, 20 Oct 2025 12:37:19 +0200 Subject: [PATCH 15/19] chore: fix testing ci --- api/v1beta2/tenantresource_global.go | 11 -- api/v1beta2/tenantresource_namespaced.go | 5 + api/v1beta2/tenantresource_types.go | 66 ++++++++ api/v1beta2/zz_generated.deepcopy.go | 25 +++ ...sule.clastix.io_globaltenantresources.yaml | 104 ++++++++++++ .../capsule.clastix.io_tenantresources.yaml | 104 ++++++++++++ controllers/resources/global.go | 36 +++- go.mod | 70 ++++---- go.sum | 64 +++++++ pkg/api/context.go | 157 +++++++++++++++++ pkg/api/zz_generated.deepcopy.go | 46 +++++ pkg/metrics/globaltenantresource_recorder.go | 2 +- pkg/template/context.go | 23 +++ pkg/template/funcmap.go | 160 ++++++++++++++++++ test.yaml | 42 ++--- 15 files changed, 844 insertions(+), 71 deletions(-) create mode 100644 pkg/api/context.go create mode 100644 pkg/template/context.go create mode 100644 pkg/template/funcmap.go diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index b73d6e953..b5cbd00eb 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -18,7 +18,6 @@ type GlobalTenantResourceSpec struct { // - Namespace: Create Resources for each namespace in selected Tenants // +kubebuilder:default:=Namespace Scope api.ResourceScope `json:"scope"` - // Defines the Tenant selector used target the tenants on which resources must be propagated. TenantSelector metav1.LabelSelector `json:"tenantSelector,omitempty"` TenantResourceSpec `json:",inline"` @@ -34,17 +33,7 @@ type GlobalTenantResourceStatus struct { Conditions meta.ConditionList `json:"conditions,omitempty"` } -type ProcessedItems []ObjectReferenceStatus - -func (p *ProcessedItems) AsSet() sets.Set[string] { - set := sets.New[string]() - for _, i := range *p { - set.Insert(i.String()) - } - - return set -} // +kubebuilder:object:root=true // +kubebuilder:subresource:status diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index ad7d7799a..7a5ab73fd 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -26,6 +26,9 @@ type TenantResourceSpec struct { // Local ServiceAccount which will perform all the actions defined in the TenantResource // You must provide permissions accordingly to that ServiceAccount ServiceAccount *api.ServiceAccountReference `json:"serviceAccount,omitempty"` + // Provide additional template context, which can be used throughout all + // the declared items for the replication + Template *api.TemplateContext `json:"context,omitempty"` } type ResourceSpec struct { @@ -39,6 +42,8 @@ type ResourceSpec struct { // Besides the Capsule metadata required by TenantResource controller, defines additional metadata that must be // added to the replicated resources. AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"` + // Generators for advanced use cases + Generators []GeneratorItemSpec `json:"generators,omitempty"` } // +kubebuilder:validation:XEmbeddedResource diff --git a/api/v1beta2/tenantresource_types.go b/api/v1beta2/tenantresource_types.go index 112c0cd4a..f3f9635a4 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -9,8 +9,74 @@ import ( "github.com/projectcapsule/capsule/pkg/api" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" ) +type GeneratorItemSpec struct { + // Template contains any amount of yaml which is applied to Kubernetes. + // This can be a single resource or multiple resources + Template string `json:"template,omitempty"` +} + +type ProcessedItems []ObjectReferenceStatus + +// Adds a condition by type. +func (p *ProcessedItems) UpdateItem(item ObjectReferenceStatus) { + if !p.validItem(item) { + return + } + + for i, cond := range *c { + if cond.Type == condition.Type { + (*c)[i].UpdateCondition(condition) + + return + } + } + + *c = append(*c, condition) +} + +// Removes a condition by type. +func (p *ProcessedItems) RemoveItem(item ObjectReferenceStatus) { + if c == nil { + return + } + + filtered := make(ProcessedItems, 0, len(*c)) + + for _, cond := range *c { + if cond.Type != condition.Type { + filtered = append(filtered, cond) + } + } + + *c = filtered +} + +// Adds a condition by type. +func (p *ProcessedItems) validItem(item ObjectReferenceStatus) bool { + if item.Name == "" { + return false + } + + if item.Namespace == "" { + return false + } + + return true +} + +func (p *ProcessedItems) AsSet() sets.Set[string] { + set := sets.New[string]() + + for _, i := range *p { + set.Insert(i.String()) + } + + return set +} + type ObjectReferenceAbstract struct { // Kind of the referent. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index a0c40023b..6207c39e8 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -191,6 +191,21 @@ func (in *GatewayOptions) DeepCopy() *GatewayOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GeneratorItemSpec) DeepCopyInto(out *GeneratorItemSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratorItemSpec. +func (in *GeneratorItemSpec) DeepCopy() *GeneratorItemSpec { + if in == nil { + return nil + } + out := new(GeneratorItemSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GlobalTenantResource) DeepCopyInto(out *GlobalTenantResource) { *out = *in @@ -956,6 +971,11 @@ func (in *ResourceSpec) DeepCopyInto(out *ResourceSpec) { *out = new(api.AdditionalMetadataSpec) (*in).DeepCopyInto(*out) } + if in.Generators != nil { + in, out := &in.Generators, &out.Generators + *out = make([]GeneratorItemSpec, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSpec. @@ -1107,6 +1127,11 @@ func (in *TenantResourceSpec) DeepCopyInto(out *TenantResourceSpec) { *out = new(api.ServiceAccountReference) **out = **in } + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(api.TemplateContext) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceSpec. diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 941eb0c2d..c3da7f45c 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -52,6 +52,99 @@ spec: spec: description: GlobalTenantResourceSpec defines the desired state of GlobalTenantResource. properties: + context: + description: |- + Provide additional template context, which can be used throughout all + the declared items for the replication + properties: + resources: + items: + properties: + apiVersion: + description: API version of the referent. + type: string + index: + description: Index where the results are published in the + templating/CEL + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the values referent. This is useful + when you traying to get a specific resource + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace of the values referent. + maxLength: 253 + minLength: 1 + type: string + optional: + default: false + description: |- + Optional indicates whether the referenced resource must exist, or whether to + tolerate its absence. If true and the referenced resource is absent, proceed + as if the resource was present but empty, without any variables defined. + type: boolean + selector: + description: Selector which allows to get any amount of + these resources based on labels + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - apiVersion + - index + - kind + type: object + type: array + type: object pruningOnDelete: default: true description: |- @@ -77,6 +170,17 @@ spec: type: string type: object type: object + generators: + description: Generators for advanced use cases + items: + properties: + template: + description: |- + Template contains any amount of yaml which is applied to Kubernetes. + This can be a single resource or multiple resources + type: string + type: object + type: array namespaceSelector: description: |- Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated. diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 13f08ec79..feb2e7470 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -54,6 +54,99 @@ spec: spec: description: TenantResourceSpec defines the desired state of TenantResource. properties: + context: + description: |- + Provide additional template context, which can be used throughout all + the declared items for the replication + properties: + resources: + items: + properties: + apiVersion: + description: API version of the referent. + type: string + index: + description: Index where the results are published in the + templating/CEL + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the values referent. This is useful + when you traying to get a specific resource + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace of the values referent. + maxLength: 253 + minLength: 1 + type: string + optional: + default: false + description: |- + Optional indicates whether the referenced resource must exist, or whether to + tolerate its absence. If true and the referenced resource is absent, proceed + as if the resource was present but empty, without any variables defined. + type: boolean + selector: + description: Selector which allows to get any amount of + these resources based on labels + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - apiVersion + - index + - kind + type: object + type: array + type: object pruningOnDelete: default: true description: |- @@ -79,6 +172,17 @@ spec: type: string type: object type: object + generators: + description: Generators for advanced use cases + items: + properties: + template: + description: |- + Template contains any amount of yaml which is applied to Kubernetes. + This can be a single resource or multiple resources + type: string + type: object + type: array namespaceSelector: description: |- Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated. diff --git a/controllers/resources/global.go b/controllers/resources/global.go index ebc43d183..aaa71e89d 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -6,6 +6,7 @@ package resources import ( "context" "errors" + "fmt" "reflect" "github.com/go-logr/logr" @@ -15,6 +16,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/util/retry" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -28,6 +30,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/meta" "github.com/projectcapsule/capsule/pkg/metrics" "github.com/projectcapsule/capsule/pkg/utils" ) @@ -92,9 +95,7 @@ func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *globalResourceController) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { - var err error - +func (r *globalResourceController) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) { log := ctrllog.FromContext(ctx) log.V(5).Info("start processing") @@ -118,6 +119,12 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc } defer func() { + if uerr := r.updateGlobalResourceStatus(ctx, tntResource, err); uerr != nil { + err = fmt.Errorf("cannot update globaltenantresource status: %w", uerr) + + return + } + r.metrics.RecordConditions(tntResource) if e := patchHelper.Patch(ctx, tntResource); e != nil { @@ -354,3 +361,26 @@ func (r *globalResourceController) loadClient( return saClient, nil } + +func (r *globalResourceController) updateGlobalResourceStatus(ctx context.Context, instance *capsulev1beta2.GlobalTenantResource, reconcileError error) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { + latest := &capsulev1beta2.GlobalTenantResource{} + if err = r.client.Get(ctx, types.NamespacedName{Name: instance.GetName()}, latest); err != nil { + return err + } + + latest.Status = instance.Status + + // Set Ready Condition + readyCondition := meta.NewReadyCondition(instance) + if reconcileError != nil { + readyCondition.Message = reconcileError.Error() + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = meta.FailedReason + } + + latest.Status.Conditions.UpdateConditionByType(readyCondition) + + return r.client.Status().Update(ctx, latest) + }) +} diff --git a/go.mod b/go.mod index 7485a07eb..b51bc5cb6 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.24.0 toolchain go1.25.1 require ( + github.com/BurntSushi/toml v1.5.0 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/go-logr/logr v1.4.3 github.com/onsi/ginkgo/v2 v2.25.3 github.com/onsi/gomega v1.38.2 @@ -16,18 +18,21 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.27.0 golang.org/x/sync v0.17.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.34.1 k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/apiserver v0.34.1 k8s.io/client-go v0.34.1 - k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d - sigs.k8s.io/cluster-api v1.11.1 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + sigs.k8s.io/cluster-api v1.11.2 sigs.k8s.io/controller-runtime v0.22.2 - sigs.k8s.io/gateway-api v1.3.0 + sigs.k8s.io/gateway-api v1.4.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -39,20 +44,20 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.22.0 // indirect - github.com/go-openapi/jsonreference v0.21.1 // indirect - github.com/go-openapi/swag v0.24.1 // indirect - github.com/go-openapi/swag/cmdutils v0.24.0 // indirect - github.com/go-openapi/swag/conv v0.24.0 // indirect - github.com/go-openapi/swag/fileutils v0.24.0 // indirect - github.com/go-openapi/swag/jsonname v0.24.0 // indirect - github.com/go-openapi/swag/jsonutils v0.24.0 // indirect - github.com/go-openapi/swag/loading v0.24.0 // indirect - github.com/go-openapi/swag/mangling v0.24.0 // indirect - github.com/go-openapi/swag/netutils v0.24.0 // indirect - github.com/go-openapi/swag/stringutils v0.24.0 // indirect - github.com/go-openapi/swag/typeutils v0.24.0 // indirect - github.com/go-openapi/swag/yamlutils v0.24.0 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -62,16 +67,21 @@ require ( github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/common v0.67.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel/sdk v1.37.0 // indirect @@ -79,22 +89,22 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.43.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/net v0.44.0 // indirect - golang.org/x/oauth2 v0.31.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect - golang.org/x/time v0.13.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.37.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect - google.golang.org/grpc v1.75.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/grpc v1.75.1 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect diff --git a/go.sum b/go.sum index 7accc08d5..57ff58917 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -51,32 +55,60 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= @@ -120,6 +152,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -151,6 +185,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= +github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -159,6 +195,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= @@ -219,6 +257,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -229,8 +269,12 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -241,20 +285,29 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -265,10 +318,15 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1: google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -299,16 +357,22 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/cluster-api v1.11.1 h1:7CyGCTxv1p3Y2kRe1ljTj/w4TcdIdWNj0CTBc4i1aBo= sigs.k8s.io/cluster-api v1.11.1/go.mod h1:zyrjgJ5RbXhwKcAdUlGPNK5YOHpcmxXvur+5I8lkMUQ= +sigs.k8s.io/cluster-api v1.11.2 h1:uAczaBavU5Y6aDgyoXWtq28k1kalpSZnVItwXHusw1c= +sigs.k8s.io/cluster-api v1.11.2/go.mod h1:C1gJVAjMXRG+M+djjGYNkoi5kBMhFnOUI9QqZDAtMms= sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/controller-runtime v0.22.2 h1:cK2l8BGWsSWkXz09tcS4rJh95iOLney5eawcK5A33r4= sigs.k8s.io/controller-runtime v0.22.2/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/gateway-api v1.3.0 h1:q6okN+/UKDATola4JY7zXzx40WO4VISk7i9DIfOvr9M= sigs.k8s.io/gateway-api v1.3.0/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk= +sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= +sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/pkg/api/context.go b/pkg/api/context.go new file mode 100644 index 000000000..6de93973a --- /dev/null +++ b/pkg/api/context.go @@ -0,0 +1,157 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "text/template" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + tpl "github.com/projectcapsule/capsule/pkg/template" +) + +// Additional Context to enhance templating +// +kubebuilder:object:generate=true +type TemplateContext struct { + Resources []*ResourceReference `json:"resources,omitempty"` +} + +func (t *TemplateContext) GatherContext( + ctx context.Context, + kubeClient client.Client, + data map[string]interface{}, + context tpl.ReferenceContext, +) (errors []error) { + // Template Context for Tenant + if len(data) != 0 { + if err := t.selfTemplate(data); err != nil { + return []error{fmt.Errorf("cloud not template: %w", err)} + } + } + + // Load external Resources + for _, resource := range t.Resources { + val, err := resource.LoadResources(ctx, kubeClient) + if err != nil { + errors = append(errors, err) + + continue + } + + if len(val) > 0 { + context[resource.Index] = val + } + } + + return +} + +// Templates itself with the option to populate tenant fields +// this can be useful if you have per tenant items, that you want to interact with +func (t *TemplateContext) selfTemplate( + data map[string]interface{}, +) (err error) { + dataBytes, err := json.Marshal(t) + if err != nil { + return fmt.Errorf("error marshaling TemplateContext: %w", err) + } + + if err := json.Unmarshal(dataBytes, &data); err != nil { + return fmt.Errorf("error unmarshaling TemplateContext into map: %w", err) + } + + tmpl, err := template.New("tpl").Option("missingkey=error").Funcs(tpl.ExtraFuncMap()).Parse(string(dataBytes)) + if err != nil { + return fmt.Errorf("error parsing template: %w", err) + } + + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, data); err != nil { + return fmt.Errorf("error executing template: %w", err) + } + + tplContext := &TemplateContext{} + if err := json.Unmarshal(rendered.Bytes(), tplContext); err != nil { + return fmt.Errorf("error unmarshaling JSON into TemplateContext: %w", err) + } + + // Reassing templated context + *t = *tplContext + + return nil +} + +// +kubebuilder:object:generate=true +type ResourceReference struct { + // Index where the results are published in the templating/CEL + Index string `json:"index"` + // Kind of the referent. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"` + // API version of the referent. + APIVersion string `json:"apiVersion" protobuf:"bytes,5,opt,name=apiVersion"` + // Name of the values referent. This is useful + // when you traying to get a specific resource + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +optional + Name string `json:"name,omitempty"` + // Namespace of the values referent. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +optional + Namespace string `json:"namespace,omitempty"` + // Selector which allows to get any amount of these resources based on labels + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` + // Optional indicates whether the referenced resource must exist, or whether to + // tolerate its absence. If true and the referenced resource is absent, proceed + // as if the resource was present but empty, without any variables defined. + // +kubebuilder:default:=false + // +optional + Optional bool `json:"optional,omitempty"` +} + +func (t ResourceReference) LoadResources(ctx context.Context, kubeClient client.Client) ([]unstructured.Unstructured, error) { + list := &unstructured.UnstructuredList{} + list.SetAPIVersion(t.APIVersion) + list.SetKind(t.Kind + "List") + + // Prepare list options. + var opts []client.ListOption + if t.Namespace != "" { + opts = append(opts, client.InNamespace(t.Namespace)) + } + + if t.Selector != nil { + selector, err := metav1.LabelSelectorAsSelector(t.Selector) + if err != nil { + return nil, fmt.Errorf("invalid label selector: %w", err) + } + + opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) + } + + // Optionally, if t.Name is specified, you can filter by name. + //if t.Name != "" { + // opts = append(opts, client.MatchingFields{"metadata.name": t.Name}) + //} + + // List the resources. + if err := kubeClient.List(ctx, list, opts...); err != nil { + return nil, fmt.Errorf("failed to list: %w", err) + } + + // Prepare a result map. For example, mapping resource name to its UID. + results := []unstructured.Unstructured{} + for _, item := range list.Items { + results = append(results, item) + } + + return results, nil + +} diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 8fcbb96cb..e5362808f 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -326,6 +326,26 @@ func (in *ResourceQuotaSpec) DeepCopy() *ResourceQuotaSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceReference) DeepCopyInto(out *ResourceReference) { + *out = *in + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceReference. +func (in *ResourceReference) DeepCopy() *ResourceReference { + if in == nil { + return nil + } + out := new(ResourceReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SelectionListWithDefaultSpec) DeepCopyInto(out *SelectionListWithDefaultSpec) { *out = *in @@ -436,3 +456,29 @@ func (in *ServiceOptions) DeepCopy() *ServiceOptions { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateContext) DeepCopyInto(out *TemplateContext) { + *out = *in + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]*ResourceReference, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(ResourceReference) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateContext. +func (in *TemplateContext) DeepCopy() *TemplateContext { + if in == nil { + return nil + } + out := new(TemplateContext) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/metrics/globaltenantresource_recorder.go b/pkg/metrics/globaltenantresource_recorder.go index 2fb955ccf..29f6ce314 100644 --- a/pkg/metrics/globaltenantresource_recorder.go +++ b/pkg/metrics/globaltenantresource_recorder.go @@ -57,7 +57,7 @@ func (r *GlobalTenantResourceRecorder) RecordConditions(resource *capsulev1beta2 value = 1 } - r.resourceConditionGauge.WithLabelValues(resource.GetName(), resource.GetNamespace(), status).Set(value) + r.resourceConditionGauge.WithLabelValues(resource.GetName(), status).Set(value) } } diff --git a/pkg/template/context.go b/pkg/template/context.go new file mode 100644 index 000000000..331b04ca3 --- /dev/null +++ b/pkg/template/context.go @@ -0,0 +1,23 @@ +package template + +import ( + "encoding/json" + "fmt" +) + +// Context which results from all +// +kubebuilder:object:generate=false +type ReferenceContext map[string]interface{} + +func (t *ReferenceContext) String() (string, error) { + dataBytes, err := json.Marshal(t) + if err != nil { + return "", fmt.Errorf("error marshaling TemplateContext: %w", err) + } + + if err := json.Unmarshal(dataBytes, t); err != nil { + return "", fmt.Errorf("error unmarshaling TemplateContext into map: %w", err) + } + + return string(dataBytes), nil +} diff --git a/pkg/template/funcmap.go b/pkg/template/funcmap.go new file mode 100644 index 000000000..f56ae0b0b --- /dev/null +++ b/pkg/template/funcmap.go @@ -0,0 +1,160 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 +package template + +import ( + "bytes" + "encoding/json" + "strings" + "text/template" + + "github.com/BurntSushi/toml" + "github.com/Masterminds/sprig/v3" + "gopkg.in/yaml.v3" +) + +// TxtFuncMap returns an aggregated template function map. Currently (custom functions + sprig). +func ExtraFuncMap() template.FuncMap { + funcMap := sprig.FuncMap() + + extraFuncs := template.FuncMap{ + "toToml": toTOML, + "fromToml": fromTOML, + "toYaml": toYAML, + "fromYaml": fromYAML, + "fromYamlArray": fromYAMLArray, + "toJson": toJSON, + "fromJson": fromJSON, + "fromJsonArray": fromJSONArray, + } + + for k, v := range extraFuncs { + funcMap[k] = v + } + + return funcMap +} + +// toYAML takes an interface, marshals it to yaml, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toYAML(v interface{}) string { + data, err := yaml.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + + return strings.TrimSuffix(string(data), "\n") +} + +// fromYAML converts a YAML document into a map[string]interface{}. +// +// This is not a general-purpose YAML parser, and will not parse all valid +// YAML documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string into +// m["Error"] in the returned map. +func fromYAML(str string) map[string]interface{} { + m := map[string]interface{}{} + + if err := yaml.Unmarshal([]byte(str), &m); err != nil { + m["Error"] = err.Error() + } + + return m +} + +// fromYAMLArray converts a YAML array into a []interface{}. +// +// This is not a general-purpose YAML parser, and will not parse all valid +// YAML documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string as +// the first and only item in the returned array. +func fromYAMLArray(str string) []interface{} { + a := []interface{}{} + + if err := yaml.Unmarshal([]byte(str), &a); err != nil { + a = []interface{}{err.Error()} + } + + return a +} + +// toTOML takes an interface, marshals it to toml, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toTOML(v interface{}) string { + b := bytes.NewBuffer(nil) + e := toml.NewEncoder(b) + + err := e.Encode(v) + if err != nil { + return err.Error() + } + + return b.String() +} + +// fromTOML converts a TOML document into a map[string]interface{}. +// +// This is not a general-purpose TOML parser, and will not parse all valid +// TOML documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string into +// m["Error"] in the returned map. +func fromTOML(str string) map[string]interface{} { + m := make(map[string]interface{}) + + if err := toml.Unmarshal([]byte(str), &m); err != nil { + m["Error"] = err.Error() + } + + return m +} + +// toJSON takes an interface, marshals it to json, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toJSON(v interface{}) string { + data, err := json.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + + return string(data) +} + +// fromJSON converts a JSON document into a map[string]interface{}. +// +// This is not a general-purpose JSON parser, and will not parse all valid +// JSON documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string into +// m["Error"] in the returned map. +func fromJSON(str string) map[string]interface{} { + m := make(map[string]interface{}) + + if err := json.Unmarshal([]byte(str), &m); err != nil { + m["Error"] = err.Error() + } + + return m +} + +// fromJSONArray converts a JSON array into a []interface{}. +// +// This is not a general-purpose JSON parser, and will not parse all valid +// JSON documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string as +// the first and only item in the returned array. +func fromJSONArray(str string) []interface{} { + a := []interface{}{} + + if err := json.Unmarshal([]byte(str), &a); err != nil { + a = []interface{}{err.Error()} + } + + return a +} diff --git a/test.yaml b/test.yaml index 6e8beb801..1402bb5ba 100644 --- a/test.yaml +++ b/test.yaml @@ -3,32 +3,22 @@ kind: GlobalTenantResource metadata: name: renewable-pull-secrets spec: - tenantSelector: - matchLabels: - energy: renewable + tenantSelector: {} resyncPeriod: 5s + scope: Tenant resources: - - templates: - resources: - - index: "sec" - apiVersion: v1 - kind: Secret - items: - - | - --- - apiVersion: v1 - kind: Secret - metadata: - name: "some-secret-bruv" - stringData: - username: "some-username" - --- - apiVersion: v1 - kind: ConfigMap - metadata: - name: "the-context" - data: - context: | - {{ . | toYaml | nindent 4}} - + - additionalMetadata: + labels: + "replicated-by": "capsule" + rawItems: + - apiVersion: v1 + kind: Namespace + metadata: + name: "{{tenant.name}}-system" + - apiVersion: v1 + kind: Secret + namespace: "{{tenant.name}}-system" + selector: + matchLabels: + tenant: renewable From 692072b95e10b62ed96f67a8942363d039754d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Mon, 3 Nov 2025 09:02:32 +0100 Subject: [PATCH 16/19] feat: migrate to resources api --- Makefile | 8 +- api/v1beta2/capsuleconfiguration_types.go | 9 + api/v1beta2/tenantresource_global.go | 6 +- api/v1beta2/tenantresource_namespaced.go | 21 +- api/v1beta2/tenantresource_types.go | 16 + api/v1beta2/zz_generated.deepcopy.go | 48 ++- ...sule.clastix.io_capsuleconfigurations.yaml | 17 + ...sule.clastix.io_globaltenantresources.yaml | 274 +++++++++------ .../capsule.clastix.io_tenantresources.yaml | 274 +++++++++------ controllers/resources/global.go | 19 +- controllers/resources/namespaced.go | 48 +++ controllers/resources/processor.go | 325 +++++++++++++++--- controllers/resources/utils.go | 61 ++++ controllers/tenant/manager.go | 4 +- global-scope.yaml | 60 +++- go.mod | 3 +- go.sum | 2 + pkg/api/context.go | 59 +++- pkg/api/ignore.go | 18 + pkg/api/zz_generated.deepcopy.go | 26 ++ pkg/metrics/globaltenantresource_recorder.go | 2 +- pkg/metrics/tenantresource_recorder.go | 2 +- 22 files changed, 1009 insertions(+), 293 deletions(-) create mode 100644 pkg/api/ignore.go diff --git a/Makefile b/Makefile index cf324d5df..c11eebabe 100644 --- a/Makefile +++ b/Makefile @@ -152,6 +152,8 @@ dev-setup: --set 'crds.install=true' \ --set 'crds.exclusive=true'\ --set 'crds.createConfig=true'\ + --set 'tls.enableController=false'\ + --set "webhooks.exclusive=true"\ --set "webhooks.exclusive=true"\ --set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \ @@ -242,10 +244,10 @@ API_GW_LOOKUP := kubernetes-sigs/gateway-api/ e2e-install-deps: @$(KUBECTL) apply --force-conflicts --server-side=true -f https://github.com/$(API_GW_LOOKUP)/releases/download/$(API_GW_VERSION)/standard-install.yaml -e2e-build: kind +e2e-build: e2e-build-cluster e2e-install-deps e2e-install + +e2e-build-cluster: kind $(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) - $(MAKE) e2e-install-deps - $(MAKE) e2e-install .PHONY: e2e-install e2e-install: ko-build-all diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index ff58721bc..539327851 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -41,10 +41,19 @@ type CapsuleConfigurationSpec struct { // when not using an already provided CA and certificate, or when these are managed externally with Vault, or cert-manager. // +kubebuilder:default=true EnableTLSReconciler bool `json:"enableTLSReconciler"` //nolint:tagliatelle + // Omit Replications + Replications ReplicationsSpec `json:"replications,omitempty"` // Define Kubernetes-Client Configurations ServiceAccountClient *api.ServiceAccountClient `json:"serviceAccountClient,omitempty"` } +type ReplicationsSpec struct { + // Define labels which are not reconciled for Global/TenantResources + IgnoreLabels []string `json:"ignoreLabels"` + // Define Annotations which are not reconciled for Global/TenantResources + IgnoreAnnotations []string `json:"ignoreAnnotations"` +} + type NodeMetadata struct { // Define the labels that a Tenant Owner cannot set for their nodes. ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels"` diff --git a/api/v1beta2/tenantresource_global.go b/api/v1beta2/tenantresource_global.go index aa9dedb1e..cbe443b5a 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -35,9 +35,9 @@ type GlobalTenantResourceStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.condition.type",description="Status for claim" -// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.condition.reason",description="Reason for status" -// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status for the tenant" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message for the tenant" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" // GlobalTenantResource allows to propagate resource replications to a specific subset of Tenant resources. type GlobalTenantResource struct { diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index 7a5ab73fd..d4b04651a 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -21,14 +21,15 @@ type TenantResourceSpec struct { // Disable this to keep replicated resources although the deletion of the replication manifest. // +kubebuilder:default=true PruningOnDelete *bool `json:"pruningOnDelete,omitempty"` + // When cordoning a replication it will no longer execute any applies or deletions (paused). + // This is useful for maintenances + // +kubebuilder:default=false + Cordoned *bool `json:"cordoned,omitempty"` // Defines the rules to select targeting Namespace, along with the objects that must be replicated. Resources []ResourceSpec `json:"resources"` // Local ServiceAccount which will perform all the actions defined in the TenantResource // You must provide permissions accordingly to that ServiceAccount ServiceAccount *api.ServiceAccountReference `json:"serviceAccount,omitempty"` - // Provide additional template context, which can be used throughout all - // the declared items for the replication - Template *api.TemplateContext `json:"context,omitempty"` } type ResourceSpec struct { @@ -44,6 +45,14 @@ type ResourceSpec struct { AdditionalMetadata *api.AdditionalMetadataSpec `json:"additionalMetadata,omitempty"` // Generators for advanced use cases Generators []GeneratorItemSpec `json:"generators,omitempty"` + // Provide additional template context, which can be used throughout all + // the declared items for the replication + // +optional + Context *api.TemplateContext `json:"context,omitempty"` + // Ignore contains a list of rules for specifying which changes to ignore + // during diffing. + // +optional + Ignore []api.IgnoreRule `json:"ignore,omitempty"` } // +kubebuilder:validation:XEmbeddedResource @@ -62,9 +71,9 @@ type TenantResourceStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status -// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.condition.type",description="Status for claim" -// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.condition.reason",description="Reason for status" -// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Reconcile Status for the tenant" +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Reconcile Message for the tenant" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" // TenantResource allows a Tenant Owner, if enabled with proper RBAC, to propagate resources in its Namespace. // The object must be deployed in a Tenant Namespace, and cannot reference object living in non-Tenant namespaces. diff --git a/api/v1beta2/tenantresource_types.go b/api/v1beta2/tenantresource_types.go index 6f27d09f9..4487b1fff 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -13,10 +13,26 @@ import ( "k8s.io/apimachinery/pkg/util/sets" ) +// +kubebuilder:validation:Enum=default;zero;error +type MissingKeyOption string + +func (p MissingKeyOption) String() string { + return string(p) +} + +const ( + MissingKeyDefault MissingKeyOption = "default" + MissingKeyZero MissingKeyOption = "zero" + MissingKeyError MissingKeyOption = "error" +) + type GeneratorItemSpec struct { // Template contains any amount of yaml which is applied to Kubernetes. // This can be a single resource or multiple resources Template string `json:"template,omitempty"` + // Missing Key Option for templating + // +kubebuilder:default=default + MissingKey MissingKeyOption `json:"missingKey,omitempty"` } type ProcessedItems []ObjectReferenceStatus diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index eb3bdd760..09fdf56ab 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -139,6 +139,7 @@ func (in *CapsuleConfigurationSpec) DeepCopyInto(out *CapsuleConfigurationSpec) *out = new(NodeMetadata) (*in).DeepCopyInto(*out) } + in.Replications.DeepCopyInto(&out.Replications) if in.ServiceAccountClient != nil { in, out := &in.ServiceAccountClient, &out.ServiceAccountClient *out = new(api.ServiceAccountClient) @@ -602,6 +603,31 @@ func (in *RawExtension) DeepCopy() *RawExtension { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReplicationsSpec) DeepCopyInto(out *ReplicationsSpec) { + *out = *in + if in.IgnoreLabels != nil { + in, out := &in.IgnoreLabels, &out.IgnoreLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.IgnoreAnnotations != nil { + in, out := &in.IgnoreAnnotations, &out.IgnoreAnnotations + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReplicationsSpec. +func (in *ReplicationsSpec) DeepCopy() *ReplicationsSpec { + if in == nil { + return nil + } + out := new(ReplicationsSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourcePool) DeepCopyInto(out *ResourcePool) { *out = *in @@ -1022,6 +1048,18 @@ func (in *ResourceSpec) DeepCopyInto(out *ResourceSpec) { *out = make([]GeneratorItemSpec, len(*in)) copy(*out, *in) } + if in.Context != nil { + in, out := &in.Context, &out.Context + *out = new(api.TemplateContext) + (*in).DeepCopyInto(*out) + } + if in.Ignore != nil { + in, out := &in.Ignore, &out.Ignore + *out = make([]api.IgnoreRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSpec. @@ -1161,6 +1199,11 @@ func (in *TenantResourceSpec) DeepCopyInto(out *TenantResourceSpec) { *out = new(bool) **out = **in } + if in.Cordoned != nil { + in, out := &in.Cordoned, &out.Cordoned + *out = new(bool) + **out = **in + } if in.Resources != nil { in, out := &in.Resources, &out.Resources *out = make([]ResourceSpec, len(*in)) @@ -1173,11 +1216,6 @@ func (in *TenantResourceSpec) DeepCopyInto(out *TenantResourceSpec) { *out = new(api.ServiceAccountReference) **out = **in } - if in.Template != nil { - in, out := &in.Template, &out.Template - *out = new(api.TemplateContext) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceSpec. diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 58672c435..8bc1ab821 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -131,6 +131,23 @@ spec: description: Disallow creation of namespaces, whose name matches this regexp type: string + replications: + description: Omit Replications + properties: + ignoreAnnotations: + description: Define Annotations which are not reconciled for Global/TenantResources + items: + type: string + type: array + ignoreLabels: + description: Define labels which are not reconciled for Global/TenantResources + items: + type: string + type: array + required: + - ignoreAnnotations + - ignoreLabels + type: object serviceAccountClient: description: Define Kubernetes-Client Configurations properties: diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 00a949b51..fb95f9165 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -15,15 +15,16 @@ spec: scope: Cluster versions: - additionalPrinterColumns: - - description: Status for claim - jsonPath: .status.condition.type - name: Status + - description: Reconcile Status for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready type: string - - description: Reason for status - jsonPath: .status.condition.reason - name: Reason + - description: Reconcile Message for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status type: string - - jsonPath: .metadata.creationTimestamp + - description: Age + jsonPath: .metadata.creationTimestamp name: Age type: date name: v1beta2 @@ -52,99 +53,12 @@ spec: spec: description: GlobalTenantResourceSpec defines the desired state of GlobalTenantResource. properties: - context: + cordoned: + default: false description: |- - Provide additional template context, which can be used throughout all - the declared items for the replication - properties: - resources: - items: - properties: - apiVersion: - description: API version of the referent. - type: string - index: - description: Index where the results are published in the - templating/CEL - type: string - kind: - description: |- - Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - name: - description: |- - Name of the values referent. This is useful - when you traying to get a specific resource - maxLength: 253 - minLength: 1 - type: string - namespace: - description: Namespace of the values referent. - maxLength: 253 - minLength: 1 - type: string - optional: - default: false - description: |- - Optional indicates whether the referenced resource must exist, or whether to - tolerate its absence. If true and the referenced resource is absent, proceed - as if the resource was present but empty, without any variables defined. - type: boolean - selector: - description: Selector which allows to get any amount of - these resources based on labels - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - required: - - apiVersion - - index - - kind - type: object - type: array - type: object + When cordoning a replication it will no longer execute any applies or deletions (paused). + This is useful for maintenances + type: boolean pruningOnDelete: default: true description: |- @@ -170,10 +84,110 @@ spec: type: string type: object type: object + context: + description: |- + Provide additional template context, which can be used throughout all + the declared items for the replication + properties: + resources: + items: + properties: + apiVersion: + description: API version of the referent. + type: string + index: + description: Index where the results are published + in the templating/CEL + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the values referent. This is useful + when you traying to get a specific resource + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace of the values referent. + maxLength: 253 + minLength: 1 + type: string + optional: + default: true + description: Only relevant if name is set. If an item + is not optional, there will be an error thrown when + it does not exist + type: boolean + selector: + description: Selector which allows to get any amount + of these resources based on labels + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - apiVersion + - kind + type: object + type: array + type: object generators: description: Generators for advanced use cases items: properties: + missingKey: + default: default + description: Missing Key Option for templating + enum: + - default + - zero + - error + type: string template: description: |- Template contains any amount of yaml which is applied to Kubernetes. @@ -181,6 +195,68 @@ spec: type: string type: object type: array + ignore: + description: |- + Ignore contains a list of rules for specifying which changes to ignore + during diffing. + items: + properties: + paths: + description: |- + Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from + consideration in a Kubernetes object. + items: + type: string + type: array + target: + description: |- + Target is a selector for specifying Kubernetes objects to which this + rule applies. + If Target is not set, the Paths will be ignored for all Kubernetes + objects within the manifest of the Helm release. + properties: + annotationSelector: + description: |- + AnnotationSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: |- + Group is the API group to select resources from. + Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: |- + Kind of the API Group to select resources from. + Together with Group and Version it is capable of unambiguously + identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: |- + LabelSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: |- + Version of the API Group to select resources from. + Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - paths + type: object + type: array namespaceSelector: description: |- Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated. diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index 16e3c1bda..d041588e4 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -15,15 +15,16 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - description: Status for claim - jsonPath: .status.condition.type - name: Status + - description: Reconcile Status for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready type: string - - description: Reason for status - jsonPath: .status.condition.reason - name: Reason + - description: Reconcile Message for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status type: string - - jsonPath: .metadata.creationTimestamp + - description: Age + jsonPath: .metadata.creationTimestamp name: Age type: date name: v1beta2 @@ -54,99 +55,12 @@ spec: spec: description: TenantResourceSpec defines the desired state of TenantResource. properties: - context: + cordoned: + default: false description: |- - Provide additional template context, which can be used throughout all - the declared items for the replication - properties: - resources: - items: - properties: - apiVersion: - description: API version of the referent. - type: string - index: - description: Index where the results are published in the - templating/CEL - type: string - kind: - description: |- - Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds - type: string - name: - description: |- - Name of the values referent. This is useful - when you traying to get a specific resource - maxLength: 253 - minLength: 1 - type: string - namespace: - description: Namespace of the values referent. - maxLength: 253 - minLength: 1 - type: string - optional: - default: false - description: |- - Optional indicates whether the referenced resource must exist, or whether to - tolerate its absence. If true and the referenced resource is absent, proceed - as if the resource was present but empty, without any variables defined. - type: boolean - selector: - description: Selector which allows to get any amount of - these resources based on labels - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - required: - - apiVersion - - index - - kind - type: object - type: array - type: object + When cordoning a replication it will no longer execute any applies or deletions (paused). + This is useful for maintenances + type: boolean pruningOnDelete: default: true description: |- @@ -172,10 +86,110 @@ spec: type: string type: object type: object + context: + description: |- + Provide additional template context, which can be used throughout all + the declared items for the replication + properties: + resources: + items: + properties: + apiVersion: + description: API version of the referent. + type: string + index: + description: Index where the results are published + in the templating/CEL + type: string + kind: + description: |- + Kind of the referent. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + name: + description: |- + Name of the values referent. This is useful + when you traying to get a specific resource + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace of the values referent. + maxLength: 253 + minLength: 1 + type: string + optional: + default: true + description: Only relevant if name is set. If an item + is not optional, there will be an error thrown when + it does not exist + type: boolean + selector: + description: Selector which allows to get any amount + of these resources based on labels + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - apiVersion + - kind + type: object + type: array + type: object generators: description: Generators for advanced use cases items: properties: + missingKey: + default: default + description: Missing Key Option for templating + enum: + - default + - zero + - error + type: string template: description: |- Template contains any amount of yaml which is applied to Kubernetes. @@ -183,6 +197,68 @@ spec: type: string type: object type: array + ignore: + description: |- + Ignore contains a list of rules for specifying which changes to ignore + during diffing. + items: + properties: + paths: + description: |- + Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from + consideration in a Kubernetes object. + items: + type: string + type: array + target: + description: |- + Target is a selector for specifying Kubernetes objects to which this + rule applies. + If Target is not set, the Paths will be ignored for all Kubernetes + objects within the manifest of the Helm release. + properties: + annotationSelector: + description: |- + AnnotationSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: |- + Group is the API group to select resources from. + Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: |- + Kind of the API Group to select resources from. + Together with Group and Version it is capable of unambiguously + identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: |- + LabelSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: |- + Version of the API Group to select resources from. + Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - paths + type: object + type: array namespaceSelector: description: |- Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated. diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 44d047451..8d15a0463 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -119,7 +119,7 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc } defer func() { - if uerr := r.updateGlobalResourceStatus(ctx, tntResource, err); uerr != nil { + if uerr := r.updateStatus(ctx, tntResource, err); uerr != nil { err = fmt.Errorf("cannot update globaltenantresource status: %w", uerr) return @@ -134,6 +134,10 @@ func (r *globalResourceController) Reconcile(ctx context.Context, request reconc } }() + if *tntResource.Spec.Cordoned { + log.V(5).Info("tenant resource is cordoned") + } + c, err := r.loadClient(ctx, log, tntResource) if err != nil { return reconcile.Result{}, gherrors.Wrap(err, "failed to load serviceaccount client") @@ -367,7 +371,7 @@ func (r *globalResourceController) loadClient( return saClient, nil } -func (r *globalResourceController) updateGlobalResourceStatus(ctx context.Context, instance *capsulev1beta2.GlobalTenantResource, reconcileError error) error { +func (r *globalResourceController) updateStatus(ctx context.Context, instance *capsulev1beta2.GlobalTenantResource, reconcileError error) error { return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { latest := &capsulev1beta2.GlobalTenantResource{} if err = r.client.Get(ctx, types.NamespacedName{Name: instance.GetName()}, latest); err != nil { @@ -386,6 +390,17 @@ func (r *globalResourceController) updateGlobalResourceStatus(ctx context.Contex latest.Status.Conditions.UpdateConditionByType(readyCondition) + // Set Cordoned Condition + cordonedCondition := meta.NewCordonedCondition(instance) + + if *instance.Spec.Cordoned { + cordonedCondition.Reason = meta.CordonedReason + cordonedCondition.Message = "is cordoned" + cordonedCondition.Status = metav1.ConditionTrue + } + + latest.Status.Conditions.UpdateConditionByType(cordonedCondition) + return r.client.Status().Update(ctx, latest) }) } diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index faffbbce9..759a72f89 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -6,14 +6,17 @@ package resources import ( "context" "errors" + "fmt" "reflect" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/client-go/util/retry" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -28,6 +31,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/meta" "github.com/projectcapsule/capsule/pkg/metrics" "github.com/projectcapsule/capsule/pkg/utils" ) @@ -115,6 +119,12 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re } defer func() { + if uerr := r.updateStatus(ctx, tntResource, err); uerr != nil { + err = fmt.Errorf("cannot update globaltenantresource status: %w", uerr) + + return + } + r.metrics.RecordConditions(tntResource) if e := patchHelper.Patch(ctx, tntResource); e != nil { @@ -124,6 +134,10 @@ func (r *namespacedResourceController) Reconcile(ctx context.Context, request re } }() + if *tntResource.Spec.Cordoned { + log.V(5).Info("tenant resource is cordoned") + } + c, err := r.loadClient(ctx, log, tntResource) if err != nil { return reconcile.Result{}, gherrors.Wrap(err, "failed to load serviceaccount client") @@ -319,3 +333,37 @@ func (r *namespacedResourceController) loadClient( return saClient, nil } + +func (r *namespacedResourceController) updateStatus(ctx context.Context, instance *capsulev1beta2.TenantResource, reconcileError error) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { + latest := &capsulev1beta2.TenantResource{} + if err = r.client.Get(ctx, types.NamespacedName{Name: instance.GetName()}, latest); err != nil { + return err + } + + latest.Status = instance.Status + + // Set Ready Condition + readyCondition := meta.NewReadyCondition(instance) + if reconcileError != nil { + readyCondition.Message = reconcileError.Error() + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = meta.FailedReason + } + + latest.Status.Conditions.UpdateConditionByType(readyCondition) + + // Set Cordoned Condition + cordonedCondition := meta.NewCordonedCondition(instance) + + if *instance.Spec.Cordoned { + cordonedCondition.Reason = meta.CordonedReason + cordonedCondition.Message = "is cordoned" + cordonedCondition.Status = metav1.ConditionTrue + } + + latest.Status.Conditions.UpdateConditionByType(cordonedCondition) + + return r.client.Status().Update(ctx, latest) + }) +} diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 217d09063..e0a43ae6a 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -7,6 +7,8 @@ import ( "context" "errors" "fmt" + "strconv" + "strings" "sync" "github.com/valyala/fasttemplate" @@ -26,6 +28,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/meta" + tpl "github.com/projectcapsule/capsule/pkg/template" ) const ( @@ -125,8 +128,15 @@ func (r *Processor) HandleSectionPreflight( ) (processed []string, err error) { log := ctrllog.FromContext(ctx) + tplContext := loadTenantToContext(&tnt) + switch scope { case api.ResourceScopeTenant: + + tplContext, _ := spec.Context.GatherContext(ctx, c, nil, "") + + log.Info("got context", "context", tplContext) + return r.handleSection( ctx, c, @@ -140,7 +150,9 @@ func (r *Processor) HandleSectionPreflight( UID: tnt.GetUID(), Scope: api.ResourceScopeTenant, }, - nil) + nil, + tplContext, + ) default: // Creating Namespace selector @@ -175,6 +187,9 @@ func (r *Processor) HandleSectionPreflight( } for _, ns := range namespaces.Items { + + //spec.Context.GatherContext(ctx, c, nil, ns.GetName()) + p, perr := r.handleSection( ctx, c, @@ -188,7 +203,8 @@ func (r *Processor) HandleSectionPreflight( UID: ns.GetUID(), Scope: api.ResourceScopeNamespace, }, - &ns) + &ns, + tplContext) if perr != nil { err = errors.Join(err, perr) } @@ -211,6 +227,7 @@ func (r *Processor) handleSection( spec capsulev1beta2.ResourceSpec, owner capsulev1beta2.ObjectReferenceStatusOwner, ns *corev1.Namespace, + tmplContext tpl.ReferenceContext, ) ([]string, error) { log := ctrllog.FromContext(ctx) @@ -302,7 +319,7 @@ func (r *Processor) handleSection( replicatedItem.Namespace = ns.Name } - if opErr := r.createOrUpdate(ctx, c, &obj, objLabels, objAnnotations); opErr != nil { + if opErr := r.createOrPatch(ctx, c, &obj, objLabels, objAnnotations, spec.Ignore); opErr != nil { log.Error(opErr, "unable to sync namespacedItems", kv...) errorsChan <- opErr @@ -375,7 +392,7 @@ func (r *Processor) handleSection( replicatedItem.Namespace = ns.Name } - if rawErr := r.createOrUpdate(ctx, c, &obj, objLabels, objAnnotations); rawErr != nil { + if rawErr := r.createOrPatch(ctx, c, &obj, objLabels, objAnnotations, spec.Ignore); rawErr != nil { log.Info("unable to sync rawItem", keysAndValues...) replicatedItem.Status = metav1.ConditionFalse @@ -393,57 +410,285 @@ func (r *Processor) handleSection( processed.Insert(replicatedItem.String()) } + // Run Generators + for generatorIndex, item := range spec.Generators { + keysAndValues := []interface{}{"index", generatorIndex} + + log.V(5).Info("reconciling generator", keysAndValues...) + + objs, err := renderGeneratorItem(item, tmplContext) + if err != nil { + syncErr = errors.Join(syncErr, err) + + log.Error(err, "unable to deserialize rawItem", keysAndValues...) + + continue + + } + + log.V(5).Info("obtained objects", "items", len(objs)) + + for _, obj := range objs { + if ns != nil { + obj.SetNamespace(ns.Name) + } + + replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} + replicatedItem.Name = obj.GetName() + replicatedItem.Kind = obj.GetKind() + replicatedItem.APIVersion = obj.GetAPIVersion() + replicatedItem.Type = meta.ReadyCondition + replicatedItem.Owner = owner + + if ns != nil { + replicatedItem.Namespace = ns.Name + } + + if rawErr := r.createOrPatch(ctx, c, &obj, objLabels, objAnnotations, spec.Ignore); rawErr != nil { + log.Info("unable to sync rawItem", keysAndValues...) + + replicatedItem.Status = metav1.ConditionFalse + replicatedItem.Message = rawErr.Error() + + // In case of error processing an item in one of any selected Namespaces, storing it to report it lately + // to the upper call to ensure a partial sync that will be fixed by a subsequent reconciliation. + syncErr = errors.Join(syncErr, rawErr) + } else { + log.Info("resource has been replicated", keysAndValues...) + + replicatedItem.Status = metav1.ConditionTrue + } + + processed.Insert(replicatedItem.String()) + } + } + return processed.List(), syncErr } -// createOrUpdate replicates the provided unstructured object to all the provided Namespaces: -// this function mimics the CreateOrUpdate, by retrieving the object to understand if it must be created or updated, -// along adding the additional metadata, if required. -func (r *Processor) createOrUpdate( +func (r *Processor) createOrPatch( ctx context.Context, c client.Client, obj *unstructured.Unstructured, - labels map[string]string, - annotations map[string]string, -) (err error) { - actual, desired := &unstructured.Unstructured{}, obj.DeepCopy() - - actual.SetAPIVersion(desired.GetAPIVersion()) - actual.SetKind(desired.GetKind()) - actual.SetNamespace(desired.GetNamespace()) - actual.SetName(desired.GetName()) - - _, err = controllerutil.CreateOrUpdate(ctx, c, actual, func() error { - UID := actual.GetUID() + labels, annotations map[string]string, + ignore []api.IgnoreRule, +) error { + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + // Fetch current to have a stable mutate func input + _ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) // ignore notfound here + + igPaths := matchIgnorePaths(ignore, obj.GetKind(), obj.GetAPIVersion()) + + _, err := controllerutil.CreateOrPatch(ctx, c, actual, func() error { + // Keep copies + live := actual.DeepCopy() // current from cluster (may be empty) + desired := obj.DeepCopy() // what we want + + // Merge controller-managed labels/annotations into desired + mergeLabelsAnnotations(desired, labels, annotations) + + // Preserve ignored JSON pointers: copy live -> desired at those paths + if len(igPaths) > 0 { + preserveIgnoredPaths(desired.Object, live.Object, igPaths) + } + + // Replace actual content with the prepared desired content + uid := actual.GetUID() rv := actual.GetResourceVersion() - actual.SetUnstructuredContent(desired.Object) - combinedLabels := obj.GetLabels() - if combinedLabels == nil { - combinedLabels = make(map[string]string) - } + actual.Object = desired.Object + actual.SetUID(uid) + actual.SetResourceVersion(rv) - for key, value := range labels { - combinedLabels[key] = value - } + return nil + }) + return err +} - actual.SetLabels(combinedLabels) +func mergeLabelsAnnotations(u *unstructured.Unstructured, ls, as map[string]string) { + lbl := u.GetLabels() + if lbl == nil { + lbl = map[string]string{} + } + for k, v := range ls { + lbl[k] = v + } + u.SetLabels(lbl) + + ann := u.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + for k, v := range as { + ann[k] = v + } + u.SetAnnotations(ann) +} - combinedAnnotations := obj.GetAnnotations() - if combinedAnnotations == nil { - combinedAnnotations = make(map[string]string) +// jsonPointerGet returns (value, true) if JSON pointer p exists. +func jsonPointerGet(obj map[string]any, p string) (any, bool) { + if p == "" || p == "/" { + return obj, true + } + parts := strings.Split(p, "/")[1:] + cur := any(obj) + for _, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + switch node := cur.(type) { + case map[string]any: + next, ok := node[key] + if !ok { + return nil, false + } + cur = next + case []any: + idx, err := strconv.Atoi(key) + if err != nil || idx < 0 || idx >= len(node) { + return nil, false + } + cur = node[idx] + default: + return nil, false } + } + return cur, true +} - for key, value := range annotations { - combinedAnnotations[key] = value +func jsonPointerSet(obj map[string]any, p string, val any) error { + if p == "" || p == "/" { + return fmt.Errorf("cannot set root with pointer") + } + parts := strings.Split(p, "/")[1:] + cur := obj + for i, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + last := i == len(parts)-1 + if last { + cur[key] = val + return nil + } + nxt, ok := cur[key] + if !ok { + n := map[string]any{} + cur[key] = n + cur = n + continue } + switch m := nxt.(type) { + case map[string]any: + cur = m + default: + n := map[string]any{} + cur[key] = n + cur = n + } + } + return nil +} - actual.SetAnnotations(combinedAnnotations) - actual.SetResourceVersion(rv) - actual.SetUID(UID) +func jsonPointerDelete(obj map[string]any, p string) error { + if p == "" || p == "/" { + return fmt.Errorf("cannot delete root with pointer") + } + parts := strings.Split(p, "/")[1:] + cur := obj + for i, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + last := i == len(parts)-1 + if last { + delete(cur, key) + return nil + } + nxt, ok := cur[key] + if !ok { + return nil + } + m, ok := nxt.(map[string]any) + if !ok { + return nil + } + cur = m + } + return nil +} - return nil - }) +func preserveIgnoredPaths(desired, live map[string]any, ptrs []string) { + for _, p := range ptrs { + if v, ok := jsonPointerGet(live, p); ok { + _ = jsonPointerSet(desired, p, v) + } else { + _ = jsonPointerDelete(desired, p) + } + } +} - return err +func matchIgnorePaths(rules []api.IgnoreRule, kind, apiver string) []string { + var out []string + + for _, r := range rules { + if r.Target.Kind != "" && r.Target.Kind != kind { + continue + } + if r.Target.Version != "" && r.Target.Version != apiver { + continue + } + out = append(out, r.Paths...) + } + return out } + +// createOrUpdate replicates the provided unstructured object to all the provided Namespaces: +// this function mimics the CreateOrUpdate, by retrieving the object to understand if it must be created or updated, +// along adding the additional metadata, if required. +//func (r *Processor) createOrUpdate( +// ctx context.Context, +// c client.Client, +// obj *unstructured.Unstructured, +// labels map[string]string, +// annotations map[string]string, +//) (err error) { +// actual, desired := &unstructured.Unstructured{}, obj.DeepCopy() +// +// actual.SetAPIVersion(desired.GetAPIVersion()) +// actual.SetKind(desired.GetKind()) +// actual.SetNamespace(desired.GetNamespace()) +// actual.SetName(desired.GetName()) +// +// _, err = controllerutil.CreateOrUpdate(ctx, c, actual, func() error { +// UID := actual.GetUID() +// rv := actual.GetResourceVersion() +// actual.SetUnstructuredContent(desired.Object) +// +// combinedLabels := obj.GetLabels() +// if combinedLabels == nil { +// combinedLabels = make(map[string]string) +// } +// +// for key, value := range labels { +// combinedLabels[key] = value +// } +// +// actual.SetLabels(combinedLabels) +// +// combinedAnnotations := obj.GetAnnotations() +// if combinedAnnotations == nil { +// combinedAnnotations = make(map[string]string) +// } +// +// for key, value := range annotations { +// combinedAnnotations[key] = value +// } +// +// actual.SetAnnotations(combinedAnnotations) +// actual.SetResourceVersion(rv) +// actual.SetUID(UID) +// +// return nil +// }) +// +// return err +//} diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go index 9872e32c2..d1629bcdd 100644 --- a/controllers/resources/utils.go +++ b/controllers/resources/utils.go @@ -4,9 +4,18 @@ package resources import ( + "bytes" + "fmt" + "html/template" + "io" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kyaml "k8s.io/apimachinery/pkg/util/yaml" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/configuration" + tpl "github.com/projectcapsule/capsule/pkg/template" caputils "github.com/projectcapsule/capsule/pkg/utils" ) @@ -141,3 +150,55 @@ func setGlobalTenantDefaultResourceServiceAccount( return true } + +// Field templating for the ArgoCD project properties. Needs to unmarshal in json, because of the json tags from argocd. +func loadTenantToContext( + tenant *capsulev1beta2.Tenant, +) (context map[string]interface{}) { + context = make(map[string]interface{}) + context["Tenant"] = tenant + + return +} + +// Field templating for the ArgoCD project properties. Needs to unmarshal in json, because of the json tags from argocd. +func renderGeneratorItem( + generator capsulev1beta2.GeneratorItemSpec, + context tpl.ReferenceContext, +) (items []unstructured.Unstructured, err error) { + tmpl, err := template.New("tpl").Option("missingkey=" + generator.MissingKey.String()).Funcs(tpl.ExtraFuncMap()).Parse(generator.Template) + if err != nil { + return + } + + var rendered bytes.Buffer + if err = tmpl.Execute(&rendered, context); err != nil { + return + } + + dec := kyaml.NewYAMLOrJSONDecoder(bytes.NewReader(rendered.Bytes()), 4096) + + var out []unstructured.Unstructured + for { + var obj map[string]any + if err := dec.Decode(&obj); err != nil { + if err == io.EOF { + break + } + // Skip pure whitespace/--- separators that decode to nil/empty + return nil, fmt.Errorf("decode yaml: %w", err) + } + if len(obj) == 0 { + continue + } + + u := unstructured.Unstructured{Object: obj} + if u.GetAPIVersion() == "" && u.GetKind() == "" { + continue + } + + out = append(out, u) + } + + return out, nil +} diff --git a/controllers/tenant/manager.go b/controllers/tenant/manager.go index 42c00f803..436fc58c9 100644 --- a/controllers/tenant/manager.go +++ b/controllers/tenant/manager.go @@ -165,10 +165,8 @@ func (r *Manager) updateTenantStatus(ctx context.Context, tnt *capsulev1beta2.Te latest.Status.State = capsulev1beta2.TenantStateCordoned cordonedCondition.Reason = meta.CordonedReason - cordonedCondition.Message = "Tenant is cordoned" + cordonedCondition.Message = "is cordoned" cordonedCondition.Status = metav1.ConditionTrue - } else { - latest.Status.State = capsulev1beta2.TenantStateActive } latest.Status.Conditions.UpdateConditionByType(cordonedCondition) diff --git a/global-scope.yaml b/global-scope.yaml index 6ae6ebaa8..ad3865c07 100644 --- a/global-scope.yaml +++ b/global-scope.yaml @@ -1,20 +1,54 @@ apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalTenantResource +kind: GlobalTenantReplication metadata: name: global-scope spec: - tenantSelector: - matchLabels: - energy: renewable scope: Tenant resyncPeriod: 5s + ignore: + - paths: ["/metadata/labels/capsule.managed-by"] + target: + kind: Deployment resources: - - rawItems: - - apiVersion: v1 - kind: Secret - metadata: - name: "some-secret-bruv" - namespace: "solar-test" - stringData: - username: "some-username" - + - ignore: + - paths: ["/metadata/labels/capsule.managed-by"] + target: + kind: Deployment + name: {{.tenant.name}}-controller-manager + context: + resources: + - index: "sec" + apiVersion: v1 + kind: Secret + name: capsule-tls + namespace: capsule-system + generators: + - template: | + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + namespace: default + data: + # property-like keys; each key maps to a simple value + player_initial_lives: "3" + ui_properties_file_name: "user-interface.properties" + + # file-like keys + game.properties: | + {{- toYaml . | nindent 4 }} + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo-2 + namespace: default + data: + # property-like keys; each key maps to a simple value + player_initial_lives: "3" + ui_properties_file_name: "user-interface.properties" + + # file-like keys + game.properties: | + {{- toYaml .Tenant.spec | nindent 4 }} diff --git a/go.mod b/go.mod index 6e9ebc780..c88726bc9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/projectcapsule/capsule -go 1.24.0 +go 1.25.0 toolchain go1.25.3 @@ -41,6 +41,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.13.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/zapr v1.3.0 // indirect diff --git a/go.sum b/go.sum index a514770d9..5657d6da5 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/pkg/apis/kustomize v1.13.0 h1:GGf0UBVRIku+gebY944icVeEIhyg1P/KE3IrhOyJJnE= +github.com/fluxcd/pkg/apis/kustomize v1.13.0/go.mod h1:TLKVqbtnzkhDuhWnAsN35977HvRfIjs+lgMuNro/LEc= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= diff --git a/pkg/api/context.go b/pkg/api/context.go index 6de93973a..192b86804 100644 --- a/pkg/api/context.go +++ b/pkg/api/context.go @@ -24,18 +24,20 @@ func (t *TemplateContext) GatherContext( ctx context.Context, kubeClient client.Client, data map[string]interface{}, - context tpl.ReferenceContext, -) (errors []error) { + namespace string, +) (context tpl.ReferenceContext, errors []error) { + context = tpl.ReferenceContext{} + // Template Context for Tenant if len(data) != 0 { if err := t.selfTemplate(data); err != nil { - return []error{fmt.Errorf("cloud not template: %w", err)} + return context, []error{fmt.Errorf("cloud not template: %w", err)} } } // Load external Resources - for _, resource := range t.Resources { - val, err := resource.LoadResources(ctx, kubeClient) + for index, resource := range t.Resources { + val, err := resource.LoadResources(ctx, kubeClient, namespace) if err != nil { errors = append(errors, err) @@ -43,6 +45,11 @@ func (t *TemplateContext) GatherContext( } if len(val) > 0 { + resourceIndex := resource.Index + if resourceIndex == "" { + resourceIndex = string(index) + } + context[resource.Index] = val } } @@ -88,7 +95,7 @@ func (t *TemplateContext) selfTemplate( // +kubebuilder:object:generate=true type ResourceReference struct { // Index where the results are published in the templating/CEL - Index string `json:"index"` + Index string `json:"index,omitempty"` // Kind of the referent. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds Kind string `json:"kind" protobuf:"bytes,1,opt,name=kind"` @@ -108,15 +115,38 @@ type ResourceReference struct { // Selector which allows to get any amount of these resources based on labels // +optional Selector *metav1.LabelSelector `json:"selector,omitempty"` - // Optional indicates whether the referenced resource must exist, or whether to - // tolerate its absence. If true and the referenced resource is absent, proceed - // as if the resource was present but empty, without any variables defined. - // +kubebuilder:default:=false - // +optional + // Only relevant if name is set. If an item is not optional, there will be an error thrown when it does not exist + // +kubebuilder:default:=true Optional bool `json:"optional,omitempty"` } -func (t ResourceReference) LoadResources(ctx context.Context, kubeClient client.Client) ([]unstructured.Unstructured, error) { +func (t ResourceReference) LoadResources( + ctx context.Context, + kubeClient client.Client, + namespace string, +) ([]unstructured.Unstructured, error) { + if namespace != "" { + t.Namespace = namespace + } + + // For a single item we are not using list + if t.Name != "" { + obj := &unstructured.Unstructured{} + obj.SetAPIVersion(t.APIVersion) + obj.SetKind(t.Kind) + + key := client.ObjectKey{ + Name: t.Name, + Namespace: t.Namespace, + } + + if err := kubeClient.Get(ctx, key, obj); err != nil { + return nil, fmt.Errorf("failed to get %s/%s: %w", t.Kind, t.Name, err) + } + + return []unstructured.Unstructured{*obj}, nil + } + list := &unstructured.UnstructuredList{} list.SetAPIVersion(t.APIVersion) list.SetKind(t.Kind + "List") @@ -136,11 +166,6 @@ func (t ResourceReference) LoadResources(ctx context.Context, kubeClient client. opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) } - // Optionally, if t.Name is specified, you can filter by name. - //if t.Name != "" { - // opts = append(opts, client.MatchingFields{"metadata.name": t.Name}) - //} - // List the resources. if err := kubeClient.List(ctx, list, opts...); err != nil { return nil, fmt.Errorf("failed to list: %w", err) diff --git a/pkg/api/ignore.go b/pkg/api/ignore.go new file mode 100644 index 000000000..1e959a20e --- /dev/null +++ b/pkg/api/ignore.go @@ -0,0 +1,18 @@ +package api + +import "github.com/fluxcd/pkg/apis/kustomize" + +// +kubebuilder:object:generate=true +type IgnoreRule struct { + // Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from + // consideration in a Kubernetes object. + // +required + Paths []string `json:"paths"` + + // Target is a selector for specifying Kubernetes objects to which this + // rule applies. + // If Target is not set, the Paths will be ignored for all Kubernetes + // objects within the manifest of the Helm release. + // +optional + Target *kustomize.Selector `json:"target,omitempty"` +} diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index e4108d67a..3f3fc471f 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -8,6 +8,7 @@ package api import ( + "github.com/fluxcd/pkg/apis/kustomize" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -217,6 +218,31 @@ func (in *ForbiddenListSpec) DeepCopy() *ForbiddenListSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IgnoreRule) DeepCopyInto(out *IgnoreRule) { + *out = *in + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Target != nil { + in, out := &in.Target, &out.Target + *out = new(kustomize.Selector) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IgnoreRule. +func (in *IgnoreRule) DeepCopy() *IgnoreRule { + if in == nil { + return nil + } + out := new(IgnoreRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LimitRangesSpec) DeepCopyInto(out *LimitRangesSpec) { *out = *in diff --git a/pkg/metrics/globaltenantresource_recorder.go b/pkg/metrics/globaltenantresource_recorder.go index 29f6ce314..d2f421e05 100644 --- a/pkg/metrics/globaltenantresource_recorder.go +++ b/pkg/metrics/globaltenantresource_recorder.go @@ -43,7 +43,7 @@ func (r *GlobalTenantResourceRecorder) Collectors() []prometheus.Collector { } func (r *GlobalTenantResourceRecorder) RecordConditions(resource *capsulev1beta2.GlobalTenantResource) { - for _, status := range []string{meta.ReadyCondition} { + for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { var value float64 cond := resource.Status.Conditions.GetConditionByType(status) diff --git a/pkg/metrics/tenantresource_recorder.go b/pkg/metrics/tenantresource_recorder.go index 601ccc4a9..6db3ee43e 100644 --- a/pkg/metrics/tenantresource_recorder.go +++ b/pkg/metrics/tenantresource_recorder.go @@ -44,7 +44,7 @@ func (r *TenantResourceRecorder) Collectors() []prometheus.Collector { // RecordCondition records the condition as given for the ref. func (r *TenantResourceRecorder) RecordConditions(resource *capsulev1beta2.TenantResource) { - for _, status := range []string{meta.ReadyCondition} { + for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { var value float64 cond := resource.Status.Conditions.GetConditionByType(status) From 64f3c7202f03abf3d63e869350fffa74dd4462a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 5 Nov 2025 10:30:38 +0100 Subject: [PATCH 17/19] somewaht functional --- api/v1beta2/capsuleconfiguration_types.go | 8 +- api/v1beta2/tenantresource_namespaced.go | 8 + api/v1beta2/tenantresource_types.go | 12 +- api/v1beta2/zz_generated.deepcopy.go | 20 +- ...sule.clastix.io_capsuleconfigurations.yaml | 71 ++- ...sule.clastix.io_globaltenantresources.yaml | 6 + .../capsule.clastix.io_tenantresources.yaml | 6 + charts/capsule/values.yaml | 19 +- config.yaml | 30 ++ controllers/resources/global.go | 14 +- controllers/resources/namespaced.go | 9 +- controllers/resources/processor.go | 490 ++---------------- controllers/resources/processor_handle.go | 141 +++++ controllers/resources/utils.go | 19 +- global-scope.yaml | 69 ++- go.mod | 30 ++ go.sum | 59 +++ pkg/api/context.go | 10 +- pkg/api/ignore.go | 26 +- pkg/configuration/client.go | 13 +- pkg/configuration/configuration.go | 10 +- pkg/meta/labels.go | 3 +- pkg/meta/ownership.go | 9 + pkg/utils/patch.go | 94 ++++ 24 files changed, 653 insertions(+), 523 deletions(-) create mode 100644 config.yaml create mode 100644 controllers/resources/processor_handle.go create mode 100644 pkg/meta/ownership.go create mode 100644 pkg/utils/patch.go diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 539327851..52769353a 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -48,10 +48,10 @@ type CapsuleConfigurationSpec struct { } type ReplicationsSpec struct { - // Define labels which are not reconciled for Global/TenantResources - IgnoreLabels []string `json:"ignoreLabels"` - // Define Annotations which are not reconciled for Global/TenantResources - IgnoreAnnotations []string `json:"ignoreAnnotations"` + // Ignore contains a list of rules for specifying which changes to ignore + // during diffing. + // +optional + Ignore []api.IgnoreRule `json:"ignore,omitempty"` } type NodeMetadata struct { diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index d4b04651a..de328ef5b 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -49,6 +49,14 @@ type ResourceSpec struct { // the declared items for the replication // +optional Context *api.TemplateContext `json:"context,omitempty"` + // Automatically adds a label to all resources being patched by a tenantresource blocking any interactions from tenant users via admission webhook + // The label added is called + // +kubebuilder:default=true + Managed *bool `json:"managed,omitempty"` + // Force indicates that in case of conflicts with server-side apply, the client should acquire ownership of the conflicting field. + // You may create collisions with this. + // +kubebuilder:default=false + Force *bool `json:"force,omitempty"` // Ignore contains a list of rules for specifying which changes to ignore // during diffing. // +optional diff --git a/api/v1beta2/tenantresource_types.go b/api/v1beta2/tenantresource_types.go index 4487b1fff..03f8328ae 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -92,6 +92,10 @@ type ObjectReferenceStatus struct { // Name of the referent. // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names Name string `json:"name"` + + // The resource index this item was created from + ResourceIndex string `json:"index"` + // Tenant of the referent. Owner ObjectReferenceStatusOwner `json:"owner,omitempty"` @@ -139,14 +143,14 @@ type ObjectReference struct { func (in *ObjectReferenceStatus) String() string { return fmt.Sprintf( - "Kind=%s,APIVersion=%s,Namespace=%s,Name=%s,Message=%s,Type=%s,Owner=%s,UID=%s,Scope=%s", - in.Kind, in.APIVersion, in.Namespace, in.Name, in.Message, in.Type, in.Owner.Name, in.Owner.UID, in.Owner.Scope) + "Kind=%s,APIVersion=%s,Namespace=%s,Name=%s,Message=%s,Type=%s,Status=%s,Owner=%s,UID=%s,Scope=%s,Index=%s", + in.Kind, in.APIVersion, in.Namespace, in.Name, in.Message, in.Type, in.Status, in.Owner.Name, in.Owner.UID, in.Owner.Scope, in.ResourceIndex) } func (in *ObjectReferenceStatus) ParseFromString(value string) error { rawParts := strings.Split(value, ",") - if len(rawParts) != 9 { + if len(rawParts) != 11 { return fmt.Errorf("unexpected raw parts") } @@ -185,6 +189,8 @@ func (in *ObjectReferenceStatus) ParseFromString(value string) error { in.Owner.UID = k8stypes.UID(v) case "Scope": in.Owner.Scope = api.ResourceScope(v) + case "Index": + in.ResourceIndex = v default: return fmt.Errorf("unrecognized marker: %s", k) diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 09fdf56ab..d66372657 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -606,15 +606,12 @@ func (in *RawExtension) DeepCopy() *RawExtension { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ReplicationsSpec) DeepCopyInto(out *ReplicationsSpec) { *out = *in - if in.IgnoreLabels != nil { - in, out := &in.IgnoreLabels, &out.IgnoreLabels - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.IgnoreAnnotations != nil { - in, out := &in.IgnoreAnnotations, &out.IgnoreAnnotations - *out = make([]string, len(*in)) - copy(*out, *in) + if in.Ignore != nil { + in, out := &in.Ignore, &out.Ignore + *out = make([]api.IgnoreRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } } @@ -1053,6 +1050,11 @@ func (in *ResourceSpec) DeepCopyInto(out *ResourceSpec) { *out = new(api.TemplateContext) (*in).DeepCopyInto(*out) } + if in.Force != nil { + in, out := &in.Force, &out.Force + *out = new(bool) + **out = **in + } if in.Ignore != nil { in, out := &in.Ignore, &out.Ignore *out = make([]api.IgnoreRule, len(*in)) diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 8bc1ab821..279ed3952 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -134,19 +134,68 @@ spec: replications: description: Omit Replications properties: - ignoreAnnotations: - description: Define Annotations which are not reconciled for Global/TenantResources - items: - type: string - type: array - ignoreLabels: - description: Define labels which are not reconciled for Global/TenantResources + ignore: + description: |- + Ignore contains a list of rules for specifying which changes to ignore + during diffing. items: - type: string + properties: + paths: + description: |- + Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from + consideration in a Kubernetes object. + items: + type: string + type: array + target: + description: |- + Target is a selector for specifying Kubernetes objects to which this + rule applies. + If Target is not set, the Paths will be ignored for all Kubernetes + objects within the manifest of the Helm release. + properties: + annotationSelector: + description: |- + AnnotationSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource annotations. + type: string + group: + description: |- + Group is the API group to select resources from. + Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + kind: + description: |- + Kind of the API Group to select resources from. + Together with Group and Version it is capable of unambiguously + identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + labelSelector: + description: |- + LabelSelector is a string that follows the label selection expression + https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api + It matches with the resource labels. + type: string + name: + description: Name to match resources with. + type: string + namespace: + description: Namespace to select resources from. + type: string + version: + description: |- + Version of the API Group to select resources from. + Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. + https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md + type: string + type: object + required: + - paths + type: object type: array - required: - - ignoreAnnotations - - ignoreLabels type: object serviceAccountClient: description: Define Kubernetes-Client Configurations diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index fb95f9165..8290739c8 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -176,6 +176,12 @@ spec: type: object type: array type: object + force: + default: false + description: Force indicates that in case of conflicts with + server-side apply, the client should acquire ownership of + the conflicting field. + type: boolean generators: description: Generators for advanced use cases items: diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index d041588e4..ce24c86cb 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -178,6 +178,12 @@ spec: type: object type: array type: object + force: + default: false + description: Force indicates that in case of conflicts with + server-side apply, the client should acquire ownership of + the conflicting field. + type: boolean generators: description: Generators for advanced use cases items: diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 274bf3269..0356eeae7 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -593,8 +593,10 @@ webhooks: # -- [ObjectSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-objectselector) objectSelector: matchExpressions: - - key: capsule.clastix.io/tenant - operator: Exists + - key: "projectcapsule.dev/managed-by" + operator: In + values: + - replications # -- [NamespaceSelector](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-namespaceselector) namespaceSelector: matchExpressions: @@ -602,6 +604,19 @@ webhooks: operator: Exists # -- [MatchConditions](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-matchpolicy) matchConditions: [] + # -- [Rules](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#matching-requests-rules) + rules: + - apiGroups: + - '*' + apiVersions: + - '*' + operations: + - UPDATE + - DELETE + resources: + - '*' + scope: "*" + services: # -- Enable the Hook diff --git a/config.yaml b/config.yaml new file mode 100644 index 000000000..1b280e6e9 --- /dev/null +++ b/config.yaml @@ -0,0 +1,30 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: CapsuleConfiguration +metadata: + annotations: + meta.helm.sh/release-name: capsule + meta.helm.sh/release-namespace: capsule-system + creationTimestamp: "2025-10-29T12:14:59Z" + generation: 2 + labels: + app.kubernetes.io/instance: capsule + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: capsule + app.kubernetes.io/version: 0.0.0 + helm.sh/chart: capsule-0.0.0 + name: default + resourceVersion: "1185" + uid: 22c5cb09-3f3c-4faa-b183-18fb12d02e55 +spec: + replications: + serviceAccountClient: + endpoint: https://proxy.capsule-system.svc:9001 + + globalDefaultServiceAccount: "capsule" + globalDefaultServiceAccountNamespace: capsule-system + + tenantDefaultServiceAccount: default + ignore: + - paths: + - "/metadata/labels/company.com~1some-label" + \ No newline at end of file diff --git a/controllers/resources/global.go b/controllers/resources/global.go index 8d15a0463..aa03e6b17 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "reflect" + "strings" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" @@ -46,7 +47,8 @@ type globalResourceController struct { func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager) error { r.client = mgr.GetClient() r.processor = Processor{ - client: mgr.GetClient(), + client: mgr.GetClient(), + configuration: r.configuration, } return ctrl.NewControllerManagedBy(mgr). @@ -243,6 +245,8 @@ func (r *globalResourceController) reconcileNormal( } else { err = errors.Join(err, fmt.Errorf("processed item %q parse failed: %w", item, parseErr)) } + + log.Info("PARSED", "OR", or) } log.Info("STATUS", "STATUS", tntResource.Status) @@ -252,6 +256,8 @@ func (r *globalResourceController) reconcileNormal( var itemErrors error for index, resource := range tntResource.Spec.Resources { + owner := "cluster/" + strings.ToLower(tntResource.Name) + tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) if labelErr != nil { log.Error(labelErr, "expected label for selection") @@ -262,7 +268,7 @@ func (r *globalResourceController) reconcileNormal( for _, tnt := range tntList.Items { tntSet.Insert(tnt.GetName()) - items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tnt, true, tenantLabel, index, resource, tntResource.Spec.Scope) + items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tnt, true, tenantLabel, index, resource, owner, tntResource.Spec.Scope) if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. @@ -275,6 +281,10 @@ func (r *globalResourceController) reconcileNormal( } } + if itemErrors != nil { + err = itemErrors + } + if err != nil { log.Error(err, "unable to replicate the requested resources") diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 759a72f89..320a4c82c 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -8,6 +8,8 @@ import ( "errors" "fmt" "reflect" + "strconv" + "strings" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" @@ -47,7 +49,8 @@ type namespacedResourceController struct { func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager) error { r.client = mgr.GetClient() r.processor = Processor{ - client: mgr.GetClient(), + client: mgr.GetClient(), + configuration: r.configuration, } return ctrl.NewControllerManagedBy(mgr). @@ -216,7 +219,9 @@ func (r *namespacedResourceController) reconcileNormal( var itemErrors error for index, resource := range tntResource.Spec.Resources { - items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tl.Items[0], false, tenantLabel, index, resource, api.ResourceScopeNamespace) + owner := "cluster/" + strings.ToLower(tntResource.Name) + "/" + strconv.Itoa(index) + + items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tl.Items[0], false, tenantLabel, index, resource, owner, api.ResourceScopeNamespace) if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index e0a43ae6a..6a9f6a9d1 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -6,29 +6,25 @@ package resources import ( "context" "errors" - "fmt" "strconv" - "strings" - "sync" - "github.com/valyala/fasttemplate" corev1 "k8s.io/api/core/v1" apierr "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/labels" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/selection" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/configuration" "github.com/projectcapsule/capsule/pkg/meta" tpl "github.com/projectcapsule/capsule/pkg/template" + "github.com/projectcapsule/capsule/pkg/utils" ) const ( @@ -36,7 +32,8 @@ const ( ) type Processor struct { - client client.Client + client client.Client + configuration configuration.Configuration } func prepareAdditionalMetadata(m map[string]string) map[string]string { @@ -124,6 +121,7 @@ func (r *Processor) HandleSectionPreflight( tenantLabel string, resourceIndex int, spec capsulev1beta2.ResourceSpec, + fieldOwner string, scope api.ResourceScope, ) (processed []string, err error) { log := ctrllog.FromContext(ctx) @@ -132,12 +130,12 @@ func (r *Processor) HandleSectionPreflight( switch scope { case api.ResourceScopeTenant: + tplContext, _ = spec.Context.GatherContext(ctx, c, nil, "") + tplContext["Tenant"] = tnt - tplContext, _ := spec.Context.GatherContext(ctx, c, nil, "") + owner := fieldOwner + "/" + tnt.Name + "/" + strconv.Itoa(resourceIndex) - log.Info("got context", "context", tplContext) - - return r.handleSection( + return r.reconcile( ctx, c, tnt, @@ -145,6 +143,7 @@ func (r *Processor) HandleSectionPreflight( tenantLabel, resourceIndex, spec, + owner, capsulev1beta2.ObjectReferenceStatusOwner{ Name: tnt.GetName(), UID: tnt.GetUID(), @@ -190,7 +189,9 @@ func (r *Processor) HandleSectionPreflight( //spec.Context.GatherContext(ctx, c, nil, ns.GetName()) - p, perr := r.handleSection( + owner := fieldOwner + "/" + tnt.Name + "/" + ns.Name + "/" + strconv.Itoa(resourceIndex) + + p, perr := r.reconcile( ctx, c, tnt, @@ -198,6 +199,7 @@ func (r *Processor) HandleSectionPreflight( tenantLabel, resourceIndex, spec, + owner, capsulev1beta2.ObjectReferenceStatusOwner{ Name: ns.GetName(), UID: ns.GetUID(), @@ -216,253 +218,70 @@ func (r *Processor) HandleSectionPreflight( return } -//nolint:gocognit -func (r *Processor) handleSection( +func (r *Processor) reconcile( ctx context.Context, c client.Client, tnt capsulev1beta2.Tenant, allowCrossNamespaceSelection bool, tenantLabel string, resourceIndex int, - spec capsulev1beta2.ResourceSpec, + resource capsulev1beta2.ResourceSpec, + fieldOwner string, owner capsulev1beta2.ObjectReferenceStatusOwner, ns *corev1.Namespace, tmplContext tpl.ReferenceContext, ) ([]string, error) { log := ctrllog.FromContext(ctx) - // Generating additional metadata - objAnnotations, objLabels := map[string]string{}, map[string]string{} - - if spec.AdditionalMetadata != nil { - objAnnotations = prepareAdditionalMetadata(spec.AdditionalMetadata.Annotations) - objLabels = prepareAdditionalMetadata(spec.AdditionalMetadata.Labels) + // Collect Resources to apply + objects, err := r.handleResources( + ctx, + c, + tnt, + allowCrossNamespaceSelection, + tenantLabel, + resourceIndex, + resource, + owner, + ns, + tmplContext, + ) + if err != nil { + log.Error(err, "some error happend", "here", "here") + return nil, err } - objAnnotations[tenantLabel] = tnt.GetName() - - objLabels[meta.ResourcesLabel] = fmt.Sprintf("%d", resourceIndex) - objLabels[tenantLabel] = tnt.GetName() - // processed will contain the sets of resources replicated, both for the raw and the Namespaced ones: - // these are required to perform a final pruning once the replication has been occurred. - processed := sets.NewString() - - tntNamespaces := sets.NewString(tnt.Status.Namespaces...) - var syncErr error - codecFactory := serializer.NewCodecFactory(r.client.Scheme()) - - for nsIndex, item := range spec.NamespacedItems { - keysAndValues := []any{"index", nsIndex, "namespace", item.Namespace, "tenant", tnt.GetName()} - // A TenantResource is created by a TenantOwner, and potentially, they could point to a resource in a non-owned - // Namespace: this must be blocked by checking it this is the case. - if !allowCrossNamespaceSelection && !tntNamespaces.Has(item.Namespace) { - log.Info("skipping processing of namespacedItem, referring a Namespace that is not part of the given Tenant", keysAndValues...) - - continue - } - // Namespaced Items are relying on selecting resources, rather than specifying a specific name: - // creating it to get used by the client List action. - objSelector := item.Selector - - itemSelector, selectorErr := metav1.LabelSelectorAsSelector(&objSelector) - if selectorErr != nil { - log.Error(selectorErr, "cannot create Selector for namespacedItem", keysAndValues...) - - syncErr = errors.Join(syncErr, selectorErr) - - continue - } - - objs := unstructured.UnstructuredList{} - objs.SetGroupVersionKind(schema.FromAPIVersionAndKind(item.APIVersion, fmt.Sprintf("%sList", item.Kind))) - - if clientErr := c.List(ctx, &objs, client.InNamespace(item.Namespace), client.MatchingLabelsSelector{Selector: itemSelector}); clientErr != nil { - log.Error(clientErr, "cannot retrieve object for namespacedItem", keysAndValues...) - - syncErr = errors.Join(syncErr, clientErr) - - continue - } - - var wg sync.WaitGroup - - errorsChan := make(chan error, len(objs.Items)) - // processedRaw is used to avoid concurrent map writes during iteration of namespaced items: - // the objects will be then added to processed variable if the resulting string is not empty, - // meaning it has been processed correctly. - processedRaw := make([]string, len(objs.Items)) - // Iterating over all the retrieved objects from the resource spec to get replicated in all the selected Namespaces: - // in case of error during the create or update function, this will be appended to the list of errors. - for i, o := range objs.Items { - obj := o - obj.SetNamespace(ns.Name) - obj.SetOwnerReferences(nil) - - wg.Add(1) - - go func(index int, obj unstructured.Unstructured) { - defer wg.Done() - - kv := keysAndValues - kv = append(kv, "resource", fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetNamespace())) - - replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} - replicatedItem.Name = obj.GetName() - replicatedItem.Kind = obj.GetKind() - replicatedItem.APIVersion = obj.GetAPIVersion() - replicatedItem.Type = meta.ReadyCondition - replicatedItem.Owner = owner - - if ns != nil { - replicatedItem.Namespace = ns.Name - } - - if opErr := r.createOrPatch(ctx, c, &obj, objLabels, objAnnotations, spec.Ignore); opErr != nil { - log.Error(opErr, "unable to sync namespacedItems", kv...) - errorsChan <- opErr - - replicatedItem.Status = metav1.ConditionFalse - replicatedItem.Message = opErr.Error() - } else { - replicatedItem.Status = metav1.ConditionTrue - } - - log.Info("resource has been replicated", kv...) - - processedRaw[index] = replicatedItem.String() - }(i, obj) - } - - wg.Wait() - close(errorsChan) - - for err := range errorsChan { - if err != nil { - syncErr = errors.Join(syncErr, err) - } - } - - for _, p := range processedRaw { - if p == "" { - continue - } - - processed.Insert(p) - } - } - - for rawIndex, item := range spec.RawItems { - template := string(item.Raw) - - t := fasttemplate.New(template, "{{ ", " }}") - - tContext := map[string]interface{}{ - "tenant.name": tnt.Name, - } - if ns != nil { - tContext["namespace"] = ns.Name - } - - tmplString := t.ExecuteString(tContext) - - obj, keysAndValues := unstructured.Unstructured{}, []interface{}{"index", rawIndex} - - if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode([]byte(tmplString), nil, &obj); decodeErr != nil { - log.Error(decodeErr, "unable to deserialize rawItem", keysAndValues...) - - syncErr = errors.Join(syncErr, decodeErr) - - continue - } + processed := sets.NewString() - if ns != nil { - obj.SetNamespace(ns.Name) - } + log.V(4).Info("processing items", "items", len(objects)) + // Apply objects and return processed + for i, obj := range objects { replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} replicatedItem.Name = obj.GetName() replicatedItem.Kind = obj.GetKind() replicatedItem.APIVersion = obj.GetAPIVersion() - replicatedItem.Type = meta.ReadyCondition replicatedItem.Owner = owner + replicatedItem.Type = meta.ReadyCondition if ns != nil { - replicatedItem.Namespace = ns.Name + replicatedItem.Namespace = ns.GetName() } - if rawErr := r.createOrPatch(ctx, c, &obj, objLabels, objAnnotations, spec.Ignore); rawErr != nil { - log.Info("unable to sync rawItem", keysAndValues...) + fieldOwnerw := fieldOwner + "/" + tnt.Name + "/" + strconv.Itoa(i) + if err := r.createOrPatch(ctx, c, obj, resource, fieldOwnerw); err != nil { replicatedItem.Status = metav1.ConditionFalse - replicatedItem.Message = rawErr.Error() - - // In case of error processing an item in one of any selected Namespaces, storing it to report it lately - // to the upper call to ensure a partial sync that will be fixed by a subsequent reconciliation. - syncErr = errors.Join(syncErr, rawErr) + replicatedItem.Message = err.Error() } else { - log.Info("resource has been replicated", keysAndValues...) - replicatedItem.Status = metav1.ConditionTrue } processed.Insert(replicatedItem.String()) } - // Run Generators - for generatorIndex, item := range spec.Generators { - keysAndValues := []interface{}{"index", generatorIndex} - - log.V(5).Info("reconciling generator", keysAndValues...) - - objs, err := renderGeneratorItem(item, tmplContext) - if err != nil { - syncErr = errors.Join(syncErr, err) - - log.Error(err, "unable to deserialize rawItem", keysAndValues...) - - continue - - } - - log.V(5).Info("obtained objects", "items", len(objs)) - - for _, obj := range objs { - if ns != nil { - obj.SetNamespace(ns.Name) - } - - replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} - replicatedItem.Name = obj.GetName() - replicatedItem.Kind = obj.GetKind() - replicatedItem.APIVersion = obj.GetAPIVersion() - replicatedItem.Type = meta.ReadyCondition - replicatedItem.Owner = owner - - if ns != nil { - replicatedItem.Namespace = ns.Name - } - - if rawErr := r.createOrPatch(ctx, c, &obj, objLabels, objAnnotations, spec.Ignore); rawErr != nil { - log.Info("unable to sync rawItem", keysAndValues...) - - replicatedItem.Status = metav1.ConditionFalse - replicatedItem.Message = rawErr.Error() - - // In case of error processing an item in one of any selected Namespaces, storing it to report it lately - // to the upper call to ensure a partial sync that will be fixed by a subsequent reconciliation. - syncErr = errors.Join(syncErr, rawErr) - } else { - log.Info("resource has been replicated", keysAndValues...) - - replicatedItem.Status = metav1.ConditionTrue - } - - processed.Insert(replicatedItem.String()) - } - } - return processed.List(), syncErr } @@ -470,8 +289,8 @@ func (r *Processor) createOrPatch( ctx context.Context, c client.Client, obj *unstructured.Unstructured, - labels, annotations map[string]string, - ignore []api.IgnoreRule, + resource capsulev1beta2.ResourceSpec, + fieldOwner string, ) error { actual := &unstructured.Unstructured{} actual.SetGroupVersionKind(obj.GroupVersionKind()) @@ -479,216 +298,19 @@ func (r *Processor) createOrPatch( actual.SetName(obj.GetName()) // Fetch current to have a stable mutate func input - _ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) // ignore notfound here - - igPaths := matchIgnorePaths(ignore, obj.GetKind(), obj.GetAPIVersion()) - - _, err := controllerutil.CreateOrPatch(ctx, c, actual, func() error { - // Keep copies - live := actual.DeepCopy() // current from cluster (may be empty) - desired := obj.DeepCopy() // what we want - - // Merge controller-managed labels/annotations into desired - mergeLabelsAnnotations(desired, labels, annotations) - - // Preserve ignored JSON pointers: copy live -> desired at those paths - if len(igPaths) > 0 { - preserveIgnoredPaths(desired.Object, live.Object, igPaths) - } - - // Replace actual content with the prepared desired content - uid := actual.GetUID() - rv := actual.GetResourceVersion() - - actual.Object = desired.Object - actual.SetUID(uid) - actual.SetResourceVersion(rv) - - return nil - }) - return err -} - -func mergeLabelsAnnotations(u *unstructured.Unstructured, ls, as map[string]string) { - lbl := u.GetLabels() - if lbl == nil { - lbl = map[string]string{} - } - for k, v := range ls { - lbl[k] = v - } - u.SetLabels(lbl) - - ann := u.GetAnnotations() - if ann == nil { - ann = map[string]string{} - } - for k, v := range as { - ann[k] = v - } - u.SetAnnotations(ann) -} + _ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) -// jsonPointerGet returns (value, true) if JSON pointer p exists. -func jsonPointerGet(obj map[string]any, p string) (any, bool) { - if p == "" || p == "/" { - return obj, true + if resource.AdditionalMetadata != nil { + obj.SetAnnotations(resource.AdditionalMetadata.Annotations) + obj.SetLabels(resource.AdditionalMetadata.Labels) } - parts := strings.Split(p, "/")[1:] - cur := any(obj) - for _, raw := range parts { - key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") - switch node := cur.(type) { - case map[string]any: - next, ok := node[key] - if !ok { - return nil, false - } - cur = next - case []any: - idx, err := strconv.Atoi(key) - if err != nil || idx < 0 || idx >= len(node) { - return nil, false - } - cur = node[idx] - default: - return nil, false - } - } - return cur, true -} - -func jsonPointerSet(obj map[string]any, p string, val any) error { - if p == "" || p == "/" { - return fmt.Errorf("cannot set root with pointer") - } - parts := strings.Split(p, "/")[1:] - cur := obj - for i, raw := range parts { - key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") - last := i == len(parts)-1 - if last { - cur[key] = val - return nil - } - nxt, ok := cur[key] - if !ok { - n := map[string]any{} - cur[key] = n - cur = n - continue - } - switch m := nxt.(type) { - case map[string]any: - cur = m - default: - n := map[string]any{} - cur[key] = n - cur = n - } - } - return nil -} - -func jsonPointerDelete(obj map[string]any, p string) error { - if p == "" || p == "/" { - return fmt.Errorf("cannot delete root with pointer") - } - parts := strings.Split(p, "/")[1:] - cur := obj - for i, raw := range parts { - key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") - last := i == len(parts)-1 - if last { - delete(cur, key) - return nil - } - nxt, ok := cur[key] - if !ok { - return nil - } - m, ok := nxt.(map[string]any) - if !ok { - return nil - } - cur = m - } - return nil -} - -func preserveIgnoredPaths(desired, live map[string]any, ptrs []string) { - for _, p := range ptrs { - if v, ok := jsonPointerGet(live, p); ok { - _ = jsonPointerSet(desired, p, v) - } else { - _ = jsonPointerDelete(desired, p) - } - } -} - -func matchIgnorePaths(rules []api.IgnoreRule, kind, apiver string) []string { - var out []string - for _, r := range rules { - if r.Target.Kind != "" && r.Target.Kind != kind { - continue - } - if r.Target.Version != "" && r.Target.Version != apiver { - continue - } - out = append(out, r.Paths...) - } - return out + return utils.CreateOrPatch( + ctx, + c, + obj, + fieldOwner, + append(resource.Ignore, r.configuration.ReplicationIgnoreRules()...), + *resource.Force, + ) } - -// createOrUpdate replicates the provided unstructured object to all the provided Namespaces: -// this function mimics the CreateOrUpdate, by retrieving the object to understand if it must be created or updated, -// along adding the additional metadata, if required. -//func (r *Processor) createOrUpdate( -// ctx context.Context, -// c client.Client, -// obj *unstructured.Unstructured, -// labels map[string]string, -// annotations map[string]string, -//) (err error) { -// actual, desired := &unstructured.Unstructured{}, obj.DeepCopy() -// -// actual.SetAPIVersion(desired.GetAPIVersion()) -// actual.SetKind(desired.GetKind()) -// actual.SetNamespace(desired.GetNamespace()) -// actual.SetName(desired.GetName()) -// -// _, err = controllerutil.CreateOrUpdate(ctx, c, actual, func() error { -// UID := actual.GetUID() -// rv := actual.GetResourceVersion() -// actual.SetUnstructuredContent(desired.Object) -// -// combinedLabels := obj.GetLabels() -// if combinedLabels == nil { -// combinedLabels = make(map[string]string) -// } -// -// for key, value := range labels { -// combinedLabels[key] = value -// } -// -// actual.SetLabels(combinedLabels) -// -// combinedAnnotations := obj.GetAnnotations() -// if combinedAnnotations == nil { -// combinedAnnotations = make(map[string]string) -// } -// -// for key, value := range annotations { -// combinedAnnotations[key] = value -// } -// -// actual.SetAnnotations(combinedAnnotations) -// actual.SetResourceVersion(rv) -// actual.SetUID(UID) -// -// return nil -// }) -// -// return err -//} diff --git a/controllers/resources/processor_handle.go b/controllers/resources/processor_handle.go new file mode 100644 index 000000000..218acedb2 --- /dev/null +++ b/controllers/resources/processor_handle.go @@ -0,0 +1,141 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "context" + "errors" + "fmt" + + "github.com/valyala/fasttemplate" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/serializer" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/meta" + tpl "github.com/projectcapsule/capsule/pkg/template" +) + +// With this function we are attempting to collect all the unstructured items +// No Interacting is done with the kubernetes regarding applying etc. +// +//nolint:gocognit +func (r *Processor) handleResources( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + allowCrossNamespaceSelection bool, + tenantLabel string, + resourceIndex int, + spec capsulev1beta2.ResourceSpec, + owner capsulev1beta2.ObjectReferenceStatusOwner, + ns *corev1.Namespace, + tmplContext tpl.ReferenceContext, +) (processed []*unstructured.Unstructured, err error) { + //log := ctrllog.FromContext(ctx) + + // Generating additional metadata + objAnnotations, objLabels := map[string]string{}, map[string]string{} + + if spec.AdditionalMetadata != nil { + objAnnotations = prepareAdditionalMetadata(spec.AdditionalMetadata.Annotations) + objLabels = prepareAdditionalMetadata(spec.AdditionalMetadata.Labels) + } + + objAnnotations[tenantLabel] = tnt.GetName() + + objLabels[meta.ResourcesLabel] = fmt.Sprintf("%d", resourceIndex) + objLabels[tenantLabel] = tnt.GetName() + + var syncErr error + + codecFactory := serializer.NewCodecFactory(r.client.Scheme()) + + // Run Raw Items + for rawIndex, item := range spec.RawItems { + p, rawError := r.handleRawItem(ctx, c, codecFactory, rawIndex, item, ns, tnt) + if rawError != nil { + syncErr = errors.Join(syncErr, rawError) + + continue + } + + processed = append(processed, p) + } + + // Run Generators + for generatorIndex, item := range spec.Generators { + p, genError := r.handleGeneratorItem(ctx, c, generatorIndex, item, ns, tmplContext) + if genError != nil { + syncErr = errors.Join(syncErr, genError) + + continue + } + + processed = append(processed, p...) + } + + return processed, syncErr +} + +// Handles a single generator item +func (r *Processor) handleGeneratorItem( + ctx context.Context, + c client.Client, + index int, + item capsulev1beta2.GeneratorItemSpec, + ns *corev1.Namespace, + tmplContext tpl.ReferenceContext, +) (processed []*unstructured.Unstructured, err error) { + objs, err := renderGeneratorItem(item, tmplContext) + if err != nil { + return nil, fmt.Errorf("error running generator: %w", err, "hello") + } + + for _, obj := range objs { + if ns != nil { + obj.SetNamespace(ns.Name) + } + + processed = append(processed, obj) + } + + return +} + +func (r *Processor) handleRawItem( + ctx context.Context, + c client.Client, + codecFactory serializer.CodecFactory, + index int, + item capsulev1beta2.RawExtension, + ns *corev1.Namespace, + tnt capsulev1beta2.Tenant, +) (processed *unstructured.Unstructured, err error) { + template := string(item.Raw) + + t := fasttemplate.New(template, "{{ ", " }}") + + tContext := map[string]interface{}{ + "tenant.name": tnt.Name, + } + if ns != nil { + tContext["namespace"] = ns.Name + } + + tmplString := t.ExecuteString(tContext) + + obj := &unstructured.Unstructured{} + if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode([]byte(tmplString), nil, obj); decodeErr != nil { + return nil, fmt.Errorf("error rendering raw: %w", err, "hello") + } + + if ns != nil { + obj.SetNamespace(ns.Name) + } + + return obj, nil +} diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go index d1629bcdd..ce9082d49 100644 --- a/controllers/resources/utils.go +++ b/controllers/resources/utils.go @@ -5,10 +5,13 @@ package resources import ( "bytes" + "errors" "fmt" "html/template" "io" + "strings" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" kyaml "k8s.io/apimachinery/pkg/util/yaml" @@ -151,6 +154,16 @@ func setGlobalTenantDefaultResourceServiceAccount( return true } +func maskSensitiveErrData(err error) error { + if apierrors.IsInvalid(err) { + // The last part of the error message is the reason for the error. + if i := strings.LastIndex(err.Error(), `:`); i != -1 { + err = errors.New(strings.TrimSpace(err.Error()[i+1:])) + } + } + return err +} + // Field templating for the ArgoCD project properties. Needs to unmarshal in json, because of the json tags from argocd. func loadTenantToContext( tenant *capsulev1beta2.Tenant, @@ -165,7 +178,7 @@ func loadTenantToContext( func renderGeneratorItem( generator capsulev1beta2.GeneratorItemSpec, context tpl.ReferenceContext, -) (items []unstructured.Unstructured, err error) { +) (items []*unstructured.Unstructured, err error) { tmpl, err := template.New("tpl").Option("missingkey=" + generator.MissingKey.String()).Funcs(tpl.ExtraFuncMap()).Parse(generator.Template) if err != nil { return @@ -178,7 +191,7 @@ func renderGeneratorItem( dec := kyaml.NewYAMLOrJSONDecoder(bytes.NewReader(rendered.Bytes()), 4096) - var out []unstructured.Unstructured + var out []*unstructured.Unstructured for { var obj map[string]any if err := dec.Decode(&obj); err != nil { @@ -192,7 +205,7 @@ func renderGeneratorItem( continue } - u := unstructured.Unstructured{Object: obj} + u := &unstructured.Unstructured{Object: obj} if u.GetAPIVersion() == "" && u.GetKind() == "" { continue } diff --git a/global-scope.yaml b/global-scope.yaml index ad3865c07..9fab81462 100644 --- a/global-scope.yaml +++ b/global-scope.yaml @@ -1,54 +1,47 @@ apiVersion: capsule.clastix.io/v1beta2 -kind: GlobalTenantReplication +kind: GlobalTenantResource metadata: name: global-scope spec: + # New scope: Tenant + + resyncPeriod: 5s - ignore: - - paths: ["/metadata/labels/capsule.managed-by"] - target: - kind: Deployment + + # New + #serviceaccount: + # name: capsule + # namespace: capsule-system + resources: - - ignore: - - paths: ["/metadata/labels/capsule.managed-by"] - target: - kind: Deployment - name: {{.tenant.name}}-controller-manager - context: + - #additionalMetadata: + # labels: + # "replicated-by": "capsule" + + ignore: + - paths: ["/data/ui_properties_file_name"] + target: + kind: ConfigMap + + context: resources: - - index: "sec" - apiVersion: v1 - kind: Secret - name: capsule-tls - namespace: capsule-system + - index: "sec" + apiVersion: v1 + kind: Secret + name: capsule-tls + namespace: capsule-system + force: false + generators: - template: | --- apiVersion: v1 kind: ConfigMap metadata: - name: game-demo - namespace: default - data: - # property-like keys; each key maps to a simple value - player_initial_lives: "3" - ui_properties_file_name: "user-interface.properties" - - # file-like keys - game.properties: | - {{- toYaml . | nindent 4 }} - --- - apiVersion: v1 - kind: ConfigMap - metadata: - name: game-demo-2 + name: test-demo namespace: default data: - # property-like keys; each key maps to a simple value - player_initial_lives: "3" - ui_properties_file_name: "user-interface.properties" - - # file-like keys - game.properties: | - {{- toYaml .Tenant.spec | nindent 4 }} + {{ .Tenant.ObjectMeta.Name }}.conf: | + 1 + diff --git a/go.mod b/go.mod index c88726bc9..d73d1c35d 100644 --- a/go.mod +++ b/go.mod @@ -32,18 +32,25 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fluxcd/cli-utils v0.36.0-flux.15 // indirect github.com/fluxcd/pkg/apis/kustomize v1.13.0 // indirect + github.com/fluxcd/pkg/ssa v0.60.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.5.1 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect github.com/go-openapi/jsonreference v0.21.2 // indirect @@ -67,24 +74,42 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/wI2L/jsondiff v0.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -107,9 +132,14 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/cli-runtime v0.34.0 // indirect + k8s.io/component-base v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/kubectl v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect diff --git a/go.sum b/go.sum index 5657d6da5..02f7c5409 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -26,10 +28,13 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0= github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= github.com/coredns/corefile-migration v1.0.27 h1:WIIw5sU0LfGgoGnhdrYdVcto/aWmJoGA/C62iwkU0JM= github.com/coredns/corefile-migration v1.0.27/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -42,14 +47,22 @@ github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/cli-utils v0.36.0-flux.15 h1:Et5QLnIpRjj+oZtM9gEybkAaoNsjysHq0y1253Ai94Y= +github.com/fluxcd/cli-utils v0.36.0-flux.15/go.mod h1:AqRUmWIfNE7cdL6NWSGF0bAlypGs+9x5UQ2qOtlEzv4= github.com/fluxcd/pkg/apis/kustomize v1.13.0 h1:GGf0UBVRIku+gebY944icVeEIhyg1P/KE3IrhOyJJnE= github.com/fluxcd/pkg/apis/kustomize v1.13.0/go.mod h1:TLKVqbtnzkhDuhWnAsN35977HvRfIjs+lgMuNro/LEc= +github.com/fluxcd/pkg/ssa v0.60.0 h1:ikA78TWSLDmIc8I/goGAU/buYF6jto/gswE5hnOfWGk= +github.com/fluxcd/pkg/ssa v0.60.0/go.mod h1:3k9t4B4UjOF0536RQssQ4r9BXLSCq6FSTnUNKseFVHQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -133,6 +146,10 @@ github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pI github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= @@ -153,22 +170,34 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw= github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE= @@ -177,6 +206,8 @@ github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -196,6 +227,8 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= @@ -204,6 +237,7 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= @@ -214,17 +248,33 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wI2L/jsondiff v0.6.1 h1:ISZb9oNWbP64LHnu4AUhsMF5W0FIj5Ok3Krip9Shqpw= +github.com/wI2L/jsondiff v0.6.1/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -291,6 +341,7 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= @@ -353,6 +404,8 @@ k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= +k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= +k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= k8s.io/cluster-bootstrap v0.33.3 h1:u2NTxJ5CFSBFXaDxLQoOWMly8eni31psVso+caq6uwI= @@ -363,6 +416,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.34.0 h1:NcXz4TPTaUwhiX4LU+6r6udrlm0NsVnSkP3R9t0dmxs= +k8s.io/kubectl v0.34.0/go.mod h1:bmd0W5i+HuG7/p5sqicr0Li0rR2iIhXL0oUyLF3OjR4= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= @@ -385,6 +440,10 @@ sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= diff --git a/pkg/api/context.go b/pkg/api/context.go index 192b86804..0de88963f 100644 --- a/pkg/api/context.go +++ b/pkg/api/context.go @@ -120,11 +120,12 @@ type ResourceReference struct { Optional bool `json:"optional,omitempty"` } +// Load Resources for the template context from the cluster func (t ResourceReference) LoadResources( ctx context.Context, kubeClient client.Client, namespace string, -) ([]unstructured.Unstructured, error) { +) ([]*unstructured.Unstructured, error) { if namespace != "" { t.Namespace = namespace } @@ -144,7 +145,7 @@ func (t ResourceReference) LoadResources( return nil, fmt.Errorf("failed to get %s/%s: %w", t.Kind, t.Name, err) } - return []unstructured.Unstructured{*obj}, nil + return []*unstructured.Unstructured{obj}, nil } list := &unstructured.UnstructuredList{} @@ -172,11 +173,10 @@ func (t ResourceReference) LoadResources( } // Prepare a result map. For example, mapping resource name to its UID. - results := []unstructured.Unstructured{} + results := []*unstructured.Unstructured{} for _, item := range list.Items { - results = append(results, item) + results = append(results, &item) } return results, nil - } diff --git a/pkg/api/ignore.go b/pkg/api/ignore.go index 1e959a20e..da1a76e05 100644 --- a/pkg/api/ignore.go +++ b/pkg/api/ignore.go @@ -1,6 +1,10 @@ package api -import "github.com/fluxcd/pkg/apis/kustomize" +import ( + "github.com/fluxcd/pkg/apis/kustomize" + "github.com/fluxcd/pkg/ssa/jsondiff" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) // +kubebuilder:object:generate=true type IgnoreRule struct { @@ -16,3 +20,23 @@ type IgnoreRule struct { // +optional Target *kustomize.Selector `json:"target,omitempty"` } + +func (i *IgnoreRule) Matches(obj *unstructured.Unstructured) bool { + if i == nil || i.Target == nil { + return true + } + + sr, err := jsondiff.NewSelectorRegex(&jsondiff.Selector{ + Group: i.Target.Group, + Version: i.Target.Version, + Kind: i.Target.Kind, + Namespace: i.Target.Namespace, + Name: i.Target.Name, + LabelSelector: i.Target.LabelSelector, + AnnotationSelector: i.Target.AnnotationSelector, + }) + if err != nil { + return false + } + return sr.MatchUnstructured(obj) +} diff --git a/pkg/configuration/client.go b/pkg/configuration/client.go index 14c4288b4..948b8ef0b 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -17,7 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api" ) // capsuleConfiguration is the Capsule Configuration retrieval mode @@ -114,7 +114,7 @@ func (c *capsuleConfiguration) IgnoreUserWithGroups() []string { return c.retrievalFn().Spec.IgnoreUserWithGroups } -func (c *capsuleConfiguration) ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec { +func (c *capsuleConfiguration) ForbiddenUserNodeLabels() *api.ForbiddenListSpec { if c.retrievalFn().Spec.NodeMetadata == nil { return nil } @@ -122,7 +122,7 @@ func (c *capsuleConfiguration) ForbiddenUserNodeLabels() *capsuleapi.ForbiddenLi return &c.retrievalFn().Spec.NodeMetadata.ForbiddenLabels } -func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec { +func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *api.ForbiddenListSpec { if c.retrievalFn().Spec.NodeMetadata == nil { return nil } @@ -130,7 +130,7 @@ func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.Forbid return &c.retrievalFn().Spec.NodeMetadata.ForbiddenAnnotations } -func (c *capsuleConfiguration) ServiceAccountClientProperties() *capsuleapi.ServiceAccountClient { +func (c *capsuleConfiguration) ServiceAccountClientProperties() *api.ServiceAccountClient { if c.retrievalFn().Spec.ServiceAccountClient == nil { return nil } @@ -138,6 +138,11 @@ func (c *capsuleConfiguration) ServiceAccountClientProperties() *capsuleapi.Serv return c.retrievalFn().Spec.ServiceAccountClient } +func (c *capsuleConfiguration) ReplicationIgnoreRules() []api.IgnoreRule { + + return c.retrievalFn().Spec.Replications.Ignore +} + func (c *capsuleConfiguration) ServiceAccountClient(ctx context.Context) (client *rest.Config, err error) { props := c.ServiceAccountClientProperties() diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 841d17df9..fa4a4365e 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -7,8 +7,9 @@ import ( "context" "regexp" - capsuleapi "github.com/projectcapsule/capsule/pkg/api" "k8s.io/client-go/rest" + + "github.com/projectcapsule/capsule/pkg/api" ) const ( @@ -29,8 +30,9 @@ type Configuration interface { UserNames() []string UserGroups() []string IgnoreUserWithGroups() []string - ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec - ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec - ServiceAccountClientProperties() *capsuleapi.ServiceAccountClient + ForbiddenUserNodeLabels() *api.ForbiddenListSpec + ForbiddenUserNodeAnnotations() *api.ForbiddenListSpec + ServiceAccountClientProperties() *api.ServiceAccountClient + ReplicationIgnoreRules() []api.IgnoreRule ServiceAccountClient(context.Context) (*rest.Config, error) } diff --git a/pkg/meta/labels.go b/pkg/meta/labels.go index 043b76a8f..3940f0e17 100644 --- a/pkg/meta/labels.go +++ b/pkg/meta/labels.go @@ -21,7 +21,8 @@ const ( CordonedLabel = "projectcapsule.dev/cordoned" CordonedLabelTrigger = "true" - ManagedByCapsuleLabel = "capsule.clastix.io/managed-by" + ManagedByCapsuleLabel = "capsule.clastix.io/managed-by" + NewManagedByCapsuleLabel = "projectcapsule.dev/managed-by" ) func FreezeLabelTriggers(obj client.Object) bool { diff --git a/pkg/meta/ownership.go b/pkg/meta/ownership.go new file mode 100644 index 000000000..2f3107174 --- /dev/null +++ b/pkg/meta/ownership.go @@ -0,0 +1,9 @@ +package meta + +const ( + CapsuleFieldOwnerPrefix = "capsule" +) + +func ControllerFieldOwnerPrefix(fieldowner string) string { + return CapsuleFieldOwnerPrefix + "/" + fieldowner +} diff --git a/pkg/utils/patch.go b/pkg/utils/patch.go new file mode 100644 index 000000000..6814bd60c --- /dev/null +++ b/pkg/utils/patch.go @@ -0,0 +1,94 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + "github.com/projectcapsule/capsule/pkg/api" + apierr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func CreateOrPatch( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, + fieldOwner string, + ignore []api.IgnoreRule, + overwrite bool, +) error { + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + // Fetch current to have a stable mutate func input + err := c.Get(ctx, client.ObjectKeyFromObject(actual), actual) + notFound := apierr.IsNotFound(err) + if err != nil && !notFound { + return err + } + + // Respect Ignores + igPaths := matchIgnorePaths(ignore, obj) + for _, p := range igPaths { + _ = jsonPointerDelete(obj.Object, p) + } + + if !notFound { + obj.SetResourceVersion(actual.GetResourceVersion()) + } else { + obj.SetResourceVersion("") // avoid accidental conflicts + } + + patchOpts := []client.PatchOption{ + client.FieldOwner(fieldOwner), + } + + if overwrite { + patchOpts = append(patchOpts, client.ForceOwnership) + } + + return c.Patch(ctx, obj, client.Apply, patchOpts...) +} + +func jsonPointerDelete(obj map[string]any, p string) error { + if p == "" || p == "/" { + return fmt.Errorf("cannot delete root with pointer") + } + parts := strings.Split(p, "/")[1:] + cur := obj + for i, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + last := i == len(parts)-1 + if last { + delete(cur, key) + return nil + } + nxt, ok := cur[key] + if !ok { + return nil + } + m, ok := nxt.(map[string]any) + if !ok { + return nil + } + cur = m + } + return nil +} + +func matchIgnorePaths(rules []api.IgnoreRule, obj *unstructured.Unstructured) []string { + var out []string + for _, r := range rules { + if !r.Matches(obj) { + continue + } + + out = append(out, r.Paths...) + } + + return out +} From 55ec198142cecc9cf3bbc3bfb73f30844d6e69c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 12 Nov 2025 13:57:52 +0100 Subject: [PATCH 18/19] fix(tenants): functional --- api/v1beta2/capsuleconfiguration_types.go | 9 - api/v1beta2/tenantresource_namespaced.go | 17 +- api/v1beta2/tenantresource_types.go | 98 +--- api/v1beta2/zz_generated.deepcopy.go | 47 +- ...sule.clastix.io_capsuleconfigurations.yaml | 66 --- ...sule.clastix.io_globaltenantresources.yaml | 120 +--- .../capsule.clastix.io_tenantresources.yaml | 124 +--- config.yaml | 3 +- controllers/resources/global.go | 236 +++++--- controllers/resources/namespaced.go | 200 ++++--- controllers/resources/processor.go | 536 ++++++++++-------- controllers/resources/processor_handle.go | 97 +++- controllers/resources/utils.go | 8 + global-scope.yaml | 35 +- pkg/api/resource_id.go | 95 ++++ pkg/configuration/client.go | 5 - pkg/configuration/configuration.go | 1 - pkg/meta/labels.go | 4 + .../{namespace_selector.go => namespace.go} | 0 pkg/utils/patch.go | 94 --- pkg/utils/update.go | 204 +++++++ .../tenantresource/objects_validating.go | 12 +- sad.yaml | 11 + 23 files changed, 1069 insertions(+), 953 deletions(-) create mode 100644 pkg/api/resource_id.go rename pkg/utils/{namespace_selector.go => namespace.go} (100%) delete mode 100644 pkg/utils/patch.go create mode 100644 pkg/utils/update.go create mode 100644 sad.yaml diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 52769353a..ff58721bc 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -41,19 +41,10 @@ type CapsuleConfigurationSpec struct { // when not using an already provided CA and certificate, or when these are managed externally with Vault, or cert-manager. // +kubebuilder:default=true EnableTLSReconciler bool `json:"enableTLSReconciler"` //nolint:tagliatelle - // Omit Replications - Replications ReplicationsSpec `json:"replications,omitempty"` // Define Kubernetes-Client Configurations ServiceAccountClient *api.ServiceAccountClient `json:"serviceAccountClient,omitempty"` } -type ReplicationsSpec struct { - // Ignore contains a list of rules for specifying which changes to ignore - // during diffing. - // +optional - Ignore []api.IgnoreRule `json:"ignore,omitempty"` -} - type NodeMetadata struct { // Define the labels that a Tenant Owner cannot set for their nodes. ForbiddenLabels api.ForbiddenListSpec `json:"forbiddenLabels"` diff --git a/api/v1beta2/tenantresource_namespaced.go b/api/v1beta2/tenantresource_namespaced.go index de328ef5b..4100b2f9d 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -30,6 +30,13 @@ type TenantResourceSpec struct { // Local ServiceAccount which will perform all the actions defined in the TenantResource // You must provide permissions accordingly to that ServiceAccount ServiceAccount *api.ServiceAccountReference `json:"serviceAccount,omitempty"` + // Enabling this allows TenanResources to interact with objects which were not created by a TenantResource. In this case on prune no deletion of the entire object is made. + // +kubebuilder:default=false + Adopt *bool `json:"adopt,omitempty"` + // Force indicates that in case of conflicts with server-side apply, the client should acquire ownership of the conflicting field. + // You may create collisions with this. + // +kubebuilder:default=false + Force *bool `json:"force,omitempty"` } type ResourceSpec struct { @@ -53,14 +60,6 @@ type ResourceSpec struct { // The label added is called // +kubebuilder:default=true Managed *bool `json:"managed,omitempty"` - // Force indicates that in case of conflicts with server-side apply, the client should acquire ownership of the conflicting field. - // You may create collisions with this. - // +kubebuilder:default=false - Force *bool `json:"force,omitempty"` - // Ignore contains a list of rules for specifying which changes to ignore - // during diffing. - // +optional - Ignore []api.IgnoreRule `json:"ignore,omitempty"` } // +kubebuilder:validation:XEmbeddedResource @@ -71,6 +70,8 @@ type RawExtension struct { // TenantResourceStatus defines the observed state of TenantResource. type TenantResourceStatus struct { + // How items are processed by this instance + Size uint `json:"size"` // List of the replicated resources for the given TenantResource. ProcessedItems ProcessedItems `json:"processedItems"` // Conditions of the TenantResource. diff --git a/api/v1beta2/tenantresource_types.go b/api/v1beta2/tenantresource_types.go index 03f8328ae..12a2eb3aa 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -5,14 +5,21 @@ package v1beta2 import ( "fmt" - "strings" "github.com/projectcapsule/capsule/pkg/api" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8stypes "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" ) +type ResourceOptions struct { + // Template contains any amount of yaml which is applied to Kubernetes. + // This can be a single resource or multiple resources + Template string `json:"template,omitempty"` + // Missing Key Option for templating + // +kubebuilder:default=default + MissingKey MissingKeyOption `json:"missingKey,omitempty"` +} + // +kubebuilder:validation:Enum=default;zero;error type MissingKeyOption string @@ -64,17 +71,7 @@ func (p *ProcessedItems) RemoveItem(item ObjectReferenceStatus) { } func (p *ProcessedItems) isEqual(a, b ObjectReferenceStatus) bool { - return a.Owner == b.Owner && a.APIVersion == b.APIVersion && a.Kind == b.Kind && a.Name == b.Name && a.Namespace == b.Namespace -} - -func (p *ProcessedItems) AsSet() sets.Set[string] { - set := sets.New[string]() - - for _, i := range *p { - set.Insert(i.String()) - } - - return set + return a.ResourceID == b.ResourceID } type ObjectReferenceAbstract struct { @@ -89,20 +86,14 @@ type ObjectReferenceAbstract struct { } type ObjectReferenceStatus struct { - // Name of the referent. - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - Name string `json:"name"` - - // The resource index this item was created from - ResourceIndex string `json:"index"` - - // Tenant of the referent. - Owner ObjectReferenceStatusOwner `json:"owner,omitempty"` - - ObjectReferenceAbstract `json:",inline"` + api.ResourceID `json:",inline"` ObjectReferenceStatusCondition `json:",inline"` } +func (in *ObjectReferenceStatus) String() string { + return fmt.Sprintf("Kind=%s,Group=%s,APIVersion=%s,Namespace=%s,Name=%s", in.Kind, in.Group, in.Version, in.Namespace, in.Name) +} + type ObjectReferenceStatusOwner struct { // Name of the owning object. Name string `json:"name,omitempty"` @@ -140,62 +131,3 @@ type ObjectReference struct { // Label selector used to select the given resources in the given Namespace. Selector metav1.LabelSelector `json:"selector"` } - -func (in *ObjectReferenceStatus) String() string { - return fmt.Sprintf( - "Kind=%s,APIVersion=%s,Namespace=%s,Name=%s,Message=%s,Type=%s,Status=%s,Owner=%s,UID=%s,Scope=%s,Index=%s", - in.Kind, in.APIVersion, in.Namespace, in.Name, in.Message, in.Type, in.Status, in.Owner.Name, in.Owner.UID, in.Owner.Scope, in.ResourceIndex) -} - -func (in *ObjectReferenceStatus) ParseFromString(value string) error { - rawParts := strings.Split(value, ",") - - if len(rawParts) != 11 { - return fmt.Errorf("unexpected raw parts") - } - - for _, i := range rawParts { - parts := strings.Split(i, "=") - - if len(parts) != 2 { - return fmt.Errorf("unrecognized separator") - } - - k, v := parts[0], parts[1] - - switch k { - case "Kind": - in.Kind = v - case "APIVersion": - in.APIVersion = v - case "Namespace": - in.Namespace = v - case "Name": - in.Name = v - case "Status": - switch metav1.ConditionStatus(v) { - case metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown: - in.Status = metav1.ConditionStatus(v) - default: - return fmt.Errorf("invalid status value: %q", v) - } - case "Message": - in.Message = v - case "Type": - in.Type = v - case "Owner": - in.Owner.Name = v - case "UID": - in.Owner.UID = k8stypes.UID(v) - case "Scope": - in.Owner.Scope = api.ResourceScope(v) - case "Index": - in.ResourceIndex = v - - default: - return fmt.Errorf("unrecognized marker: %s", k) - } - } - - return nil -} diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index d66372657..14067832e 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -139,7 +139,6 @@ func (in *CapsuleConfigurationSpec) DeepCopyInto(out *CapsuleConfigurationSpec) *out = new(NodeMetadata) (*in).DeepCopyInto(*out) } - in.Replications.DeepCopyInto(&out.Replications) if in.ServiceAccountClient != nil { in, out := &in.ServiceAccountClient, &out.ServiceAccountClient *out = new(api.ServiceAccountClient) @@ -441,8 +440,7 @@ func (in *ObjectReferenceAbstract) DeepCopy() *ObjectReferenceAbstract { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectReferenceStatus) DeepCopyInto(out *ObjectReferenceStatus) { *out = *in - out.Owner = in.Owner - out.ObjectReferenceAbstract = in.ObjectReferenceAbstract + out.ResourceID = in.ResourceID out.ObjectReferenceStatusCondition = in.ObjectReferenceStatusCondition } @@ -603,28 +601,6 @@ func (in *RawExtension) DeepCopy() *RawExtension { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ReplicationsSpec) DeepCopyInto(out *ReplicationsSpec) { - *out = *in - if in.Ignore != nil { - in, out := &in.Ignore, &out.Ignore - *out = make([]api.IgnoreRule, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReplicationsSpec. -func (in *ReplicationsSpec) DeepCopy() *ReplicationsSpec { - if in == nil { - return nil - } - out := new(ReplicationsSpec) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourcePool) DeepCopyInto(out *ResourcePool) { *out = *in @@ -1050,18 +1026,11 @@ func (in *ResourceSpec) DeepCopyInto(out *ResourceSpec) { *out = new(api.TemplateContext) (*in).DeepCopyInto(*out) } - if in.Force != nil { - in, out := &in.Force, &out.Force + if in.Managed != nil { + in, out := &in.Managed, &out.Managed *out = new(bool) **out = **in } - if in.Ignore != nil { - in, out := &in.Ignore, &out.Ignore - *out = make([]api.IgnoreRule, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSpec. @@ -1218,6 +1187,16 @@ func (in *TenantResourceSpec) DeepCopyInto(out *TenantResourceSpec) { *out = new(api.ServiceAccountReference) **out = **in } + if in.Adopt != nil { + in, out := &in.Adopt, &out.Adopt + *out = new(bool) + **out = **in + } + if in.Force != nil { + in, out := &in.Force, &out.Force + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceSpec. diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 279ed3952..58672c435 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -131,72 +131,6 @@ spec: description: Disallow creation of namespaces, whose name matches this regexp type: string - replications: - description: Omit Replications - properties: - ignore: - description: |- - Ignore contains a list of rules for specifying which changes to ignore - during diffing. - items: - properties: - paths: - description: |- - Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from - consideration in a Kubernetes object. - items: - type: string - type: array - target: - description: |- - Target is a selector for specifying Kubernetes objects to which this - rule applies. - If Target is not set, the Paths will be ignored for all Kubernetes - objects within the manifest of the Helm release. - properties: - annotationSelector: - description: |- - AnnotationSelector is a string that follows the label selection expression - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api - It matches with the resource annotations. - type: string - group: - description: |- - Group is the API group to select resources from. - Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - kind: - description: |- - Kind of the API Group to select resources from. - Together with Group and Version it is capable of unambiguously - identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - labelSelector: - description: |- - LabelSelector is a string that follows the label selection expression - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api - It matches with the resource labels. - type: string - name: - description: Name to match resources with. - type: string - namespace: - description: Namespace to select resources from. - type: string - version: - description: |- - Version of the API Group to select resources from. - Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - type: object - required: - - paths - type: object - type: array - type: object serviceAccountClient: description: Define Kubernetes-Client Configurations properties: diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index 8290739c8..efbdb0725 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -53,12 +53,24 @@ spec: spec: description: GlobalTenantResourceSpec defines the desired state of GlobalTenantResource. properties: + adopt: + default: false + description: Enabling this allows TenanResources to interact with + objects which were not created by a TenantResource. In this case + on prune no deletion of the entire object is made. + type: boolean cordoned: default: false description: |- When cordoning a replication it will no longer execute any applies or deletions (paused). This is useful for maintenances type: boolean + force: + default: false + description: |- + Force indicates that in case of conflicts with server-side apply, the client should acquire ownership of the conflicting field. + You may create collisions with this. + type: boolean pruningOnDelete: default: true description: |- @@ -176,12 +188,6 @@ spec: type: object type: array type: object - force: - default: false - description: Force indicates that in case of conflicts with - server-side apply, the client should acquire ownership of - the conflicting field. - type: boolean generators: description: Generators for advanced use cases items: @@ -201,68 +207,12 @@ spec: type: string type: object type: array - ignore: + managed: + default: true description: |- - Ignore contains a list of rules for specifying which changes to ignore - during diffing. - items: - properties: - paths: - description: |- - Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from - consideration in a Kubernetes object. - items: - type: string - type: array - target: - description: |- - Target is a selector for specifying Kubernetes objects to which this - rule applies. - If Target is not set, the Paths will be ignored for all Kubernetes - objects within the manifest of the Helm release. - properties: - annotationSelector: - description: |- - AnnotationSelector is a string that follows the label selection expression - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api - It matches with the resource annotations. - type: string - group: - description: |- - Group is the API group to select resources from. - Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - kind: - description: |- - Kind of the API Group to select resources from. - Together with Group and Version it is capable of unambiguously - identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - labelSelector: - description: |- - LabelSelector is a string that follows the label selection expression - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api - It matches with the resource labels. - type: string - name: - description: Name to match resources with. - type: string - namespace: - description: Namespace to select resources from. - type: string - version: - description: |- - Version of the API Group to select resources from. - Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - type: object - required: - - paths - type: object - type: array + Automatically adds a label to all resources being patched by a tenantresource blocking any interactions from tenant users via admission webhook + The label added is called + type: boolean namespaceSelector: description: |- Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated. @@ -539,13 +489,11 @@ spec: description: List of the replicated resources for the given TenantResource. items: properties: - apiVersion: - description: API version of the referent. + group: + type: string + index: type: string kind: - description: |- - Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string message: description: |- @@ -554,31 +502,9 @@ spec: maxLength: 32768 type: string name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string namespace: - description: |- - Namespace of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ type: string - owner: - description: Tenant of the referent. - properties: - name: - description: Name of the owning object. - type: string - scope: - description: Scope of the owning object. - enum: - - Namespace - - Tenant - type: string - uid: - description: UID of the owning object. - type: string - type: object status: description: status of the condition, one of True, False, Unknown. enum: @@ -586,14 +512,16 @@ spec: - "False" - Unknown type: string + tenant: + type: string type: description: type of condition in CamelCase or in foo.example.com/CamelCase. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string + version: + type: string required: - - kind - - name - status - type type: object diff --git a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml index ce24c86cb..aebee7226 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -55,12 +55,24 @@ spec: spec: description: TenantResourceSpec defines the desired state of TenantResource. properties: + adopt: + default: false + description: Enabling this allows TenanResources to interact with + objects which were not created by a TenantResource. In this case + on prune no deletion of the entire object is made. + type: boolean cordoned: default: false description: |- When cordoning a replication it will no longer execute any applies or deletions (paused). This is useful for maintenances type: boolean + force: + default: false + description: |- + Force indicates that in case of conflicts with server-side apply, the client should acquire ownership of the conflicting field. + You may create collisions with this. + type: boolean pruningOnDelete: default: true description: |- @@ -178,12 +190,6 @@ spec: type: object type: array type: object - force: - default: false - description: Force indicates that in case of conflicts with - server-side apply, the client should acquire ownership of - the conflicting field. - type: boolean generators: description: Generators for advanced use cases items: @@ -203,68 +209,12 @@ spec: type: string type: object type: array - ignore: + managed: + default: true description: |- - Ignore contains a list of rules for specifying which changes to ignore - during diffing. - items: - properties: - paths: - description: |- - Paths is a list of JSON Pointer (RFC 6901) paths to be excluded from - consideration in a Kubernetes object. - items: - type: string - type: array - target: - description: |- - Target is a selector for specifying Kubernetes objects to which this - rule applies. - If Target is not set, the Paths will be ignored for all Kubernetes - objects within the manifest of the Helm release. - properties: - annotationSelector: - description: |- - AnnotationSelector is a string that follows the label selection expression - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api - It matches with the resource annotations. - type: string - group: - description: |- - Group is the API group to select resources from. - Together with Version and Kind it is capable of unambiguously identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - kind: - description: |- - Kind of the API Group to select resources from. - Together with Group and Version it is capable of unambiguously - identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - labelSelector: - description: |- - LabelSelector is a string that follows the label selection expression - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#api - It matches with the resource labels. - type: string - name: - description: Name to match resources with. - type: string - namespace: - description: Namespace to select resources from. - type: string - version: - description: |- - Version of the API Group to select resources from. - Together with Group and Kind it is capable of unambiguously identifying and/or selecting resources. - https://github.com/kubernetes/community/blob/master/contributors/design-proposals/api-machinery/api-group.md - type: string - type: object - required: - - paths - type: object - type: array + Automatically adds a label to all resources being patched by a tenantresource blocking any interactions from tenant users via admission webhook + The label added is called + type: boolean namespaceSelector: description: |- Defines the Namespace selector to select the Tenant Namespaces on which the resources must be propagated. @@ -482,13 +432,11 @@ spec: description: List of the replicated resources for the given TenantResource. items: properties: - apiVersion: - description: API version of the referent. + group: + type: string + index: type: string kind: - description: |- - Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string message: description: |- @@ -497,31 +445,9 @@ spec: maxLength: 32768 type: string name: - description: |- - Name of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string namespace: - description: |- - Namespace of the referent. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ type: string - owner: - description: Tenant of the referent. - properties: - name: - description: Name of the owning object. - type: string - scope: - description: Scope of the owning object. - enum: - - Namespace - - Tenant - type: string - uid: - description: UID of the owning object. - type: string - type: object status: description: status of the condition, one of True, False, Unknown. enum: @@ -529,20 +455,26 @@ spec: - "False" - Unknown type: string + tenant: + type: string type: description: type of condition in CamelCase or in foo.example.com/CamelCase. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string + version: + type: string required: - - kind - - name - status - type type: object type: array + size: + description: How items are processed by this instance + type: integer required: - processedItems + - size type: object type: object served: true diff --git a/config.yaml b/config.yaml index 1b280e6e9..b26d36f5c 100644 --- a/config.yaml +++ b/config.yaml @@ -22,8 +22,9 @@ spec: globalDefaultServiceAccount: "capsule" globalDefaultServiceAccountNamespace: capsule-system - + tenantDefaultServiceAccount: default + ignore: - paths: - "/metadata/labels/company.com~1some-label" diff --git a/controllers/resources/global.go b/controllers/resources/global.go index aa03e6b17..0e442a00e 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -5,16 +5,17 @@ package resources import ( "context" - "errors" "fmt" "reflect" - "strings" + "strconv" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "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/labels" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/retry" @@ -30,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/configuration" "github.com/projectcapsule/capsule/pkg/meta" "github.com/projectcapsule/capsule/pkg/metrics" @@ -46,9 +48,18 @@ type globalResourceController struct { func (r *globalResourceController) SetupWithManager(mgr ctrl.Manager) error { r.client = mgr.GetClient() + + tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) + if labelErr != nil { + return labelErr + } + r.processor = Processor{ - client: mgr.GetClient(), - configuration: r.configuration, + client: mgr.GetClient(), + factory: serializer.NewCodecFactory(r.client.Scheme()), + configuration: r.configuration, + allowCrossNamespaceSelection: true, + tenantLabel: tenantLabel, } return ctrl.NewControllerManagedBy(mgr). @@ -230,81 +241,136 @@ func (r *globalResourceController) reconcileNormal( // A TenantResource is made of several Resource sections, each one with specific options: // the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. - processedItems := sets.NewString() + //processedItems := sets.NewString() // Always post the processed items, as they allow users to track errors - defer func() { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) - - for _, item := range processedItems.List() { - log.Info("PROCESSED", "ITEM", item) - - or := capsulev1beta2.ObjectReferenceStatus{} - if parseErr := or.ParseFromString(item); parseErr == nil { - tntResource.Status.ProcessedItems.UpdateItem(or) - } else { - err = errors.Join(err, fmt.Errorf("processed item %q parse failed: %w", item, parseErr)) - } - - log.Info("PARSED", "OR", or) - } - - log.Info("STATUS", "STATUS", tntResource.Status) - - }() - - var itemErrors error - + //defer func() { + // tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + // + // for _, item := range processedItems.List() { + // log.Info("PROCESSED", "ITEM", item) + // + // or := capsulev1beta2.ObjectReferenceStatus{} + // if parseErr := or.ParseFromString(item); parseErr == nil { + // tntResource.Status.ProcessedItems.UpdateItem(or) + // } else { + // err = errors.Join(err, fmt.Errorf("processed item %q parse failed: %w", item, parseErr)) + // } + // + // log.Info("PARSED", "OR", or) + // } + // + // log.Info("STATUS", "STATUS", tntResource.Status) + // + //}() + + //status := capsulev1beta2.ProcessedItems{} + acc := api.Accumulator{} + + // Gather Resources for index, resource := range tntResource.Spec.Resources { - owner := "cluster/" + strings.ToLower(tntResource.Name) - - tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) - if labelErr != nil { - log.Error(labelErr, "expected label for selection") - - return reconcile.Result{}, labelErr - } - for _, tnt := range tntList.Items { - tntSet.Insert(tnt.GetName()) + var resourceError error + + tplContext, _ := resource.Context.GatherContext(ctx, c, nil, "") + tplContext["Tenant"] = tnt + + switch tntResource.Spec.Scope { + case api.ResourceScopeTenant: + //tplContext, _ = spec.Context.GatherContext(ctx, c, nil, "") + //tplContext["Tenant"] = tnt + + //owner := fieldOwner + "/" + tnt.Name + "/" + + resourceError = r.processor.handleResources( + ctx, + c, + tnt, + strconv.Itoa(index), + resource, + nil, + tplContext, + acc, + ) + default: + resourceError = r.processor.foreachTenantNamespace(ctx, c, tnt, resource, strconv.Itoa(index), tplContext, acc) - items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tnt, true, tenantLabel, index, resource, owner, tntResource.Spec.Scope) - if sectionErr != nil { - // Upon a process error storing the last error occurred and continuing to iterate, - // avoid to block the whole processing. - itemErrors = errors.Join(itemErrors, sectionErr) } - log.Info("replicate items", "amount", len(items)) - - processedItems.Insert(items...) + // Only start pruning when the resource item itself did not throw an error + if resourceError != nil { + return reconcile.Result{}, resourceError + } } } - if itemErrors != nil { - err = itemErrors - } - - if err != nil { - log.Error(err, "unable to replicate the requested resources") - - return reconcile.Result{}, err - } + // Prune first, to work on a consistent Status + for _, p := range tntResource.Status.ProcessedItems { + if _, exists := acc[p.ResourceID]; !exists { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(p.GetGVK()) + obj.SetNamespace(p.GetNamespace()) + obj.SetName(p.GetName()) + + if *tntResource.Spec.PruningOnDelete { + err := r.processor.Prune(ctx, c, obj, getFieldOwner(tntResource.GetName(), "", p.ResourceID)) + if err != nil { + p.Status = metav1.ConditionFalse + p.Message = err.Error() + tntResource.Status.ProcessedItems.UpdateItem(p) + + continue + } + } - failed, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) - if err != nil { - return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources") + tntResource.Status.ProcessedItems.RemoveItem(p) + } } - if len(failed) > 0 { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + // + log.Info("accumulation", "items", len(acc)) + + // Apply + for id, obj := range acc { + or := capsulev1beta2.ObjectReferenceStatus{ + ResourceID: id, + ObjectReferenceStatusCondition: capsulev1beta2.ObjectReferenceStatusCondition{ + Type: meta.ReadyCondition, + }, + } - for _, item := range processedItems.List() { - if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { - tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) - } + err := r.processor.Apply( + ctx, + c, + obj, + getFieldOwner(tntResource.GetName(), "", id), + *tntResource.Spec.Force, + *tntResource.Spec.Adopt, + ) + if err != nil { + or.Status = metav1.ConditionTrue + or.Message = err.Error() + } else { + or.Status = metav1.ConditionTrue } + + tntResource.Status.ProcessedItems.UpdateItem(or) } + // Prune Resources + //failed, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) + //if err != nil { + // return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources") + //} + //if len(failed) > 0 { + // tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + // + // for _, item := range processedItems.List() { + // if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { + // tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + // } + // } + //} + tntResource.Status.SelectedTenants = tntSet.List() log.Info("processing completed") @@ -317,28 +383,28 @@ func (r *globalResourceController) reconcileDelete( c client.Client, tntResource *capsulev1beta2.GlobalTenantResource, ) (reconcile.Result, error) { - log := ctrllog.FromContext(ctx) - - if *tntResource.Spec.PruningOnDelete { - failedItems, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), nil) - if len(failedItems) > 0 { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) - - for _, item := range failedItems { - if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { - tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) - } - } - } - - if len(failedItems) > 0 || err != nil { - return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources on delete") - } - - controllerutil.RemoveFinalizer(tntResource, finalizer) - } - - log.Info("processing completed") + //_ := ctrllog.FromContext(ctx) + + //if *tntResource.Spec.PruningOnDelete { + // failedItems, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), nil) + // if len(failedItems) > 0 { + // tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) + // + // for _, item := range failedItems { + // if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { + // tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + // } + // } + // } + // + // if len(failedItems) > 0 || err != nil { + // return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources on delete") + // } + // + // controllerutil.RemoveFinalizer(tntResource, finalizer) + //} + // + //log.Info("processing completed") return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } diff --git a/controllers/resources/namespaced.go b/controllers/resources/namespaced.go index 320a4c82c..63022d96c 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -5,19 +5,16 @@ package resources import ( "context" - "errors" "fmt" "reflect" - "strconv" - "strings" "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/util/retry" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" @@ -31,7 +28,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/configuration" "github.com/projectcapsule/capsule/pkg/meta" "github.com/projectcapsule/capsule/pkg/metrics" @@ -48,9 +44,18 @@ type namespacedResourceController struct { func (r *namespacedResourceController) SetupWithManager(mgr ctrl.Manager) error { r.client = mgr.GetClient() + + tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) + if labelErr != nil { + return labelErr + } + r.processor = Processor{ - client: mgr.GetClient(), - configuration: r.configuration, + client: mgr.GetClient(), + factory: serializer.NewCodecFactory(r.client.Scheme()), + configuration: r.configuration, + allowCrossNamespaceSelection: false, + tenantLabel: tenantLabel, } return ctrl.NewControllerManagedBy(mgr). @@ -190,74 +195,67 @@ func (r *namespacedResourceController) reconcileNormal( return reconcile.Result{}, nil } - // A TenantResource is made of several Resource sections, each one with specific options: - // the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. - processedItems := sets.NewString() - - tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) - if labelErr != nil { - log.Error(labelErr, "expected label for selection") - - return reconcile.Result{}, labelErr - } - - // Always post the processed items, as they allow users to track errors - defer func() { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) - - for _, item := range processedItems.List() { - or := capsulev1beta2.ObjectReferenceStatus{} - if err := or.ParseFromString(item); err == nil { - tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) - } else { - log.Error(err, "failed to parse processed item", "item", item) - } - } - }() + //// A TenantResource is made of several Resource sections, each one with specific options: + //// the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. + //processedItems := sets.NewString() + // + //// Always post the processed items, as they allow users to track errors + //defer func() { + // tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + // + // for _, item := range processedItems.List() { + // or := capsulev1beta2.ObjectReferenceStatus{} + // if err := or.ParseFromString(item); err == nil { + // tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + // } else { + // log.Error(err, "failed to parse processed item", "item", item) + // } + // } + //}() // new empty error - var itemErrors error - - for index, resource := range tntResource.Spec.Resources { - owner := "cluster/" + strings.ToLower(tntResource.Name) + "/" + strconv.Itoa(index) - - items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tl.Items[0], false, tenantLabel, index, resource, owner, api.ResourceScopeNamespace) - if sectionErr != nil { - // Upon a process error storing the last error occurred and continuing to iterate, - // avoid to block the whole processing. - itemErrors = errors.Join(itemErrors, sectionErr) - } - - log.Info("replicate items", "amount", len(items)) - - processedItems.Insert(items...) - } - - if itemErrors != nil { - return reconcile.Result{}, nil - } - - failedItems, err := r.processor.HandlePruning( - ctx, - c, - tntResource.Status.ProcessedItems.AsSet(), - sets.Set[string](processedItems), - ) - if len(failedItems) > 0 { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) - - for _, item := range failedItems { - if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { - tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) - } - } - } - - if err != nil { - return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources") - } - - log.Info("processing completed") + //var itemErrors error + // + //acc := make(Accumulator) + // + //for index, resource := range tntResource.Spec.Resources { + // owner := "cluster/" + strings.ToLower(tntResource.Name) + "/" + strconv.Itoa(index) + // + // sectionErr := r.processor.HandleSectionPreflight(ctx, c, resource, strconv.Itoa(index), tl.Items[0], owner, api.ResourceScopeNamespace, acc) + // if sectionErr != nil { + // // Upon a process error storing the last error occurred and continuing to iterate, + // // avoid to block the whole processing. + // itemErrors = errors.Join(itemErrors, sectionErr) + // } + // + // log.Info("replicate items", "acc", len(acc), "items", acc) + //} + // + //if itemErrors != nil { + // return reconcile.Result{}, nil + //} + // + //failedItems, err := r.processor.HandlePruning( + // ctx, + // c, + // tntResource.Status.ProcessedItems.AsSet(), + // sets.Set[string](processedItems), + //) + //if len(failedItems) > 0 { + // tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) + // + // for _, item := range failedItems { + // if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { + // tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + // } + // } + //} + // + //if err != nil { + // return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources") + //} + // + //log.Info("processing completed") return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } @@ -267,34 +265,34 @@ func (r *namespacedResourceController) reconcileDelete( c client.Client, tntResource *capsulev1beta2.TenantResource, ) (reconcile.Result, error) { - log := ctrllog.FromContext(ctx) - - if *tntResource.Spec.PruningOnDelete { - failedItems, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), nil) - if len(failedItems) > 0 { - log.V(5).Info("failed items", "amount", len(failedItems), "items", failedItems) - - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) - - for _, item := range failedItems { - if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { - tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) - } - } - - log.V(5).Info("new status", "status", tntResource.Status.ProcessedItems) - - } - - if len(failedItems) > 0 || err != nil { - return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources on delete") - } - - } - - controllerutil.RemoveFinalizer(tntResource, finalizer) - - log.Info("processing completed") + //log := ctrllog.FromContext(ctx) + // + //if *tntResource.Spec.PruningOnDelete { + // failedItems, err := r.processor.HandlePruning(ctx, c, tntResource.Status.ProcessedItems.AsSet(), nil) + // if len(failedItems) > 0 { + // log.V(5).Info("failed items", "amount", len(failedItems), "items", failedItems) + // + // tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) + // + // for _, item := range failedItems { + // if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { + // tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + // } + // } + // + // log.V(5).Info("new status", "status", tntResource.Status.ProcessedItems) + // + // } + // + // if len(failedItems) > 0 || err != nil { + // return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources on delete") + // } + // + //} + + //controllerutil.RemoveFinalizer(tntResource, finalizer) + // + //log.Info("processing completed") return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index 6a9f6a9d1..597c1263f 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -5,17 +5,15 @@ package resources import ( "context" - "errors" - "strconv" + "fmt" corev1 "k8s.io/api/core/v1" - apierr "k8s.io/apimachinery/pkg/api/errors" + apierrors "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/labels" - "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/selection" - "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" @@ -32,277 +30,316 @@ const ( ) type Processor struct { - client client.Client - configuration configuration.Configuration + client client.Client + configuration configuration.Configuration + factory serializer.CodecFactory + allowCrossNamespaceSelection bool + tenantLabel string } -func prepareAdditionalMetadata(m map[string]string) map[string]string { - if m == nil { - return make(map[string]string) - } - - // we need to create a new map to avoid modifying the original one - copied := make(map[string]string, len(m)) - for k, v := range m { - copied[k] = v - } +//func (r *Processor) HandlePruning( +// ctx context.Context, +// c client.Client, +// current, +// desired sets.Set[string], +//) (failedProcess []string, err error) { +// log := ctrllog.FromContext(ctx) +// +// diff := current.Difference(desired) +// // We don't want to trigger a reconciliation of the Status every time, +// // rather, only in case of a difference between the processed and the actual status. +// // This can happen upon the first reconciliation, or a removal, or a change, of a resource. +// reconcile := diff.Len() > 0 || current.Len() != desired.Len() +// +// if !reconcile { +// return +// } +// +// processed := sets.NewString() +// +// log.Info("starting processing pruning", "length", diff.Len()) +// +// // The outer resources must be removed, iterating over these to clean-up +// for item := range diff { +// or := capsulev1beta2.ObjectReferenceStatus{} +// if sectionErr := or.ParseFromString(item); sectionErr != nil { +// processed.Insert(or.String()) +// +// log.Error(sectionErr, "unable to parse resource to prune", "resource", item) +// +// continue +// } +// +// obj := unstructured.Unstructured{} +// obj.SetNamespace(or.Namespace) +// obj.SetName(or.Name) +// obj.SetGroupVersionKind(schema.FromAPIVersionAndKind(or.APIVersion, or.Kind)) +// +// log.V(5).Info("pruning", "resource", obj.GroupVersionKind(), "name", obj.GetName(), "namespace", obj.GetNamespace()) +// +// if sectionErr := c.Delete(ctx, &obj); err != sectionErr { +// if apierr.IsNotFound(sectionErr) { +// // Object may have been already deleted, we can ignore this error +// continue +// } +// +// or.Status = metav1.ConditionFalse +// or.Message = sectionErr.Error() +// or.Type = meta.ReadyCondition +// processed.Insert(or.String()) +// +// err = errors.Join(sectionErr) +// +// continue +// } +// +// log.V(5).Info("resource has been pruned", "resource", item) +// } +// +// return processed.List(), nil +//} - return copied -} - -func (r *Processor) HandlePruning( +//nolint:gocognit +func (r *Processor) foreachTenantNamespace( ctx context.Context, c client.Client, - current, - desired sets.Set[string], -) (failedProcess []string, err error) { + tnt capsulev1beta2.Tenant, + resource capsulev1beta2.ResourceSpec, + resourceIndex string, + tmplContext tpl.ReferenceContext, + acc api.Accumulator, +) (err error) { log := ctrllog.FromContext(ctx) - diff := current.Difference(desired) - // We don't want to trigger a reconciliation of the Status every time, - // rather, only in case of a difference between the processed and the actual status. - // This can happen upon the first reconciliation, or a removal, or a change, of a resource. - reconcile := diff.Len() > 0 || current.Len() != desired.Len() - - if !reconcile { - return - } - - processed := sets.NewString() - - log.Info("starting processing pruning", "length", diff.Len()) + // Creating Namespace selector + var selector labels.Selector - // The outer resources must be removed, iterating over these to clean-up - for item := range diff { - or := capsulev1beta2.ObjectReferenceStatus{} - if sectionErr := or.ParseFromString(item); sectionErr != nil { - processed.Insert(or.String()) - - log.Error(sectionErr, "unable to parse resource to prune", "resource", item) - - continue - } - - obj := unstructured.Unstructured{} - obj.SetNamespace(or.Namespace) - obj.SetName(or.Name) - obj.SetGroupVersionKind(schema.FromAPIVersionAndKind(or.APIVersion, or.Kind)) - - log.V(5).Info("pruning", "resource", obj.GroupVersionKind(), "name", obj.GetName(), "namespace", obj.GetNamespace()) - - if sectionErr := c.Delete(ctx, &obj); err != sectionErr { - if apierr.IsNotFound(sectionErr) { - // Object may have been already deleted, we can ignore this error - continue - } - - or.Status = metav1.ConditionFalse - or.Message = sectionErr.Error() - or.Type = meta.ReadyCondition - processed.Insert(or.String()) - - err = errors.Join(sectionErr) + if resource.NamespaceSelector != nil { + selector, err = metav1.LabelSelectorAsSelector(resource.NamespaceSelector) + if err != nil { + log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication") - continue + return err } - - log.V(5).Info("resource has been pruned", "resource", item) + } else { + selector = labels.NewSelector() } + // Resources can be replicated only on Namespaces belonging to the same Global: + // preventing a boundary cross by enforcing the selection. + tntRequirement, err := labels.NewRequirement(r.tenantLabel, selection.Equals, []string{tnt.GetName()}) + if err != nil { + log.Error(err, "unable to create requirement for Namespace filtering and resource replication") - return processed.List(), nil -} - -//nolint:gocognit -func (r *Processor) HandleSectionPreflight( - ctx context.Context, - c client.Client, - tnt capsulev1beta2.Tenant, - allowCrossNamespaceSelection bool, - tenantLabel string, - resourceIndex int, - spec capsulev1beta2.ResourceSpec, - fieldOwner string, - scope api.ResourceScope, -) (processed []string, err error) { - log := ctrllog.FromContext(ctx) + return err + } - tplContext := loadTenantToContext(&tnt) + selector = selector.Add(*tntRequirement) + // Selecting the targeted Namespace according to the TenantResource specification. + namespaces := corev1.NamespaceList{} + if err = r.client.List(ctx, &namespaces, client.MatchingLabelsSelector{Selector: selector}); err != nil { + log.Error(err, "cannot retrieve Namespaces for resource") - switch scope { - case api.ResourceScopeTenant: - tplContext, _ = spec.Context.GatherContext(ctx, c, nil, "") - tplContext["Tenant"] = tnt + return err + } - owner := fieldOwner + "/" + tnt.Name + "/" + strconv.Itoa(resourceIndex) + for _, ns := range namespaces.Items { - return r.reconcile( + //spec.Context.GatherContext(ctx, c, nil, ns.GetName()) + err = r.handleResources( ctx, c, tnt, - allowCrossNamespaceSelection, - tenantLabel, resourceIndex, - spec, - owner, - capsulev1beta2.ObjectReferenceStatusOwner{ - Name: tnt.GetName(), - UID: tnt.GetUID(), - Scope: api.ResourceScopeTenant, - }, - nil, - tplContext, + resource, + &ns, + tmplContext, + acc, ) - default: - - // Creating Namespace selector - var selector labels.Selector - - if spec.NamespaceSelector != nil { - selector, err = metav1.LabelSelectorAsSelector(spec.NamespaceSelector) - if err != nil { - log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication", "index", resourceIndex) - - return nil, err - } - } else { - selector = labels.NewSelector() - } - // Resources can be replicated only on Namespaces belonging to the same Global: - // preventing a boundary cross by enforcing the selection. - tntRequirement, err := labels.NewRequirement(tenantLabel, selection.Equals, []string{tnt.GetName()}) if err != nil { - log.Error(err, "unable to create requirement for Namespace filtering and resource replication", "index", resourceIndex) - - return nil, err - } - - selector = selector.Add(*tntRequirement) - // Selecting the targeted Namespace according to the TenantResource specification. - namespaces := corev1.NamespaceList{} - if err = r.client.List(ctx, &namespaces, client.MatchingLabelsSelector{Selector: selector}); err != nil { - log.Error(err, "cannot retrieve Namespaces for resource", "index", resourceIndex) - - return nil, err - } - - for _, ns := range namespaces.Items { - - //spec.Context.GatherContext(ctx, c, nil, ns.GetName()) - - owner := fieldOwner + "/" + tnt.Name + "/" + ns.Name + "/" + strconv.Itoa(resourceIndex) - - p, perr := r.reconcile( - ctx, - c, - tnt, - allowCrossNamespaceSelection, - tenantLabel, - resourceIndex, - spec, - owner, - capsulev1beta2.ObjectReferenceStatusOwner{ - Name: ns.GetName(), - UID: ns.GetUID(), - Scope: api.ResourceScopeNamespace, - }, - &ns, - tplContext) - if perr != nil { - err = errors.Join(err, perr) - } - - processed = append(processed, p...) + return } } return } -func (r *Processor) reconcile( +//func (r *Processor) reconcile( +// ctx context.Context, +// c client.Client, +// resources []capsulev1beta2.ResourceSpec, +// tnt capsulev1beta2.Tenant, +// allowCrossNamespaceSelection bool, +// fieldOwner string, +// owner capsulev1beta2.ObjectReferenceStatusOwner, +// ns *corev1.Namespace, +// tmplContext tpl.ReferenceContext, +// acc Accumulator, +//) error { +// log := ctrllog.FromContext(ctx) +// +// for resourceIndex, resource := range resources { +// // Collect Resources to apply +// err := r.handleResources( +// ctx, +// c, +// codecFactory, +// tnt, +// allowCrossNamespaceSelection, +// strconv.Itoa(resourceIndex), +// resource, +// owner, +// ns, +// tmplContext, +// acc, +// ) +// +// log.Error(err, "sadd me") +// } +// +// log.Info("ACCUMULATION", "acc", acc) +// +// return nil, nil +// +// // Prune First +// +// // Collect Resources to apply +// //objects, err := r.handleResources( +// // ctx, +// // c, +// // tnt, +// // allowCrossNamespaceSelection, +// // tenantLabel, +// // resourceIndex, +// // resource, +// // owner, +// // ns, +// // tmplContext, +// //) +// //if err != nil { +// // log.Error(err, "some error happend", "here", "here") +// // return nil, err +// //} +// // +// //var syncErr error +// // +// //processed := sets.NewString() +// // +// //log.V(4).Info("processing items", "items", len(objects)) +// // +// //// Apply objects and return processed +// //for i, obj := range objects { +// // replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} +// // replicatedItem.Name = obj.GetName() +// // replicatedItem.Kind = obj.GetKind() +// // replicatedItem.APIVersion = obj.GetAPIVersion() +// // replicatedItem.Owner = owner +// // replicatedItem.Type = meta.ReadyCondition +// // +// // if ns != nil { +// // replicatedItem.Namespace = ns.GetName() +// // } +// // +// // fieldOwnerw := fieldOwner + "/" + tnt.Name + "/" + strconv.Itoa(i) +// // +// // if err := r.createOrPatch(ctx, c, obj, resource, fieldOwnerw); err != nil { +// // replicatedItem.Status = metav1.ConditionFalse +// // replicatedItem.Message = err.Error() +// // } else { +// // replicatedItem.Status = metav1.ConditionTrue +// // } +// // +// // processed.Insert(replicatedItem.String()) +// //} +// // +// //// Run Garbage Collection +// // +// //return processed.List(), syncErr +//} + +// Prune by reverting the patch by the given fieldOwner +// If the item was created by the controller and has no more field-managers we are going to delete +func (r *Processor) Prune( ctx context.Context, c client.Client, - tnt capsulev1beta2.Tenant, - allowCrossNamespaceSelection bool, - tenantLabel string, - resourceIndex int, - resource capsulev1beta2.ResourceSpec, + obj *unstructured.Unstructured, fieldOwner string, - owner capsulev1beta2.ObjectReferenceStatusOwner, - ns *corev1.Namespace, - tmplContext tpl.ReferenceContext, -) ([]string, error) { - log := ctrllog.FromContext(ctx) - - // Collect Resources to apply - objects, err := r.handleResources( - ctx, - c, - tnt, - allowCrossNamespaceSelection, - tenantLabel, - resourceIndex, - resource, - owner, - ns, - tmplContext, - ) - if err != nil { - log.Error(err, "some error happend", "here", "here") - return nil, err - } - - var syncErr error +) (err error) { + target := &unstructured.Unstructured{} + target.SetGroupVersionKind(obj.GroupVersionKind()) + target.SetNamespace(obj.GetNamespace()) + target.SetName(obj.GetName()) - processed := sets.NewString() + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) - log.V(4).Info("processing items", "items", len(objects)) + err = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } - // Apply objects and return processed - for i, obj := range objects { - replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} - replicatedItem.Name = obj.GetName() - replicatedItem.Kind = obj.GetKind() - replicatedItem.APIVersion = obj.GetAPIVersion() - replicatedItem.Owner = owner - replicatedItem.Type = meta.ReadyCondition + return err + } - if ns != nil { - replicatedItem.Namespace = ns.GetName() - } + if err = utils.CreateOrPatch( + ctx, + c, + obj, + fieldOwner, + false, + ); err != nil { + return + } - fieldOwnerw := fieldOwner + "/" + tnt.Name + "/" + strconv.Itoa(i) + return r.handlePruneDeletion( + ctx, + c, + obj, + ) +} - if err := r.createOrPatch(ctx, c, obj, resource, fieldOwnerw); err != nil { - replicatedItem.Status = metav1.ConditionFalse - replicatedItem.Message = err.Error() - } else { - replicatedItem.Status = metav1.ConditionTrue - } +// Completely prune the resource when there's no more managers and the resource was created by the controller +func (r *Processor) handlePruneDeletion( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, +) (err error) { + if len(obj.GetManagedFields()) > 0 { + return + } - processed.Insert(replicatedItem.String()) + labels := obj.GetLabels() + if _, ok := labels[meta.CreatedByCapsuleLabel]; !ok { + return } - return processed.List(), syncErr + return c.Delete(ctx, obj) } -func (r *Processor) createOrPatch( +func (r *Processor) Apply( ctx context.Context, c client.Client, obj *unstructured.Unstructured, - resource capsulev1beta2.ResourceSpec, fieldOwner string, -) error { + force bool, + adopt bool, +) (err error) { actual := &unstructured.Unstructured{} actual.SetGroupVersionKind(obj.GroupVersionKind()) actual.SetNamespace(obj.GetNamespace()) actual.SetName(obj.GetName()) - // Fetch current to have a stable mutate func input - _ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) + // We need to mark an item if we create it with our patch to make proper Garbage Collection + // If it does not yet exist mark it + adoptable, err := r.handleApplyAdoption(ctx, c, obj) + if err != nil { + return err + } - if resource.AdditionalMetadata != nil { - obj.SetAnnotations(resource.AdditionalMetadata.Annotations) - obj.SetLabels(resource.AdditionalMetadata.Labels) + if !adopt && !adoptable { + return fmt.Errorf("big non no") } return utils.CreateOrPatch( @@ -310,7 +347,54 @@ func (r *Processor) createOrPatch( c, obj, fieldOwner, - append(resource.Ignore, r.configuration.ReplicationIgnoreRules()...), - *resource.Force, + force, + ) +} + +func (r *Processor) handleApplyAdoption( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, +) (adoptable bool, err error) { + adoptable = false + + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + target := &unstructured.Unstructured{} + target.SetGroupVersionKind(obj.GroupVersionKind()) + target.SetNamespace(obj.GetNamespace()) + target.SetName(obj.GetName()) + + err = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) + switch { + case apierrors.IsNotFound(err): + adoptable = true + case err != nil: + return + default: + labels := actual.GetLabels() + + if _, ok := labels[meta.ResourceCapsuleLabel]; ok { + adoptable = true + } + } + + if !adoptable { + return + } + + target.SetLabels(map[string]string{ + meta.CreatedByCapsuleLabel: "controller", + }) + + return adoptable, utils.CreateOrPatch( + ctx, + c, + target, + "capsule/controller/resources", + false, ) } diff --git a/controllers/resources/processor_handle.go b/controllers/resources/processor_handle.go index 218acedb2..9d43c93ca 100644 --- a/controllers/resources/processor_handle.go +++ b/controllers/resources/processor_handle.go @@ -7,63 +7,63 @@ import ( "context" "errors" "fmt" + "strconv" "github.com/valyala/fasttemplate" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/serializer" "sigs.k8s.io/controller-runtime/pkg/client" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/meta" + "github.com/projectcapsule/capsule/pkg/api" tpl "github.com/projectcapsule/capsule/pkg/template" ) +func (r *Processor) handleResources( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + resourceIndex string, + spec capsulev1beta2.ResourceSpec, + ns *corev1.Namespace, + tmplContext tpl.ReferenceContext, + acc api.Accumulator, +) (err error) { + return r.collectResources(ctx, c, tnt, resourceIndex, spec, ns, tmplContext, acc) + +} + // With this function we are attempting to collect all the unstructured items // No Interacting is done with the kubernetes regarding applying etc. // //nolint:gocognit -func (r *Processor) handleResources( +func (r *Processor) collectResources( ctx context.Context, c client.Client, tnt capsulev1beta2.Tenant, - allowCrossNamespaceSelection bool, - tenantLabel string, - resourceIndex int, + resourceIndex string, spec capsulev1beta2.ResourceSpec, - owner capsulev1beta2.ObjectReferenceStatusOwner, ns *corev1.Namespace, tmplContext tpl.ReferenceContext, -) (processed []*unstructured.Unstructured, err error) { - //log := ctrllog.FromContext(ctx) - - // Generating additional metadata - objAnnotations, objLabels := map[string]string{}, map[string]string{} - - if spec.AdditionalMetadata != nil { - objAnnotations = prepareAdditionalMetadata(spec.AdditionalMetadata.Annotations) - objLabels = prepareAdditionalMetadata(spec.AdditionalMetadata.Labels) - } - - objAnnotations[tenantLabel] = tnt.GetName() - - objLabels[meta.ResourcesLabel] = fmt.Sprintf("%d", resourceIndex) - objLabels[tenantLabel] = tnt.GetName() - + acc api.Accumulator, +) (err error) { var syncErr error - codecFactory := serializer.NewCodecFactory(r.client.Scheme()) - // Run Raw Items for rawIndex, item := range spec.RawItems { - p, rawError := r.handleRawItem(ctx, c, codecFactory, rawIndex, item, ns, tnt) + p, rawError := r.handleRawItem(ctx, c, rawIndex, item, ns, tnt) if rawError != nil { syncErr = errors.Join(syncErr, rawError) continue } - processed = append(processed, p) + rawError = r.addToAccumulation(tnt, spec, acc, p, resourceIndex+"/gen-"+strconv.Itoa(rawIndex)) + if rawError != nil { + syncErr = errors.Join(syncErr, rawError) + + continue + } } // Run Generators @@ -75,10 +75,36 @@ func (r *Processor) handleResources( continue } - processed = append(processed, p...) + for i, o := range p { + genError = r.addToAccumulation(tnt, spec, acc, o, resourceIndex+"/gen-"+strconv.Itoa(generatorIndex)+"-"+strconv.Itoa(i)) + if genError != nil { + syncErr = errors.Join(syncErr, genError) + + continue + } + + } } - return processed, syncErr + return syncErr +} + +// Add an item to the accumulator +// Mainly handles conflicts +func (r *Processor) addToAccumulation( + tnt capsulev1beta2.Tenant, + spec capsulev1beta2.ResourceSpec, + acc api.Accumulator, + obj *unstructured.Unstructured, + index string, +) (err error) { + r.handleResource(spec, obj) + + key := api.NewResourceID(obj, tnt.GetName(), index) + + acc[key] = obj + + return nil } // Handles a single generator item @@ -109,7 +135,6 @@ func (r *Processor) handleGeneratorItem( func (r *Processor) handleRawItem( ctx context.Context, c client.Client, - codecFactory serializer.CodecFactory, index int, item capsulev1beta2.RawExtension, ns *corev1.Namespace, @@ -129,7 +154,7 @@ func (r *Processor) handleRawItem( tmplString := t.ExecuteString(tContext) obj := &unstructured.Unstructured{} - if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode([]byte(tmplString), nil, obj); decodeErr != nil { + if _, _, decodeErr := r.factory.UniversalDeserializer().Decode([]byte(tmplString), nil, obj); decodeErr != nil { return nil, fmt.Errorf("error rendering raw: %w", err, "hello") } @@ -139,3 +164,13 @@ func (r *Processor) handleRawItem( return obj, nil } + +func (r *Processor) handleResource( + spec capsulev1beta2.ResourceSpec, + obj *unstructured.Unstructured, +) { + if spec.AdditionalMetadata != nil { + obj.SetAnnotations(spec.AdditionalMetadata.Annotations) + obj.SetLabels(spec.AdditionalMetadata.Labels) + } +} diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go index ce9082d49..a69835197 100644 --- a/controllers/resources/utils.go +++ b/controllers/resources/utils.go @@ -164,6 +164,14 @@ func maskSensitiveErrData(err error) error { return err } +func getFieldOwner(name string, namespace string, id api.ResourceID) string { + if namespace == "" { + namespace = "cluster" + } + + return "capsule/" + namespace + "/" + name + "/" + id.Tenant + "/" + id.Namespace + "/" + id.Kind + "/" + id.Name + "/" + id.Index +} + // Field templating for the ArgoCD project properties. Needs to unmarshal in json, because of the json tags from argocd. func loadTenantToContext( tenant *capsulev1beta2.Tenant, diff --git a/global-scope.yaml b/global-scope.yaml index 9fab81462..bced0fea0 100644 --- a/global-scope.yaml +++ b/global-scope.yaml @@ -5,33 +5,43 @@ metadata: spec: # New scope: Tenant - - + #force: false resyncPeriod: 5s - - # New #serviceaccount: # name: capsule # namespace: capsule-system - resources: - #additionalMetadata: # labels: # "replicated-by": "capsule" - - ignore: - - paths: ["/data/ui_properties_file_name"] - target: + context: + resources: + - index: "sec" + apiVersion: v1 + kind: Secret + name: capsule-tls + generators: + - template: | + --- + apiVersion: v1 kind: ConfigMap + metadata: + name: test-demo + namespace: default + data: + {{ .Tenant.ObjectMeta.Name }}.conf: | + 1 + - #additionalMetadata: + # labels: + # "replicated-by": "capsule" + context: resources: - index: "sec" apiVersion: v1 kind: Secret name: capsule-tls - namespace: capsule-system - force: false generators: - template: | @@ -39,9 +49,10 @@ spec: apiVersion: v1 kind: ConfigMap metadata: - name: test-demo + name: test-demo-2 namespace: default data: {{ .Tenant.ObjectMeta.Name }}.conf: | 1 + diff --git a/pkg/api/resource_id.go b/pkg/api/resource_id.go new file mode 100644 index 000000000..bc348940e --- /dev/null +++ b/pkg/api/resource_id.go @@ -0,0 +1,95 @@ +package api + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Keeps track of generated items +type Accumulator = map[ResourceID]*unstructured.Unstructured + +// ResourceID represents the decomposed parts of a Kubernetes resource identity. +type ResourceID struct { + Group string `json:"group,omitempty"` + Version string `json:"version,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Tenant string `json:"tenant,omitempty"` + Index string `json:"index,omitempty"` +} + +// ResourceKey builds the canonical key string used for maps/sets. +// Non-namespaced objects will have "_" as the namespace component. +func NewResourceID(u *unstructured.Unstructured, tenant string, index string) ResourceID { + gvk := u.GroupVersionKind() + + return ResourceID{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + Name: u.GetName(), + Namespace: u.GetNamespace(), + Tenant: tenant, + Index: index, + } +} + +// ParseResourceKey parses a key created by ResourceKey back into structured form. +func ParseResourceKey(key string) (ResourceID, error) { + parts := strings.Split(key, ",") + if len(parts) != 5 { + return ResourceID{}, fmt.Errorf("invalid resource key: %q", key) + } + id := ResourceID{ + Group: parts[0], + Version: parts[1], + Kind: parts[2], + Namespace: parts[3], + Name: parts[4], + } + if id.Namespace == "_" { + id.Namespace = "" + } + return id, nil +} + +func (r ResourceID) GetName() string { + return r.Name +} + +func (r ResourceID) GetNamespace() string { + return r.Namespace +} + +// GVK returns the schema.GroupVersionKind of the resource. +func (r ResourceID) GetGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: r.Group, + Version: r.Version, + Kind: r.Kind, + } +} + +// Key returns the string key form again (inverse of ParseResourceKey). +func (r ResourceID) GetIndex() string { + i := r.Index + if i == "" { + i = r.GetKey() + } + + return i +} + +// Key returns the string key form again (inverse of ParseResourceKey). +func (r ResourceID) GetKey() string { + ns := r.Namespace + if ns == "" { + ns = "_" + } + return fmt.Sprintf("%s/%s/%s/%s/%s", + r.Group, r.Version, r.Kind, ns, r.Name) +} diff --git a/pkg/configuration/client.go b/pkg/configuration/client.go index 948b8ef0b..dc910f0ac 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -138,11 +138,6 @@ func (c *capsuleConfiguration) ServiceAccountClientProperties() *api.ServiceAcco return c.retrievalFn().Spec.ServiceAccountClient } -func (c *capsuleConfiguration) ReplicationIgnoreRules() []api.IgnoreRule { - - return c.retrievalFn().Spec.Replications.Ignore -} - func (c *capsuleConfiguration) ServiceAccountClient(ctx context.Context) (client *rest.Config, err error) { props := c.ServiceAccountClientProperties() diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index fa4a4365e..796fb6a86 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -33,6 +33,5 @@ type Configuration interface { ForbiddenUserNodeLabels() *api.ForbiddenListSpec ForbiddenUserNodeAnnotations() *api.ForbiddenListSpec ServiceAccountClientProperties() *api.ServiceAccountClient - ReplicationIgnoreRules() []api.IgnoreRule ServiceAccountClient(context.Context) (*rest.Config, error) } diff --git a/pkg/meta/labels.go b/pkg/meta/labels.go index 3940f0e17..25e89eed4 100644 --- a/pkg/meta/labels.go +++ b/pkg/meta/labels.go @@ -23,6 +23,10 @@ const ( ManagedByCapsuleLabel = "capsule.clastix.io/managed-by" NewManagedByCapsuleLabel = "projectcapsule.dev/managed-by" + + CreatedByCapsuleLabel = "projectcapsule.dev/created-by" + + ResourceCapsuleLabel = "capsule.clastix.io/resources" ) func FreezeLabelTriggers(obj client.Object) bool { diff --git a/pkg/utils/namespace_selector.go b/pkg/utils/namespace.go similarity index 100% rename from pkg/utils/namespace_selector.go rename to pkg/utils/namespace.go diff --git a/pkg/utils/patch.go b/pkg/utils/patch.go deleted file mode 100644 index 6814bd60c..000000000 --- a/pkg/utils/patch.go +++ /dev/null @@ -1,94 +0,0 @@ -package utils - -import ( - "context" - "fmt" - "strings" - - "github.com/projectcapsule/capsule/pkg/api" - apierr "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func CreateOrPatch( - ctx context.Context, - c client.Client, - obj *unstructured.Unstructured, - fieldOwner string, - ignore []api.IgnoreRule, - overwrite bool, -) error { - actual := &unstructured.Unstructured{} - actual.SetGroupVersionKind(obj.GroupVersionKind()) - actual.SetNamespace(obj.GetNamespace()) - actual.SetName(obj.GetName()) - - // Fetch current to have a stable mutate func input - err := c.Get(ctx, client.ObjectKeyFromObject(actual), actual) - notFound := apierr.IsNotFound(err) - if err != nil && !notFound { - return err - } - - // Respect Ignores - igPaths := matchIgnorePaths(ignore, obj) - for _, p := range igPaths { - _ = jsonPointerDelete(obj.Object, p) - } - - if !notFound { - obj.SetResourceVersion(actual.GetResourceVersion()) - } else { - obj.SetResourceVersion("") // avoid accidental conflicts - } - - patchOpts := []client.PatchOption{ - client.FieldOwner(fieldOwner), - } - - if overwrite { - patchOpts = append(patchOpts, client.ForceOwnership) - } - - return c.Patch(ctx, obj, client.Apply, patchOpts...) -} - -func jsonPointerDelete(obj map[string]any, p string) error { - if p == "" || p == "/" { - return fmt.Errorf("cannot delete root with pointer") - } - parts := strings.Split(p, "/")[1:] - cur := obj - for i, raw := range parts { - key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") - last := i == len(parts)-1 - if last { - delete(cur, key) - return nil - } - nxt, ok := cur[key] - if !ok { - return nil - } - m, ok := nxt.(map[string]any) - if !ok { - return nil - } - cur = m - } - return nil -} - -func matchIgnorePaths(rules []api.IgnoreRule, obj *unstructured.Unstructured) []string { - var out []string - for _, r := range rules { - if !r.Matches(obj) { - continue - } - - out = append(out, r.Paths...) - } - - return out -} diff --git a/pkg/utils/update.go b/pkg/utils/update.go new file mode 100644 index 000000000..ab4945b04 --- /dev/null +++ b/pkg/utils/update.go @@ -0,0 +1,204 @@ +package utils + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/projectcapsule/capsule/pkg/api" + apierr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func CreateOrPatch( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, + fieldOwner string, + overwrite bool, +) error { + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + // Fetch current to have a stable mutate func input + err := c.Get(ctx, client.ObjectKeyFromObject(actual), actual) + notFound := apierr.IsNotFound(err) + if err != nil && !notFound { + return err + } + + if !notFound { + obj.SetResourceVersion(actual.GetResourceVersion()) + } else { + obj.SetResourceVersion("") // avoid accidental conflicts + } + + patchOpts := []client.PatchOption{ + client.FieldOwner(fieldOwner), + } + + if overwrite { + patchOpts = append(patchOpts, client.ForceOwnership) + } + + return c.Patch(ctx, obj, client.Apply, patchOpts...) +} + +func CreateOrUpdate( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, + labels, annotations map[string]string, + ignore []api.IgnoreRule, +) error { + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + // Fetch current to have a stable mutate func input + _ = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) // ignore notfound here + + // Respect Ignores + igPaths := matchIgnorePaths(ignore, obj) + for _, p := range igPaths { + _ = jsonPointerDelete(obj.Object, p) + } + + _, err := controllerutil.CreateOrPatch(ctx, c, actual, func() error { + // Keep copies + live := actual.DeepCopy() // current from cluster (may be empty) + desired := obj.DeepCopy() // what we want + + // Preserve ignored JSON pointers: copy live -> desired at those paths + if len(igPaths) > 0 { + preserveIgnoredPaths(desired.Object, live.Object, igPaths) + } + + // Replace actual content with the prepared desired content + uid := actual.GetUID() + rv := actual.GetResourceVersion() + + actual.Object = desired.Object + actual.SetUID(uid) + actual.SetResourceVersion(rv) + + return nil + }) + return err +} + +// jsonPointerGet returns (value, true) if JSON pointer p exists. +func jsonPointerGet(obj map[string]any, p string) (any, bool) { + if p == "" || p == "/" { + return obj, true + } + parts := strings.Split(p, "/")[1:] + cur := any(obj) + for _, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + switch node := cur.(type) { + case map[string]any: + next, ok := node[key] + if !ok { + return nil, false + } + cur = next + case []any: + idx, err := strconv.Atoi(key) + if err != nil || idx < 0 || idx >= len(node) { + return nil, false + } + cur = node[idx] + default: + return nil, false + } + } + return cur, true +} + +func jsonPointerSet(obj map[string]any, p string, val any) error { + if p == "" || p == "/" { + return fmt.Errorf("cannot set root with pointer") + } + parts := strings.Split(p, "/")[1:] + cur := obj + for i, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + last := i == len(parts)-1 + if last { + cur[key] = val + return nil + } + nxt, ok := cur[key] + if !ok { + n := map[string]any{} + cur[key] = n + cur = n + continue + } + switch m := nxt.(type) { + case map[string]any: + cur = m + default: + n := map[string]any{} + cur[key] = n + cur = n + } + } + return nil +} + +func jsonPointerDelete(obj map[string]any, p string) error { + if p == "" || p == "/" { + return fmt.Errorf("cannot delete root with pointer") + } + parts := strings.Split(p, "/")[1:] + cur := obj + for i, raw := range parts { + key := strings.ReplaceAll(strings.ReplaceAll(raw, "~1", "/"), "~0", "~") + last := i == len(parts)-1 + if last { + delete(cur, key) + return nil + } + nxt, ok := cur[key] + if !ok { + return nil + } + m, ok := nxt.(map[string]any) + if !ok { + return nil + } + cur = m + } + return nil +} + +func preserveIgnoredPaths(desired, live map[string]any, ptrs []string) { + for _, p := range ptrs { + if v, ok := jsonPointerGet(live, p); ok { + _ = jsonPointerSet(desired, p, v) + } else { + _ = jsonPointerDelete(desired, p) + } + } +} + +func matchIgnorePaths(rules []api.IgnoreRule, obj *unstructured.Unstructured) []string { + var out []string + for _, r := range rules { + if !r.Matches(obj) { + continue + } + + out = append(out, r.Paths...) + } + + return out +} diff --git a/pkg/webhook/tenantresource/objects_validating.go b/pkg/webhook/tenantresource/objects_validating.go index 0c375c735..71206ad8b 100644 --- a/pkg/webhook/tenantresource/objects_validating.go +++ b/pkg/webhook/tenantresource/objects_validating.go @@ -15,6 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/indexer/tenantresource" capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook" "github.com/projectcapsule/capsule/pkg/webhook/utils" @@ -57,12 +58,13 @@ func (h *objectsValidatingHandler) handler(ctx context.Context, clt client.Clien } // Checking if the object is managed by a TenantResource, local or global ors := capsulev1beta2.ObjectReferenceStatus{ - ObjectReferenceAbstract: capsulev1beta2.ObjectReferenceAbstract{ - Kind: req.Kind.Kind, - Namespace: req.Namespace, - APIVersion: req.Kind.Version, + ResourceID: api.ResourceID{ + Kind: req.Kind.Kind, + Namespace: req.Namespace, + Name: req.Name, + Group: req.Kind.Group, + Version: req.Kind.Version, }, - Name: req.Name, } global, local := &capsulev1beta2.GlobalTenantResourceList{}, &capsulev1beta2.TenantResourceList{} diff --git a/sad.yaml b/sad.yaml new file mode 100644 index 000000000..e8726ee63 --- /dev/null +++ b/sad.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +data: + green.conf: | + 1 +kind: ConfigMap +metadata: + creationTimestamp: "2025-11-11T12:06:34Z" + name: green-demo + namespace: default + resourceVersion: "618494" + uid: 3f958ab1-cd65-4aa9-83c5-db2d4a148cf4 From 248dc8a715b31d06bf7a570945a645ac8a433743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Wed, 12 Nov 2025 13:58:08 +0100 Subject: [PATCH 19/19] fix(tenants): functional --- config.yaml | 4 ++-- global-scope.yaml | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/config.yaml b/config.yaml index b26d36f5c..d0c80fe7c 100644 --- a/config.yaml +++ b/config.yaml @@ -24,8 +24,8 @@ spec: globalDefaultServiceAccountNamespace: capsule-system tenantDefaultServiceAccount: default - + ignore: - paths: - "/metadata/labels/company.com~1some-label" - \ No newline at end of file + diff --git a/global-scope.yaml b/global-scope.yaml index bced0fea0..e8b09db60 100644 --- a/global-scope.yaml +++ b/global-scope.yaml @@ -7,13 +7,13 @@ spec: scope: Tenant #force: false resyncPeriod: 5s - #serviceaccount: + #serviceaccount: # name: capsule # namespace: capsule-system resources: - #additionalMetadata: # labels: - # "replicated-by": "capsule" + # "replicated-by": "capsule" context: resources: - index: "sec" @@ -34,8 +34,8 @@ spec: - #additionalMetadata: # labels: - # "replicated-by": "capsule" - + # "replicated-by": "capsule" + context: resources: - index: "sec" @@ -54,5 +54,3 @@ spec: data: {{ .Tenant.ObjectMeta.Name }}.conf: | 1 - -