feat: add dynamic capsule user evaluation (#1811)

* chore: improve dev targets

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(controller): implement deterministic rolebinding reflection

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(controller): capsule users are determined from configuration status

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(tenantowners): added agreggate option - tenantowners are always considered capsule users

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* feat(tenantowner): add implicit aggregation for tenants

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* chore: remove helm flags

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

* fix(config): remove usergroups default

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>

---------

Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com>
This commit is contained in:
Oliver Bähler
2025-12-31 11:37:30 +01:00
committed by GitHub
parent bbbb9a2aa1
commit 730151cb44
44 changed files with 1441 additions and 290 deletions

View File

@@ -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. **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 ```bash
# If you haven't installed or run `make deploy` before, do it first # Create a KinD cluster if not already created
# Note: please retry if you saw errors $ make dev-cluster
$ make deploy
# To retrieve your laptop's IP and execute `make dev-setup` to setup dev env # 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 # For example: LAPTOP_HOST_IP=192.168.10.101 make dev-setup

View File

@@ -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 @$(CT) install --config $(SRC_ROOT)/.github/configs/ct.yaml --namespace=capsule-system --all --debug
# Setup development env # 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: # Usage:
# LAPTOP_HOST_IP=<YOUR_LAPTOP_IP> make dev-setup # LAPTOP_HOST_IP=<YOUR_LAPTOP_IP> make dev-setup
# For example: # For example:
@@ -127,6 +141,7 @@ IP.1 = $(LAPTOP_HOST_IP)
endef endef
export TLS_CNF export TLS_CNF
dev-setup: dev-setup:
$(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true
mkdir -p /tmp/k8s-webhook-server/serving-certs mkdir -p /tmp/k8s-webhook-server/serving-certs
echo "$${TLS_CNF}" > _tls.cnf echo "$${TLS_CNF}" > _tls.cnf
openssl req -newkey rsa:4096 -days 3650 -nodes -x509 \ openssl req -newkey rsa:4096 -days 3650 -nodes -x509 \
@@ -156,8 +171,7 @@ dev-setup:
--set "webhooks.service.url=$${WEBHOOK_URL}" \ --set "webhooks.service.url=$${WEBHOOK_URL}" \
--set "webhooks.service.caBundle=$${CA_BUNDLE}" \ --set "webhooks.service.caBundle=$${CA_BUNDLE}" \
capsule \ capsule \
./charts/capsule ./charts/capsule || true
$(KUBECTL) -n capsule-system scale deployment capsule-controller-manager --replicas=0 || true
setup-monitoring: dev-setup-fluxcd setup-monitoring: dev-setup-fluxcd
@$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/monitoring | envsubst | kubectl apply -f - @$(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: dev-setup-fluxcd:
@$(KUBECTL) kustomize --load-restrictor='LoadRestrictionsNone' hack/distro/fluxcd | envsubst | kubectl apply -f - @$(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: wait-for-helmreleases:
@ echo "Waiting for all HelmReleases to have observedGeneration >= 0..." @ 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 \ @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 e2e: ginkgo
$(MAKE) e2e-build && $(MAKE) e2e-exec && $(MAKE) e2e-destroy $(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 e2e-build: kind
$(MAKE) e2e-build-cluster $(MAKE) dev-build
$(MAKE) e2e-install $(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 .PHONY: e2e-install
e2e-install: ko-build-all e2e-install: ko-build-all
$(MAKE) e2e-load-image CLUSTER_NAME=$(CLUSTER_NAME) IMAGE=$(CAPSULE_IMG) VERSION=$(VERSION) $(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 $(GINKGO) -v -tags e2e ./e2e
.PHONY: e2e-destroy .PHONY: e2e-destroy
e2e-destroy: kind e2e-destroy: dev-destroy
$(KIND) delete cluster --name capsule
SPELL_CHECKER = npx spellchecker-cli SPELL_CHECKER = npx spellchecker-cli
docs-lint: docs-lint:

View File

@@ -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"`
}

View File

