diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a7ac0969..359d6418 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -63,9 +63,8 @@ So the TL;DR answer is: **Make sure a *KinD* cluster is running on your laptop, and then run `make dev-setup` to setup the dev environment.**. This is not done in the `make dev-setup` setup. ```bash -# If you haven't installed or run `make deploy` before, do it first -# Note: please retry if you saw errors -$ make deploy +# Create a KinD cluster if not already created +$ make dev-cluster # To retrieve your laptop's IP and execute `make dev-setup` to setup dev env # For example: LAPTOP_HOST_IP=192.168.10.101 make dev-setup diff --git a/Makefile b/Makefile index af42079e..0a86848f 100644 --- a/Makefile +++ b/Makefile @@ -105,6 +105,20 @@ helm-test-exec: ct helm-controller-version ko-build-all @$(CT) install --config $(SRC_ROOT)/.github/configs/ct.yaml --namespace=capsule-system --all --debug # Setup development env +dev-build: kind + $(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) + $(MAKE) dev-install-deps + +.PHONY: dev-destroy +dev-destroy: kind + $(KIND) delete cluster --name capsule + +API_GW := none +API_GW_VERSION := v1.3.0 +API_GW_LOOKUP := kubernetes-sigs/gateway-api +dev-install-deps: + @$(KUBECTL) apply --force-conflicts --server-side=true -f https://github.com/$(API_GW_LOOKUP)/releases/download/$(API_GW_VERSION)/standard-install.yaml + # Usage: # LAPTOP_HOST_IP= make dev-setup # For example: @@ -127,6 +141,7 @@ IP.1 = $(LAPTOP_HOST_IP) endef export TLS_CNF dev-setup: + $(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true mkdir -p /tmp/k8s-webhook-server/serving-certs echo "$${TLS_CNF}" > _tls.cnf openssl req -newkey rsa:4096 -days 3650 -nodes -x509 \ @@ -156,8 +171,7 @@ dev-setup: --set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \ capsule \ - ./charts/capsule - $(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true + ./charts/capsule || true setup-monitoring: dev-setup-fluxcd @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/monitoring | envsubst | kubectl apply -f - @@ -180,6 +194,22 @@ dev-setup-argocd: dev-setup-fluxcd dev-setup-fluxcd: @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/fluxcd | envsubst | kubectl apply -f - +# Here to setup the current capsule version +# Intended to test updates to new version +dev-setup-capsule: dev-setup-fluxcd + @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/capsule | envsubst | kubectl apply -f - + @$(MAKE) wait-for-helmreleases + @$(MAKE) dev-setup-capsule-example + +dev-setup-capsule-example: dev-setup-fluxcd + @$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/capsule/example-setup | envsubst | kubectl apply -f - + @$(KUBECTL) create ns wind-test --as joe --as-group projectcapsule.dev + @$(KUBECTL) create ns wind-prod --as joe --as-group projectcapsule.dev + @$(KUBECTL) create ns green-test --as bob --as-group projectcapsule.dev + @$(KUBECTL) create ns green-prod --as bob --as-group projectcapsule.dev + @$(KUBECTL) create ns solar-test --as alice --as-group projectcapsule.dev + @$(KUBECTL) create ns solar-prod --as alice --as-group projectcapsule.dev + wait-for-helmreleases: @ echo "Waiting for all HelmReleases to have observedGeneration >= 0..." @while [ "$$($(KUBECTL) get helmrelease -A -o jsonpath='{range .items[?(@.status.observedGeneration<0)]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' | wc -l)" -ne 0 ]; do \ @@ -264,20 +294,10 @@ golint-fix: golangci-lint e2e: ginkgo $(MAKE) e2e-build && $(MAKE) e2e-exec && $(MAKE) e2e-destroy -API_GW := none -API_GW_VERSION := v1.3.0 -API_GW_LOOKUP := kubernetes-sigs/gateway-api/ -e2e-install-deps: - @$(KUBECTL) apply --force-conflicts --server-side=true -f https://github.com/$(API_GW_LOOKUP)/releases/download/$(API_GW_VERSION)/standard-install.yaml - e2e-build: kind - $(MAKE) e2e-build-cluster + $(MAKE) dev-build $(MAKE) e2e-install -e2e-build-cluster: kind - $(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) - $(MAKE) e2e-install-deps - .PHONY: e2e-install e2e-install: ko-build-all $(MAKE) e2e-load-image CLUSTER_NAME=$(CLUSTER_NAME) IMAGE=$(CAPSULE_IMG) VERSION=$(VERSION) @@ -339,8 +359,7 @@ e2e-exec: ginkgo $(GINKGO) -v -tags e2e ./e2e .PHONY: e2e-destroy -e2e-destroy: kind - $(KIND) delete cluster --name capsule +e2e-destroy: dev-destroy SPELL_CHECKER = npx spellchecker-cli docs-lint: diff --git a/api/v1beta2/capsuleconfiguration_status.go b/api/v1beta2/capsuleconfiguration_status.go new file mode 100644 index 00000000..b62f05c0 --- /dev/null +++ b/api/v1beta2/capsuleconfiguration_status.go @@ -0,0 +1,14 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + "github.com/projectcapsule/capsule/pkg/api" +) + +// CapsuleConfigurationStatus defines the Capsule configuration status. +type CapsuleConfigurationStatus struct { + // Users which are considered Capsule Users and are bound to the Capsule Tenant construct. + Users api.UserListSpec `json:"users,omitempty"` +} diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index f8d35fd7..cee5851b 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -21,7 +21,6 @@ type CapsuleConfigurationSpec struct { // Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users) // // Names of the groups considered as Capsule users. - // +kubebuilder:default={capsule.clastix.io} UserGroups []string `json:"userGroups,omitempty"` // Define groups which when found in the request of a user will be ignored by the Capsule // this might be useful if you have one group where all the users are in, but you want to separate administrators from normal users with additional groups. @@ -79,6 +78,7 @@ type CapsuleResources struct { } // +kubebuilder:object:root=true +// +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster // +kubebuilder:storageversion @@ -90,6 +90,9 @@ type CapsuleConfiguration struct { metav1.ObjectMeta `json:"metadata,omitzero"` Spec CapsuleConfigurationSpec `json:"spec"` + + // +optional + Status CapsuleConfigurationStatus `json:"status,omitzero"` } // +kubebuilder:object:root=true diff --git a/api/v1beta2/tenant_func.go b/api/v1beta2/tenant_func.go index a9d1d27d..f96850bd 100644 --- a/api/v1beta2/tenant_func.go +++ b/api/v1beta2/tenant_func.go @@ -62,7 +62,7 @@ func (in *Tenant) CollectOwners(ctx context.Context, c client.Client, allowPromo } // Dedicated Owner Objects - listed, err := in.Spec.Permissions.ListMatchingOwners(ctx, c) + listed, err := in.Spec.Permissions.ListMatchingOwners(ctx, c, in.GetName()) if err != nil { return nil, err } @@ -74,6 +74,18 @@ func (in *Tenant) CollectOwners(ctx context.Context, c client.Client, allowPromo return owners, nil } +func (in *Tenant) GetRoleBindings() []api.AdditionalRoleBindingsSpec { + roleBindings := make([]api.AdditionalRoleBindingsSpec, 0) + + for _, owner := range in.Status.Owners { + roleBindings = append(roleBindings, owner.ToAdditionalRolebindings()...) + } + + roleBindings = append(roleBindings, in.Spec.AdditionalRoleBindings...) + + return roleBindings +} + func (in *Tenant) IsFull() bool { // we don't have limits on assigned Namespaces if in.Spec.NamespaceOptions == nil || in.Spec.NamespaceOptions.Quota == nil { diff --git a/api/v1beta2/tenant_types.go b/api/v1beta2/tenant_types.go index 68958631..eaafa7d6 100644 --- a/api/v1beta2/tenant_types.go +++ b/api/v1beta2/tenant_types.go @@ -10,6 +10,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/misc" ) @@ -98,9 +99,16 @@ type Permissions struct { func (p *Permissions) ListMatchingOwners( ctx context.Context, c client.Client, + tnt string, opts ...client.ListOption, ) ([]*TenantOwner, error) { - return misc.ListBySelectors[*TenantOwner](ctx, c, &TenantOwnerList{}, p.MatchOwners) + defaultSelector := &metav1.LabelSelector{ + MatchLabels: map[string]string{ + meta.NewTenantLabel: tnt, + }, + } + + return misc.ListBySelectors[*TenantOwner](ctx, c, &TenantOwnerList{}, append(p.MatchOwners, defaultSelector)) } // +kubebuilder:object:root=true diff --git a/api/v1beta2/tenantowner_types.go b/api/v1beta2/tenantowner_types.go index 9714f1ca..f9e3166c 100644 --- a/api/v1beta2/tenantowner_types.go +++ b/api/v1beta2/tenantowner_types.go @@ -11,7 +11,14 @@ import ( // TenantOwnerSpec defines the desired state of TenantOwner. type TenantOwnerSpec struct { + // Subject api.CoreOwnerSpec `json:",inline"` + + // Adds the given subject as capsule user. When enabled this subject does not have to be + // mentioned in the CapsuleConfiguration as Capsule User. In almost all scenarios Tenant Owners + // must be Capsule Users. + //+kubebuilder:default:=true + Aggregate bool `json:"aggregate"` } // TenantOwnerStatus defines the observed state of TenantOwner. diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 0b2c0b14..4664d573 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -43,6 +43,7 @@ func (in *CapsuleConfiguration) DeepCopyInto(out *CapsuleConfiguration) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfiguration. @@ -141,6 +142,26 @@ func (in *CapsuleConfigurationSpec) DeepCopy() *CapsuleConfigurationSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CapsuleConfigurationStatus) DeepCopyInto(out *CapsuleConfigurationStatus) { + *out = *in + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make(api.UserListSpec, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfigurationStatus. +func (in *CapsuleConfigurationStatus) DeepCopy() *CapsuleConfigurationStatus { + if in == nil { + return nil + } + out := new(CapsuleConfigurationStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CapsuleResources) DeepCopyInto(out *CapsuleResources) { *out = *in diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index d77b43da..5323c277 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -153,8 +153,6 @@ spec: regexp type: string userGroups: - default: - - capsule.clastix.io description: |- Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users) @@ -195,8 +193,36 @@ spec: required: - enableTLSReconciler type: object + status: + description: CapsuleConfigurationStatus defines the Capsule configuration + status. + properties: + users: + description: Users which are considered Capsule Users and are bound + to the Capsule Tenant construct. + items: + properties: + kind: + description: Kind of entity. Possible values are "User", "Group", + and "ServiceAccount" + enum: + - User + - Group + - ServiceAccount + type: string + name: + description: Name of the entity. + type: string + required: + - kind + - name + type: object + type: array + type: object required: - spec type: object served: true storage: true + subresources: + status: {} diff --git a/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml b/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml index 5d89b717..1e18b892 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenantowners.yaml @@ -39,6 +39,13 @@ spec: spec: description: spec defines the desired state of TenantOwner. properties: + aggregate: + default: true + description: |- + Adds the given subject as capsule user. When enabled this subject does not have to be + mentioned in the CapsuleConfiguration as Capsule User. In almost all scenarios Tenant Owners + must be Capsule Users. + type: boolean clusterRoles: default: - admin @@ -59,6 +66,7 @@ spec: description: Name of the entity. type: string required: + - aggregate - kind - name type: object diff --git a/cmd/main.go b/cmd/main.go index dc31c824..a5465697 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -374,7 +374,8 @@ func main() { } if err = (&configcontroller.Manager{ - Log: ctrl.Log.WithName("controllers").WithName("CapsuleConfiguration"), + Client: manager.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("CapsuleConfiguration"), }).SetupWithManager(manager, controllerConfig); err != nil { setupLog.Error(err, "unable to create controller", "controller", "CapsuleConfiguration") os.Exit(1) diff --git a/e2e/additional_role_bindings_test.go b/e2e/additional_role_bindings_test.go index a29c262b..21c2f1ae 100644 --- a/e2e/additional_role_bindings_test.go +++ b/e2e/additional_role_bindings_test.go @@ -5,12 +5,12 @@ package e2e import ( "context" - "fmt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" @@ -58,20 +58,10 @@ var _ = Describe("creating a Namespace with an additional Role Binding", Label(" }) It("should be assigned to each Namespace", func() { - for _, ns := range []string{"rb-1", "rb-2", "rb-3"} { - ns := NewNamespace(ns) - NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) - TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName())) - var rb *rbacv1.RoleBinding + t := &capsulev1beta2.Tenant{} + Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed()) - Eventually(func() (err error) { - cs := ownerClient(tnt.Spec.Owners[0].UserSpec) - rb, err = cs.RbacV1().RoleBindings(ns.Name).Get(context.Background(), fmt.Sprintf("capsule-%s-2-%s", tnt.Name, "crds-rolebinding"), metav1.GetOptions{}) - return err - }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) - Expect(rb.RoleRef.Name).Should(Equal(tnt.Spec.AdditionalRoleBindings[0].ClusterRoleName)) - Expect(rb.Subjects).Should(Equal(tnt.Spec.AdditionalRoleBindings[0].Subjects)) - } + VerifyTenantRoleBindings(t) }) }) diff --git a/e2e/owners_test.go b/e2e/owners_test.go index fc41b49b..b6309c4f 100644 --- a/e2e/owners_test.go +++ b/e2e/owners_test.go @@ -14,6 +14,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/meta" ) var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { @@ -123,6 +124,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, }, Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, CoreOwnerSpec: api.CoreOwnerSpec{ UserSpec: api.UserSpec{ Kind: api.GroupOwner, @@ -143,6 +145,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, }, Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, CoreOwnerSpec: api.CoreOwnerSpec{ UserSpec: api.UserSpec{ Kind: api.GroupOwner, @@ -164,6 +167,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, }, Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, CoreOwnerSpec: api.CoreOwnerSpec{ UserSpec: api.UserSpec{ Kind: api.ServiceAccountOwner, @@ -176,6 +180,49 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, } + tnt1Owner := &capsulev1beta2.TenantOwner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-owners-tnt", + Labels: map[string]string{ + meta.NewTenantLabel: tnt1.GetName(), + }, + }, + Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: true, + CoreOwnerSpec: api.CoreOwnerSpec{ + UserSpec: api.UserSpec{ + Kind: api.UserOwner, + Name: "tnt-1-user", + }, + ClusterRoles: []string{ + "service-admin", + }, + }, + }, + } + + userOwnersCommon := &capsulev1beta2.TenantOwner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-owners-common-user", + Labels: map[string]string{ + "team": "infrastructure", + "customer": "x", + }, + }, + Spec: capsulev1beta2.TenantOwnerSpec{ + Aggregate: false, + CoreOwnerSpec: api.CoreOwnerSpec{ + UserSpec: api.UserSpec{ + Kind: api.UserOwner, + Name: "some-user", + }, + ClusterRoles: []string{ + "service-admin", + }, + }, + }, + } + JustBeforeEach(func() { Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed()) @@ -187,7 +234,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }).Should(Succeed()) } - for _, tnt := range []*capsulev1beta2.TenantOwner{ownersInfra, ownersDevops, ownersCommon} { + for _, tnt := range []*capsulev1beta2.TenantOwner{ownersInfra, ownersDevops, ownersCommon, userOwnersCommon, tnt1Owner} { EventuallyCreation(func() error { tnt.ResourceVersion = "" @@ -202,13 +249,39 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { Expect(client.IgnoreNotFound(err)).To(Succeed()) } - for _, owners := range []*capsulev1beta2.TenantOwner{ownersInfra, ownersDevops, ownersCommon} { + for _, owners := range []*capsulev1beta2.TenantOwner{ownersInfra, ownersDevops, ownersCommon, userOwnersCommon, tnt1Owner} { err := k8sClient.Delete(context.TODO(), owners) Expect(client.IgnoreNotFound(err)).To(Succeed()) } }) It("Verify owners for", func() { + By("checking configuration", func() { + Eventually(func(g Gomega) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + err := k8sClient.Get( + context.Background(), + client.ObjectKey{Name: defaultConfigurationName}, + cfg, + ) + g.Expect(err).ToNot(HaveOccurred()) + + expected := api.UserListSpec{ + {Kind: ownersInfra.Spec.Kind, Name: ownersInfra.Spec.Name}, + {Kind: ownersDevops.Spec.Kind, Name: ownersDevops.Spec.Name}, + {Kind: ownersCommon.Spec.Kind, Name: ownersCommon.Spec.Name}, + {Kind: tnt1Owner.Spec.Kind, Name: tnt1Owner.Spec.Name}, + {Kind: api.GroupOwner, Name: "projectcapsule.dev"}, + } + + g.Expect(cfg.Status.Users).To(ConsistOf(expected)) + + g.Expect(cfg.Status.Users).NotTo(ContainElement( + api.UserSpec{Kind: userOwnersCommon.Spec.Kind, Name: userOwnersCommon.Spec.Name}, + )) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + By("checking owners (e2e-owners-1)", func() { t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) @@ -242,6 +315,20 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, + { + UserSpec: api.UserSpec{ + Kind: tnt1Owner.Spec.Kind, + Name: tnt1Owner.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -250,6 +337,41 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { VerifyTenantRoleBindings(t) }) + By("creating namespaces (e2e-owners-1)", func() { + for _, u := range []api.UserSpec{ + api.UserSpec{ + Kind: api.GroupOwner, + Name: "e2e-owners-1-group", + }, + api.UserSpec{ + Kind: api.GroupOwner, + Name: "oidc:comp:devops", + }, + api.UserSpec{ + Kind: api.ServiceAccountOwner, + Name: "system:serviceaccount:capsule-system:capsule", + }, + api.UserSpec{ + Kind: api.UserOwner, + Name: "e2e-owners-1", + }, + api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + api.UserSpec{ + Kind: tnt1Owner.Spec.Kind, + Name: tnt1Owner.Spec.Name, + }, + } { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt1.GetName(), + }) + NamespaceCreation(ns, u, defaultTimeoutInterval).Should(Succeed()) + TenantNamespaceList(tnt1, defaultTimeoutInterval).Should(ContainElements(ns.GetName())) + } + }) + By("checking owners (e2e-owners-2)", func() { t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt2.GetName()}, t)).Should(Succeed()) @@ -283,6 +405,14 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -291,10 +421,62 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { VerifyTenantRoleBindings(t) }) + By("creating namespaces (e2e-owners-2)", func() { + for _, u := range []api.UserSpec{ + api.UserSpec{ + Kind: api.GroupOwner, + Name: "e2e-owners-2-group", + }, + api.UserSpec{ + Kind: api.GroupOwner, + Name: "oidc:comp:administrators", + }, + api.UserSpec{ + Kind: api.ServiceAccountOwner, + Name: "system:serviceaccount:capsule-system:capsule", + }, + api.UserSpec{ + Kind: api.UserOwner, + Name: "e2e-owners-2", + }, + api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + } { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt2.GetName(), + }) + NamespaceCreation(ns, u, defaultTimeoutInterval).Should(Succeed()) + TenantNamespaceList(tnt2, defaultTimeoutInterval).Should(ContainElements(ns.GetName())) + } + }) + By("remove common tenant-owners", func() { Expect(k8sClient.Delete(context.TODO(), ownersCommon)).Should(Succeed()) }) + By("checking configuration", func() { + Eventually(func(g Gomega) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + err := k8sClient.Get( + context.Background(), + client.ObjectKey{Name: defaultConfigurationName}, + cfg, + ) + g.Expect(err).ToNot(HaveOccurred()) + + expected := api.UserListSpec{ + {Kind: ownersInfra.Spec.Kind, Name: ownersInfra.Spec.Name}, + {Kind: ownersDevops.Spec.Kind, Name: ownersDevops.Spec.Name}, + {Kind: tnt1Owner.Spec.Kind, Name: tnt1Owner.Spec.Name}, + {Kind: api.GroupOwner, Name: "projectcapsule.dev"}, + } + + g.Expect(cfg.Status.Users).To(ConsistOf(expected)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + By("checking owners (e2e-owners-1)", func() { t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) @@ -328,6 +510,20 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, + { + UserSpec: api.UserSpec{ + Kind: tnt1Owner.Spec.Kind, + Name: tnt1Owner.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -369,6 +565,13 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -381,6 +584,26 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { Expect(k8sClient.Delete(context.TODO(), ownersInfra)).Should(Succeed()) }) + By("checking configuration", func() { + Eventually(func(g Gomega) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + err := k8sClient.Get( + context.Background(), + client.ObjectKey{Name: defaultConfigurationName}, + cfg, + ) + g.Expect(err).ToNot(HaveOccurred()) + + expected := api.UserListSpec{ + {Kind: ownersDevops.Spec.Kind, Name: ownersDevops.Spec.Name}, + {Kind: tnt1Owner.Spec.Kind, Name: tnt1Owner.Spec.Name}, + {Kind: api.GroupOwner, Name: "projectcapsule.dev"}, + } + + g.Expect(cfg.Status.Users).To(ConsistOf(expected)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + By("checking owners (e2e-owners-1)", func() { t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) @@ -414,6 +637,20 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, + { + UserSpec: api.UserSpec{ + Kind: tnt1Owner.Spec.Kind, + Name: tnt1Owner.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -448,6 +685,13 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -460,6 +704,25 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { Expect(k8sClient.Delete(context.TODO(), ownersDevops)).Should(Succeed()) }) + By("checking configuration", func() { + Eventually(func(g Gomega) { + cfg := &capsulev1beta2.CapsuleConfiguration{} + err := k8sClient.Get( + context.Background(), + client.ObjectKey{Name: defaultConfigurationName}, + cfg, + ) + g.Expect(err).ToNot(HaveOccurred()) + + expected := api.UserListSpec{ + {Kind: tnt1Owner.Spec.Kind, Name: tnt1Owner.Spec.Name}, + {Kind: api.GroupOwner, Name: "projectcapsule.dev"}, + } + + g.Expect(cfg.Status.Users).To(ConsistOf(expected)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + By("checking owners (e2e-owners-1)", func() { t := &capsulev1beta2.Tenant{} Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) @@ -486,6 +749,20 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, + { + UserSpec: api.UserSpec{ + Kind: tnt1Owner.Spec.Kind, + Name: tnt1Owner.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). @@ -520,6 +797,13 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { }, ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, }, + { + UserSpec: api.UserSpec{ + Kind: userOwnersCommon.Spec.Kind, + Name: userOwnersCommon.Spec.Name, + }, + ClusterRoles: []string{"service-admin"}, + }, } Expect(normalizeOwners(t.Status.Owners)). diff --git a/e2e/suite_test.go b/e2e/suite_test.go index 77250708..366c53e0 100644 --- a/e2e/suite_test.go +++ b/e2e/suite_test.go @@ -25,6 +25,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/configuration" ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to @@ -65,6 +66,11 @@ var _ = BeforeSuite(func() { Expect(ctrlClient).ToNot(BeNil()) k8sClient = &e2eClient{Client: ctrlClient} + + ModifyCapsuleConfigurationOpts(func(cfg *capsulev1beta2.CapsuleConfiguration) { + cfg.Spec = configuration.DefaultCapsuleConfiguration() + }) + }) var _ = AfterSuite(func() { diff --git a/e2e/utils_test.go b/e2e/utils_test.go index 66876dd8..b770053a 100644 --- a/e2e/utils_test.go +++ b/e2e/utils_test.go @@ -29,6 +29,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/utils" ) const ( @@ -332,43 +333,38 @@ func VerifyTenantRoleBindings( tnt *capsulev1beta2.Tenant, ) { Eventually(func(g Gomega) { + roles := tnt.GetRoleBindings() + // List all RoleBindings once per namespace to avoid repeated API calls. for _, ns := range tnt.Status.Namespaces { - for i, owner := range tnt.Status.Owners { - for _, role := range owner.ClusterRoles { - rbName := fmt.Sprintf("capsule-%s-%d-%s", tnt.Name, i, role) + for _, role := range roles { + rbName := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(role)) - rb := &rbacv1.RoleBinding{} - err := k8sClient.Get(context.Background(), client.ObjectKey{ - Namespace: ns, - Name: rbName, - }, rb) + rb := &rbacv1.RoleBinding{} + err := k8sClient.Get(context.Background(), client.ObjectKey{ + Namespace: ns, + Name: rbName, + }, rb) - g.Expect(err).ToNot(HaveOccurred(), - "expected RoleBinding %s/%s to exist", ns, rbName) + g.Expect(err).ToNot(HaveOccurred(), + "expected RoleBinding %s/%s to exist (Owner: %s)", ns, rbName, role.Subjects, + ) - g.Expect(rb.RoleRef.Name).To(Equal(role), - "expected RoleBinding %s/%s to have RoleRef.Name=%q", - ns, rbName, role) + g.Expect(rb.RoleRef.Name).To(Equal(role.ClusterRoleName), + "expected RoleBinding %s/%s to have RoleRef.Name=%q", + ns, rbName, role.ClusterRoleName) - g.Expect(rb.Subjects).ToNot(BeEmpty(), - "expected RoleBinding %s/%s to have at least one subject", ns, rbName) + g.Expect(rb.Subjects).ToNot(BeEmpty(), + "expected RoleBinding %s/%s to have at least one subject", ns, rbName) - foundSubject := false - for _, s := range rb.Subjects { - if s.Kind == string(owner.Kind) && s.Name == owner.Name { - foundSubject = true - break - } - } + g.Expect(rb.Subjects).To(ConsistOf(role.Subjects), + "expected RoleBinding %s/%s to have exact subjects", + ns, rb.Name, + ) - g.Expect(foundSubject).To(BeTrue(), - "expected RoleBinding %s/%s to contain subject %s/%s", - ns, rb.Name, owner.Kind, owner.Name) - - } } } + }).WithTimeout(30 * time.Second).WithPolling(500 * time.Millisecond).Should(Succeed()) } diff --git a/go.mod b/go.mod index ce10bbcb..4d15431d 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( k8s.io/apimachinery v0.35.0 k8s.io/apiserver v0.35.0 k8s.io/client-go v0.35.0 - k8s.io/dynamic-resource-allocation v0.35.0 k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 sigs.k8s.io/cluster-api v1.12.1 sigs.k8s.io/controller-runtime v0.22.4 @@ -53,7 +52,6 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/go.sum b/go.sum index f8ae5cdf..f1463c1c 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,6 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= @@ -108,8 +106,6 @@ 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= @@ -124,8 +120,6 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -150,12 +144,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -179,10 +169,10 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= @@ -212,24 +202,22 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 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.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 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= @@ -244,55 +232,28 @@ 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= 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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= @@ -313,68 +274,32 @@ 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.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= -k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= -k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= -k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= -k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= -k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= -k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE= -k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= -k8s.io/apiserver v0.34.3 h1:uGH1qpDvSiYG4HVFqc6A3L4CKiX+aBWDrrsxHYK0Bdo= -k8s.io/apiserver v0.34.3/go.mod h1:QPnnahMO5C2m3lm6fPW3+JmyQbvHZQ8uudAu/493P2w= k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= -k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= -k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= -k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= -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.2 h1:HQRqK9x2sSAsd8+R4xxRirlTjowsg6fWCPwWYeSvogQ= -k8s.io/component-base v0.34.2/go.mod h1:9xw2FHJavUHBFpiGkZoKuYZ5pdtLKe97DEByaA+hHbM= -k8s.io/dynamic-resource-allocation v0.34.2 h1:SjlRGSWl6CZXoJwQNL+Y0wRfdH8PkJ4mHRNK6MMj0bY= -k8s.io/dynamic-resource-allocation v0.34.2/go.mod h1:ul6I+gfrCmC+OCuVdN0/iykyB2sPrIqh2WyKQ3RQPCU= -k8s.io/dynamic-resource-allocation v0.34.3/go.mod h1:eYjQqNaHLfqXT94lbSXEy8ZLaUg1mGJ2JCEtNWM7e7M= -k8s.io/dynamic-resource-allocation v0.35.0/go.mod h1:uaFga3VJtwyfpfZwpuJG7mlurWGQaaiGUa+QZmooz2U= +k8s.io/cluster-bootstrap v0.34.2 h1:oKckPeunVCns37BntcsxaOesDul32yzGd3DFLjW2fc8= +k8s.io/cluster-bootstrap v0.34.2/go.mod h1:f21byPR7X5nt12ivZi+J3pb4sG4SH6VySX8KAAJA8BY= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/utils v0.0.0-20251218160917-61b37f7a4624 h1:wadElzGW3vTZ1Et18CImPEErLaXvMSU5369b0to32+0= -k8s.io/utils v0.0.0-20251218160917-61b37f7a4624/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -k8s.io/utils v0.0.0-20251219084037-98d557b7f1e7 h1:H6xtwB5tC+KFSHoEhA1o7DnOtHDEo+n9OBSHjlajVKc= -k8s.io/utils v0.0.0-20251219084037-98d557b7f1e7/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -k8s.io/utils v0.0.0-20251222190033-383b50a9004e h1:cdvXqyVPudW9BZL5+lPjMedlEHJDVMnE6lzvcQaC5UE= -k8s.io/utils v0.0.0-20251222190033-383b50a9004e/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE= k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/cluster-api v1.11.3 h1:apxfugbP1X8AG7THCM74CTarCOW4H2oOc6hlbm1hY80= -sigs.k8s.io/cluster-api v1.11.3/go.mod h1:CA471SACi81M8DzRKTlWpHV33G0cfWEj7sC4fALFVok= sigs.k8s.io/cluster-api v1.12.1 h1:s3DivSZjXdu2HPyOtV/n6XwSZBaIycZdKNs4y8X+3lY= sigs.k8s.io/cluster-api v1.12.1/go.mod h1:+S6WJdi8UPdqv5q9nka5al3ed/Qa0zAcSBgzTaa9VKA= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= -sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8= sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/hack/distro/capsule/example-setup/kustomization.yaml b/hack/distro/capsule/example-setup/kustomization.yaml new file mode 100644 index 00000000..3ca18b75 --- /dev/null +++ b/hack/distro/capsule/example-setup/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - owners.yaml + - tenants.yaml + - resource.yaml diff --git a/hack/distro/capsule/example-setup/owners.yaml b/hack/distro/capsule/example-setup/owners.yaml new file mode 100644 index 00000000..79c4b68f --- /dev/null +++ b/hack/distro/capsule/example-setup/owners.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + team: devops + name: devops +spec: + kind: Group + name: "oidc:org:devops" +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: TenantOwner +metadata: + labels: + team: platform + name: platform +spec: + kind: Group + name: "oidc:org:platform" diff --git a/hack/distro/capsule/example-setup/resource.yaml b/hack/distro/capsule/example-setup/resource.yaml new file mode 100644 index 00000000..afc33992 --- /dev/null +++ b/hack/distro/capsule/example-setup/resource.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: GlobalTenantResource +metadata: + name: custom-cm + 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" diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml new file mode 100644 index 00000000..79cb8907 --- /dev/null +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -0,0 +1,63 @@ +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: solar +spec: + permissions: + matchOwners: + - matchLabels: + team: platform + - matchLabels: + tenant: solar + owners: + - name: alice + kind: User + additionalRoleBindings: + - clusterRoleName: 'view' + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: joe +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: green +spec: + permissions: + matchOwners: + - matchLabels: + team: devops + - matchLabels: + tenant: green + owners: + - name: bob + kind: User + additionalRoleBindings: + - clusterRoleName: 'view' + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: alice +--- +apiVersion: capsule.clastix.io/v1beta2 +kind: Tenant +metadata: + name: wind +spec: + permissions: + matchOwners: + - matchLabels: + team: devops + - matchLabels: + tenant: wind + owners: + - name: joe + kind: User + additionalRoleBindings: + - clusterRoleName: 'view' + subjects: + - apiGroup: rbac.authorization.k8s.io + kind: Group + name: wind-users diff --git a/hack/distro/capsule/kustomization.yaml b/hack/distro/capsule/kustomization.yaml new file mode 100644 index 00000000..7fcbf108 --- /dev/null +++ b/hack/distro/capsule/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - release.flux.yaml diff --git a/hack/distro/capsule/release.flux.yaml b/hack/distro/capsule/release.flux.yaml new file mode 100644 index 00000000..89a93cd5 --- /dev/null +++ b/hack/distro/capsule/release.flux.yaml @@ -0,0 +1,42 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: capsule + namespace: flux-system +spec: + serviceAccountName: kustomize-controller + interval: 30s + timeout: 10m + targetNamespace: capsule-system + releaseName: "capsule" + chart: + spec: + chart: capsule + version: "0.12.4" + sourceRef: + kind: HelmRepository + name: capsule + interval: 24h + install: + createNamespace: true + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + retries: -1 + driftDetection: + mode: enabled + ignore: + - paths: ["/spec/replicas"] +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: capsule + namespace: flux-system +spec: + type: "oci" + interval: 12h0m0s + url: oci://ghcr.io/projectcapsule/charts diff --git a/internal/controllers/cfg/manager.go b/internal/controllers/cfg/manager.go index 76d01dfe..7e152b25 100644 --- a/internal/controllers/cfg/manager.go +++ b/internal/controllers/cfg/manager.go @@ -5,42 +5,169 @@ package config import ( "context" + "fmt" "github.com/go-logr/logr" "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" 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/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "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/internal/controllers/utils" + "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/configuration" ) type Manager struct { - client client.Client + client.Client Log logr.Logger } -func (c *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { - c.client = mgr.GetClient() - +func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { return ctrl.NewControllerManagedBy(mgr). For(&capsulev1beta2.CapsuleConfiguration{}, utils.NamesMatchingPredicate(ctrlConfig.ConfigurationName)). - Complete(c) + Watches( + &capsulev1beta2.TenantOwner{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + return []reconcile.Request{ + { + NamespacedName: types.NamespacedName{ + Name: ctrlConfig.ConfigurationName, + }, + }, + } + }), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + to, ok := e.Object.(*capsulev1beta2.TenantOwner) + + return ok && to.Spec.Aggregate + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldTo, ok1 := e.ObjectOld.(*capsulev1beta2.TenantOwner) + newTo, ok2 := e.ObjectNew.(*capsulev1beta2.TenantOwner) + + if !ok1 || !ok2 { + return false + } + + if oldTo.Spec.Aggregate != newTo.Spec.Aggregate { + return true + } + + if oldTo.Spec.Name != newTo.Spec.Name { + return true + } + + if oldTo.Spec.Kind != newTo.Spec.Kind { + return true + } + + return false + }, + DeleteFunc: func(e event.DeleteEvent) bool { + to, ok := e.Object.(*capsulev1beta2.TenantOwner) + + return ok && to.Spec.Aggregate + }, + }), + ). + Complete(r) } -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) +func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) { + r.Log.V(5).Info("CapsuleConfiguration reconciliation started", "request.name", request.Name) + + cfg := configuration.NewCapsuleConfiguration(ctx, r.Client, request.Name) + + instance := &capsulev1beta2.CapsuleConfiguration{} + if err = r.Get(ctx, request.NamespacedName, instance); err != nil { + if apierrors.IsNotFound(err) { + r.Log.V(3).Info("Request object not found, could have been deleted after reconcile request") + + return reconcile.Result{}, nil + } + + r.Log.Error(err, "Error reading the object") + + return res, err + } + + defer func() { + if uerr := r.updateConfigStatus(ctx, instance); uerr != nil { + err = fmt.Errorf("cannot update config status: %w", uerr) + + return + } + }() - cfg := configuration.NewCapsuleConfiguration(ctx, c.client, request.Name) // Validating the Capsule Configuration options if _, err = cfg.ProtectedNamespaceRegexp(); err != nil { panic(errors.Wrap(err, "Invalid configuration for protected Namespace regex")) } - c.Log.V(5).Info("CapsuleConfiguration reconciliation finished", "request.name", request.Name) + r.Log.V(5).Info("Validated Regex") + + if err := r.gatherCapsuleUsers(ctx, instance, cfg); err != nil { + return reconcile.Result{}, err + } + + r.Log.V(5).Info("Gathered users", "users", len(instance.Status.Users)) return res, err } + +func (r *Manager) gatherCapsuleUsers( + ctx context.Context, + instance *capsulev1beta2.CapsuleConfiguration, + cfg configuration.Configuration, +) (err error) { + users := cfg.Users() + + toList := &capsulev1beta2.TenantOwnerList{} + if err := r.List(ctx, toList); err != nil { + return fmt.Errorf("listing TenantOwner CRs: %w", err) + } + + for i := range toList.Items { + to := &toList.Items[i] + + if !to.Spec.Aggregate { + continue + } + + users.Upsert(api.UserSpec{ + Kind: to.Spec.Kind, + Name: to.Spec.Name, + }) + } + + instance.Status.Users = users + + return nil +} + +func (r *Manager) updateConfigStatus( + ctx context.Context, + instance *capsulev1beta2.CapsuleConfiguration, +) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { + latest := &capsulev1beta2.CapsuleConfiguration{} + if err = r.Get(ctx, types.NamespacedName{Name: instance.GetName(), Namespace: instance.GetNamespace()}, latest); err != nil { + return err + } + + latest.Status = instance.Status + + return r.Status().Update(ctx, latest) + }) +} diff --git a/internal/controllers/rbac/manager.go b/internal/controllers/rbac/manager.go index 83ec53d0..0b8acbc0 100644 --- a/internal/controllers/rbac/manager.go +++ b/internal/controllers/rbac/manager.go @@ -114,7 +114,12 @@ func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) erro crb.RoleRef = api.ProvisionerClusterRoleBinding.RoleRef crb.Subjects = nil - for _, entity := range r.Configuration.Administrators() { + users := r.Configuration.GetUsersByStatus() + for _, u := range r.Configuration.Administrators() { + users.Upsert(u) + } + + for _, entity := range users { switch entity.Kind { case api.UserOwner: crb.Subjects = append(crb.Subjects, rbacv1.Subject{ @@ -140,20 +145,6 @@ func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) erro } } - for _, group := range r.Configuration.UserGroups() { - crb.Subjects = append(crb.Subjects, rbacv1.Subject{ - Kind: rbacv1.GroupKind, - Name: group, - }) - } - - for _, user := range r.Configuration.UserNames() { - crb.Subjects = append(crb.Subjects, rbacv1.Subject{ - Kind: rbacv1.UserKind, - Name: user, - }) - } - if r.Configuration.AllowServiceAccountPromotion() { saList := &corev1.ServiceAccountList{} if err := r.Client.List(ctx, saList, client.MatchingLabels{ diff --git a/internal/controllers/tenant/rolebindings.go b/internal/controllers/tenant/rolebindings.go index 5add5878..60d7ac05 100644 --- a/internal/controllers/tenant/rolebindings.go +++ b/internal/controllers/tenant/rolebindings.go @@ -6,8 +6,6 @@ package tenant import ( "context" "fmt" - "hash/fnv" - "strings" "golang.org/x/sync/errgroup" rbacv1 "k8s.io/api/rbac/v1" @@ -17,64 +15,21 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/utils" ) -func (r *Manager) userClusterRoleBindings(owner api.CoreOwnerSpec, clusterRole string) api.AdditionalRoleBindingsSpec { - var subject rbacv1.Subject - - if owner.Kind == "ServiceAccount" { - splitName := strings.Split(owner.Name, ":") - - subject = rbacv1.Subject{ - Kind: owner.Kind.String(), - Name: splitName[len(splitName)-1], - Namespace: splitName[len(splitName)-2], - } - } else { - subject = rbacv1.Subject{ - APIGroup: rbacv1.GroupName, - Kind: owner.Kind.String(), - Name: owner.Name, - } - } - - return api.AdditionalRoleBindingsSpec{ - ClusterRoleName: clusterRole, - Subjects: []rbacv1.Subject{ - subject, - }, - } -} - // Sync the dynamic Tenant Owner specific cluster-roles and additional Role Bindings, which can be used in many ways: // applying Pod Security Policies or giving access to CRDs or specific API groups. func (r *Manager) syncRoleBindings(ctx context.Context, tenant *capsulev1beta2.Tenant) (err error) { - // hashing the RoleBinding name due to DNS RFC-1123 applied to Kubernetes labels - hashFn := func(binding api.AdditionalRoleBindingsSpec) string { - h := fnv.New64a() + roleBindings := tenant.GetRoleBindings() - _, _ = h.Write([]byte(binding.ClusterRoleName)) + // Hashing + hashes := map[string]api.AdditionalRoleBindingsSpec{} - for _, sub := range binding.Subjects { - _, _ = h.Write([]byte(sub.Kind + sub.Name)) - } + for _, binding := range roleBindings { + hash := utils.RoleBindingHashFunc(binding) - return fmt.Sprintf("%x", h.Sum64()) - } - - // getting requested Role Binding keys - keys := make([]string, 0, len(tenant.Status.Owners)) - // Generating for dynamic tenant owners cluster roles - for _, owner := range tenant.Status.Owners { - for _, clusterRoleName := range owner.ClusterRoles { - cr := r.userClusterRoleBindings(owner, clusterRoleName) - - keys = append(keys, hashFn(cr)) - } - } - // Generating hash of additional role bindings - for _, i := range tenant.Spec.AdditionalRoleBindings { - keys = append(keys, hashFn(i)) + hashes[hash] = binding } group := new(errgroup.Group) @@ -83,34 +38,29 @@ func (r *Manager) syncRoleBindings(ctx context.Context, tenant *capsulev1beta2.T namespace := ns group.Go(func() error { - return r.syncAdditionalRoleBinding(ctx, tenant, namespace, keys, hashFn) + return r.syncAdditionalRoleBinding(ctx, tenant, namespace, hashes) }) } return group.Wait() } -func (r *Manager) syncAdditionalRoleBinding(ctx context.Context, tenant *capsulev1beta2.Tenant, ns string, keys []string, hashFn func(binding api.AdditionalRoleBindingsSpec) string) (err error) { - if err = r.pruningResources(ctx, ns, keys, &rbacv1.RoleBinding{}); err != nil { - return err - } +func (r *Manager) syncAdditionalRoleBinding( + ctx context.Context, + tenant *capsulev1beta2.Tenant, + ns string, + bindings map[string]api.AdditionalRoleBindingsSpec, +) (err error) { + keys := []string{} - roleBindings := make([]api.AdditionalRoleBindingsSpec, 0) + for hash, roleBinding := range bindings { + name := meta.NameForManagedRoleBindings(hash) - for _, owner := range tenant.Status.Owners { - for _, clusterRoleName := range owner.ClusterRoles { - roleBindings = append(roleBindings, r.userClusterRoleBindings(owner, clusterRoleName)) - } - } - - roleBindings = append(roleBindings, tenant.Spec.AdditionalRoleBindings...) - - for i, roleBinding := range roleBindings { - roleBindingHashLabel := hashFn(roleBinding) + keys = append(keys, hash) target := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("capsule-%s-%d-%s", tenant.Name, i, roleBinding.ClusterRoleName), + Name: name, Namespace: ns, }, } @@ -126,7 +76,7 @@ func (r *Manager) syncAdditionalRoleBinding(ctx context.Context, tenant *capsule } target.Labels[meta.TenantLabel] = tenant.Name - target.Labels[meta.RolebindingLabel] = roleBindingHashLabel + target.Labels[meta.RolebindingLabel] = hash if roleBinding.Annotations != nil { target.Annotations = roleBinding.Annotations @@ -156,5 +106,6 @@ func (r *Manager) syncAdditionalRoleBinding(ctx context.Context, tenant *capsule } } - return nil + // Prune at finish to prevent gaps + return r.pruningResources(ctx, ns, keys, &rbacv1.RoleBinding{}) } diff --git a/pkg/api/meta/labels.go b/pkg/api/meta/labels.go index cf23aa3e..e452acae 100644 --- a/pkg/api/meta/labels.go +++ b/pkg/api/meta/labels.go @@ -11,7 +11,9 @@ import ( const ( TenantNameLabel = "kubernetes.io/metadata.name" - TenantLabel = "capsule.clastix.io/tenant" + + TenantLabel = "capsule.clastix.io/tenant" + NewTenantLabel = "projectcapsule.dev/tenant" ResourcePoolLabel = "projectcapsule.dev/pool" diff --git a/pkg/api/meta/names.go b/pkg/api/meta/names.go new file mode 100644 index 00000000..d6ffbdbf --- /dev/null +++ b/pkg/api/meta/names.go @@ -0,0 +1,10 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package meta + +import "fmt" + +func NameForManagedRoleBindings(hash string) string { + return fmt.Sprintf("capsule:managed:%s", hash) +} diff --git a/pkg/api/misc/selectors.go b/pkg/api/misc/selectors.go index d9e76490..003c9171 100644 --- a/pkg/api/misc/selectors.go +++ b/pkg/api/misc/selectors.go @@ -23,7 +23,10 @@ type NamespaceSelector struct { } // GetMatchingNamespaces retrieves the list of namespaces that match the NamespaceSelector. -func (s *NamespaceSelector) GetMatchingNamespaces(ctx context.Context, client client.Client) ([]corev1.Namespace, error) { +func (s *NamespaceSelector) GetMatchingNamespaces( + ctx context.Context, + c client.Client, +) ([]corev1.Namespace, error) { if s.LabelSelector == nil { return nil, nil // No namespace selector means all namespaces } @@ -34,7 +37,7 @@ func (s *NamespaceSelector) GetMatchingNamespaces(ctx context.Context, client cl } namespaceList := &corev1.NamespaceList{} - if err := client.List(ctx, namespaceList); err != nil { + if err := c.List(ctx, namespaceList); err != nil { return nil, fmt.Errorf("failed to list namespaces: %w", err) } @@ -49,6 +52,76 @@ func (s *NamespaceSelector) GetMatchingNamespaces(ctx context.Context, client cl return matchingNamespaces, nil } +// Selector for resources and their labels or selecting origin namespaces +// +kubebuilder:object:generate=true +type SelectorWithNamespaceSelector struct { + // Select Items based on their labels. If the namespaceSelector is also set, the selector is applied + // to items within the selected namespaces. Otherwise for all the items. + *metav1.LabelSelector `json:",inline"` + + // NamespaceSelector for filtering namespaces by labels where items can be located in + NamespaceSelector *NamespaceSelector `json:"namespaceSelector,omitempty"` +} + +func (s *SelectorWithNamespaceSelector) MatchObjects( + ctx context.Context, + c client.Client, + objects []metav1.Object, +) ([]metav1.Object, error) { + if s == nil { + return nil, nil + } + + var objSelector labels.Selector + + if s.LabelSelector != nil { + var err error + + objSelector, err = metav1.LabelSelectorAsSelector(s.LabelSelector) + if err != nil { + return nil, fmt.Errorf("invalid namespace selector: %w", err) + } + } + + labelFilteredObjects := make([]metav1.Object, 0, len(objects)) + + for _, obj := range objects { + if objSelector != nil && !objSelector.Matches(labels.Set(obj.GetLabels())) { + continue // Skip non-matching objects + } + + labelFilteredObjects = append(labelFilteredObjects, obj) + } + + if s.NamespaceSelector == nil { + return labelFilteredObjects, nil + } + + matchingNamespaces, err := s.NamespaceSelector.GetMatchingNamespaces(ctx, c) + if err != nil { + return nil, fmt.Errorf("error fetching matching namespaces: %w", err) + } + + namespaceSet := make(map[string]struct{}) + for _, ns := range matchingNamespaces { + namespaceSet[ns.Name] = struct{}{} + } + + finalMatchingObjects := make([]metav1.Object, 0, len(labelFilteredObjects)) + + for _, obj := range labelFilteredObjects { + if len(namespaceSet) > 0 { + if _, exists := namespaceSet[obj.GetNamespace()]; !exists { + continue // Skip objects in disallowed namespaces + } + } + + finalMatchingObjects = append(finalMatchingObjects, obj) + } + + return finalMatchingObjects, nil +} + // ListBySelectors lists objects of type T (using list L), then returns all items that // match ANY of the provided LabelSelectors. The result is unique by namespace/name. func ListBySelectors[T client.Object]( diff --git a/pkg/api/misc/zz_generated.deepcopy.go b/pkg/api/misc/zz_generated.deepcopy.go index 21db5fa4..90ee0e31 100644 --- a/pkg/api/misc/zz_generated.deepcopy.go +++ b/pkg/api/misc/zz_generated.deepcopy.go @@ -30,3 +30,28 @@ func (in *NamespaceSelector) DeepCopy() *NamespaceSelector { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SelectorWithNamespaceSelector) DeepCopyInto(out *SelectorWithNamespaceSelector) { + *out = *in + if in.LabelSelector != nil { + in, out := &in.LabelSelector, &out.LabelSelector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } + if in.NamespaceSelector != nil { + in, out := &in.NamespaceSelector, &out.NamespaceSelector + *out = new(NamespaceSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SelectorWithNamespaceSelector. +func (in *SelectorWithNamespaceSelector) DeepCopy() *SelectorWithNamespaceSelector { + if in == nil { + return nil + } + out := new(SelectorWithNamespaceSelector) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/api/owner.go b/pkg/api/owner.go index 4a59909e..f400f08c 100644 --- a/pkg/api/owner.go +++ b/pkg/api/owner.go @@ -3,6 +3,10 @@ package api +import ( + rbacv1 "k8s.io/api/rbac/v1" +) + // +kubebuilder:object:generate=true type OwnerSpec struct { @@ -26,6 +30,21 @@ type CoreOwnerSpec struct { ClusterRoles []string `json:"clusterRoles,omitempty"` } +func (o CoreOwnerSpec) ToAdditionalRolebindings() []AdditionalRoleBindingsSpec { + bindings := make([]AdditionalRoleBindingsSpec, 0, len(o.ClusterRoles)) + + for _, clusterRoleName := range o.ClusterRoles { + bindings = append(bindings, AdditionalRoleBindingsSpec{ + ClusterRoleName: clusterRoleName, + Subjects: []rbacv1.Subject{ + o.Subject(), + }, + }) + } + + return bindings +} + // +kubebuilder:validation:Enum=User;Group;ServiceAccount type OwnerKind string diff --git a/pkg/api/owner_status_list.go b/pkg/api/owner_status_list.go index 100e7396..67943935 100644 --- a/pkg/api/owner_status_list.go +++ b/pkg/api/owner_status_list.go @@ -48,6 +48,8 @@ func (o *OwnerStatusListSpec) Upsert( } } + sort.Strings(existing.ClusterRoles) + *o = owners return diff --git a/pkg/api/owner_status_list_test.go b/pkg/api/owner_status_list_test.go index 78ccd343..61549f97 100644 --- a/pkg/api/owner_status_list_test.go +++ b/pkg/api/owner_status_list_test.go @@ -132,7 +132,7 @@ func TestUpsert_DeduplicatesClusterRoles(t *testing.T) { } got := list[0] - expected := []string{"admin", "viewer", "editor"} + expected := []string{"admin", "editor", "viewer"} if !reflect.DeepEqual(got.ClusterRoles, expected) { t.Fatalf("expected roles %v, got %v", expected, got.ClusterRoles) } diff --git a/pkg/api/owner_test.go b/pkg/api/owner_test.go new file mode 100644 index 00000000..8156cdad --- /dev/null +++ b/pkg/api/owner_test.go @@ -0,0 +1,121 @@ +package api_test + +import ( + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func TestCoreOwnerSpec_ToAdditionalRolebindings(t *testing.T) { + tests := []struct { + name string + in api.CoreOwnerSpec + want []api.AdditionalRoleBindingsSpec + }{ + { + name: "no cluster roles yields empty slice", + in: api.CoreOwnerSpec{ + UserSpec: api.UserSpec{ + Kind: api.UserOwner, + Name: "alice", + }, + ClusterRoles: nil, + }, + want: []api.AdditionalRoleBindingsSpec{}, + }, + { + name: "one role creates one binding with subject", + in: api.CoreOwnerSpec{ + UserSpec: api.UserSpec{ + Kind: api.UserOwner, + Name: "alice", + }, + ClusterRoles: []string{"admin"}, + }, + want: []api.AdditionalRoleBindingsSpec{ + { + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{ + {APIGroup: rbacv1.GroupName, Kind: "User", Name: "alice"}, + }, + }, + }, + }, + { + name: "multiple roles create one binding per role (preserves order)", + in: api.CoreOwnerSpec{ + UserSpec: api.UserSpec{ + Kind: api.GroupOwner, + Name: "devops", + }, + ClusterRoles: []string{"view", "edit"}, + }, + want: []api.AdditionalRoleBindingsSpec{ + { + ClusterRoleName: "view", + Subjects: []rbacv1.Subject{ + {APIGroup: rbacv1.GroupName, Kind: "Group", Name: "devops"}, + }, + }, + { + ClusterRoleName: "edit", + Subjects: []rbacv1.Subject{ + {APIGroup: rbacv1.GroupName, Kind: "Group", Name: "devops"}, + }, + }, + }, + }, + { + name: "serviceaccount subject is split correctly in bindings", + in: api.CoreOwnerSpec{ + UserSpec: api.UserSpec{ + Kind: api.ServiceAccountOwner, + Name: "system:serviceaccount:capsule-system:capsule", + }, + ClusterRoles: []string{"admin", "service-admin"}, + }, + want: []api.AdditionalRoleBindingsSpec{ + { + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{ + {Kind: "ServiceAccount", Namespace: "capsule-system", Name: "capsule"}, + }, + }, + { + ClusterRoleName: "service-admin", + Subjects: []rbacv1.Subject{ + {Kind: "ServiceAccount", Namespace: "capsule-system", Name: "capsule"}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.in.ToAdditionalRolebindings() + + if len(got) != len(tt.want) { + t.Fatalf("expected %d bindings, got %d: %#v", len(tt.want), len(got), got) + } + + for i := range tt.want { + if got[i].ClusterRoleName != tt.want[i].ClusterRoleName { + t.Fatalf("binding[%d].ClusterRoleName: expected %q, got %q", i, tt.want[i].ClusterRoleName, got[i].ClusterRoleName) + } + + if len(got[i].Subjects) != len(tt.want[i].Subjects) { + t.Fatalf("binding[%d].Subjects length: expected %d, got %d", i, len(tt.want[i].Subjects), len(got[i].Subjects)) + } + + for j := range tt.want[i].Subjects { + if got[i].Subjects[j] != tt.want[i].Subjects[j] { + t.Fatalf("binding[%d].Subjects[%d]: expected %#v, got %#v", i, j, tt.want[i].Subjects[j], got[i].Subjects[j]) + } + } + } + }) + } +} diff --git a/pkg/api/users.go b/pkg/api/users.go index 353bc9a4..093f994e 100644 --- a/pkg/api/users.go +++ b/pkg/api/users.go @@ -3,6 +3,12 @@ package api +import ( + "strings" + + rbacv1 "k8s.io/api/rbac/v1" +) + // +kubebuilder:validation:Enum=User;Group;ServiceAccount type UserKind string @@ -17,3 +23,23 @@ type UserSpec struct { // Name of the entity. Name string `json:"name"` } + +func (u UserSpec) Subject() (subject rbacv1.Subject) { + if u.Kind == ServiceAccountOwner { + splitName := strings.Split(u.Name, ":") + + subject = rbacv1.Subject{ + Kind: u.Kind.String(), + Name: splitName[len(splitName)-1], + Namespace: splitName[len(splitName)-2], + } + } else { + subject = rbacv1.Subject{ + APIGroup: rbacv1.GroupName, + Kind: u.Kind.String(), + Name: u.Name, + } + } + + return subject +} diff --git a/pkg/api/users_list.go b/pkg/api/users_list.go index edb42118..fd833ac3 100644 --- a/pkg/api/users_list.go +++ b/pkg/api/users_list.go @@ -10,6 +10,37 @@ import ( // +kubebuilder:object:generate=true type UserListSpec []UserSpec +func (o *UserListSpec) Upsert(newUser UserSpec) { + users := *o + + // Comparator consistent with ByKindName + less := func(a, b UserSpec) bool { + ak, bk := a.Kind.String(), b.Kind.String() + if ak != bk { + return ak < bk + } + + return a.Name < b.Name + } + + // Ensure sorted before binary search + sort.Sort(ByKindName(users)) + + // Find first index where users[i] >= newUser + idx := sort.Search(len(users), func(i int) bool { + return !less(users[i], newUser) + }) + + // In this case merging for duplicates makes little sense as the values are identical + if idx < len(users) && !less(newUser, users[idx]) && !less(users[idx], newUser) { + return + } + + users = append(users, newUser) + sort.Sort(ByKindName(users)) + *o = users +} + func (u UserListSpec) IsPresent(name string, groups []string) bool { groupSet := make(map[string]struct{}, len(groups)) for _, g := range groups { diff --git a/pkg/api/users_test.go b/pkg/api/users_test.go new file mode 100644 index 00000000..0efbca3b --- /dev/null +++ b/pkg/api/users_test.go @@ -0,0 +1,105 @@ +package api_test + +import ( + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func TestUserSpec_Subject_ServiceAccount(t *testing.T) { + tests := []struct { + name string + in api.UserSpec + want rbacv1.Subject + }{ + { + name: "system serviceaccount format", + in: api.UserSpec{ + Kind: api.ServiceAccountOwner, + Name: "system:serviceaccount:capsule-system:capsule", + }, + want: rbacv1.Subject{ + Kind: "ServiceAccount", + Namespace: "capsule-system", + Name: "capsule", + }, + }, + { + name: "minimal ns:name style (still splits from end)", + in: api.UserSpec{ + Kind: api.ServiceAccountOwner, + Name: "ns:sa", + }, + want: rbacv1.Subject{ + Kind: "ServiceAccount", + Namespace: "ns", + Name: "sa", + }, + }, + { + name: "extra segments (uses last two)", + in: api.UserSpec{ + Kind: api.ServiceAccountOwner, + Name: "a:b:c:d", + }, + want: rbacv1.Subject{ + Kind: "ServiceAccount", + Namespace: "c", + Name: "d", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.in.Subject() + if got != tt.want { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} + +func TestUserSpec_Subject_UserAndGroup(t *testing.T) { + tests := []struct { + name string + in api.UserSpec + want rbacv1.Subject + }{ + { + name: "user subject", + in: api.UserSpec{ + Kind: api.UserOwner, + Name: "alice", + }, + want: rbacv1.Subject{ + APIGroup: rbacv1.GroupName, + Kind: "User", + Name: "alice", + }, + }, + { + name: "group subject", + in: api.UserSpec{ + Kind: api.GroupOwner, + Name: "devops", + }, + want: rbacv1.Subject{ + APIGroup: rbacv1.GroupName, + Kind: "Group", + Name: "devops", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.in.Subject() + if got != tt.want { + t.Fatalf("expected %#v, got %#v", tt.want, got) + } + }) + } +} diff --git a/pkg/configuration/client.go b/pkg/configuration/client.go index 39949b1e..eb9227dd 100644 --- a/pkg/configuration/client.go +++ b/pkg/configuration/client.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -22,25 +23,50 @@ type capsuleConfiguration struct { retrievalFn func() *capsulev1beta2.CapsuleConfiguration } -func NewCapsuleConfiguration(ctx context.Context, client client.Client, name string) Configuration { +func DefaultCapsuleConfiguration() capsulev1beta2.CapsuleConfigurationSpec { + return capsulev1beta2.CapsuleConfigurationSpec{ + Users: []capsuleapi.UserSpec{ + { + Name: "projectcapsule.dev", + Kind: capsuleapi.GroupOwner, + }, + }, + ForceTenantPrefix: false, + ProtectedNamespaceRegexpString: "", + } +} + +func NewCapsuleConfiguration(ctx context.Context, c client.Client, name string) Configuration { return &capsuleConfiguration{retrievalFn: func() *capsulev1beta2.CapsuleConfiguration { - config := &capsulev1beta2.CapsuleConfiguration{} + cfg := &capsulev1beta2.CapsuleConfiguration{} + key := types.NamespacedName{Name: name} - if err := client.Get(ctx, types.NamespacedName{Name: name}, config); err != nil { - if apierrors.IsNotFound(err) { - return &capsulev1beta2.CapsuleConfiguration{ - Spec: capsulev1beta2.CapsuleConfigurationSpec{ - Users: []capsuleapi.UserSpec{{Name: "projectcapsule.dev", Kind: capsuleapi.GroupOwner}}, - ForceTenantPrefix: false, - ProtectedNamespaceRegexpString: "", - }, - } - } - - panic(errors.Wrap(err, "Cannot retrieve Capsule configuration with name "+name)) + if err := c.Get(ctx, key, cfg); err == nil { + return cfg + } else if !apierrors.IsNotFound(err) { + panic(errors.Wrap(err, "cannot retrieve Capsule configuration with name "+name)) } - return config + cfg = &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: DefaultCapsuleConfiguration(), + } + + if err := c.Create(ctx, cfg); err != nil { + if apierrors.IsAlreadyExists(err) { + if err := c.Get(ctx, key, cfg); err != nil { + panic(errors.Wrap(err, "configuration created concurrently but cannot be retrieved")) + } + + return cfg + } + + panic(errors.Wrap(err, "cannot create Capsule configuration with name "+name)) + } + + return cfg }} } @@ -96,6 +122,30 @@ func (c *capsuleConfiguration) UserNames() []string { return append(c.retrievalFn().Spec.UserNames, c.retrievalFn().Spec.Users.GetByKinds([]capsuleapi.OwnerKind{capsuleapi.UserOwner, capsuleapi.ServiceAccountOwner})...) } +func (c *capsuleConfiguration) Users() capsuleapi.UserListSpec { + out := capsuleapi.UserListSpec{} + + for _, user := range c.UserNames() { + out.Upsert(capsuleapi.UserSpec{ + Kind: capsuleapi.UserOwner, + Name: user, + }) + } + + for _, group := range c.UserGroups() { + out.Upsert(capsuleapi.UserSpec{ + Kind: capsuleapi.GroupOwner, + Name: group, + }) + } + + return out +} + +func (c *capsuleConfiguration) GetUsersByStatus() capsuleapi.UserListSpec { + return c.retrievalFn().Status.Users +} + func (c *capsuleConfiguration) IgnoreUserWithGroups() []string { return c.retrievalFn().Spec.IgnoreUserWithGroups } diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 72512fb2..6b208eb4 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -26,6 +26,8 @@ type Configuration interface { TenantCRDName() string UserNames() []string UserGroups() []string + Users() capsuleapi.UserListSpec + GetUsersByStatus() capsuleapi.UserListSpec IgnoreUserWithGroups() []string ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec diff --git a/pkg/utils/hashes.go b/pkg/utils/hashes.go new file mode 100644 index 00000000..42ab2eab --- /dev/null +++ b/pkg/utils/hashes.go @@ -0,0 +1,23 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "fmt" + "hash/fnv" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func RoleBindingHashFunc(binding api.AdditionalRoleBindingsSpec) string { + h := fnv.New64a() + + _, _ = h.Write([]byte(binding.ClusterRoleName)) + + for _, sub := range binding.Subjects { + _, _ = h.Write([]byte(sub.Kind + sub.Name)) + } + + return fmt.Sprintf("%x", h.Sum64()) +} diff --git a/pkg/utils/hashes_test.go b/pkg/utils/hashes_test.go new file mode 100644 index 00000000..1a81f435 --- /dev/null +++ b/pkg/utils/hashes_test.go @@ -0,0 +1,125 @@ +// Copyright 2020-2025 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package utils_test + +import ( + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/utils" +) + +func TestRoleBindingHashFunc_Deterministic(t *testing.T) { + b := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{ + {Kind: "User", Name: "alice"}, + {Kind: "Group", Name: "devops"}, + }, + } + + h1 := utils.RoleBindingHashFunc(b) + h2 := utils.RoleBindingHashFunc(b) + + if h1 != h2 { + t.Fatalf("expected deterministic hash, got %q and %q", h1, h2) + } + if h1 == "" { + t.Fatalf("expected non-empty hash") + } +} + +func TestRoleBindingHashFunc_ChangesWhenClusterRoleChanges(t *testing.T) { + b1 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{{Kind: "User", Name: "alice"}}, + } + b2 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "view", + Subjects: []rbacv1.Subject{{Kind: "User", Name: "alice"}}, + } + + h1 := utils.RoleBindingHashFunc(b1) + h2 := utils.RoleBindingHashFunc(b2) + + if h1 == h2 { + t.Fatalf("expected different hashes when ClusterRoleName changes, got %q", h1) + } +} + +func TestRoleBindingHashFunc_ChangesWhenSubjectKindChanges(t *testing.T) { + b1 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{{Kind: "User", Name: "alice"}}, + } + b2 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{{Kind: "Group", Name: "alice"}}, + } + + h1 := utils.RoleBindingHashFunc(b1) + h2 := utils.RoleBindingHashFunc(b2) + + if h1 == h2 { + t.Fatalf("expected different hashes when subject Kind changes, got %q", h1) + } +} + +func TestRoleBindingHashFunc_ChangesWhenSubjectNameChanges(t *testing.T) { + b1 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{{Kind: "User", Name: "alice"}}, + } + b2 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{{Kind: "User", Name: "bob"}}, + } + + h1 := utils.RoleBindingHashFunc(b1) + h2 := utils.RoleBindingHashFunc(b2) + + if h1 == h2 { + t.Fatalf("expected different hashes when subject Name changes, got %q", h1) + } +} + +func TestRoleBindingHashFunc_EmptyInputsStillProduceHash(t *testing.T) { + b := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "", + Subjects: nil, + } + + h := utils.RoleBindingHashFunc(b) + if h == "" { + t.Fatalf("expected non-empty hash even for empty input") + } +} + +func TestRoleBindingHashFunc_SubjectOrderMatters_CurrentBehavior(t *testing.T) { + // This test documents the CURRENT behavior: + // the hash is order-dependent because subjects are written in slice order. + b1 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{ + {Kind: "User", Name: "alice"}, + {Kind: "Group", Name: "devops"}, + }, + } + b2 := api.AdditionalRoleBindingsSpec{ + ClusterRoleName: "admin", + Subjects: []rbacv1.Subject{ + {Kind: "Group", Name: "devops"}, + {Kind: "User", Name: "alice"}, + }, + } + + h1 := utils.RoleBindingHashFunc(b1) + h2 := utils.RoleBindingHashFunc(b2) + + if h1 == h2 { + t.Fatalf("expected different hashes when subject order changes (current behavior), got %q", h1) + } +} diff --git a/pkg/utils/tenant/get_by.go b/pkg/utils/tenant/get_by.go index 2e3f6590..88aeb851 100644 --- a/pkg/utils/tenant/get_by.go +++ b/pkg/utils/tenant/get_by.go @@ -67,7 +67,6 @@ func GetTenantByOwnerreferences( return nil, nil } -//nolint:nestif func GetTenantByUserInfo( ctx context.Context, c client.Client, @@ -104,14 +103,6 @@ func GetTenantByUserInfo( } tenants = append(tenants, saTntList.Items...) - - if cfg.AllowServiceAccountPromotion() { - if tnt, err := users.ResolveServiceAccountActor(ctx, c, ns, username, cfg); err != nil { - return nil, err - } else if tnt != nil { - tenants = append(tenants, *tnt) - } - } } // Group tenants. diff --git a/pkg/utils/tenant/owners.go b/pkg/utils/tenant/owners.go index 8b878f39..8c85c068 100644 --- a/pkg/utils/tenant/owners.go +++ b/pkg/utils/tenant/owners.go @@ -10,7 +10,7 @@ import ( ) func GetOwnersWithKinds(tenant *capsulev1beta2.Tenant) (owners []string) { - for _, owner := range tenant.Spec.Owners { + for _, owner := range tenant.Status.Owners { owners = append(owners, fmt.Sprintf("%s:%s", owner.Kind.String(), owner.Name)) } diff --git a/pkg/utils/users/is_capsule_user.go b/pkg/utils/users/is_capsule_user.go index 40d2c8b1..bc260e26 100644 --- a/pkg/utils/users/is_capsule_user.go +++ b/pkg/utils/users/is_capsule_user.go @@ -13,6 +13,7 @@ import ( "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" ) @@ -50,8 +51,10 @@ func IsCapsuleUser( } } + capsuleUsers := cfg.GetUsersByStatus() + //nolint:modernize - for _, group := range cfg.UserGroups() { + for _, group := range capsuleUsers.GetByKinds([]api.OwnerKind{api.GroupOwner}) { if groupList.Find(group) { if len(cfg.IgnoreUserWithGroups()) > 0 { for _, ignoreGroup := range cfg.IgnoreUserWithGroups() { @@ -65,7 +68,8 @@ func IsCapsuleUser( } } - if len(cfg.UserNames()) > 0 && sets.New[string](cfg.UserNames()...).Has(user) { + users := capsuleUsers.GetByKinds([]api.OwnerKind{api.UserOwner}) + if len(users) > 0 && sets.New[string](users...).Has(user) { return true }