diff --git a/Makefile b/Makefile index 37c4e6ea..07a28f3b 100644 --- a/Makefile +++ b/Makefile @@ -151,7 +151,7 @@ dev-setup: --create-namespace \ --set 'crds.install=true' \ --set 'crds.exclusive=true'\ - --set 'crds.createConfig=true'\ + --set 'crds.createConfig=true'\ --set "webhooks.exclusive=true"\ --set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \ diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 65d2c686..ff58721b 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 163a9d75..70ebeb49 100644 --- a/api/v1beta2/tenantresource_global.go +++ b/api/v1beta2/tenantresource_global.go @@ -6,14 +6,23 @@ 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 + // - Cluster: Just once per cluster + // - Tenant: Create Resources for each tenant in selected Tenants + // - Namespace: Create Resources for each namespace in selected Tenants + // +kubebuilder:default:=Namespace + 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,6 +31,31 @@ type GlobalTenantResourceStatus struct { SelectedTenants []string `json:"selectedTenants"` // List of the replicated resources for the given TenantResource. ProcessedItems ProcessedItems `json:"processedItems"` + // Condition of the GlobalTenantResource. + Condition api.Condition `json:"condition,omitempty"` +} + +func (p *GlobalTenantResource) SetCondition() { + failures := 0 + + for _, item := range p.Status.ProcessedItems { + if item.Status != metav1.ConditionTrue { + failures++ + } + } + + cond := meta.NewReadyCondition(p) + if failures > 0 { + cond.Status = metav1.ConditionFalse + cond.Reason = meta.FailedReason + cond.Message = "Reconcile completed with errors" + } else { + cond.Status = metav1.ConditionTrue + cond.Reason = meta.SucceededReason + cond.Message = "Reconcile completed successfully" + } + + p.Status.Condition.UpdateCondition(cond) } type ProcessedItems []ObjectReferenceStatus @@ -39,6 +73,9 @@ func (p *ProcessedItems) AsSet() sets.Set[string] { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.condition.type",description="Status for claim" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.condition.reason",description="Reason for status" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // 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 e3a22858..9bd9e5b8 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. @@ -22,6 +23,9 @@ type TenantResourceSpec struct { PruningOnDelete *bool `json:"pruningOnDelete,omitempty"` // Defines the rules to select targeting Namespace, along with the objects that must be replicated. Resources []ResourceSpec `json:"resources"` + // Local ServiceAccount which will perform all the actions defined in the TenantResource + // You must provide permissions accordingly to that ServiceAccount + ServiceAccount *api.ServiceAccountReference `json:"serviceAccount,omitempty"` } type ResourceSpec struct { @@ -47,10 +51,38 @@ type RawExtension struct { type TenantResourceStatus struct { // List of the replicated resources for the given TenantResource. ProcessedItems ProcessedItems `json:"processedItems"` + // Condition of the TenantResource. + Condition api.Condition `json:"condition,omitempty"` +} + +func (p *TenantResource) SetCondition() { + failures := 0 + + for _, item := range p.Status.ProcessedItems { + if item.Status != metav1.ConditionTrue { + failures++ + } + } + + cond := meta.NewReadyCondition(p) + if failures > 0 { + cond.Status = metav1.ConditionFalse + cond.Reason = meta.FailedReason + cond.Message = "Reconcile completed with errors" + } else { + cond.Status = metav1.ConditionTrue + cond.Reason = meta.SucceededReason + cond.Message = "Reconcile completed successfully" + } + + p.Status.Condition.UpdateCondition(cond) } // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.condition.type",description="Status for claim" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.condition.reason",description="Reason for status" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" // 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 8803c1ec..112c0cd4 100644 --- a/api/v1beta2/tenantresource_types.go +++ b/api/v1beta2/tenantresource_types.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + "github.com/projectcapsule/capsule/pkg/api" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -27,6 +28,29 @@ type ObjectReferenceStatus struct { // Name of the referent. // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names Name string `json:"name"` + // Tenant of the referent. + Tenant string `json:"tenant,omitempty"` + // 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"` + // Resource Scope + Scope api.ResourceScope `json:"scope,omitempty"` } type ObjectReference struct { @@ -37,13 +61,15 @@ type ObjectReference struct { } 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,APIVersion=%s,Tenant=%s,Namespace=%s,Name=%s,Status=%s,Message=%s,Type=%s", + in.Kind, in.APIVersion, in.Tenant, in.Namespace, in.Name, in.Status, in.Message, in.Type) } func (in *ObjectReferenceStatus) ParseFromString(value string) error { rawParts := strings.Split(value, ",") - if len(rawParts) != 4 { + if len(rawParts) != 8 { return fmt.Errorf("unexpected raw parts") } @@ -61,10 +87,26 @@ func (in *ObjectReferenceStatus) ParseFromString(value string) error { in.Kind = v case "APIVersion": in.APIVersion = v + case "Tenant": + in.Tenant = v case "Namespace": in.Namespace = v case "Name": in.Name = v + case "Status": + switch metav1.ConditionStatus(v) { + case metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown: + in.Status = metav1.ConditionStatus(v) + default: + return fmt.Errorf("invalid status value: %q", v) + } + case "Message": + in.Message = v + case "Type": + in.Type = v + case "Scope": + in.Scope = api.ResourceScope(v) + default: return fmt.Errorf("unrecognized marker: %s", k) } diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 6eb84e92..8682fed5 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -138,6 +138,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. @@ -247,8 +252,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. @@ -274,6 +279,7 @@ func (in *GlobalTenantResourceStatus) DeepCopyInto(out *GlobalTenantResourceStat *out = make(ProcessedItems, len(*in)) copy(*out, *in) } + in.Condition.DeepCopyInto(&out.Condition) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GlobalTenantResourceStatus. @@ -1089,6 +1095,11 @@ 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 + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceSpec. @@ -1109,6 +1120,7 @@ func (in *TenantResourceStatus) DeepCopyInto(out *TenantResourceStatus) { *out = make(ProcessedItems, len(*in)) copy(*out, *in) } + in.Condition.DeepCopyInto(&out.Condition) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TenantResourceStatus. diff --git a/charts/capsule/ci/proxy-values.yaml b/charts/capsule/ci/proxy-values.yaml index 33b580d1..465ea974 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 2046d1ee..58672c43 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 d74c9f00..3010d71e 100644 --- a/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_globaltenantresources.yaml @@ -14,7 +14,19 @@ spec: singular: globaltenantresource scope: Cluster versions: - - name: v1beta2 + - additionalPrinterColumns: + - description: Status for claim + jsonPath: .status.condition.type + name: Status + type: string + - description: Reason for status + jsonPath: .status.condition.reason + name: Reason + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta2 schema: openAPIV3Schema: description: GlobalTenantResource allows to propagate resource replications @@ -199,6 +211,33 @@ spec: Define the period of time upon a second reconciliation must be invoked. Keep in mind that any change to the manifests will trigger a new reconciliation. type: string + scope: + default: Namespace + description: |- + Resource Scope, Can either be + - Cluster: Just once per cluster + - Tenant: Create Resources for each tenant in selected Tenants + - Namespace: Create Resources for each namespace in selected Tenants + enum: + - Namespace + - Tenant + type: string + serviceAccount: + description: |- + Local ServiceAccount which will perform all the actions defined in the TenantResource + 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,11 +288,65 @@ spec: required: - resources - resyncPeriod + - scope type: object status: description: GlobalTenantResourceStatus defines the observed state of GlobalTenantResource. properties: + condition: + description: Condition of the GlobalTenantResource. + 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 processedItems: description: List of the replicated resources for the given TenantResource. items: @@ -266,6 +359,12 @@ spec: Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 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 name: description: |- Name of the referent. @@ -276,10 +375,35 @@ spec: Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ type: string + scope: + description: Resource Scope + enum: + - Namespace + - Tenant + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + tenant: + description: |- + Tenant of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + 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: - 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 dbfef905..caadc802 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 65368c02..e2b5daa6 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 51009469..06de7965 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantresources.yaml @@ -14,7 +14,19 @@ spec: singular: tenantresource scope: Namespaced versions: - - name: v1beta2 + - additionalPrinterColumns: + - description: Status for claim + jsonPath: .status.condition.type + name: Status + type: string + - description: Reason for status + jsonPath: .status.condition.reason + name: Reason + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta2 schema: openAPIV3Schema: description: |- @@ -201,6 +213,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,6 +236,59 @@ spec: status: description: TenantResourceStatus defines the observed state of TenantResource. properties: + condition: + description: Condition of the TenantResource. + 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 processedItems: description: List of the replicated resources for the given TenantResource. items: @@ -220,6 +301,12 @@ spec: Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds 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 name: description: |- Name of the referent. @@ -230,10 +317,35 @@ spec: Namespace of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ type: string + scope: + description: Resource Scope + enum: + - Namespace + - Tenant + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + tenant: + description: |- + Tenant of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/ + 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: - kind - name - namespace + - status + - type type: object type: array required: diff --git a/charts/capsule/templates/configuration-default.yaml b/charts/capsule/templates/configuration-default.yaml index 792257d9..41969ec3 100644 --- a/charts/capsule/templates/configuration-default.yaml +++ b/charts/capsule/templates/configuration-default.yaml @@ -16,6 +16,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 }} @@ -29,3 +38,11 @@ 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/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml index d0ce8a85..76f01ddf 100644 --- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml @@ -274,4 +274,4 @@ webhooks: timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} {{- end }} {{- end }} -{{- end }} +{{- end }} \ No newline at end of file diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml index 06ba9241..67932333 100644 --- a/charts/capsule/templates/validatingwebhookconfiguration.yaml +++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml @@ -345,7 +345,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 bfc44812..e766f61a 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -66,7 +66,7 @@ crds: # -- Only install the CRDs, no other primitives exclusive: false # -- Create additionally CapsuleConfiguration even if CRDs are exclusive - createConfig: false + createConfig: false # -- Extra Labels for CRDs labels: {} # -- Extra Annotations for CRDs @@ -187,6 +187,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: diff --git a/cmd/main.go b/cmd/main.go index 786541fe..b1d0e89d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -157,7 +157,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(), @@ -168,7 +168,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{ @@ -232,7 +232,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.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()), route.Cordoning(tenant.CordoningHandler(cfg)), @@ -304,13 +306,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/controllers/config/manager.go b/controllers/config/manager.go index eee0bc95..445a676f 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 f3c464c0..fadc0d15 100644 --- a/controllers/resources/global.go +++ b/controllers/resources/global.go @@ -6,7 +6,9 @@ package resources import ( "context" "errors" + "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" @@ -15,21 +17,30 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/configuration" + "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() r.processor = Processor{ client: mgr.GetClient(), @@ -38,20 +49,62 @@ func (r *Global) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&capsulev1beta2.GlobalTenantResource{}). Watches(&capsulev1beta2.Tenant{}, handler.EnqueueRequestsFromMapFunc(r.enqueueRequestFromTenant)). + Watches( + &capsulev1beta2.CapsuleConfiguration{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { + var list capsulev1beta2.GlobalTenantResourceList + if err := r.client.List(ctx, &list); err != nil { + r.log.Error(err, "unable to list GlobalTenantResources") + + return nil + } + + var requests []reconcile.Request + for _, s := range list.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: s.Name, + Namespace: s.Namespace, + }, + }) + } + + return requests + }), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldObj, okOld := e.ObjectOld.(*capsulev1beta2.CapsuleConfiguration) + newObj, okNew := e.ObjectNew.(*capsulev1beta2.CapsuleConfiguration) + if !okOld || !okNew { + return false + } + + return !reflect.DeepEqual(oldObj.Spec.ServiceAccountClient, newObj.Spec.ServiceAccountClient) + }, + DeleteFunc: func(event.DeleteEvent) bool { + return false + }, + }), + ). Complete(r) } -func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { +func (r *globalResourceController) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { var err error log := ctrllog.FromContext(ctx) - 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 +118,9 @@ func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reco } defer func() { + r.metrics.RecordCondition(tntResource) + tntResource.SetCondition() + if e := patchHelper.Patch(ctx, tntResource); e != nil { if err == nil { err = gherrors.Wrap(e, "failed to patch GlobalTenantResource") @@ -72,16 +128,27 @@ func (r *Global) Reconcile(ctx context.Context, request reconcile.Request) (reco } }() + 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 +182,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, +) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { @@ -134,6 +205,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") @@ -148,6 +220,22 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta // the Status can be updated only in case of no errors across all of them to guarantee a valid and coherent status. processedItems := sets.NewString() + // Always post the processed items, as they allow users to track errors + defer func() { + tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + + for _, item := range processedItems.List() { + or := capsulev1beta2.ObjectReferenceStatus{} + if err := or.ParseFromString(item); err == nil { + tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + } else { + log.Error(err, "failed to parse processed item", "item", item) + } + } + }() + + var itemErrors error + for index, resource := range tntResource.Spec.Resources { tenantLabel, labelErr := capsulev1beta2.GetTypeLabel(&capsulev1beta2.Tenant{}) if labelErr != nil { @@ -159,14 +247,16 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta for _, tnt := range tntList.Items { tntSet.Insert(tnt.GetName()) - items, sectionErr := r.processor.HandleSection(ctx, tnt, true, tenantLabel, index, resource) + items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tnt, true, tenantLabel, index, resource, tntResource.Spec.Scope) if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. - err = errors.Join(err, sectionErr) - } else { - processedItems.Insert(items...) + itemErrors = errors.Join(itemErrors, sectionErr) } + + log.Info("replicate items", "amount", len(items)) + + processedItems.Insert(items...) } } @@ -176,7 +266,11 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta return reconcile.Result{}, err } - if r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) { + 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() { @@ -193,11 +287,28 @@ func (r *Global) reconcileNormal(ctx context.Context, tntResource *capsulev1beta return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } -func (r *Global) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.GlobalTenantResource) (reconcile.Result, error) { +func (r *globalResourceController) reconcileDelete( + ctx context.Context, + c client.Client, + tntResource *capsulev1beta2.GlobalTenantResource, +) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { - r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), nil) + 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) } @@ -206,3 +317,41 @@ func (r *Global) reconcileDelete(ctx context.Context, tntResource *capsulev1beta return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } + +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 + } + + // 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 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 +} diff --git a/controllers/resources/manager.go b/controllers/resources/manager.go new file mode 100644 index 00000000..4a922d4a --- /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 b266722f..3cde7e69 100644 --- a/controllers/resources/namespaced.go +++ b/controllers/resources/namespaced.go @@ -6,27 +6,41 @@ package resources import ( "context" "errors" + "reflect" + "github.com/go-logr/logr" gherrors "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/cluster-api/util/patch" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/configuration" + "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() r.processor = Processor{ client: mgr.GetClient(), @@ -34,18 +48,60 @@ func (r *Namespaced) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&capsulev1beta2.TenantResource{}). + Watches( + &capsulev1beta2.CapsuleConfiguration{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { + var list capsulev1beta2.TenantResourceList + if err := r.client.List(ctx, &list); err != nil { + r.log.Error(err, "unable to list TenantResources") + + return nil + } + + var requests []reconcile.Request + for _, s := range list.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: s.Name, + Namespace: s.Namespace, + }, + }) + } + + return requests + }), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(event.CreateEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldObj, okOld := e.ObjectOld.(*capsulev1beta2.CapsuleConfiguration) + newObj, okNew := e.ObjectNew.(*capsulev1beta2.CapsuleConfiguration) + if !okOld || !okNew { + return false + } + + return !reflect.DeepEqual(oldObj.Spec.ServiceAccountClient, newObj.Spec.ServiceAccountClient) + }, + DeleteFunc: func(event.DeleteEvent) bool { + return false + }, + }), + ). Complete(r) } -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 +115,9 @@ func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) ( } defer func() { + r.metrics.RecordCondition(tntResource) + + tntResource.SetCondition() if e := patchHelper.Patch(ctx, tntResource); e != nil { if err == nil { err = gherrors.Wrap(e, "failed to patch TenantResource") @@ -66,16 +125,29 @@ func (r *Namespaced) Reconcile(ctx context.Context, request reconcile.Request) ( } }() + 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 { @@ -113,46 +185,93 @@ func (r *Namespaced) reconcileNormal(ctx context.Context, tntResource *capsulev1 return reconcile.Result{}, labelErr } + // Always post the processed items, as they allow users to track errors + defer func() { + tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + + for _, item := range processedItems.List() { + or := capsulev1beta2.ObjectReferenceStatus{} + if err := or.ParseFromString(item); err == nil { + tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) + } else { + log.Error(err, "failed to parse processed item", "item", item) + } + } + }() + // new empty error - var err error + var itemErrors error for index, resource := range tntResource.Spec.Resources { - items, sectionErr := r.processor.HandleSection(ctx, tl.Items[0], false, tenantLabel, index, resource) + items, sectionErr := r.processor.HandleSectionPreflight(ctx, c, tl.Items[0], false, tenantLabel, index, resource, api.ResourceScopeNamespace) if sectionErr != nil { // Upon a process error storing the last error occurred and continuing to iterate, // avoid to block the whole processing. - err = errors.Join(err, sectionErr) - } else { - processedItems.Insert(items...) + itemErrors = errors.Join(itemErrors, sectionErr) } - } - if err != nil { - log.Error(err, "unable to replicate the requested resources") + log.Info("replicate items", "amount", len(items)) - return reconcile.Result{}, err + processedItems.Insert(items...) } - if r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), sets.Set[string](processedItems)) { - tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(processedItems)) + if itemErrors != nil { + return reconcile.Result{}, nil + } - for _, item := range processedItems.List() { + failedItems, err := r.processor.HandlePruning( + ctx, + c, + tntResource.Status.ProcessedItems.AsSet(), + sets.Set[string](processedItems), + ) + if len(failedItems) > 0 { + tntResource.Status.ProcessedItems = make([]capsulev1beta2.ObjectReferenceStatus, 0, len(failedItems)) + + for _, item := range failedItems { if or := (capsulev1beta2.ObjectReferenceStatus{}); or.ParseFromString(item) == nil { tntResource.Status.ProcessedItems = append(tntResource.Status.ProcessedItems, or) } } } + if err != nil { + return reconcile.Result{}, gherrors.Wrap(err, "failed to prune resources") + } + log.Info("processing completed") return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } -func (r *Namespaced) reconcileDelete(ctx context.Context, tntResource *capsulev1beta2.TenantResource) (reconcile.Result, error) { +func (r *namespacedResourceController) reconcileDelete( + ctx context.Context, + c client.Client, + tntResource *capsulev1beta2.TenantResource, +) (reconcile.Result, error) { log := ctrllog.FromContext(ctx) if *tntResource.Spec.PruningOnDelete { - r.processor.HandlePruning(ctx, tntResource.Status.ProcessedItems.AsSet(), nil) + 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) @@ -161,3 +280,43 @@ func (r *Namespaced) reconcileDelete(ctx context.Context, tntResource *capsulev1 return reconcile.Result{Requeue: true, RequeueAfter: tntResource.Spec.ResyncPeriod.Duration}, nil } + +func (r *namespacedResourceController) loadClient( + ctx context.Context, + log logr.Logger, + tntResource *capsulev1beta2.TenantResource, +) (client.Client, error) { + // Add ServiceAccount if required, Retriggers reconcile + // This is done in the background, Everything else should be handeled at admission + if changed := SetTenantResourceServiceAccount(r.configuration, tntResource); changed { + log.V(5).Info("adding default serviceAccount", "serviceaccount", tntResource.Spec.ServiceAccount.GetFullName()) + + return nil, nil + } + + // 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 nil, err + } + + //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 saClient, nil +} diff --git a/controllers/resources/processor.go b/controllers/resources/processor.go index cd00a0e3..85b3eeed 100644 --- a/controllers/resources/processor.go +++ b/controllers/resources/processor.go @@ -24,10 +24,11 @@ import ( 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/meta" ) const ( - Label = "capsule.clastix.io/resources" finalizer = "capsule.clastix.io/resources" ) @@ -49,24 +50,35 @@ func prepareAdditionalMetadata(m map[string]string) map[string]string { return copied } -func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set[string]) (updateStatus bool) { +func (r *Processor) HandlePruning( + ctx context.Context, + c client.Client, + current, + desired sets.Set[string], +) (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. - updateStatus = diff.Len() > 0 || current.Len() != desired.Len() + reconcile := diff.Len() > 0 || current.Len() != desired.Len() - if diff.Len() > 0 { - log.Info("starting processing pruning", "length", diff.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 err := or.ParseFromString(item); err != nil { - log.Error(err, "unable to parse resource to prune", "resource", item) + if sectionErr := or.ParseFromString(item); sectionErr != nil { + processed.Insert(or.String()) + + log.Error(sectionErr, "unable to parse resource to prune", "resource", item) continue } @@ -76,58 +88,124 @@ func (r *Processor) HandlePruning(ctx context.Context, current, desired sets.Set obj.SetName(or.Name) obj.SetGroupVersionKind(schema.FromAPIVersionAndKind(or.APIVersion, or.Kind)) - if err := r.client.Delete(ctx, &obj); err != nil { - if apierr.IsNotFound(err) { + 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 } - log.Error(err, "unable to prune resource", "resource", item) + or.Status = metav1.ConditionFalse + or.Message = sectionErr.Error() + or.Type = meta.PruningCondition + processed.Insert(or.String()) + + err = errors.Join(sectionErr) continue } - log.Info("resource has been pruned", "resource", item) + log.V(5).Info("resource has been pruned", "resource", item) } - return updateStatus + 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) HandleSectionPreflight( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + allowCrossNamespaceSelection bool, + tenantLabel string, + resourceIndex int, + spec capsulev1beta2.ResourceSpec, + scope api.ResourceScope, +) (processed []string, 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) + switch scope { + case api.ResourceScopeTenant: + return r.handleSection( + ctx, + c, + tnt, + allowCrossNamespaceSelection, + tenantLabel, + resourceIndex, + spec, + api.ResourceScopeTenant, + nil) + default: + + // Creating Namespace selector + var selector labels.Selector + + if spec.NamespaceSelector != nil { + selector, err = metav1.LabelSelectorAsSelector(spec.NamespaceSelector) + if err != nil { + log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication", "index", resourceIndex) + + return nil, err + } + } else { + selector = labels.NewSelector() + } + // Resources can be replicated only on Namespaces belonging to the same Global: + // preventing a boundary cross by enforcing the selection. + tntRequirement, err := labels.NewRequirement(tenantLabel, selection.Equals, []string{tnt.GetName()}) if err != nil { - log.Error(err, "cannot create Namespace selector for Namespace filtering and resource replication", "index", resourceIndex) + log.Error(err, "unable to create requirement for Namespace filtering and resource replication", "index", resourceIndex) return nil, err } - } else { - selector = labels.NewSelector() - } - // Resources can be replicated only on Namespaces belonging to the same Global: - // preventing a boundary cross by enforcing the selection. - tntRequirement, err := labels.NewRequirement(tenantLabel, selection.Equals, []string{tnt.GetName()}) - if err != nil { - log.Error(err, "unable to create requirement for Namespace filtering and resource replication", "index", resourceIndex) - return nil, err - } + selector = selector.Add(*tntRequirement) + // Selecting the targeted Namespace according to the TenantResource specification. + namespaces := corev1.NamespaceList{} + if err = r.client.List(ctx, &namespaces, client.MatchingLabelsSelector{Selector: selector}); err != nil { + log.Error(err, "cannot retrieve Namespaces for resource", "index", resourceIndex) - 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 + } - return nil, err + for _, ns := range namespaces.Items { + p, perr := r.handleSection( + ctx, + c, + tnt, + allowCrossNamespaceSelection, + tenantLabel, + resourceIndex, + spec, + api.ResourceScopeNamespace, + &ns) + if perr != nil { + err = errors.Join(err, perr) + } + + processed = append(processed, p...) + } } + + return +} + +//nolint:gocognit +func (r *Processor) handleSection( + ctx context.Context, + c client.Client, + tnt capsulev1beta2.Tenant, + allowCrossNamespaceSelection bool, + tenantLabel string, + resourceIndex int, + spec capsulev1beta2.ResourceSpec, + scope api.ResourceScope, + ns *corev1.Namespace, +) ([]string, error) { + log := ctrllog.FromContext(ctx) + // Generating additional metadata objAnnotations, objLabels := map[string]string{}, map[string]string{} @@ -138,7 +216,7 @@ func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant objAnnotations[tenantLabel] = tnt.GetName() - objLabels[Label] = fmt.Sprintf("%d", resourceIndex) + objLabels[meta.ResourcesLabel] = fmt.Sprintf("%d", resourceIndex) objLabels[tenantLabel] = tnt.GetName() // processed will contain the sets of resources replicated, both for the raw and the Namespaced ones: // these are required to perform a final pruning once the replication has been occurred. @@ -150,139 +228,165 @@ func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant 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...) + for nsIndex, item := range spec.NamespacedItems { + keysAndValues := []any{"index", nsIndex, "namespace", item.Namespace, "tenant", tnt.GetName()} + // A TenantResource is created by a TenantOwner, and potentially, they could point to a resource in a non-owned + // Namespace: this must be blocked by checking it this is the case. + if !allowCrossNamespaceSelection && !tntNamespaces.Has(item.Namespace) { + log.Info("skipping processing of namespacedItem, referring a Namespace that is not part of the given Tenant", keysAndValues...) - continue - } - // Namespaced Items are relying on selecting resources, rather than specifying a specific name: - // creating it to get used by the client List action. - objSelector := item.Selector + 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...) + itemSelector, selectorErr := metav1.LabelSelectorAsSelector(&objSelector) + if selectorErr != nil { + log.Error(selectorErr, "cannot create Selector for namespacedItem", keysAndValues...) - syncErr = errors.Join(syncErr, selectorErr) + syncErr = errors.Join(syncErr, selectorErr) - continue - } + continue + } - objs := unstructured.UnstructuredList{} - objs.SetGroupVersionKind(schema.FromAPIVersionAndKind(item.APIVersion, fmt.Sprintf("%sList", item.Kind))) + 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...) + if clientErr := c.List(ctx, &objs, client.InNamespace(item.Namespace), client.MatchingLabelsSelector{Selector: itemSelector}); clientErr != nil { + log.Error(clientErr, "cannot retrieve object for namespacedItem", keysAndValues...) - syncErr = errors.Join(syncErr, clientErr) + syncErr = errors.Join(syncErr, clientErr) - continue - } + continue + } - var wg sync.WaitGroup + 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) - 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) - wg.Add(1) + go func(index int, obj unstructured.Unstructured) { + defer wg.Done() - go func(index int, obj unstructured.Unstructured) { - defer wg.Done() + kv := keysAndValues + kv = append(kv, "resource", fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetNamespace())) - kv := keysAndValues - kv = append(kv, "resource", fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetNamespace())) + replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} + replicatedItem.Name = obj.GetName() + replicatedItem.Kind = obj.GetKind() + replicatedItem.APIVersion = obj.GetAPIVersion() + replicatedItem.Type = meta.ReplicationCondition + replicatedItem.Scope = scope + replicatedItem.Tenant = tnt.GetName() - if opErr := r.createOrUpdate(ctx, &obj, objLabels, objAnnotations); opErr != nil { - log.Error(opErr, "unable to sync namespacedItems", kv...) + if ns != nil { + replicatedItem.Namespace = ns.Name + } - errorsChan <- opErr + if opErr := r.createOrUpdate(ctx, c, &obj, objLabels, objAnnotations); opErr != nil { + log.Error(opErr, "unable to sync namespacedItems", kv...) + errorsChan <- opErr - return - } + replicatedItem.Status = metav1.ConditionFalse + replicatedItem.Message = opErr.Error() + } else { + replicatedItem.Status = metav1.ConditionTrue + } - log.Info("resource has been replicated", kv...) + 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() - processedRaw[index] = replicatedItem.String() - }(i, obj) - } + return + }(i, obj) + } - wg.Wait() - close(errorsChan) + wg.Wait() + close(errorsChan) - for err := range errorsChan { - if err != nil { - syncErr = errors.Join(syncErr, err) - } + for err := range errorsChan { + if err != nil { + syncErr = errors.Join(syncErr, err) } + } - for _, p := range processedRaw { - if p == "" { - continue - } - - processed.Insert(p) + for _, p := range processedRaw { + if p == "" { + continue } + + processed.Insert(p) } + } - for rawIndex, item := range spec.RawItems { - template := string(item.Raw) + for rawIndex, item := range spec.RawItems { + template := string(item.Raw) - t := fasttemplate.New(template, "{{ ", " }}") + t := fasttemplate.New(template, "{{ ", " }}") - tmplString := t.ExecuteString(map[string]interface{}{ - "tenant.name": tnt.Name, - "namespace": ns.Name, - }) + tContext := map[string]interface{}{ + "tenant.name": tnt.Name, + } + if ns != nil { + tContext["namespace"] = ns.Name + } - obj, keysAndValues := unstructured.Unstructured{}, []interface{}{"index", rawIndex} + tmplString := t.ExecuteString(tContext) - if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode([]byte(tmplString), nil, &obj); decodeErr != nil { - log.Error(decodeErr, "unable to deserialize rawItem", keysAndValues...) + obj, keysAndValues := unstructured.Unstructured{}, []interface{}{"index", rawIndex} - syncErr = errors.Join(syncErr, decodeErr) + if _, _, decodeErr := codecFactory.UniversalDeserializer().Decode([]byte(tmplString), nil, &obj); decodeErr != nil { + log.Error(decodeErr, "unable to deserialize rawItem", keysAndValues...) - continue - } + syncErr = errors.Join(syncErr, decodeErr) + continue + } + + if ns != nil { obj.SetNamespace(ns.Name) + } - 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...) + replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} + replicatedItem.Name = obj.GetName() + replicatedItem.Kind = obj.GetKind() + replicatedItem.APIVersion = obj.GetAPIVersion() + replicatedItem.Type = meta.ReplicationCondition + replicatedItem.Scope = scope + replicatedItem.Tenant = tnt.GetName() - replicatedItem := &capsulev1beta2.ObjectReferenceStatus{} - replicatedItem.Name = obj.GetName() - replicatedItem.Kind = obj.GetKind() - replicatedItem.Namespace = ns.Name - replicatedItem.APIVersion = obj.GetAPIVersion() + if ns != nil { + replicatedItem.Namespace = ns.Name + } - processed.Insert(replicatedItem.String()) - } + if rawErr := r.createOrUpdate(ctx, c, &obj, objLabels, objAnnotations); rawErr != nil { + log.Info("unable to sync rawItem", keysAndValues...) + + replicatedItem.Status = metav1.ConditionFalse + replicatedItem.Message = rawErr.Error() + + // In case of error processing an item in one of any selected Namespaces, storing it to report it lately + // to the upper call to ensure a partial sync that will be fixed by a subsequent reconciliation. + syncErr = errors.Join(syncErr, rawErr) + } else { + log.Info("resource has been replicated", keysAndValues...) + + replicatedItem.Status = metav1.ConditionTrue } + + processed.Insert(replicatedItem.String()) } return processed.List(), syncErr @@ -291,7 +395,13 @@ func (r *Processor) HandleSection(ctx context.Context, tnt capsulev1beta2.Tenant // createOrUpdate replicates the provided unstructured object to all the provided Namespaces: // this function mimics the CreateOrUpdate, by retrieving the object to understand if it must be created or updated, // along adding the additional metadata, if required. -func (r *Processor) createOrUpdate(ctx context.Context, obj *unstructured.Unstructured, labels map[string]string, annotations map[string]string) (err error) { +func (r *Processor) createOrUpdate( + ctx context.Context, + c client.Client, + obj *unstructured.Unstructured, + labels map[string]string, + annotations map[string]string, +) (err error) { actual, desired := &unstructured.Unstructured{}, obj.DeepCopy() actual.SetAPIVersion(desired.GetAPIVersion()) @@ -299,7 +409,7 @@ func (r *Processor) createOrUpdate(ctx context.Context, obj *unstructured.Unstru actual.SetNamespace(desired.GetNamespace()) actual.SetName(desired.GetName()) - _, err = controllerutil.CreateOrUpdate(ctx, r.client, actual, func() error { + _, err = controllerutil.CreateOrUpdate(ctx, c, actual, func() error { UID := actual.GetUID() rv := actual.GetResourceVersion() actual.SetUnstructuredContent(desired.Object) diff --git a/controllers/resources/utils.go b/controllers/resources/utils.go new file mode 100644 index 00000000..9872e32c --- /dev/null +++ b/controllers/resources/utils.go @@ -0,0 +1,143 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/configuration" + 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 +} diff --git a/e2e/config_client_test.go b/e2e/config_client_test.go new file mode 100644 index 00000000..1a577797 --- /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 25ac3895..e6edd946 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 00000000..b2c966c5 --- /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 c24f57de..f92bcac5 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 00000000..6ae6ebaa --- /dev/null +++ b/global-scope.yaml @@ -0,0 +1,20 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: global-scope +spec: + tenantSelector: + matchLabels: + energy: renewable + scope: Tenant + resyncPeriod: 5s + resources: + - rawItems: + - apiVersion: v1 + kind: Secret + metadata: + name: "some-secret-bruv" + namespace: "solar-test" + stringData: + username: "some-username" + diff --git a/go.mod b/go.mod index 9e50c11f..a1123b2e 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -58,8 +59,9 @@ require ( 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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -72,6 +74,8 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + 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 @@ -84,6 +88,9 @@ require ( golang.org/x/time v0.13.0 // indirect golang.org/x/tools v0.36.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 45559b8e..819eedde 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lpr github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -94,12 +94,12 @@ 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/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= @@ -188,18 +188,18 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS 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= @@ -210,8 +210,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= @@ -219,9 +217,8 @@ 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/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= @@ -230,40 +227,26 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn 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/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.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.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/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 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/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -278,14 +261,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/genproto/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/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -298,35 +279,22 @@ 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/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/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= diff --git a/pkg/api/condition.go b/pkg/api/condition.go new file mode 100644 index 00000000..e33168a2 --- /dev/null +++ b/pkg/api/condition.go @@ -0,0 +1,30 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:generate=true +type Condition metav1.Condition + +// Disregards fields like LastTransitionTime and Version, which are not relevant for the API. +func (c *Condition) UpdateCondition(condition metav1.Condition) (updated bool) { + if condition.Type == c.Type && + condition.Status == c.Status && + condition.Reason == c.Reason && + condition.Message == c.Message { + return false + } + + c.Type = condition.Type + c.Status = condition.Status + c.Reason = condition.Reason + c.Message = condition.Message + c.ObservedGeneration = condition.ObservedGeneration + c.LastTransitionTime = condition.LastTransitionTime + + return true +} diff --git a/pkg/api/condition_test.go b/pkg/api/condition_test.go new file mode 100644 index 00000000..6582855e --- /dev/null +++ b/pkg/api/condition_test.go @@ -0,0 +1,76 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestUpdateCondition(t *testing.T) { + now := metav1.Now() + + t.Run("no update when all relevant fields match", func(t *testing.T) { + c := &Condition{ + Type: "Ready", + Status: "True", + Reason: "Success", + Message: "All good", + } + + updated := c.UpdateCondition(metav1.Condition{ + Type: "Ready", + Status: "True", + Reason: "Success", + Message: "All good", + LastTransitionTime: now, + }) + + assert.False(t, updated) + }) + + t.Run("update occurs on message change", func(t *testing.T) { + c := &Condition{ + Type: "Ready", + Status: "True", + Reason: "Success", + Message: "Old message", + } + + updated := c.UpdateCondition(metav1.Condition{ + Type: "Ready", + Status: "True", + Reason: "Success", + Message: "New message", + LastTransitionTime: now, + }) + + assert.True(t, updated) + assert.Equal(t, "New message", c.Message) + }) + + t.Run("update occurs on status change", func(t *testing.T) { + c := &Condition{ + Type: "Ready", + Status: "False", + Reason: "Pending", + Message: "Not ready yet", + } + + updated := c.UpdateCondition(metav1.Condition{ + Type: "Ready", + Status: "True", + Reason: "Success", + Message: "Ready", + LastTransitionTime: now, + }) + + assert.True(t, updated) + assert.Equal(t, "True", string(c.Status)) + assert.Equal(t, "Success", c.Reason) + assert.Equal(t, "Ready", c.Message) + }) +} diff --git a/pkg/api/serviceaccount_config.go b/pkg/api/serviceaccount_config.go new file mode 100644 index 00000000..28a27f7b --- /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 00000000..87f1c494 --- /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 00000000..be4068e0 --- /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 1015e1fa..54999bd1 100644 --- a/pkg/api/status.go +++ b/pkg/api/status.go @@ -5,6 +5,19 @@ package api import k8stypes "k8s.io/apimachinery/pkg/types" +const ( + ResourceScopeNamespace ResourceScope = "Namespace" + ResourceScopeTenant ResourceScope = "Tenant" + ResourceScopeCluster ResourceScope = "Cluster" +) + +// +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 +36,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 759b3d4c..0fe4d8ae 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -147,6 +147,22 @@ func (in *AllowedServices) DeepCopy() *AllowedServices { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Condition) DeepCopyInto(out *Condition) { + *out = *in + in.LastTransitionTime.DeepCopyInto(&out.LastTransitionTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Condition. +func (in *Condition) DeepCopy() *Condition { + if in == nil { + return nil + } + out := new(Condition) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DefaultAllowedListSpec) DeepCopyInto(out *DefaultAllowedListSpec) { *out = *in @@ -375,6 +391,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 diff --git a/pkg/configuration/client.go b/pkg/configuration/client.go index 8b507a3d..14c4288b 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -5,11 +5,15 @@ 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" @@ -20,27 +24,40 @@ import ( // using a closure that provides the desired configuration. type capsuleConfiguration struct { retrievalFn func() *capsulev1beta2.CapsuleConfiguration + rest *rest.Config + client client.Client } -func NewCapsuleConfiguration(ctx context.Context, client client.Client, name string) Configuration { - return &capsuleConfiguration{retrievalFn: func() *capsulev1beta2.CapsuleConfiguration { - config := &capsulev1beta2.CapsuleConfiguration{} +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) { @@ -112,3 +129,45 @@ func (c *capsuleConfiguration) ForbiddenUserNodeAnnotations() *capsuleapi.Forbid return &c.retrievalFn().Spec.NodeMetadata.ForbiddenAnnotations } + +func (c *capsuleConfiguration) ServiceAccountClientProperties() *capsuleapi.ServiceAccountClient { + if c.retrievalFn().Spec.ServiceAccountClient == nil { + return nil + } + + return c.retrievalFn().Spec.ServiceAccountClient +} + +func (c *capsuleConfiguration) ServiceAccountClient(ctx context.Context) (client *rest.Config, err error) { + props := c.ServiceAccountClientProperties() + + client = c.rest + + if props == nil { + return + } + + if props.Endpoint != "" { + client.Host = c.rest.Host + } + + if props.SkipTLSVerify { + client.TLSClientConfig.Insecure = true + } else { + if props.CASecretName != "" { + namespace := props.CASecretNamespace + if namespace == "" { + namespace = os.Getenv("NAMESPACE") + } + + caData, err := fetchCACertFromSecret(ctx, c.client, namespace, props.CASecretName, props.CASecretKey) + if err != nil { + return nil, fmt.Errorf("could not fetch CA cert: %w", err) + } + + client.TLSClientConfig.CAData = caData + } + } + + return client, nil +} diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 0f109f19..841d17df 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -4,9 +4,11 @@ package configuration import ( + "context" "regexp" capsuleapi "github.com/projectcapsule/capsule/pkg/api" + "k8s.io/client-go/rest" ) const ( @@ -29,4 +31,6 @@ type Configuration interface { IgnoreUserWithGroups() []string ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec + ServiceAccountClientProperties() *capsuleapi.ServiceAccountClient + ServiceAccountClient(context.Context) (*rest.Config, error) } diff --git a/pkg/configuration/utils.go b/pkg/configuration/utils.go new file mode 100644 index 00000000..b6554802 --- /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 00000000..f1e8a22c --- /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/conditions.go b/pkg/meta/conditions.go index 7034a84d..a8743b02 100644 --- a/pkg/meta/conditions.go +++ b/pkg/meta/conditions.go @@ -13,6 +13,9 @@ const ( ReadyCondition string = "Ready" NotReadyCondition string = "NotReady" + PruningCondition string = "Pruning" + ReplicationCondition string = "Replication" + AssignedCondition string = "Assigned" BoundCondition string = "Bound" @@ -24,6 +27,14 @@ const ( NamespaceExhaustedReason string = "NamespaceExhausted" ) +func NewReadyCondition(obj client.Object) metav1.Condition { + return metav1.Condition{ + Type: ReadyCondition, + ObservedGeneration: obj.GetGeneration(), + LastTransitionTime: metav1.Now(), + } +} + func NewBoundCondition(obj client.Object) metav1.Condition { return metav1.Condition{ Type: BoundCondition, diff --git a/pkg/meta/labels.go b/pkg/meta/labels.go index 5347cdae..3674ac5a 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" diff --git a/pkg/meta/ownerreference.go b/pkg/meta/ownerreference.go index 6a6e2734..5e507d19 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/metrics/globaltenantresource_recorder.go b/pkg/metrics/globaltenantresource_recorder.go new file mode 100644 index 00000000..067d13f0 --- /dev/null +++ b/pkg/metrics/globaltenantresource_recorder.go @@ -0,0 +1,65 @@ +// 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" +) + +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", "status"}, + ), + } +} + +func (r *GlobalTenantResourceRecorder) Collectors() []prometheus.Collector { + return []prometheus.Collector{ + r.resourceConditionGauge, + } +} + +// RecordCondition records the condition as given for the ref. +func (r *GlobalTenantResourceRecorder) RecordCondition(resource *capsulev1beta2.GlobalTenantResource) { + for _, status := range []metav1.ConditionStatus{metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown} { + var value float64 + if status == resource.Status.Condition.Status { + value = 1 + } + + r.resourceConditionGauge.WithLabelValues( + resource.Name, + resource.Status.Condition.Type, + string(resource.Status.Condition.Status), + ).Set(value) + } +} + +// DeleteCondition deletes the condition metrics for the ref. +func (r *GlobalTenantResourceRecorder) DeleteMetrics(resourceName string) { + r.resourceConditionGauge.DeletePartialMatch(map[string]string{ + "name": resourceName, + }) +} diff --git a/pkg/metrics/tenantresource_recorder.go b/pkg/metrics/tenantresource_recorder.go new file mode 100644 index 00000000..caec5db9 --- /dev/null +++ b/pkg/metrics/tenantresource_recorder.go @@ -0,0 +1,67 @@ +// 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" +) + +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", "status"}, + ), + } +} + +func (r *TenantResourceRecorder) Collectors() []prometheus.Collector { + return []prometheus.Collector{ + r.resourceConditionGauge, + } +} + +// RecordCondition records the condition as given for the ref. +func (r *TenantResourceRecorder) RecordCondition(resource *capsulev1beta2.TenantResource) { + for _, status := range []metav1.ConditionStatus{metav1.ConditionTrue, metav1.ConditionFalse, metav1.ConditionUnknown} { + var value float64 + if status == resource.Status.Condition.Status { + value = 1 + } + + r.resourceConditionGauge.WithLabelValues( + resource.Name, + resource.Namespace, + resource.Status.Condition.Type, + string(resource.Status.Condition.Status), + ).Set(value) + } +} + +// 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, + }) +} diff --git a/pkg/utils/serviceaccount.go b/pkg/utils/serviceaccount.go new file mode 100644 index 00000000..3de77f28 --- /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 00000000..c3012e34 --- /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/webhook/route/tenantresource.go b/pkg/webhook/route/tenantresource.go new file mode 100644 index 00000000..1b2eaf4e --- /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 00000000..5caee93c --- /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 00000000..edd76732 --- /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 78% rename from pkg/webhook/tenantresource/objects.go rename to pkg/webhook/tenantresource/objects_validating.go index 1040b00f..0c375c73 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" @@ -20,31 +20,31 @@ import ( "github.com/projectcapsule/capsule/pkg/webhook/utils" ) -type cordoningHandler struct{} +type objectsValidatingHandler struct{} -func WriteOpsHandler() capsulewebhook.Handler { - return &cordoningHandler{} +func ObjectsValidatingHandler() capsulewebhook.Handler { + return &objectsValidatingHandler{} } -func (h *cordoningHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { +func (h *objectsValidatingHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func { return func(context.Context, admission.Request) *admission.Response { return nil } } -func (h *cordoningHandler) OnDelete(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { +func (h *objectsValidatingHandler) OnDelete(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handler(ctx, client, req, recorder) } } -func (h *cordoningHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { +func (h *objectsValidatingHandler) OnUpdate(client client.Client, _ admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.handler(ctx, client, req, recorder) } } -func (h *cordoningHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder) *admission.Response { +func (h *objectsValidatingHandler) handler(ctx context.Context, clt client.Client, req admission.Request, recorder record.EventRecorder) *admission.Response { tntList := &capsulev1beta2.TenantList{} if err := clt.List(ctx, tntList, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(".status.namespaces", req.Namespace)}); err != nil { diff --git a/test.yaml b/test.yaml new file mode 100644 index 00000000..6e8beb80 --- /dev/null +++ b/test.yaml @@ -0,0 +1,34 @@ +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: renewable-pull-secrets +spec: + tenantSelector: + matchLabels: + energy: renewable + resyncPeriod: 5s + resources: + - templates: + resources: + - index: "sec" + apiVersion: v1 + kind: Secret + items: + - | + --- + apiVersion: v1 + kind: Secret + metadata: + name: "some-secret-bruv" + stringData: + username: "some-username" + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: "the-context" + data: + context: | + {{ . | toYaml | nindent 4}} + + diff --git a/tmp/claims.yaml b/tmp/claims.yaml new file mode 100644 index 00000000..88fbac59 --- /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 00000000..81804dfe --- /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 00000000..416fbbfe --- /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 00000000..75e18679 --- /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 00000000..38f7e512 --- /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