@@ -21,7 +21,6 @@ type CapsuleConfigurationSpec struct {
// Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users) // Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
// //
// Names of the groups considered as Capsule users. // Names of the groups considered as Capsule users.
// +kubebuilder:default={capsule.clastix.io}
UserGroups []string `json:"userGroups,omitempty"` UserGroups []string `json:"userGroups,omitempty"`
// Define groups which when found in the request of a user will be ignored by the Capsule // 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. // 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:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:scope=Cluster // +kubebuilder:resource:scope=Cluster
// +kubebuilder:storageversion // +kubebuilder:storageversion
@@ -90,6 +90,9 @@ type CapsuleConfiguration struct {
metav1.ObjectMeta `json:"metadata,omitzero"` metav1.ObjectMeta `json:"metadata,omitzero"`
Spec CapsuleConfigurationSpec `json:"spec"` Spec CapsuleConfigurationSpec `json:"spec"`
// +optional
Status CapsuleConfigurationStatus `json:"status,omitzero"`
} }
// +kubebuilder:object:root=true // +kubebuilder:object:root=true

View File

@@ -62,7 +62,7 @@ func (in *Tenant) CollectOwners(ctx context.Context, c client.Client, allowPromo
} }
// Dedicated Owner Objects // Dedicated Owner Objects
listed, err := in.Spec.Permissions.ListMatchingOwners(ctx, c) listed, err := in.Spec.Permissions.ListMatchingOwners(ctx, c, in.GetName())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -74,6 +74,18 @@ func (in *Tenant) CollectOwners(ctx context.Context, c client.Client, allowPromo
return owners, nil 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 { func (in *Tenant) IsFull() bool {
// we don't have limits on assigned Namespaces // we don't have limits on assigned Namespaces
if in.Spec.NamespaceOptions == nil || in.Spec.NamespaceOptions.Quota == nil { if in.Spec.NamespaceOptions == nil || in.Spec.NamespaceOptions.Quota == nil {

View File

@@ -10,6 +10,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/api/misc" "github.com/projectcapsule/capsule/pkg/api/misc"
) )
@@ -98,9 +99,16 @@ type Permissions struct {
func (p *Permissions) ListMatchingOwners( func (p *Permissions) ListMatchingOwners(
ctx context.Context, ctx context.Context,
c client.Client, c client.Client,
tnt string,
opts ...client.ListOption, opts ...client.ListOption,
) ([]*TenantOwner, error) { ) ([]*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 // +kubebuilder:object:root=true

View File

@@ -11,7 +11,14 @@ import (
// TenantOwnerSpec defines the desired state of TenantOwner. // TenantOwnerSpec defines the desired state of TenantOwner.
type TenantOwnerSpec struct { type TenantOwnerSpec struct {
// Subject
api.CoreOwnerSpec `json:",inline"` 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. // TenantOwnerStatus defines the observed state of TenantOwner.

View File

@@ -43,6 +43,7 @@ func (in *CapsuleConfiguration) DeepCopyInto(out *CapsuleConfiguration) {
out.TypeMeta = in.TypeMeta out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec) in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
} }
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfiguration. // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CapsuleConfiguration.
@@ -141,6 +142,26 @@ func (in *CapsuleConfigurationSpec) DeepCopy() *CapsuleConfigurationSpec {
return out 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. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CapsuleResources) DeepCopyInto(out *CapsuleResources) { func (in *CapsuleResources) DeepCopyInto(out *CapsuleResources) {
*out = *in *out = *in

View File

@@ -153,8 +153,6 @@ spec:
regexp regexp
type: string type: string
userGroups: userGroups:
default:
- capsule.clastix.io
description: |- description: |-
Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users) Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users)
@@ -195,8 +193,36 @@ spec:
required: required:
- enableTLSReconciler - enableTLSReconciler
type: object 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: required:
- spec - spec
type: object type: object
served: true served: true
storage: true storage: true
subresources:
status: {}

View File

@@ -39,6 +39,13 @@ spec:
spec: spec:
description: spec defines the desired state of TenantOwner. description: spec defines the desired state of TenantOwner.
properties: 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: clusterRoles:
default: default:
- admin - admin
@@ -59,6 +66,7 @@ spec:
description: Name of the entity. description: Name of the entity.
type: string type: string
required: required:
- aggregate
- kind - kind
- name - name
type: object type: object

View File

@@ -374,7 +374,8 @@ func main() {
} }
if err = (&configcontroller.Manager{ 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 { }).SetupWithManager(manager, controllerConfig); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "CapsuleConfiguration") setupLog.Error(err, "unable to create controller", "controller", "CapsuleConfiguration")
os.Exit(1) os.Exit(1)

View File

@@ -5,12 +5,12 @@ package e2e
import ( import (
"context" "context"
"fmt"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api" "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() { 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) { VerifyTenantRoleBindings(t)
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))
}
}) })
}) })

