diff --git a/Makefile b/Makefile index e8db7468c..c11eebabe 100644 --- a/Makefile +++ b/Makefile @@ -151,7 +151,9 @@ dev-setup: --create-namespace \ --set 'crds.install=true' \ --set 'crds.exclusive=true'\ - --set 'crds.createConfig=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 65d2c686c..ff58721bc 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -41,6 +41,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_global.go b/api/v1beta2/tenantresource_global.go index 163a9d755..cbe443b5a 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -5,15 +5,21 @@ package v1beta2 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/sets" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/meta" ) // GlobalTenantResourceSpec defines the desired state of GlobalTenantResource. type GlobalTenantResourceSpec struct { - TenantResourceSpec `json:",inline"` - + // 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 api.ResourceScope `json:"scope"` // Defines the Tenant selector used target the tenants on which resources must be propagated. - TenantSelector metav1.LabelSelector `json:"tenantSelector,omitempty"` + TenantSelector metav1.LabelSelector `json:"tenantSelector,omitempty"` + TenantResourceSpec `json:",inline"` } // GlobalTenantResourceStatus defines the observed state of GlobalTenantResource. @@ -22,23 +28,16 @@ type GlobalTenantResourceStatus struct { SelectedTenants []string `json:"selectedTenants"` // List of the replicated resources for the given TenantResource. ProcessedItems ProcessedItems `json:"processedItems"` -} - -type ProcessedItems []ObjectReferenceStatus - -func (p *ProcessedItems) AsSet() sets.Set[string] { - set := sets.New[string]() - - for _, i := range *p { - set.Insert(i.String()) - } - - return set + // Condition of the GlobalTenantResource. + Conditions meta.ConditionList `json:"conditions,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster +// +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 e3a228588..4100b2f9d 100644 --- a/api/v1beta2/tenantresource_namespaced.go +++ b/api/v1beta2/tenantresource_namespaced.go @@ -8,6 +8,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/meta" ) // TenantResourceSpec defines the desired state of TenantResource. @@ -20,8 +21,22 @@ 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"` + // 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 { @@ -35,6 +50,16 @@ 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"` + // Provide additional template context, which can be used throughout all + // 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"` } // +kubebuilder:validation:XEmbeddedResource @@ -45,12 +70,19 @@ 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. + Conditions meta.ConditionList `json:"conditions,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +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 8803c1ec6..12a2eb3aa 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -5,70 +5,129 @@ 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" ) +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 + +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 + +// Adds a condition by type. +func (p *ProcessedItems) UpdateItem(item ObjectReferenceStatus) { + for i, stat := range *p { + if p.isEqual(stat, item) { + (*p)[i].ObjectReferenceStatusCondition = item.ObjectReferenceStatusCondition + + return + } + } + + *p = append(*p, item) +} + +// Removes a condition by type. +func (p *ProcessedItems) RemoveItem(item ObjectReferenceStatus) { + filtered := make(ProcessedItems, 0, len(*p)) + + for _, stat := range *p { + if !p.isEqual(stat, item) { + filtered = append(filtered, stat) + } + } + + *p = filtered +} + +func (p *ProcessedItems) isEqual(a, b ObjectReferenceStatus) bool { + return a.ResourceID == b.ResourceID +} + type ObjectReferenceAbstract struct { // Kind of the referent. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds Kind string `json:"kind"` // Namespace of the referent. // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ - Namespace string `json:"namespace"` + Namespace string `json:"namespace,omitempty"` // API version of the referent. APIVersion string `json:"apiVersion,omitempty"` } type ObjectReferenceStatus struct { - ObjectReferenceAbstract `json:",inline"` - - // Name of the referent. - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - Name string `json:"name"` -} - -type ObjectReference struct { - ObjectReferenceAbstract `json:",inline"` - - // Label selector used to select the given resources in the given Namespace. - Selector metav1.LabelSelector `json:"selector"` + api.ResourceID `json:",inline"` + ObjectReferenceStatusCondition `json:",inline"` } func (in *ObjectReferenceStatus) String() string { - return fmt.Sprintf("Kind=%s,APIVersion=%s,Namespace=%s,Name=%s", in.Kind, in.APIVersion, in.Namespace, in.Name) + return fmt.Sprintf("Kind=%s,Group=%s,APIVersion=%s,Namespace=%s,Name=%s", in.Kind, in.Group, in.Version, in.Namespace, in.Name) } -func (in *ObjectReferenceStatus) ParseFromString(value string) error { - rawParts := strings.Split(value, ",") - - if len(rawParts) != 4 { - return fmt.Errorf("unexpected raw parts") - } - - for _, i := range rawParts { - parts := strings.Split(i, "=") +type ObjectReferenceStatusOwner struct { + // Name of the owning object. + Name string `json:"name,omitempty"` + // UID of the owning object. + k8stypes.UID `json:"uid,omitempty" protobuf:"bytes,5,opt,name=uid"` + // Scope of the owning object. + Scope api.ResourceScope `json:"scope,omitempty"` +} - if len(parts) != 2 { - return fmt.Errorf("unrecognized separator") - } +type ObjectReferenceStatusCondition struct { + // status of the condition, one of True, False, Unknown. + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=True;False;Unknown + Status metav1.ConditionStatus `json:"status" protobuf:"bytes,2,opt,name=status"` + // message is a human readable message indicating details about the transition. + // This may be an empty string. + // +kubebuilder:validation:MaxLength=32768 + Message string `json:"message,omitempty" protobuf:"bytes,6,opt,name=message"` + // type of condition in CamelCase or in foo.example.com/CamelCase. + // --- + // Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be + // useful (see .node.status.conditions), the ability to deconflict is important. + // The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + // +required + // +kubebuilder:validation:Required + // +kubebuilder:validation: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])$` + // +kubebuilder:validation:MaxLength=316 + Type string `json:"type" protobuf:"bytes,1,opt,name=type"` +} - 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 - default: - return fmt.Errorf("unrecognized marker: %s", k) - } - } +type ObjectReference struct { + ObjectReferenceAbstract `json:",inline"` - return nil + // Label selector used to select the given resources in the given Namespace. + Selector metav1.LabelSelector `json:"selector"` } diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index f90228c7a..14067832e 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -139,6 +139,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. @@ -186,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 @@ -248,8 +268,8 @@ func (in *GlobalTenantResourceList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GlobalTenantResourceSpec) DeepCopyInto(out *GlobalTenantResourceSpec) { *out = *in - in.TenantResourceSpec.DeepCopyInto(&out.TenantResourceSpec) in.TenantSelector.DeepCopyInto(&out.TenantSelector) + in.TenantResourceSpec.DeepCopyInto(&out.TenantResourceSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlobalTenantResourceSpec. @@ -275,6 +295,13 @@ func (in *GlobalTenantResourceStatus) DeepCopyInto(out *GlobalTenantResourceStat *out = make(ProcessedItems, len(*in)) copy(*out, *in) } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(meta.ConditionList, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlobalTenantResourceStatus. @@ -413,7 +440,8 @@ 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.ObjectReferenceAbstract = in.ObjectReferenceAbstract + out.ResourceID = in.ResourceID + out.ObjectReferenceStatusCondition = in.ObjectReferenceStatusCondition } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectReferenceStatus. @@ -426,6 +454,36 @@ func (in *ObjectReferenceStatus) DeepCopy() *ObjectReferenceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectReferenceStatusCondition) DeepCopyInto(out *ObjectReferenceStatusCondition) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectReferenceStatusCondition. +func (in *ObjectReferenceStatusCondition) DeepCopy() *ObjectReferenceStatusCondition { + if in == nil { + return nil + } + out := new(ObjectReferenceStatusCondition) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectReferenceStatusOwner) DeepCopyInto(out *ObjectReferenceStatusOwner) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectReferenceStatusOwner. +func (in *ObjectReferenceStatusOwner) DeepCopy() *ObjectReferenceStatusOwner { + if in == nil { + return nil + } + out := new(ObjectReferenceStatusOwner) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in OwnerListSpec) DeepCopyInto(out *OwnerListSpec) { { @@ -958,6 +1016,21 @@ 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) + } + if in.Context != nil { + in, out := &in.Context, &out.Context + *out = new(api.TemplateContext) + (*in).DeepCopyInto(*out) + } + if in.Managed != nil { + in, out := &in.Managed, &out.Managed + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSpec. @@ -1097,6 +1170,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)) @@ -1104,6 +1182,21 @@ func (in *TenantResourceSpec) DeepCopyInto(out *TenantResourceSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ServiceAccount != nil { + in, out := &in.ServiceAccount, &out.ServiceAccount + *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. @@ -1124,6 +1217,13 @@ func (in *TenantResourceStatus) DeepCopyInto(out *TenantResourceStatus) { *out = make(ProcessedItems, len(*in)) copy(*out, *in) } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(meta.ConditionList, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceStatus. diff --git a/charts/capsule/README.md b/charts/capsule/README.md index 65299b08c..849a62d45 100644 --- a/charts/capsule/README.md +++ b/charts/capsule/README.md @@ -117,6 +117,8 @@ The following Values have changed key or Value: | manager.options.logLevel | string | `"4"` | Set the log verbosity of the capsule with a value from 1 to 10 | | manager.options.nodeMetadata | object | `{"forbiddenAnnotations":{"denied":[],"deniedRegex":""},"forbiddenLabels":{"denied":[],"deniedRegex":""}}` | Allows to set the forbidden metadata for the worker nodes that could be patched by a Tenant | | manager.options.protectedNamespaceRegex | string | `""` | If specified, disallows creation of namespaces matching the passed regexp | +| manager.options.serviceAccountClient | object | `{}` | Define custom service account client options for the Capsule manager container. | +| manager.options.useProxyForServiceAccountClient | bool | `false` | If `proxy.enabled` is `true`, this option sets the `options.serviceAccountClient` according to the standard installation. Properties from `options.serviceAccountClient` are merged over the default values. | | manager.options.userNames | list | `[]` | Names of the users considered as Capsule users. | | manager.rbac.create | bool | `true` | Specifies whether RBAC resources should be created. | | manager.rbac.existingClusterRoles | list | `[]` | Specifies further cluster roles to be added to the Capsule manager service account. | diff --git a/charts/capsule/ci/proxy-values.yaml b/charts/capsule/ci/proxy-values.yaml index 33b580d1c..465ea9749 100644 --- a/charts/capsule/ci/proxy-values.yaml +++ b/charts/capsule/ci/proxy-values.yaml @@ -1,6 +1,8 @@ proxy: enabled: true manager: + options: + useProxyForServiceAccountClient: 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 2046d1ee5..58672c435 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -131,6 +131,53 @@ 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 + endpoint: + description: Kubernetes API Endpoint to use for impersonation + type: string + globalDefaultServiceAccount: + 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 + 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 + description: If true, TLS certificate verification is skipped + (not recommended for production) + type: boolean + tenantDefaultServiceAccount: + 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. + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + type: object userGroups: default: - capsule.clastix.io diff --git a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml index d74c9f001..efbdb0725 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -14,7 +14,20 @@ spec: singular: globaltenantresource scope: Cluster versions: - - name: v1beta2 + - additionalPrinterColumns: + - description: Reconcile Status for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - description: Reconcile Message for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta2 schema: openAPIV3Schema: description: GlobalTenantResource allows to propagate resource replications @@ -40,6 +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: |- @@ -65,6 +96,123 @@ 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. + This can be a single resource or multiple resources + type: string + type: object + type: array + managed: + default: true + description: |- + 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. @@ -180,7 +328,6 @@ spec: x-kubernetes-map-type: atomic required: - kind - - namespace - selector type: object type: array @@ -199,6 +346,32 @@ 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 + - 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 + You must provide permissions accordingly to that ServiceAccount + properties: + name: + description: ServiceAccount Name Reference + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + namespace: + description: ServiceAccount Namespace Reference + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + type: object tenantSelector: description: Defines the Tenant selector used target the tenants on which resources must be propagated. @@ -249,37 +422,108 @@ spec: required: - resources - resyncPeriod + - scope type: object status: description: GlobalTenantResourceStatus defines the observed state of GlobalTenantResource. properties: + conditions: + description: Condition of the GlobalTenantResource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + 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 + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array processedItems: 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: + type: string + message: description: |- - Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + message is a human readable message indicating details about the transition. + This may be an empty string. + 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 + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "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 - - namespace + - status + - type type: object type: array selectedTenants: diff --git a/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml b/charts/capsule/crds/capsule.clastix.io_resourcepoolclaims.yaml index dbfef905e..caadc8027 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 65368c022..e2b5daa68 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 51009469a..aebee7226 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -14,7 +14,20 @@ spec: singular: tenantresource scope: Namespaced versions: - - name: v1beta2 + - additionalPrinterColumns: + - description: Reconcile Status for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - description: Reconcile Message for the tenant + jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Status + type: string + - description: Age + jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta2 schema: openAPIV3Schema: description: |- @@ -42,6 +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: |- @@ -67,6 +98,123 @@ 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. + This can be a single resource or multiple resources + type: string + type: object + type: array + managed: + default: true + description: |- + 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. @@ -182,7 +330,6 @@ spec: x-kubernetes-map-type: atomic required: - kind - - namespace - selector type: object type: array @@ -201,6 +348,22 @@ 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 + serviceAccount: + description: |- + Local ServiceAccount which will perform all the actions defined in the TenantResource + You must provide permissions accordingly to that ServiceAccount + properties: + name: + description: ServiceAccount Name Reference + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + namespace: + description: ServiceAccount Namespace Reference + maxLength: 253 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + type: object required: - resources - resyncPeriod @@ -208,36 +371,110 @@ spec: status: description: TenantResourceStatus defines the observed state of TenantResource. properties: + conditions: + description: Conditions of the TenantResource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + 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 + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array processedItems: 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: + type: string + message: description: |- - Kind of the referent. - More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + message is a human readable message indicating details about the transition. + This may be an empty string. + 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 + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "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 - - namespace + - 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/charts/capsule/templates/configuration-default.yaml b/charts/capsule/templates/configuration-default.yaml index 34dec210c..3e5ce266c 100644 --- a/charts/capsule/templates/configuration-default.yaml +++ b/charts/capsule/templates/configuration-default.yaml @@ -19,6 +19,15 @@ spec: TLSSecretName: {{ include "capsule.secretTlsName" . }} validatingWebhookConfigurationName: {{ include "capsule.fullname" . }}-validating-webhook-configuration forceTenantPrefix: {{ .Values.manager.options.forceTenantPrefix }} + {{- $saClientValues := .Values.manager.options.serviceAccountClient -}} + {{- if and .Values.manager.options.useProxyForServiceAccountClient .Values.proxy.enabled }} + {{- $dlt_cfg := (fromYaml (include "proxy.defaults" $)) -}} + {{- $saClientValues = mergeOverwrite (default dict $dlt_cfg) (default dict .Values.manager.options.serviceAccountClient) -}} + {{- end }} + {{- with $saClientValues }} + serviceAccountClient: + {{- toYaml . | nindent 4 }} + {{- end }} allowServiceAccountPromotion: {{ .Values.manager.options.allowServiceAccountPromotion }} userGroups: {{- toYaml .Values.manager.options.capsuleUserGroups | nindent 4 }} @@ -32,3 +41,10 @@ spec: {{- toYaml . | nindent 4 }} {{- end }} {{- end }} + +{{- define "proxy.defaults" -}} +serviceAccountClient: + endpoint: https://{{ include "capsule.fullname" . }}-proxy.{{ .Release.Namespace }}.svc:9001 + caSecretNamespace: {{ .Release.Namespace }} + caSecretName: {{ .Release.Name }}-capsule-proxy +{{- end -}} diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 3b4869c73..3e0803ef8 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -346,7 +346,7 @@ webhooks: 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 }} {{- with .namespaceSelector }} diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 0d1aa126e..0356eeae7 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -191,6 +191,10 @@ manager: forbiddenAnnotations: denied: [] deniedRegex: "" + # -- If `proxy.enabled` is `true`, this option sets the `options.serviceAccountClient` according to the standard installation. Properties from `options.serviceAccountClient` are merged over the default values. + useProxyForServiceAccountClient: false + # -- Define custom service account client options for the Capsule manager container. + serviceAccountClient: {} # -- A list of extra arguments for the capsule controller extraArgs: @@ -589,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: @@ -598,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/cmd/main.go b/cmd/main.go index 0c21e8991..ca9ba384c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -158,7 +158,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(), @@ -169,7 +169,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{ @@ -233,7 +233,9 @@ 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.TenantResourceGlobalMutation(tntresource.GlobalMutatingHandler(cfg)), + route.TenantResourceObjectsValidation(utils.InCapsuleGroups(cfg, tntresource.ObjectsValidatingHandler())), route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())), route.TenantMutating(tenantmutation.MetaHandler()), route.TenantValidating(tenantvalidation.NameHandler(), tenantvalidation.RoleBindingRegexHandler(), tenantvalidation.IngressClassRegexHandler(), tenantvalidation.StorageClassRegexHandler(), tenantvalidation.ContainerRegistryRegexHandler(), tenantvalidation.HostnameRegexHandler(), tenantvalidation.FreezedEmitter(), tenantvalidation.ServiceAccountNameHandler(), tenantvalidation.ForbiddenAnnotationsRegexHandler(), tenantvalidation.ProtectedHandler()), @@ -306,13 +308,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/config.yaml b/config.yaml new file mode 100644 index 000000000..d0c80fe7c --- /dev/null +++ b/config.yaml @@ -0,0 +1,31 @@ +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" + diff --git a/controllers/config/manager.go b/controllers/config/manager.go index 69734775e..904106ef8 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 f3c464c09..0e442a00e 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -5,53 +5,120 @@ package resources import ( "context" - "errors" + "fmt" + "reflect" + "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" "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" + "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" ) -type Global struct { - client client.Client - processor Processor +type globalResourceController struct { + client client.Client + log logr.Logger + processor Processor + configuration configuration.Configuration + metrics *metrics.GlobalTenantResourceRecorder } -func (r *Global) SetupWithManager(mgr ctrl.Manager) error { +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(), + client: mgr.GetClient(), + factory: serializer.NewCodecFactory(r.client.Scheme()), + configuration: r.configuration, + allowCrossNamespaceSelection: true, + tenantLabel: tenantLabel, } 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) } -func (r *Global) 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.Info("start processing") + log.V(5).Info("start processing") // Retrieving the GlobalTenantResource tntResource := &capsulev1beta2.GlobalTenantResource{} if err = r.client.Get(ctx, request.NamespacedName, tntResource); err != nil { if apierrors.IsNotFound(err) { - log.Info("Request object not found, could have been deleted after reconcile request") + log.V(3).Info("Request object not found, could have been deleted after reconcile request") + + r.metrics.DeleteMetrics(request.Name) return reconcile.Result{}, nil } @@ -65,6 +132,14 @@ func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reco } 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 { if err == nil { err = gherrors.Wrap(e, "failed to patch GlobalTenantResource") @@ -72,16 +147,31 @@ func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reco } }() + 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") + } + + if c == nil { + log.V(5).Info("received empty client for serviceaccount") + + return reconcile.Result{}, nil + } + // Handle deleted GlobalTenantResource if !tntResource.DeletionTimestamp.IsZero() { - return r.reconcileDelete(ctx, tntResource) + return r.reconcileDelete(ctx, c, tntResource) } // Handle non-deleted GlobalTenantResource - return r.reconcileNormal(ctx, tntResource) + return r.reconcileNormal(ctx, c, 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{} @@ -115,7 +205,11 @@ 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, + c client.Client, + tntResource *capsulev1beta2.GlobalTenantResource, +) (res reconcile.Result, err error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { @@ -134,6 +228,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") @@ -146,46 +241,136 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta // 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) + // + //}() + + //status := capsulev1beta2.ProcessedItems{} + acc := api.Accumulator{} + + // Gather Resources for index, resource := range tntResource.Spec.Resources { - tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) - if labelErr != nil { - log.Error(labelErr, "expected label for selection") + for _, tnt := range tntList.Items { + 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) - return reconcile.Result{}, labelErr - } + } - for _, tnt := range tntList.Items { - tntSet.Insert(tnt.GetName()) - - items, sectionErr := r.processor.HandleSection(ctx, tnt, true, 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) - } else { - processedItems.Insert(items...) + // Only start pruning when the resource item itself did not throw an error + if resourceError != nil { + return reconcile.Result{}, resourceError } } } - if err != nil { - log.Error(err, "unable to replicate the requested resources") + // 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 + } + } - return reconcile.Result{}, err + tntResource.Status.ProcessedItems.RemoveItem(p) + } } + // + log.Info("accumulation", "items", len(acc)) + + // Apply + for id, obj := range acc { + or := capsulev1beta2.ObjectReferenceStatus{ + ResourceID: id, + ObjectReferenceStatusCondition: capsulev1beta2.ObjectReferenceStatusCondition{ + Type: meta.ReadyCondition, + }, + } - if r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) { - 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) - } + 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") @@ -193,16 +378,105 @@ 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) { - log := ctrllog.FromContext(ctx) +func (r *globalResourceController) reconcileDelete( + ctx context.Context, + c client.Client, + tntResource *capsulev1beta2.GlobalTenantResource, +) (reconcile.Result, error) { + //_ := 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") - if *tntResource.Spec.PruningOnDelete { - r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), nil) + return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil +} - controllerutil.RemoveFinalizer(tntResource, finalizer) +func (r *globalResourceController) loadClient( + ctx context.Context, + log logr.Logger, + tntResource *capsulev1beta2.GlobalTenantResource, +) (client.Client, error) { + // Add ServiceAccount if required, Retriggers reconcile + // This is done in the background, Everything else should be handeled at admission + if changed := SetGlobalTenantResourceServiceAccount(r.configuration, tntResource); changed { + log.V(5).Info("adding default serviceAccount '%s'", tntResource.Spec.ServiceAccount.GetFullName()) + + return nil, nil } - log.Info("processing completed") + // Load impersonation client + saClient := r.client + if tntResource.Spec.ServiceAccount != nil { + re, err := r.configuration.ServiceAccountClient(ctx) + if err != nil { + log.Error(err, "failed to load impersonated rest client") - return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil + return nil, err + } + + saClient, err = utils.ImpersonatedKubernetesClientForServiceAccount( + re, + r.client.Scheme(), + tntResource.Spec.ServiceAccount, + ) + if err != nil { + log.Error(err, "failed to create impersonated client") + + return nil, err + } + } + + return saClient, nil +} + +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 { + 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/manager.go b/controllers/resources/manager.go new file mode 100644 index 000000000..4a922d4a8 --- /dev/null +++ b/controllers/resources/manager.go @@ -0,0 +1,38 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "fmt" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/metrics" +) + +func Add( + log logr.Logger, + mgr manager.Manager, + configuration configuration.Configuration, +) (err error) { + if err = (&globalResourceController{ + log: log.WithName("Global"), + configuration: configuration, + metrics: metrics.MustMakeGlobalTenantResourceRecorder(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create global controller: %w", err) + } + + if err = (&namespacedResourceController{ + log: log.WithName("Namespaced"), + configuration: configuration, + metrics: metrics.MustMakeTenantResourceRecorder(), + }).SetupWithManager(mgr); err != nil { + 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 b266722f9..63022d96c 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -5,47 +5,115 @@ 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/util/sets" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" + "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" "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" + "github.com/projectcapsule/capsule/pkg/configuration" + "github.com/projectcapsule/capsule/pkg/meta" + "github.com/projectcapsule/capsule/pkg/metrics" + "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 + metrics *metrics.TenantResourceRecorder } -func (r *Namespaced) SetupWithManager(mgr ctrl.Manager) error { +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(), + client: mgr.GetClient(), + factory: serializer.NewCodecFactory(r.client.Scheme()), + configuration: r.configuration, + allowCrossNamespaceSelection: false, + tenantLabel: tenantLabel, } 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) } -func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { +func (r *namespacedResourceController) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) { log := ctrllog.FromContext(ctx) - log.Info("start processing") + log.V(5).Info("start processing") // Retrieving the TenantResource tntResource := &capsulev1beta2.TenantResource{} if err := r.client.Get(ctx, request.NamespacedName, tntResource); err != nil { if apierrors.IsNotFound(err) { - log.Info("Request object not found, could have been deleted after reconcile request") + log.V(3).Info("Request object not found, could have been deleted after reconcile request") + + r.metrics.DeleteMetrics(request.Name, request.Namespace) return reconcile.Result{}, nil } @@ -59,6 +127,14 @@ func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) ( } 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 { if err == nil { err = gherrors.Wrap(e, "failed to patch TenantResource") @@ -66,16 +142,33 @@ func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) ( } }() + 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") + } + 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) + return r.reconcileDelete(ctx, c, tntResource) } // Handle non-deleted TenantResource - return r.reconcileNormal(ctx, tntResource) + return r.reconcileNormal(ctx, c, tntResource) } -func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { +func (r *namespacedResourceController) reconcileNormal( + ctx context.Context, + c client.Client, + tntResource *capsulev1beta2.TenantResource, +) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { @@ -102,62 +195,178 @@ func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1 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() + //// 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) + // } + // } + //}() - tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) - if labelErr != nil { - log.Error(labelErr, "expected label for selection") + // new empty error + //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{}, labelErr - } + return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil +} - // new empty error - var err error - - for index, resource := range tntResource.Spec.Resources { - items, sectionErr := r.processor.HandleSection(ctx, 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) - } else { - processedItems.Insert(items...) - } - } +func (r *namespacedResourceController) reconcileDelete( + ctx context.Context, + 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") - if err != nil { - log.Error(err, "unable to replicate the requested resources") + return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil +} - return reconcile.Result{}, err +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 + if changed := SetTenantResourceServiceAccount(r.configuration, tntResource); changed { + log.V(5).Info("adding default serviceAccount", "serviceaccount", tntResource.Spec.ServiceAccount.GetFullName()) + + return nil, nil } - if r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + // Load impersonation client + saClient := r.client + if tntResource.Spec.ServiceAccount != nil { + re, err := r.configuration.ServiceAccountClient(ctx) + if err != nil { + log.Error(err, "failed to load impersonated rest client") - for _, item := range processedItems.List() { - if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { - tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) - } + return nil, err } - } - log.Info("processing completed") + //utils.NamespacedServiceAccountName() + // + saClient, err = utils.ImpersonatedKubernetesClientForServiceAccount( + re, + r.client.Scheme(), + tntResource.Spec.ServiceAccount, + ) + if err != nil { + log.Error(err, "failed to create impersonated client") + + return nil, err + } + } - return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil + return saClient, nil } -func (r *Namespaced) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { - log := ctrllog.FromContext(ctx) +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 + } - if *tntResource.Spec.PruningOnDelete { - r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), nil) - } + latest.Status = instance.Status - controllerutil.RemoveFinalizer(tntResource, finalizer) + // Set Ready Condition + readyCondition := meta.NewReadyCondition(instance) + if reconcileError != nil { + readyCondition.Message = reconcileError.Error() + readyCondition.Status = metav1.ConditionFalse + readyCondition.Reason = meta.FailedReason + } - log.Info("processing completed") + latest.Status.Conditions.UpdateConditionByType(readyCondition) - return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil + // 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 cd00a0e31..597c1263f 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -5,331 +5,396 @@ package resources import ( "context" - "errors" "fmt" - "sync" - "github.com/valyala/fasttemplate" 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" - "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 ( - Label = "capsule.clastix.io/resources" finalizer = "capsule.clastix.io/resources" ) type Processor struct { - client client.Client + 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 - } - - return copied -} - -func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set[string]) (updateStatus bool) { - 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. - updateStatus = diff.Len() > 0 || current.Len() != desired.Len() - - if diff.Len() > 0 { - 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 err := or.ParseFromString(item); err != nil { - log.Error(err, "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)) - - if err := r.client.Delete(ctx, &obj); err != nil { - if apierr.IsNotFound(err) { - // Object may have been already deleted, we can ignore this error - continue - } - - log.Error(err, "unable to prune resource", "resource", item) - - continue - } - - log.Info("resource has been pruned", "resource", item) - } - - return updateStatus -} +//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 +//} //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) foreachTenantNamespace( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + resource capsulev1beta2.ResourceSpec, + resourceIndex string, + tmplContext tpl.ReferenceContext, + acc api.Accumulator, +) (err 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 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", "index", resourceIndex) + log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication") - return nil, err + return 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()}) + 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", "index", resourceIndex) + log.Error(err, "unable to create requirement for Namespace filtering and resource replication") - return nil, err + return 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{} + log.Error(err, "cannot retrieve Namespaces for resource") - if spec.AdditionalMetadata != nil { - objAnnotations = prepareAdditionalMetadata(spec.AdditionalMetadata.Annotations) - objLabels = prepareAdditionalMetadata(spec.AdditionalMetadata.Labels) + return err } - objAnnotations[tenantLabel] = tnt.GetName() - - objLabels[Label] = 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 _, ns := range namespaces.Items { - for nsIndex, item := range spec.NamespacedItems { - keysAndValues := []any{"index", nsIndex, "namespace", item.Namespace} - // 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 := r.client.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())) - - if opErr := r.createOrUpdate(ctx, &obj, objLabels, objAnnotations); opErr != nil { - log.Error(opErr, "unable to sync namespacedItems", kv...) - - errorsChan <- opErr - - return - } - - log.Info("resource has been replicated", kv...) - - replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} - replicatedItem.Name = obj.GetName() - replicatedItem.Kind = obj.GetKind() - replicatedItem.Namespace = ns.Name - replicatedItem.APIVersion = obj.GetAPIVersion() - - 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) - } + //spec.Context.GatherContext(ctx, c, nil, ns.GetName()) + err = r.handleResources( + ctx, + c, + tnt, + resourceIndex, + resource, + &ns, + tmplContext, + acc, + ) + if err != nil { + return } + } - for rawIndex, item := range spec.RawItems { - template := string(item.Raw) - - t := fasttemplate.New(template, "{{ ", " }}") - - tmplString := t.ExecuteString(map[string]interface{}{ - "tenant.name": tnt.Name, - "namespace": ns.Name, - }) - - 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...) + return +} - syncErr = errors.Join(syncErr, decodeErr) +//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, + obj *unstructured.Unstructured, + fieldOwner string, +) (err error) { + target := &unstructured.Unstructured{} + target.SetGroupVersionKind(obj.GroupVersionKind()) + target.SetNamespace(obj.GetNamespace()) + target.SetName(obj.GetName()) + + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + err = c.Get(ctx, client.ObjectKeyFromObject(actual), actual) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } - continue - } + return err + } - obj.SetNamespace(ns.Name) + if err = utils.CreateOrPatch( + ctx, + c, + obj, + fieldOwner, + false, + ); err != nil { + return + } - if rawErr := r.createOrUpdate(ctx, &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. - syncErr = errors.Join(syncErr, rawErr) - } else { - log.Info("resource has been replicated", keysAndValues...) + return r.handlePruneDeletion( + ctx, + c, + obj, + ) +} - replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} - replicatedItem.Name = obj.GetName() - replicatedItem.Kind = obj.GetKind() - replicatedItem.Namespace = ns.Name - replicatedItem.APIVersion = obj.GetAPIVersion() +// 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) } -// 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) { - 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, r.client, 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 - } +func (r *Processor) Apply( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, + fieldOwner string, + force bool, + adopt bool, +) (err error) { + actual := &unstructured.Unstructured{} + actual.SetGroupVersionKind(obj.GroupVersionKind()) + actual.SetNamespace(obj.GetNamespace()) + actual.SetName(obj.GetName()) + + // 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 + } - actual.SetLabels(combinedLabels) + if !adopt && !adoptable { + return fmt.Errorf("big non no") + } - combinedAnnotations := obj.GetAnnotations() - if combinedAnnotations == nil { - combinedAnnotations = make(map[string]string) - } + return utils.CreateOrPatch( + ctx, + c, + obj, + fieldOwner, + force, + ) +} - for key, value := range annotations { - combinedAnnotations[key] = value +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 } + } - actual.SetAnnotations(combinedAnnotations) - actual.SetResourceVersion(rv) - actual.SetUID(UID) + if !adoptable { + return + } - return nil + target.SetLabels(map[string]string{ + meta.CreatedByCapsuleLabel: "controller", }) - return err + 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 new file mode 100644 index 000000000..9d43c93ca --- /dev/null +++ b/controllers/resources/processor_handle.go @@ -0,0 +1,176 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "context" + "errors" + "fmt" + "strconv" + + "github.com/valyala/fasttemplate" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "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) collectResources( + 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) { + var syncErr error + + // Run Raw Items + for rawIndex, item := range spec.RawItems { + p, rawError := r.handleRawItem(ctx, c, rawIndex, item, ns, tnt) + if rawError != nil { + syncErr = errors.Join(syncErr, rawError) + + continue + } + + rawError = r.addToAccumulation(tnt, spec, acc, p, resourceIndex+"/gen-"+strconv.Itoa(rawIndex)) + if rawError != nil { + syncErr = errors.Join(syncErr, rawError) + + continue + } + } + + // 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 + } + + 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 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 +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, + 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 := r.factory.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 +} + +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 new file mode 100644 index 000000000..a69835197 --- /dev/null +++ b/controllers/resources/utils.go @@ -0,0 +1,225 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +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" + + 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" +) + +func SetGlobalTenantResourceServiceAccount( + config configuration.Configuration, + resource *capsulev1beta2.GlobalTenantResource, +) (changed bool) { + + // 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 + } + + return + } + } + + // 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 + } + + // 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 +} + +func SetTenantResourceServiceAccount( + config configuration.Configuration, + resource *capsulev1beta2.TenantResource, +) (changed bool) { + changed = false + + // 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 setTenantDefaultResourceServiceAccount(config, resource) { + changed = true + } else { + // Remove invalid ServiceAccount reference + if resource.Spec.ServiceAccount != nil { + resource.Spec.ServiceAccount = nil + changed = true + } + + return + } + } + + // 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 + } + + // 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 +} + +func setTenantDefaultResourceServiceAccount( + config configuration.Configuration, + resource *capsulev1beta2.TenantResource, +) (changed bool) { + cfg := config.ServiceAccountClientProperties() + if cfg == nil { + return false + } + + if cfg.TenantDefaultServiceAccount == "" { + return false + } + + if resource.Spec.ServiceAccount == nil { + resource.Spec.ServiceAccount = &api.ServiceAccountReference{} + } + + resource.Spec.ServiceAccount.Name = api.Name( + 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 +} + +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 +} + +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, +) (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/e2e/config_client_test.go b/e2e/config_client_test.go new file mode 100644 index 000000000..1a5777974 --- /dev/null +++ b/e2e/config_client_test.go @@ -0,0 +1,87 @@ +package e2e + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/configuration" +) + +var _ = Describe("CapsuleConfiguration - ServiceAccountClient", Label("config", "impersonation"), func() { + + originalConfig := &capsulev1beta2.CapsuleConfiguration{} + testingConfig := &capsulev1beta2.CapsuleConfiguration{} + + BeforeEach(func() { + Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originalConfig)).To(Succeed()) + testingConfig = originalConfig.DeepCopy() + }) + + AfterEach(func() { + Eventually(func() error { + if err := k8sClient.Get(context.Background(), client.ObjectKey{Name: originalConfig.Name}, originalConfig); err != nil { + return err + } + + testingConfig.Spec = originalConfig.Spec + return k8sClient.Update(context.Background(), testingConfig) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("returns base config when ServiceAccountClient is nil", func() { + capsuleCfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, cfg, defaultConfigurationName) + clientCfg, err := capsuleCfg.ServiceAccountClient(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + Expect(clientCfg.Host).To(Equal(capsuleCfg.ServiceAccountClientProperties().Endpoint)) + Expect(clientCfg.TLSClientConfig.Insecure).To(BeFalse()) + Expect(clientCfg.TLSClientConfig.CAData).To(BeNil()) + }) + + It("sets skip TLS verify", func() { + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.ServiceAccountClient = &api.ServiceAccountClient{ + SkipTLSVerify: true, + } + }) + + capsuleCfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, cfg, defaultConfigurationName) + clientCfg, err := capsuleCfg.ServiceAccountClient(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + Expect(clientCfg.TLSClientConfig.Insecure).To(BeTrue()) + }) + + It("loads CA from secret", func() { + caData := []byte("dummy-ca-data") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "capsule-ca", + Namespace: "default", + }, + Data: map[string][]byte{ + "ca.crt": caData, + }, + } + Expect(k8sClient.Create(context.TODO(), secret)).To(Succeed()) + + // Create configuration pointing to the secret + ModifyCapsuleConfigurationOpts(func(configuration *capsulev1beta2.CapsuleConfiguration) { + configuration.Spec.ServiceAccountClient = &api.ServiceAccountClient{ + CASecretName: secret.Name, + CASecretNamespace: secret.Namespace, + CASecretKey: "ca.crt", + } + }) + + cfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, cfg, defaultConfigurationName) + clientCfg, err := cfg.ServiceAccountClient(context.TODO()) + Expect(err).NotTo(HaveOccurred()) + Expect(clientCfg.TLSClientConfig.CAData).To(Equal(caData)) + }) +}) 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_impersonation_test.go b/e2e/tenantresource_impersonation_test.go new file mode 100644 index 000000000..b2c966c51 --- /dev/null +++ b/e2e/tenantresource_impersonation_test.go @@ -0,0 +1,732 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + "math/rand" + "time" + + . "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" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" + "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", + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: capsulev1beta2.OwnerListSpec{ + { + Name: "tenantresource-imp-user", + Kind: "User", + }, + }, + }, + } + + tntItem := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-secret", + Namespace: "tenantresource-imp-system", + Labels: map[string]string{ + "replicate": "true", + }, + }, + Type: corev1.SecretTypeOpaque, + } + + crossNamespaceItem := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cross-reference-secret", + Namespace: "default", + Labels: map[string]string{ + "replicate": "true", + }, + }, + Type: corev1.SecretTypeOpaque, + } + + testLabels := map[string]string{ + "labels.energy.io": "namespaced", + } + testAnnotations := map[string]string{ + "annotations.energy.io": "namespaced", + } + + tr := &capsulev1beta2.TenantResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenantresource-imp-1", + Namespace: "tenantresource-imp-system", + }, + Spec: capsulev1beta2.TenantResourceSpec{ + ResyncPeriod: metav1.Duration{Duration: time.Minute}, + PruningOnDelete: ptr.To(true), + Resources: []capsulev1beta2.ResourceSpec{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "replicate": "tenantresource-imp", + }, + }, + NamespacedItems: []capsulev1beta2.ObjectReference{ + { + ObjectReferenceAbstract: capsulev1beta2.ObjectReferenceAbstract{ + Kind: "Secret", + Namespace: "tenantresource-imp-system", + APIVersion: "v1", + }, + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "replicate": "true", + }, + }, + }, + }, + RawItems: []capsulev1beta2.RawExtension{ + { + RawExtension: runtime.RawExtension{ + Object: &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "raw-secret-1", + Labels: testLabels, + Annotations: testAnnotations, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "{{ tenant.name }}": []byte("Cg=="), + "{{ namespace }}": []byte("Cg=="), + }, + }, + }, + }, + { + RawExtension: runtime.RawExtension{ + Object: &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "raw-secret-2", + Labels: testLabels, + Annotations: testAnnotations, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "{{ tenant.name }}": []byte("Cg=="), + "{{ namespace }}": []byte("Cg=="), + }, + }, + }, + }, + { + RawExtension: runtime.RawExtension{ + Object: &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "raw-secret-3", + Labels: testLabels, + Annotations: testAnnotations, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "{{ tenant.name }}": []byte("Cg=="), + "{{ namespace }}": []byte("Cg=="), + }, + }, + }, + }, + }, + AdditionalMetadata: &api.AdditionalMetadataSpec{ + Labels: map[string]string{ + "labels.energy.io": "replicate", + }, + Annotations: map[string]string{ + "annotations.energy.io": "replicate", + }, + }, + }, + }, + }, + } + + solarNs := []string{"tenantresource-imp-one", "tenantresource-imp-two", "tenantresource-imp-three"} + + JustBeforeEach(func() { + 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()) + }) + + 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()) + + 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("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()) + } + + // 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") + } + labels["replicate"] = "tenantresource-imp" + ns.SetLabels(labels) + + return k8sClient.Update(context.TODO(), &ns) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + }) + + By("creating the namespaced item", func() { + EventuallyCreation(func() error { + return k8sClient.Create(context.TODO(), tntItem) + }).Should(Succeed()) + }) + + By("verifying ServiceAccount Names", func() { + t := tr.DeepCopy() + + EventuallyCreation(func() error { + t.Spec.ServiceAccount = &api.ServiceAccountReference{ + Name: "", + Namespace: "kube-system:", + } + return 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: "", + Namespace: "kube-system", + } + return k8sClient.Update(context.TODO(), t) + }).Should(Succeed()) + k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(t), t) + Expect(t.Spec.ServiceAccount).To(BeNil()) + + 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.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) + }).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)) + + 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("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(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 t.Status.ProcessedItems { + if status.Kind == "Secret" && + status.Name == name && + status.Namespace == ns && + status.Type == meta.ReplicationCondition && + status.Status == metav1.ConditionTrue { + 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 { + 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 + } + + 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()) + + 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() { + t := tr.DeepCopy() + + t.Spec.Resources[0].NamespaceSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": "tenantresource-imp-three", + }, + } + + 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 { + 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 + } + + return secrets.Items + } + } + + for _, ns := range []string{"tenantresource-imp-one", "tenantresource-imp-two", "tenantresource-imp-three"} { + Eventually(checkFn(ns), defaultTimeoutInterval, defaultPollInterval).Should(HaveLen(0)) + } + }) + + 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: "tenantresource-imp-three"}, &secret)).ToNot(HaveOccurred()) + + for k, v := range tr.Spec.Resources[0].AdditionalMetadata.Labels { + _, err := HaveKeyWithValue(k, v).Match(secret.GetLabels()) + Expect(err).ToNot(HaveOccurred()) + } + for k, v := range testLabels { + _, err := HaveKeyWithValue(k, v).Match(secret.GetLabels()) + Expect(err).ToNot(HaveOccurred()) + } + for k, v := range tr.Spec.Resources[0].AdditionalMetadata.Annotations { + _, err := HaveKeyWithValue(k, v).Match(secret.GetAnnotations()) + Expect(err).ToNot(HaveOccurred()) + } + for k, v := range testAnnotations { + _, err := HaveKeyWithValue(k, v).Match(secret.GetAnnotations()) + Expect(err).ToNot(HaveOccurred()) + } + } + }) + + By("checking replicated object cannot be deleted by a Tenant Owner", func() { + for _, name := range []string{"dummy-secret", "raw-secret-1", "raw-secret-2", "raw-secret-3"} { + cs := ownerClient(solar.Spec.Owners[0]) + + Consistently(func() error { + return cs.CoreV1().Secrets("tenantresource-imp-three").Delete(context.TODO(), name, metav1.DeleteOptions{}) + }, 10*time.Second, time.Second).Should(HaveOccurred()) + } + }) + + By("checking replicated object cannot be update by a Tenant Owner", func() { + for _, name := range []string{"dummy-secret", "raw-secret-1", "raw-secret-2", "raw-secret-3"} { + cs := ownerClient(solar.Spec.Owners[0]) + + Consistently(func() error { + secret, err := cs.CoreV1().Secrets("tenantresource-imp-three").Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return err + } + + secret.SetLabels(nil) + secret.SetAnnotations(nil) + + _, err = cs.CoreV1().Secrets("tenantresource-imp-three").Update(context.TODO(), secret, metav1.UpdateOptions{}) + + return err + }, 10*time.Second, time.Second).Should(HaveOccurred()) + } + }) + + By("checking that cross-namespace objects are not replicated", func() { + 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, + Namespace: crossNamespaceItem.GetName(), + APIVersion: crossNamespaceItem.APIVersion, + }, + Selector: metav1.LabelSelector{ + MatchLabels: crossNamespaceItem.GetLabels(), + }, + }) + + Expect(k8sClient.Update(context.TODO(), tr)).ToNot(HaveOccurred()) + // Ensuring that although the deletion of TenantResource object, + // the replicated objects are not deleted. + Consistently(func() error { + return k8sClient.Get(context.TODO(), types.NamespacedName{Namespace: solarNs[rand.Intn(len(solarNs))], Name: crossNamespaceItem.GetName()}, &corev1.Secret{}) + }, 10*time.Second, time.Second).Should(HaveOccurred()) + }) + + By("checking pruning is deleted", func() { + 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) + + Expect(k8sClient.Update(context.TODO(), tr)).ToNot(HaveOccurred()) + + By("deleting the TenantResource", func() { + // Ensuring that although the deletion of TenantResource object, + // the replicated objects are not deleted. + Expect(k8sClient.Delete(context.TODO(), tr)).Should(Succeed()) + + r, err := labels.NewRequirement("labels.energy.io", selection.DoubleEquals, []string{"replicate"}) + Expect(err).ToNot(HaveOccurred()) + + Consistently(func() []corev1.Secret { + secrets := corev1.SecretList{} + + err = k8sClient.List(context.TODO(), &secrets, &client.ListOptions{LabelSelector: labels.NewSelector().Add(*r), Namespace: "tenantresource-imp-three"}) + Expect(err).ToNot(HaveOccurred()) + + return secrets.Items + }, 10*time.Second, time.Second).Should(HaveLen(4)) + }) + }) + }) +}) diff --git a/e2e/tenantresource_test.go b/e2e/tenantresource_test.go index c24f57de6..f92bcac55 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", @@ -226,6 +226,13 @@ var _ = Describe("Creating a TenantResource object", Label("tenantresource"), fu }).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 { diff --git a/global-scope.yaml b/global-scope.yaml new file mode 100644 index 000000000..e8b09db60 --- /dev/null +++ b/global-scope.yaml @@ -0,0 +1,56 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: global-scope +spec: + # New + scope: Tenant + #force: false + resyncPeriod: 5s + #serviceaccount: + # name: capsule + # namespace: capsule-system + resources: + - #additionalMetadata: + # labels: + # "replicated-by": "capsule" + 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 + + generators: + - template: | + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: test-demo-2 + namespace: default + data: + {{ .Tenant.ObjectMeta.Name }}.conf: | + 1 diff --git a/go.mod b/go.mod index 4ac93527d..d73d1c35d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/projectcapsule/capsule -go 1.24.0 +go 1.25.0 toolchain go1.25.3 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.26.0 github.com/onsi/gomega v1.38.2 @@ -16,6 +18,7 @@ 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 @@ -28,70 +31,115 @@ 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.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 github.com/google/btree v1.1.3 // 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-20250403155104-27863c87afa6 // 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/mailru/easyjson v0.9.0 // 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.66.1 // 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 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/mod v0.27.0 // 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/mod v0.28.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/protobuf v1.36.9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // 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/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 c6180fce9..02f7c5409 100644 --- a/go.sum +++ b/go.sum @@ -2,17 +2,24 @@ 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/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= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 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= @@ -21,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= @@ -37,12 +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= @@ -51,32 +71,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= @@ -94,12 +142,16 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= +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/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +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= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -118,20 +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= @@ -140,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= @@ -153,16 +221,23 @@ 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= 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= 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.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= @@ -173,35 +248,51 @@ 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= 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.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +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.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -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.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +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.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -212,8 +303,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -221,77 +310,82 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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/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= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 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/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +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= 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/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/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= @@ -302,37 +396,28 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= -k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= -k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= -k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= 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.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= -k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= -k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= -k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +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= k8s.io/cluster-bootstrap v0.33.3/go.mod h1:p970f8u8jf273zyQ5raD8WUu2XyAl0SAWOY82o7i/ds= -k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= -k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= +k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= 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-20250902184714-7fc278399c7f h1:wyRlmLgBSXi3kgawro8klrMRljXeRo1HFkQRs+meYfs= -k8s.io/kube-openapi v0.0.0-20250902184714-7fc278399c7f/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= 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= @@ -355,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 new file mode 100644 index 000000000..0de88963f --- /dev/null +++ b/pkg/api/context.go @@ -0,0 +1,182 @@ +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{}, + 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 context, []error{fmt.Errorf("cloud not template: %w", err)} + } + } + + // Load external Resources + for index, resource := range t.Resources { + val, err := resource.LoadResources(ctx, kubeClient, namespace) + if err != nil { + errors = append(errors, err) + + continue + } + + if len(val) > 0 { + resourceIndex := resource.Index + if resourceIndex == "" { + resourceIndex = string(index) + } + + 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,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"` + // 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"` + // 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"` +} + +// Load Resources for the template context from the cluster +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") + + // 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}) + } + + // 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/ignore.go b/pkg/api/ignore.go new file mode 100644 index 000000000..da1a76e05 --- /dev/null +++ b/pkg/api/ignore.go @@ -0,0 +1,42 @@ +package api + +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 { + // 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"` +} + +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/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/api/serviceaccount_config.go b/pkg/api/serviceaccount_config.go new file mode 100644 index 000000000..28a27f7b5 --- /dev/null +++ b/pkg/api/serviceaccount_config.go @@ -0,0 +1,32 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package api + +// +kubebuilder:object:generate=true +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 global resources (GlobalTenantResource) + // When defined, users are required to use this ServiceAccount anywhere in the cluster + // unless they explicitly provide their own. + 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 Name `json:"tenantDefaultServiceAccount,omitempty"` +} diff --git a/pkg/api/serviceaccount_reference.go b/pkg/api/serviceaccount_reference.go new file mode 100644 index 000000000..87f1c4942 --- /dev/null +++ b/pkg/api/serviceaccount_reference.go @@ -0,0 +1,35 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "fmt" + + "k8s.io/apiserver/pkg/authentication/serviceaccount" + "k8s.io/apiserver/pkg/authentication/user" +) + +// +kubebuilder:object:generate=true +type ServiceAccountReference struct { + // ServiceAccount Name Reference + Name Name `json:"name,omitempty"` + // ServiceAccount Namespace Reference + Namespace Name `json:"namespace,omitempty"` +} + +// GetMatchingNamespaces retrieves the list of namespaces that match the NamespaceSelector. +func (s *ServiceAccountReference) GetFullName() string { + return fmt.Sprintf("%s%s:%s", serviceaccount.ServiceAccountUsernamePrefix, s.Namespace, s.Name) +} + +func (s *ServiceAccountReference) GetAttributes() (name string, namespace string, groups []string, err error) { + namespace, name, err = serviceaccount.SplitUsername(s.GetFullName()) + if err == nil { + groups = append(groups, fmt.Sprintf("%s%s", serviceaccount.ServiceAccountGroupPrefix, namespace)) + groups = append(groups, serviceaccount.AllServiceAccountsGroup) + groups = append(groups, user.AllAuthenticated) + } + + return +} diff --git a/pkg/api/serviceaccount_reference_test.go b/pkg/api/serviceaccount_reference_test.go new file mode 100644 index 000000000..be4068e07 --- /dev/null +++ b/pkg/api/serviceaccount_reference_test.go @@ -0,0 +1,49 @@ +package api + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apiserver/pkg/authentication/serviceaccount" + "k8s.io/apiserver/pkg/authentication/user" +) + +func TestServiceAccountReference_GetFullName(t *testing.T) { + ref := ServiceAccountReference{ + Name: Name("my-sa"), + Namespace: Name("my-ns"), + } + + expected := fmt.Sprintf("%smy-ns:my-sa", serviceaccount.ServiceAccountUsernamePrefix) + assert.Equal(t, expected, ref.GetFullName()) +} + +func TestServiceAccountReference_GetAttributes_Success(t *testing.T) { + ref := ServiceAccountReference{ + Name: Name("my-sa"), + Namespace: Name("my-ns"), + } + + name, namespace, groups, err := ref.GetAttributes() + assert.NoError(t, err) + assert.Equal(t, "my-sa", name) + assert.Equal(t, "my-ns", namespace) + assert.Contains(t, groups, serviceaccount.ServiceAccountGroupPrefix+"my-ns") + assert.Contains(t, groups, serviceaccount.AllServiceAccountsGroup) + assert.Contains(t, groups, user.AllAuthenticated) +} + +func TestServiceAccountReference_GetAttributes_Invalid(t *testing.T) { + // Invalid because name or namespace is empty + ref := ServiceAccountReference{ + Name: Name(""), + Namespace: Name(""), + } + + name, namespace, groups, err := ref.GetAttributes() + assert.Error(t, err) + assert.Empty(t, name) + assert.Empty(t, namespace) + assert.Empty(t, groups) +} diff --git a/pkg/api/status.go b/pkg/api/status.go index 1015e1faa..3b243974e 100644 --- a/pkg/api/status.go +++ b/pkg/api/status.go @@ -5,6 +5,18 @@ package api import k8stypes "k8s.io/apimachinery/pkg/types" +const ( + ResourceScopeNamespace ResourceScope = "Namespace" + ResourceScopeTenant ResourceScope = "Tenant" +) + +// +kubebuilder:validation:Enum=Namespace;Tenant +type ResourceScope string + +func (p ResourceScope) String() string { + return string(p) +} + // Name must be unique within a namespace. Is required when creating resources, although // some resources may allow a client to request the generation of an appropriate name // automatically. Name is primarily intended for creation idempotence and configuration @@ -23,9 +35,10 @@ func (n Name) String() string { type StatusNameUID struct { // UID of the tracked Tenant to pin point tracking k8stypes.UID `json:"uid,omitempty" protobuf:"bytes,5,opt,name=uid"` - - // Name + // Name of The Resource Name Name `json:"name,omitempty"` - // Namespace + // Namespace of The Resource Namespace Name `json:"namespace,omitempty"` + // Scope of The Resource + Scope ResourceScope `json:"scope,omitempty"` } diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 811c84d36..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 @@ -340,6 +366,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 @@ -389,6 +435,36 @@ func (in *SelectorAllowedListSpec) DeepCopy() *SelectorAllowedListSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountClient) DeepCopyInto(out *ServiceAccountClient) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountClient. +func (in *ServiceAccountClient) DeepCopy() *ServiceAccountClient { + if in == nil { + return nil + } + out := new(ServiceAccountClient) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountReference) DeepCopyInto(out *ServiceAccountReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountReference. +func (in *ServiceAccountReference) DeepCopy() *ServiceAccountReference { + if in == nil { + return nil + } + out := new(ServiceAccountReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOptions) DeepCopyInto(out *ServiceOptions) { *out = *in @@ -420,3 +496,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/configuration/client.go b/pkg/configuration/client.go index 482115289..dc910f0ac 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -5,43 +5,59 @@ package configuration import ( "context" + "fmt" + "os" "regexp" "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" "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 // 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{} +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) { + config = &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: capsulev1beta2.CapsuleConfigurationSpec{ + UserGroups: []string{"projectcapsule.dev"}, + ForceTenantPrefix: false, + ProtectedNamespaceRegexpString: "", + }, + } + + _ = client.Create(ctx, config) + + return config - 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) { @@ -98,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 } @@ -106,10 +122,52 @@ 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 } return &c.retrievalFn().Spec.NodeMetadata.ForbiddenAnnotations } + +func (c *capsuleConfiguration) ServiceAccountClientProperties() *api.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 0f109f19c..796fb6a86 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -4,9 +4,12 @@ package configuration import ( + "context" "regexp" - capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "k8s.io/client-go/rest" + + "github.com/projectcapsule/capsule/pkg/api" ) const ( @@ -27,6 +30,8 @@ type Configuration interface { UserNames() []string UserGroups() []string IgnoreUserWithGroups() []string - ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec - ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec + ForbiddenUserNodeLabels() *api.ForbiddenListSpec + ForbiddenUserNodeAnnotations() *api.ForbiddenListSpec + ServiceAccountClientProperties() *api.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/pkg/errors/tenantresources.go b/pkg/errors/tenantresources.go new file mode 100644 index 000000000..f1e8a22c0 --- /dev/null +++ b/pkg/errors/tenantresources.go @@ -0,0 +1,13 @@ +package errors + +type ItemProcessingError struct { + Err error +} + +func (e *ItemProcessingError) Error() string { + return e.Err.Error() +} + +func (e *ItemProcessingError) Unwrap() error { + return e.Err +} diff --git a/pkg/meta/labels.go b/pkg/meta/labels.go index 310d55b72..25e89eed4 100644 --- a/pkg/meta/labels.go +++ b/pkg/meta/labels.go @@ -10,6 +10,8 @@ import ( ) const ( + ResourcesLabel = "capsule.clastix.io/resources" + FreezeLabel = "projectcapsule.dev/freeze" FreezeLabelTrigger = "true" @@ -19,7 +21,12 @@ const ( CordonedLabel = "projectcapsule.dev/cordoned" CordonedLabelTrigger = "true" - ManagedByCapsuleLabel = "capsule.clastix.io/managed-by" + 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/meta/ownerreference.go b/pkg/meta/ownerreference.go index 6a6e27345..5e507d19b 100644 --- a/pkg/meta/ownerreference.go +++ b/pkg/meta/ownerreference.go @@ -54,7 +54,6 @@ func RemoveLooseOwnerReference( obj.SetOwnerReferences(refs) } -// If not returns false. func HasLooseOwnerReference( obj client.Object, owner client.Object, 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/metrics/globaltenantresource_recorder.go b/pkg/metrics/globaltenantresource_recorder.go new file mode 100644 index 000000000..d2f421e05 --- /dev/null +++ b/pkg/metrics/globaltenantresource_recorder.go @@ -0,0 +1,84 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +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 { + resourceConditionGauge *prometheus.GaugeVec +} + +func MustMakeGlobalTenantResourceRecorder() *GlobalTenantResourceRecorder { + metricsRecorder := NewGlobalTenantResourceRecorder() + crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...) + + return metricsRecorder +} + +func NewGlobalTenantResourceRecorder() *GlobalTenantResourceRecorder { + return &GlobalTenantResourceRecorder{ + resourceConditionGauge: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricsPrefix, + Name: "global_resource_condition", + Help: "The current condition status of a global tenant resource.", + }, + []string{"name", "condition"}, + ), + } +} + +func (r *GlobalTenantResourceRecorder) Collectors() []prometheus.Collector { + return []prometheus.Collector{ + r.resourceConditionGauge, + } +} + +func (r *GlobalTenantResourceRecorder) RecordConditions(resource *capsulev1beta2.GlobalTenantResource) { + for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { + var value float64 + + 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.GetName(), 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 new file mode 100644 index 000000000..6db3ee43e --- /dev/null +++ b/pkg/metrics/tenantresource_recorder.go @@ -0,0 +1,88 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +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 { + resourceConditionGauge *prometheus.GaugeVec +} + +func MustMakeTenantResourceRecorder() *TenantResourceRecorder { + metricsRecorder := NewTenantResourceRecorder() + crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...) + + return metricsRecorder +} + +func NewTenantResourceRecorder() *TenantResourceRecorder { + return &TenantResourceRecorder{ + resourceConditionGauge: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricsPrefix, + Name: "resource_condition", + Help: "The current condition status of a tenant resource.", + }, + []string{"name", "target_namespace", "condition"}, + ), + } +} + +func (r *TenantResourceRecorder) Collectors() []prometheus.Collector { + return []prometheus.Collector{ + r.resourceConditionGauge, + } +} + +// RecordCondition records the condition as given for the ref. +func (r *TenantResourceRecorder) RecordConditions(resource *capsulev1beta2.TenantResource) { + for _, status := range []string{meta.ReadyCondition, meta.CordonedCondition} { + var value float64 + + 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.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/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/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/serviceaccount.go b/pkg/utils/serviceaccount.go new file mode 100644 index 000000000..3de77f283 --- /dev/null +++ b/pkg/utils/serviceaccount.go @@ -0,0 +1,48 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule/pkg/api" +) + +// Returns a namespaced serviceaccount name +func SanitizeServiceAccountProp(name string) string { + parts := strings.Split(name, ":") + if len(parts) == 1 { + return name + } + + return parts[len(parts)-1] +} + +// ImpersonatedKubernetesClientForServiceAccount returns a controller-runtime client.Client that impersonates a given ServiceAccount. +func ImpersonatedKubernetesClientForServiceAccount( + base *rest.Config, + scheme *runtime.Scheme, + reference *api.ServiceAccountReference, +) (client.Client, error) { + _, _, groups, err := reference.GetAttributes() + if err != nil { + return nil, fmt.Errorf("failed to get service account groups: %w", err) + } + + impersonated := rest.CopyConfig(base) + impersonated.Impersonate.UserName = reference.GetFullName() + impersonated.Impersonate.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/utils/serviceaccount_test.go b/pkg/utils/serviceaccount_test.go new file mode 100644 index 000000000..c3012e34f --- /dev/null +++ b/pkg/utils/serviceaccount_test.go @@ -0,0 +1,65 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/stretchr/testify/assert" +) + +func TestSanitizeServiceAccountProp(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"account", "account"}, + {"namespace:account", "account"}, + {"a:b:c:d:e:f:g", "g"}, + {":account", "account"}, + {"account:", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + actual := SanitizeServiceAccountProp(tt.input) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestImpersonatedKubernetesClientForServiceAccount(t *testing.T) { + reference := &api.ServiceAccountReference{ + Name: "account", + Namespace: "namespace", + } + + base := &rest.Config{} + scheme := runtime.NewScheme() + + client, err := ImpersonatedKubernetesClientForServiceAccount(base, scheme, reference) + assert.NoError(t, err) + assert.NotNil(t, client) + + // You can optionally cast and verify fields if needed + impersonated := rest.CopyConfig(base) + impersonated.Impersonate.UserName = reference.GetFullName() + impersonated.Impersonate.Groups = []string{ + "system:serviceaccounts:namespace", + "system:serviceaccounts", + "system:authenticated", + } + + assert.Equal(t, impersonated.Impersonate.UserName, reference.GetFullName()) + assert.ElementsMatch(t, impersonated.Impersonate.Groups, []string{ + "system:serviceaccounts:namespace", + "system:serviceaccounts", + "system:authenticated", + }) +} 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/route/tenantresource.go b/pkg/webhook/route/tenantresource.go new file mode 100644 index 000000000..1b2eaf4e1 --- /dev/null +++ b/pkg/webhook/route/tenantresource.go @@ -0,0 +1,56 @@ +// 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 +} + +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 +} diff --git a/pkg/webhook/tenantresource/namespaced_mutating.go b/pkg/webhook/tenantresource/namespaced_mutating.go new file mode 100644 index 000000000..edd767326 --- /dev/null +++ b/pkg/webhook/tenantresource/namespaced_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 namespacedMutatingHandler struct { + configuration configuration.Configuration +} + +func NamespacedMutatingHandler(configuration configuration.Configuration) capsulewebhook.Handler { + return &namespacedMutatingHandler{ + configuration: configuration, + } +} + +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 := resources.SetTenantResourceServiceAccount(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 +} diff --git a/pkg/webhook/tenantresource/objects.go b/pkg/webhook/tenantresource/objects_validating.go similarity index 71% rename from pkg/webhook/tenantresource/objects.go rename to pkg/webhook/tenantresource/objects_validating.go index 1040b00fe..71206ad8b 100644 --- a/pkg/webhook/tenantresource/objects.go +++ b/pkg/webhook/tenantresource/objects_validating.go @@ -1,7 +1,7 @@ // Copyright 2020-2025 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -package tenant +package tenantresource import ( "context" @@ -15,36 +15,37 @@ 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" ) -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 { @@ -57,12 +58,13 @@ func (h *cordoningHandler) handler(ctx context.Context, clt client.Client, req a } // 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 diff --git a/test.yaml b/test.yaml new file mode 100644 index 000000000..53514e160 --- /dev/null +++ b/test.yaml @@ -0,0 +1,17 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: renewable-pull-secrets +spec: + tenantSelector: {} + resyncPeriod: 5s + scope: Tenant + resources: + - additionalMetadata: + labels: + "replicated-by": "capsule" + rawItems: + - apiVersion: v1 + kind: Namespace + metadata: + name: "meow-system" diff --git a/tmp/claims.yaml b/tmp/claims.yaml new file mode 100644 index 000000000..88fbac59e --- /dev/null +++ b/tmp/claims.yaml @@ -0,0 +1,23 @@ +--- +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..81804dfe7 --- /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..416fbbfea --- /dev/null +++ b/tmp/ppools.yaml @@ -0,0 +1,32 @@ +--- +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/resource.yaml b/tmp/resource.yaml new file mode 100644 index 000000000..75e18679f --- /dev/null +++ b/tmp/resource.yaml @@ -0,0 +1,20 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantResource +metadata: + name: solar-db + namespace: solar-system +spec: + resyncPeriod: 60s + resources: + - additionalMetadata: + labels: + "replicated-by": "capsule" + rawItems: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: game-demo + data: + # property-like keys; each key maps to a simple value + player_initial_lives: "3" + ui_properties_file_name: "user-interface.properties" \ No newline at end of file diff --git a/tmp/tnt.yaml b/tmp/tnt.yaml new file mode 100644 index 000000000..38f7e512e --- /dev/null +++ b/tmp/tnt.yaml @@ -0,0 +1,24 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + labels: + kubernetes.io/metadata.name: solar + name: solar +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 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 +