View File

@@ -14,6 +14,7 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta"
) )
var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() { var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
@@ -123,6 +124,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
}, },
}, },
Spec: capsulev1beta2.TenantOwnerSpec{ Spec: capsulev1beta2.TenantOwnerSpec{
Aggregate: true,
CoreOwnerSpec: api.CoreOwnerSpec{ CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{ UserSpec: api.UserSpec{
Kind: api.GroupOwner, Kind: api.GroupOwner,
@@ -143,6 +145,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
}, },
}, },
Spec: capsulev1beta2.TenantOwnerSpec{ Spec: capsulev1beta2.TenantOwnerSpec{
Aggregate: true,
CoreOwnerSpec: api.CoreOwnerSpec{ CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{ UserSpec: api.UserSpec{
Kind: api.GroupOwner, Kind: api.GroupOwner,
@@ -164,6 +167,7 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
}, },
}, },
Spec: capsulev1beta2.TenantOwnerSpec{ Spec: capsulev1beta2.TenantOwnerSpec{
Aggregate: true,
CoreOwnerSpec: api.CoreOwnerSpec{ CoreOwnerSpec: api.CoreOwnerSpec{
UserSpec: api.UserSpec{ UserSpec: api.UserSpec{
Kind: api.ServiceAccountOwner, 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() { JustBeforeEach(func() {
Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: defaultConfigurationName}, originConfig)).To(Succeed()) 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()) }).Should(Succeed())
} }
for _, tnt := range []*capsulev1beta2.TenantOwner{ownersInfra, ownersDevops, ownersCommon} { for _, tnt := range []*capsulev1beta2.TenantOwner{ownersInfra, ownersDevops, ownersCommon, userOwnersCommon, tnt1Owner} {
EventuallyCreation(func() error { EventuallyCreation(func() error {
tnt.ResourceVersion = "" tnt.ResourceVersion = ""
@@ -202,13 +249,39 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
Expect(client.IgnoreNotFound(err)).To(Succeed()) 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) err := k8sClient.Delete(context.TODO(), owners)
Expect(client.IgnoreNotFound(err)).To(Succeed()) Expect(client.IgnoreNotFound(err)).To(Succeed())
} }
}) })
It("Verify owners for", func() { 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() { By("checking owners (e2e-owners-1)", func() {
t := &capsulev1beta2.Tenant{} t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) 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"}, 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)). Expect(normalizeOwners(t.Status.Owners)).
@@ -250,6 +337,41 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
VerifyTenantRoleBindings(t) 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() { By("checking owners (e2e-owners-2)", func() {
t := &capsulev1beta2.Tenant{} t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt2.GetName()}, t)).Should(Succeed()) 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"}, 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)). Expect(normalizeOwners(t.Status.Owners)).
@@ -291,10 +421,62 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
VerifyTenantRoleBindings(t) 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() { By("remove common tenant-owners", func() {
Expect(k8sClient.Delete(context.TODO(), ownersCommon)).Should(Succeed()) 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() { By("checking owners (e2e-owners-1)", func() {
t := &capsulev1beta2.Tenant{} t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) 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"}, 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)). Expect(normalizeOwners(t.Status.Owners)).
@@ -369,6 +565,13 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
}, },
ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, 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)). 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()) 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() { By("checking owners (e2e-owners-1)", func() {
t := &capsulev1beta2.Tenant{} t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) 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"}, 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)). Expect(normalizeOwners(t.Status.Owners)).
@@ -448,6 +685,13 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
}, },
ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, 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)). 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()) 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() { By("checking owners (e2e-owners-1)", func() {
t := &capsulev1beta2.Tenant{} t := &capsulev1beta2.Tenant{}
Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt1.GetName()}, t)).Should(Succeed()) 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"}, 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)). Expect(normalizeOwners(t.Status.Owners)).
@@ -520,6 +797,13 @@ var _ = Describe("Owners", Label("tenant", "permissions", "owners"), func() {
}, },
ClusterRoles: []string{"admin", "capsule-namespace-deleter"}, 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)). Expect(normalizeOwners(t.Status.Owners)).

View File

@@ -25,6 +25,7 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/configuration"
) )
// These tests use Ginkgo (BDD-style Go testing framework). Refer to // These tests use Ginkgo (BDD-style Go testing framework). Refer to
@@ -65,6 +66,11 @@ var _ = BeforeSuite(func() {
Expect(ctrlClient).ToNot(BeNil()) Expect(ctrlClient).ToNot(BeNil())
k8sClient = &e2eClient{Client: ctrlClient} k8sClient = &e2eClient{Client: ctrlClient}
ModifyCapsuleConfigurationOpts(func(cfg *capsulev1beta2.CapsuleConfiguration) {
cfg.Spec = configuration.DefaultCapsuleConfiguration()
})
}) })
var _ = AfterSuite(func() { var _ = AfterSuite(func() {

View File

@@ -29,6 +29,7 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/meta"
"github.com/projectcapsule/capsule/pkg/utils"
) )
const ( const (
@@ -332,43 +333,38 @@ func VerifyTenantRoleBindings(
tnt *capsulev1beta2.Tenant, tnt *capsulev1beta2.Tenant,
) { ) {
Eventually(func(g Gomega) { Eventually(func(g Gomega) {
roles := tnt.GetRoleBindings()
// List all RoleBindings once per namespace to avoid repeated API calls. // List all RoleBindings once per namespace to avoid repeated API calls.
for _, ns := range tnt.Status.Namespaces { for _, ns := range tnt.Status.Namespaces {
for i, owner := range tnt.Status.Owners { for _, role := range roles {
for _, role := range owner.ClusterRoles { rbName := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(role))
rbName := fmt.Sprintf("capsule-%s-%d-%s", tnt.Name, i, role)
rb := &rbacv1.RoleBinding{} rb := &rbacv1.RoleBinding{}
err := k8sClient.Get(context.Background(), client.ObjectKey{ err := k8sClient.Get(context.Background(), client.ObjectKey{
Namespace: ns, Namespace: ns,
Name: rbName, Name: rbName,
}, rb) }, rb)
g.Expect(err).ToNot(HaveOccurred(), g.Expect(err).ToNot(HaveOccurred(),
"expected RoleBinding %s/%s to exist", ns, rbName) "expected RoleBinding %s/%s to exist (Owner: %s)", ns, rbName, role.Subjects,
)
g.Expect(rb.RoleRef.Name).To(Equal(role), g.Expect(rb.RoleRef.Name).To(Equal(role.ClusterRoleName),
"expected RoleBinding %s/%s to have RoleRef.Name=%q", "expected RoleBinding %s/%s to have RoleRef.Name=%q",
ns, rbName, role) ns, rbName, role.ClusterRoleName)
g.Expect(rb.Subjects).ToNot(BeEmpty(), g.Expect(rb.Subjects).ToNot(BeEmpty(),
"expected RoleBinding %s/%s to have at least one subject", ns, rbName) "expected RoleBinding %s/%s to have at least one subject", ns, rbName)
foundSubject := false g.Expect(rb.Subjects).To(ConsistOf(role.Subjects),
for _, s := range rb.Subjects { "expected RoleBinding %s/%s to have exact subjects",
if s.Kind == string(owner.Kind) && s.Name == owner.Name { ns, rb.Name,
foundSubject = true )
break
}
}
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()) }).WithTimeout(30 * time.Second).WithPolling(500 * time.Millisecond).Should(Succeed())
} }

2
go.mod
View File

@@ -19,7 +19,6 @@ require (
k8s.io/apimachinery v0.35.0 k8s.io/apimachinery v0.35.0
k8s.io/apiserver v0.35.0 k8s.io/apiserver v0.35.0
k8s.io/client-go 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 k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2
sigs.k8s.io/cluster-api v1.12.1 sigs.k8s.io/cluster-api v1.12.1
sigs.k8s.io/controller-runtime v0.22.4 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-openapi/swag/yamlutils v0.25.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gobuffalo/flect v1.0.3 // 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/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect

115
go.sum
View File

@@ -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/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 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= 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.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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= 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 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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 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/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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= 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 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= 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= 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/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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 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.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= 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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 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 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 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 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/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.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 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 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-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 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= 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 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=
k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= 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 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= 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 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4=
k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= 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 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= 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.34.2 h1:oKckPeunVCns37BntcsxaOesDul32yzGd3DFLjW2fc8=
k8s.io/cluster-bootstrap v0.33.3/go.mod h1:p970f8u8jf273zyQ5raD8WUu2XyAl0SAWOY82o7i/ds= k8s.io/cluster-bootstrap v0.34.2/go.mod h1:f21byPR7X5nt12ivZi+J3pb4sG4SH6VySX8KAAJA8BY=
k8s.io/component-base v0.34.2 h1:HQRqK9x2sSAsd8+R4xxRirlTjowsg6fWCPwWYeSvogQ= k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94=
k8s.io/component-base v0.34.2/go.mod h1:9xw2FHJavUHBFpiGkZoKuYZ5pdtLKe97DEByaA+hHbM= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0=
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/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 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 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= 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 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE=
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= 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 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.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 h1:s3DivSZjXdu2HPyOtV/n6XwSZBaIycZdKNs4y8X+3lY=
sigs.k8s.io/cluster-api v1.12.1/go.mod h1:+S6WJdi8UPdqv5q9nka5al3ed/Qa0zAcSBgzTaa9VKA= 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 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A=
sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= 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 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8=
sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- owners.yaml
- tenants.yaml
- resource.yaml

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- release.flux.yaml

View File

@@ -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

View File

@@ -5,42 +5,169 @@ package config
import ( import (
"context" "context"
"fmt"
"github.com/go-logr/logr" "github.com/go-logr/logr"
"github.com/pkg/errors" "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" 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/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" "sigs.k8s.io/controller-runtime/pkg/reconcile"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/internal/controllers/utils"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/configuration" "github.com/projectcapsule/capsule/pkg/configuration"
) )
type Manager struct { type Manager struct {
client client.Client client.Client
Log logr.Logger Log logr.Logger
} }
func (c *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
c.client = mgr.GetClient()
return ctrl.NewControllerManagedBy(mgr). return ctrl.NewControllerManagedBy(mgr).
For(&capsulev1beta2.CapsuleConfiguration{}, utils.NamesMatchingPredicate(ctrlConfig.ConfigurationName)). 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) { func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res reconcile.Result, err error) {
c.Log.Info("CapsuleConfiguration reconciliation started", "request.name", request.Name) 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 // Validating the Capsule Configuration options
if _, err = cfg.ProtectedNamespaceRegexp(); err != nil { if _, err = cfg.ProtectedNamespaceRegexp(); err != nil {
panic(errors.Wrap(err, "Invalid configuration for protected Namespace regex")) 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 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)
})
}

View File

@@ -114,7 +114,12 @@ func (r *Manager) EnsureClusterRoleBindingsProvisioner(ctx context.Context) erro
crb.RoleRef = api.ProvisionerClusterRoleBinding.RoleRef crb.RoleRef = api.ProvisionerClusterRoleBinding.RoleRef
crb.Subjects = nil 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 { switch entity.Kind {
case api.UserOwner: case api.UserOwner:
crb.Subjects = append(crb.Subjects, rbacv1.Subject{ 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() { if r.Configuration.AllowServiceAccountPromotion() {
saList := &corev1.ServiceAccountList{} saList := &corev1.ServiceAccountList{}
if err := r.Client.List(ctx, saList, client.MatchingLabels{ if err := r.Client.List(ctx, saList, client.MatchingLabels{

View File

@@ -6,8 +6,6 @@ package tenant
import ( import (
"context" "context"
"fmt" "fmt"
"hash/fnv"
"strings"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
@@ -17,64 +15,21 @@ import (
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/api/meta" "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: // 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. // 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) { 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 roleBindings := tenant.GetRoleBindings()
hashFn := func(binding api.AdditionalRoleBindingsSpec) string {
h := fnv.New64a()
_, _ = h.Write([]byte(binding.ClusterRoleName)) // Hashing
hashes := map[string]api.AdditionalRoleBindingsSpec{}
for _, sub := range binding.Subjects { for _, binding := range roleBindings {
_, _ = h.Write([]byte(sub.Kind + sub.Name)) hash := utils.RoleBindingHashFunc(binding)
}
return fmt.Sprintf("%x", h.Sum64()) hashes[hash] = binding
}
// 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))
} }
group := new(errgroup.Group) group := new(errgroup.Group)
@@ -83,34 +38,29 @@ func (r *Manager) syncRoleBindings(ctx context.Context, tenant *capsulev1beta2.T
namespace := ns namespace := ns
group.Go(func() error { group.Go(func() error {
return r.syncAdditionalRoleBinding(ctx, tenant, namespace, keys, hashFn) return r.syncAdditionalRoleBinding(ctx, tenant, namespace, hashes)
}) })
} }
return group.Wait() 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) { func (r *Manager) syncAdditionalRoleBinding(
if err = r.pruningResources(ctx, ns, keys, &rbacv1.RoleBinding{}); err != nil { ctx context.Context,
return err 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 { keys = append(keys, hash)
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)
target := &rbacv1.RoleBinding{ target := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("capsule-%s-%d-%s", tenant.Name, i, roleBinding.ClusterRoleName), Name: name,
Namespace: ns, Namespace: ns,
}, },
} }
@@ -126,7 +76,7 @@ func (r *Manager) syncAdditionalRoleBinding(ctx context.Context, tenant *capsule
} }
target.Labels[meta.TenantLabel] = tenant.Name target.Labels[meta.TenantLabel] = tenant.Name
target.Labels[meta.RolebindingLabel] = roleBindingHashLabel target.Labels[meta.RolebindingLabel] = hash
if roleBinding.Annotations != nil { if roleBinding.Annotations != nil {
target.Annotations = roleBinding.Annotations 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{})
} }

View File

@@ -11,7 +11,9 @@ import (
const ( const (
TenantNameLabel = "kubernetes.io/metadata.name" TenantNameLabel = "kubernetes.io/metadata.name"
TenantLabel = "capsule.clastix.io/tenant"
TenantLabel = "capsule.clastix.io/tenant"
NewTenantLabel = "projectcapsule.dev/tenant"
ResourcePoolLabel = "projectcapsule.dev/pool" ResourcePoolLabel = "projectcapsule.dev/pool"

10
pkg/api/meta/names.go Normal file
View File

@@ -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)
}

View File

@@ -23,7 +23,10 @@ type NamespaceSelector struct {
} }
// GetMatchingNamespaces retrieves the list of namespaces that match the NamespaceSelector. // 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 { if s.LabelSelector == nil {
return nil, nil // No namespace selector means all namespaces 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{} 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) 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 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 // 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. // match ANY of the provided LabelSelectors. The result is unique by namespace/name.
func ListBySelectors[T client.Object]( func ListBySelectors[T client.Object](

View File

@@ -30,3 +30,28 @@ func (in *NamespaceSelector) DeepCopy() *NamespaceSelector {
in.DeepCopyInto(out) in.DeepCopyInto(out)
return 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
}

View File

@@ -3,6 +3,10 @@
package api package api
import (
rbacv1 "k8s.io/api/rbac/v1"
)
// +kubebuilder:object:generate=true // +kubebuilder:object:generate=true
type OwnerSpec struct { type OwnerSpec struct {
@@ -26,6 +30,21 @@ type CoreOwnerSpec struct {
ClusterRoles []string `json:"clusterRoles,omitempty"` 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 // +kubebuilder:validation:Enum=User;Group;ServiceAccount
type OwnerKind string type OwnerKind string

View File

@@ -48,6 +48,8 @@ func (o *OwnerStatusListSpec) Upsert(
} }
} }
sort.Strings(existing.ClusterRoles)
*o = owners *o = owners
return return

View File

@@ -132,7 +132,7 @@ func TestUpsert_DeduplicatesClusterRoles(t *testing.T) {
} }
got := list[0] got := list[0]
expected := []string{"admin", "viewer", "editor"} expected := []string{"admin", "editor", "viewer"}
if !reflect.DeepEqual(got.ClusterRoles, expected) { if !reflect.DeepEqual(got.ClusterRoles, expected) {
t.Fatalf("expected roles %v, got %v", expected, got.ClusterRoles) t.Fatalf("expected roles %v, got %v", expected, got.ClusterRoles)
} }

121
pkg/api/owner_test.go Normal file
View File

@@ -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])
}
}
}
})
}
}

View File

@@ -3,6 +3,12 @@
package api package api
import (
"strings"
rbacv1 "k8s.io/api/rbac/v1"
)
// +kubebuilder:validation:Enum=User;Group;ServiceAccount // +kubebuilder:validation:Enum=User;Group;ServiceAccount
type UserKind string type UserKind string
@@ -17,3 +23,23 @@ type UserSpec struct {
// Name of the entity. // Name of the entity.
Name string `json:"name"` 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
}

View File

@@ -10,6 +10,37 @@ import (
// +kubebuilder:object:generate=true // +kubebuilder:object:generate=true
type UserListSpec []UserSpec 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 { func (u UserListSpec) IsPresent(name string, groups []string) bool {
groupSet := make(map[string]struct{}, len(groups)) groupSet := make(map[string]struct{}, len(groups))
for _, g := range groups { for _, g := range groups {

105
pkg/api/users_test.go Normal file
View File

@@ -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)
}
})
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
@@ -22,25 +23,50 @@ type capsuleConfiguration struct {
retrievalFn func() *capsulev1beta2.CapsuleConfiguration 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 { 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 err := c.Get(ctx, key, cfg); err == nil {
if apierrors.IsNotFound(err) { return cfg
return &capsulev1beta2.CapsuleConfiguration{ } else if !apierrors.IsNotFound(err) {
Spec: capsulev1beta2.CapsuleConfigurationSpec{ panic(errors.Wrap(err, "cannot retrieve Capsule configuration with name "+name))
Users: []capsuleapi.UserSpec{{Name: "projectcapsule.dev", Kind: capsuleapi.GroupOwner}},
ForceTenantPrefix: false,
ProtectedNamespaceRegexpString: "",
},
}
}
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})...) 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 { func (c *capsuleConfiguration) IgnoreUserWithGroups() []string {
return c.retrievalFn().Spec.IgnoreUserWithGroups return c.retrievalFn().Spec.IgnoreUserWithGroups
} }

View File

@@ -26,6 +26,8 @@ type Configuration interface {
TenantCRDName() string TenantCRDName() string
UserNames() []string UserNames() []string
UserGroups() []string UserGroups() []string
Users() capsuleapi.UserListSpec
GetUsersByStatus() capsuleapi.UserListSpec
IgnoreUserWithGroups() []string IgnoreUserWithGroups() []string
ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec ForbiddenUserNodeLabels() *capsuleapi.ForbiddenListSpec
ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec ForbiddenUserNodeAnnotations() *capsuleapi.ForbiddenListSpec

23
pkg/utils/hashes.go Normal file
View File

@@ -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())
}

125
pkg/utils/hashes_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -67,7 +67,6 @@ func GetTenantByOwnerreferences(
return nil, nil return nil, nil
} }
//nolint:nestif
func GetTenantByUserInfo( func GetTenantByUserInfo(
ctx context.Context, ctx context.Context,
c client.Client, c client.Client,
@@ -104,14 +103,6 @@ func GetTenantByUserInfo(
} }
tenants = append(tenants, saTntList.Items...) 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. // Group tenants.

View File

@@ -10,7 +10,7 @@ import (
) )
func GetOwnersWithKinds(tenant *capsulev1beta2.Tenant) (owners []string) { 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)) owners = append(owners, fmt.Sprintf("%s:%s", owner.Kind.String(), owner.Name))
} }

View File

@@ -13,6 +13,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
"github.com/projectcapsule/capsule/pkg/api"
"github.com/projectcapsule/capsule/pkg/configuration" "github.com/projectcapsule/capsule/pkg/configuration"
) )
@@ -50,8 +51,10 @@ func IsCapsuleUser(
} }
} }
capsuleUsers := cfg.GetUsersByStatus()
//nolint:modernize //nolint:modernize
for _, group := range cfg.UserGroups() { for _, group := range capsuleUsers.GetByKinds([]api.OwnerKind{api.GroupOwner}) {
if groupList.Find(group) { if groupList.Find(group) {
if len(cfg.IgnoreUserWithGroups()) > 0 { if len(cfg.IgnoreUserWithGroups()) > 0 {
for _, ignoreGroup := range cfg.IgnoreUserWithGroups() { 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 return true
} }