// Copyright 2020-2023 Project Capsule Authors. // SPDX-License-Identifier: Apache-2.0 package e2e import ( "context" "encoding/json" "fmt" "reflect" "sort" "strings" "time" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/util/rand" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" . "github.com/onsi/gomega" "github.com/stretchr/testify/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" nodev1 "k8s.io/api/node/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" versionUtil "k8s.io/apimachinery/pkg/util/version" "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/util/retry" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/utils" ) const ( defaultTimeoutInterval = 80 * time.Second defaultTerminationTimeoutInterval = 120 * time.Second defaultPollInterval = 2 * time.Second defaultConfigurationName = "default" e2eClientQPS float32 = 1000 e2eClientBurst int = 2000 ) func tuneE2ERestConfig(c *rest.Config) *rest.Config { c.QPS = e2eClientQPS c.Burst = e2eClientBurst return c } func mergeMaps(base map[string]string, extra map[string]string) map[string]string { out := map[string]string{} for k, v := range base { out[k] = v } for k, v := range extra { out[k] = v } return out } func ignoreNotFound(err error) error { if apierrors.IsNotFound(err) { return nil } return err } func NewService(svc types.NamespacedName) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: svc.Name, Namespace: svc.Namespace, }, Spec: corev1.ServiceSpec{ Ports: []corev1.ServicePort{ {Port: int32(80)}, }, }, } } func ServiceCreation(svc *corev1.Service, owner rbac.UserSpec, timeout time.Duration) AsyncAssertion { cs := ownerClient(owner) return Eventually(func() (err error) { _, err = cs.CoreV1().Services(svc.Namespace).Create(context.TODO(), svc, metav1.CreateOptions{}) return }, timeout, defaultPollInterval) } func NewNamespace(name string, labels ...map[string]string) *corev1.Namespace { if len(name) == 0 { name = rand.String(10) } namespaceLabels := make(map[string]string) if len(labels) > 0 { for _, lab := range labels { for k, v := range lab { namespaceLabels[k] = v } } } namespaceLabels["env"] = "e2e" return &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: name, Labels: namespaceLabels, }, } } func NamespaceCreationAdmin(ns *corev1.Namespace, timeout time.Duration) AsyncAssertion { return Eventually(func() (err error) { return k8sClient.Create( context.TODO(), ns, ) }, timeout, defaultPollInterval) } func NamespaceDeletionAdmin(ns *corev1.Namespace, timeout time.Duration) AsyncAssertion { return Eventually(func() (err error) { return k8sClient.Delete( context.TODO(), ns, ) }, timeout, defaultPollInterval) } func ForceDeleteNamespace(ctx context.Context, name string) { cs := clusterAdminClient() Eventually(func() error { ns, err := cs.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) if apierrors.IsNotFound(err) { return nil } if err != nil { return err } if controllerutil.RemoveFinalizer(ns, namespaceTerminationHoldFinalizer) { if _, err = cs.CoreV1().Namespaces().Update(ctx, ns, metav1.UpdateOptions{}); err != nil { return err } return fmt.Errorf("removed E2E termination hold finalizer from namespace %s", name) } // Trigger deletion if not already happening if ns.DeletionTimestamp.IsZero() { if err := cs.CoreV1().Namespaces().Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { return err } return fmt.Errorf("namespace %s deletion triggered", name) } // Let the namespace controller remove the kubernetes finalizer. Forcing // finalization can orphan content that becomes visible if a namespace // with the same name is recreated. return fmt.Errorf("namespace %s still terminating", name) }, defaultTerminationTimeoutInterval, defaultPollInterval).Should(Succeed(), "failed to delete namespace %s", name) } func NamespaceCreation(ns *corev1.Namespace, owner rbac.UserSpec, timeout time.Duration) AsyncAssertion { cs := ownerClient(owner) return Eventually(func() (err error) { _, err = cs.CoreV1().Namespaces().Create(context.TODO(), ns, metav1.CreateOptions{}) return }, timeout, defaultPollInterval) } func TenantNamespaceReady( tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, expectedSize uint, ) { Eventually(func(g Gomega) { t := &capsulev1beta2.Tenant{} err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t) g.Expect(err).NotTo(HaveOccurred()) g.Expect(t.Status.Size).To( Equal(expectedSize), "expected tenant %s status size to be %d, got %d", t.GetName(), expectedSize, t.Status.Size, ) currentNS := &corev1.Namespace{} err = k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, currentNS) g.Expect(err).NotTo(HaveOccurred()) instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{ Name: currentNS.GetName(), UID: currentNS.GetUID(), }) g.Expect(instance).NotTo(BeNil(), "Namespace instance should not be nil") condition := instance.Conditions.GetConditionByType(meta.ReadyCondition) g.Expect(condition).NotTo(BeNil(), "Condition instance should not be nil") g.Expect(instance.Name).To(Equal(currentNS.GetName())) g.Expect(condition.Status).To(Equal(metav1.ConditionTrue), "Expected namespace condition status to be True") g.Expect(condition.Type).To(Equal(meta.ReadyCondition), "Expected namespace condition type to be Ready") g.Expect(condition.Reason).To(Equal(meta.SucceededReason), "Expected namespace condition reason to be Succeeded") }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func NamespaceIsNotPartOfTenant( tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, ) AsyncAssertion { return Eventually(func() error { currentNS := &corev1.Namespace{} nsUID := ns.GetUID() if err := k8sClient.Get( context.TODO(), types.NamespacedName{Name: ns.GetName()}, currentNS, ); err != nil { if !apierrors.IsNotFound(err) { return fmt.Errorf("failed to get namespace %s: %w", ns.GetName(), err) } } else { nsUID = currentNS.GetUID() } t := &capsulev1beta2.Tenant{} if err := k8sClient.Get( context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t, ); err != nil { return fmt.Errorf("failed to get tenant: %w", err) } namespaces := t.Status.Namespaces if ok, _ := ContainElement(ns.GetName()).Match(namespaces); ok { return fmt.Errorf( "expected tenant %s not to contain namespace %s, but got: %v", t.GetName(), ns.GetName(), namespaces, ) } instance := t.Status.GetInstance(&capsulev1beta2.TenantStatusNamespaceItem{ Name: ns.GetName(), UID: nsUID, }) if instance != nil { return fmt.Errorf( "expected tenant %s not to contain instance for namespace %s (uid=%s), but got: %+v", t.GetName(), ns.GetName(), nsUID, instance, ) } return nil }, defaultTimeoutInterval, defaultPollInterval) } func NamespaceIsPartOfTenant( tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, ) AsyncAssertion { return Eventually(func() error { currentNS := &corev1.Namespace{} if err := k8sClient.Get( context.TODO(), types.NamespacedName{Name: ns.GetName()}, currentNS, ); err != nil { return fmt.Errorf("failed to get namespace %s: %w", ns.GetName(), err) } t := &capsulev1beta2.Tenant{} if err := k8sClient.Get( context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t, ); err != nil { return fmt.Errorf("failed to get tenant: %w", err) } namespaces := t.Status.Namespaces if ok, _ := ContainElement(currentNS.GetName()).Match(namespaces); !ok { return fmt.Errorf( "expected tenant %s to contain namespace %s, but got: %v", t.GetName(), currentNS.GetName(), namespaces, ) } instance := t.Status.GetInstance( &capsulev1beta2.TenantStatusNamespaceItem{ Name: currentNS.GetName(), UID: currentNS.GetUID(), }, ) if instance == nil { return fmt.Errorf( "expected tenant %s to contain instance for namespace %s (uid=%s)", t.GetName(), currentNS.GetName(), currentNS.GetUID(), ) } return nil }, defaultTimeoutInterval, defaultPollInterval) } func GetTenantOwnerReference( tnt *capsulev1beta2.Tenant, ) (metav1.OwnerReference, error) { t := &capsulev1beta2.Tenant{} if err := k8sClient.Get( context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t, ); err != nil { return metav1.OwnerReference{}, fmt.Errorf("failed to get tenant: %w", err) } gvk := capsulev1beta2.GroupVersion.WithKind("Tenant") return metav1.OwnerReference{ APIVersion: gvk.GroupVersion().String(), Kind: gvk.Kind, Name: t.GetName(), UID: t.GetUID(), }, nil } func GetTenantOwnerReferenceAsPatch( tnt *capsulev1beta2.Tenant, ) (map[string]interface{}, error) { ownerRef, err := GetTenantOwnerReference(tnt) if err != nil { return nil, err } return map[string]interface{}{ "apiVersion": ownerRef.APIVersion, "kind": ownerRef.Kind, "name": ownerRef.Name, "uid": string(ownerRef.UID), }, nil } func PatchTenantAssignmentForNamespace( tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, cs kubernetes.Interface, ) error { ref, err := GetTenantOwnerReferenceAsPatch(tnt) if err != nil { return err } return PatchNamespace(ns, cs, map[string]interface{}{ "metadata": map[string]interface{}{ "labels": map[string]interface{}{ meta.TenantLabel: tnt.GetName(), }, "ownerReferences": []map[string]interface{}{ref}, }, }) } func PatchNamespace(ns *corev1.Namespace, cs kubernetes.Interface, patch map[string]interface{}) error { patchBytes, err := json.Marshal(patch) if err != nil { return err } _, err = cs.CoreV1().Namespaces().Patch( context.Background(), ns.GetName(), types.MergePatchType, patchBytes, metav1.PatchOptions{}, ) return err } func PatchTenantOwnerReferenceForNamespace( tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, cs kubernetes.Interface, timeout time.Duration, ) AsyncAssertion { return Eventually(func() error { // Build ownerRef for the tenant ownerRef := metav1.OwnerReference{ APIVersion: capsulev1beta2.GroupVersion.String(), Kind: "Tenant", Name: tnt.GetName(), UID: tnt.GetUID(), } patch := map[string]interface{}{ "metadata": map[string]interface{}{ "ownerReferences": []map[string]interface{}{ { "apiVersion": ownerRef.APIVersion, "kind": ownerRef.Kind, "name": ownerRef.Name, "uid": string(ownerRef.UID), }, }, }, } patchBytes, err := json.Marshal(patch) Expect(err).ToNot(HaveOccurred()) _, err = cs.CoreV1().Namespaces().Patch( context.Background(), ns.GetName(), types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{}, ) return err }, timeout, defaultPollInterval) } func GetTenantEventually(tnt *capsulev1beta2.Tenant) *capsulev1beta2.Tenant { t := &capsulev1beta2.Tenant{} Eventually(func() error { return k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) return t } func GetNamespaceEventually(name string) *corev1.Namespace { ns := &corev1.Namespace{} Eventually(func() error { return k8sClient.Get(context.TODO(), types.NamespacedName{Name: name}, ns) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) return ns } func UpdateTenantEventually(tnt *capsulev1beta2.Tenant, mutator func(*capsulev1beta2.Tenant)) { Eventually(func() error { current := &capsulev1beta2.Tenant{} if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, current); err != nil { return err } mutator(current) return k8sClient.Update(context.TODO(), current) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func UpdateTenantEventuallyShouldFail(tnt *capsulev1beta2.Tenant, mutator func(*capsulev1beta2.Tenant)) { Eventually(func() error { current := &capsulev1beta2.Tenant{} if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, current); err != nil { return err } mutator(current) return k8sClient.Update(context.TODO(), current) }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) } func PatchNamespaceEventually(ns *corev1.Namespace, mutator func(*corev1.Namespace)) { Eventually(func() error { current := &corev1.Namespace{} if err := k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, current); err != nil { return err } before := current.DeepCopy() mutator(current) return k8sClient.Patch(context.TODO(), current, client.MergeFrom(before)) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func ExpectNamespaceNotAssignedToTenant(ctx context.Context, name string) { ns := &corev1.Namespace{} Eventually(func(g Gomega) { g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns)).To(Succeed()) g.Expect(ns.GetOwnerReferences()).To(BeEmpty(), "namespace must not have ownerReferences") g.Expect(ns.GetLabels()).NotTo(HaveKey(meta.TenantLabel), "namespace must not have tenant label") }).WithTimeout(defaultTimeoutInterval).Should(Succeed()) } func TenantNamespaceList(tnt *capsulev1beta2.Tenant, timeout time.Duration) AsyncAssertion { t := &capsulev1beta2.Tenant{} return Eventually(func() []string { Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.GetName()}, t)).Should(Succeed()) return t.Status.Namespaces }, timeout, defaultPollInterval) } func ModifyNode(fn func(node *corev1.Node) error) error { nodeList := &corev1.NodeList{} Expect(k8sClient.List(context.Background(), nodeList)).ToNot(HaveOccurred()) return fn(&nodeList.Items[0]) } func EventuallyCreation(f interface{}) AsyncAssertion { return Eventually(f, defaultTimeoutInterval, defaultPollInterval) } func EventuallyDeletion(obj client.Object) { if tnt, ok := obj.(*capsulev1beta2.Tenant); ok { deleteTenantPodsBeforeDeletion(tnt) } eventuallyDeletionWithoutPodCleanup(obj) } func EventuallyDeletionWithoutPodCleanup(obj client.Object) { eventuallyDeletionWithoutPodCleanup(obj) } func eventuallyDeletionWithoutPodCleanup(obj client.Object) { key := client.ObjectKeyFromObject(obj) Eventually(func() error { // Retry delete until the object is really gone. err := k8sClient.Delete(context.TODO(), obj) if err != nil && !apierrors.IsNotFound(err) { return err } // Read into a fresh copy to avoid stale in-memory state. current := obj.DeepCopyObject().(client.Object) err = k8sClient.Get(context.TODO(), key, current) if apierrors.IsNotFound(err) { return nil } if err != nil { return err } return fmt.Errorf("%T %q still exists", obj, obj.GetName()) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func deleteTenantPodsBeforeDeletion(tnt *capsulev1beta2.Tenant) { Eventually(func() error { namespaces := &corev1.NamespaceList{} if err := k8sClient.List( context.TODO(), namespaces, client.MatchingLabels{meta.TenantLabel: tnt.GetName()}, ); err != nil { return fmt.Errorf("list namespaces for Tenant %q: %w", tnt.GetName(), err) } namespaceNames := make(map[string]struct{}, len(namespaces.Items)) for i := range namespaces.Items { namespaceNames[namespaces.Items[i].GetName()] = struct{}{} } currentTenant := &capsulev1beta2.Tenant{} if err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: tnt.GetName()}, currentTenant); err != nil { if !apierrors.IsNotFound(err) { return fmt.Errorf("get Tenant %q before Pod cleanup: %w", tnt.GetName(), err) } } else { for _, namespace := range currentTenant.Status.Spaces { if namespace != nil && namespace.Name != "" { namespaceNames[namespace.Name] = struct{}{} } } for _, namespace := range currentTenant.Status.Namespaces { if namespace != "" { namespaceNames[namespace] = struct{}{} } } } remaining := 0 zero := int64(0) for namespace := range namespaceNames { // Stop workload controllers before deleting Pods. Otherwise controllers // such as Deployments immediately replace the Pods and cleanup can never // observe an empty namespace. for _, workload := range []client.Object{ &appsv1.Deployment{}, &appsv1.ReplicaSet{}, &appsv1.StatefulSet{}, &appsv1.DaemonSet{}, } { if err := k8sClient.DeleteAllOf( context.TODO(), workload, client.InNamespace(namespace), client.GracePeriodSeconds(0), client.PropagationPolicy(metav1.DeletePropagationBackground), ); err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("delete %T objects in namespace %q: %w", workload, namespace, err) } } pods := &corev1.PodList{} if err := k8sClient.List(context.TODO(), pods, client.InNamespace(namespace)); err != nil { if apierrors.IsNotFound(err) { continue } return fmt.Errorf("list Pods in namespace %q: %w", namespace, err) } remaining += len(pods.Items) for j := range pods.Items { pod := &pods.Items[j] if err := k8sClient.Delete(context.TODO(), pod, &client.DeleteOptions{ GracePeriodSeconds: &zero, }); err != nil && !apierrors.IsNotFound(err) { return fmt.Errorf("delete Pod %q/%q: %w", namespace, pod.GetName(), err) } } } if remaining > 0 { return fmt.Errorf("Tenant %q still has %d Pod(s)", tnt.GetName(), remaining) } return nil }, defaultTerminationTimeoutInterval, defaultPollInterval).Should(Succeed(), "failed to delete Pods before deleting Tenant %q", tnt.GetName()) } func ModifyCapsuleConfigurationOpts(fn func(configuration *capsulev1beta2.CapsuleConfiguration)) { err := retry.RetryOnConflict(retry.DefaultRetry, func() error { config := &capsulev1beta2.CapsuleConfiguration{} if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: defaultConfigurationName}, config); err != nil { return err } fn(config) return k8sClient.Update(context.Background(), config) }) Expect(err).ToNot(HaveOccurred()) } func ExpectCapsuleConfigurationUpdateDenied( mutate func(*capsulev1beta2.CapsuleConfiguration), expectedSubstrings ...string, ) { Eventually(func() error { configuration := &capsulev1beta2.CapsuleConfiguration{} if err := k8sClient.Get( context.Background(), client.ObjectKey{Name: defaultConfigurationName}, configuration, ); err != nil { return err } mutate(configuration) err := k8sClient.Update(context.Background(), configuration) if err == nil { return fmt.Errorf("expected CapsuleConfiguration update to be denied, but it succeeded") } if apierrors.IsConflict(err) { return err } if !apierrors.IsForbidden(err) { return fmt.Errorf("expected forbidden error, got: %w", err) } message := err.Error() for _, expected := range expectedSubstrings { if !strings.Contains(message, expected) { return fmt.Errorf("expected error to contain %q, got: %s", expected, message) } } return nil }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func CheckForOwnerRoleBindings(ns *corev1.Namespace, owner rbac.OwnerSpec, roles map[string]bool) func() error { if roles == nil { roles = map[string]bool{ "admin": false, "capsule-namespace-deleter": false, } } return func() (err error) { roleBindings := &rbacv1.RoleBindingList{} if err = k8sClient.List(context.Background(), roleBindings, client.InNamespace(ns.GetName())); err != nil { return fmt.Errorf("cannot retrieve list of rolebindings: %w", err) } var ownerName string if owner.Kind == rbac.ServiceAccountOwner { parts := strings.Split(owner.Name, ":") ownerName = parts[3] } else { ownerName = owner.Name } for _, roleBinding := range roleBindings.Items { _, ok := roles[roleBinding.RoleRef.Name] if !ok { continue } subject := roleBinding.Subjects[0] if subject.Name != ownerName { continue } roles[roleBinding.RoleRef.Name] = true } for role, found := range roles { if !found { return fmt.Errorf("role %s for %s.%s has not been reconciled", role, owner.Kind.String(), owner.Name) } } return nil } } func VerifyTenantRoleBindings( tnt *capsulev1beta2.Tenant, ) { Eventually(func(g Gomega) { roles := tnt.GetRoleBindings() // List all RoleBindings once per namespace to avoid repeated API calls. for _, ns := range tnt.Status.Namespaces { for _, role := range roles { rbName := meta.NameForManagedRoleBindings(utils.RoleBindingHashFunc(role)) rb := &rbacv1.RoleBinding{} err := k8sClient.Get(context.Background(), client.ObjectKey{ Namespace: ns, Name: rbName, }, rb) g.Expect(err).ToNot(HaveOccurred(), "expected RoleBinding %s/%s to exist (Owner: %s)", ns, rbName, role.Subjects, ) g.Expect(rb.RoleRef.Name).To(Equal(role.ClusterRoleName), "expected RoleBinding %s/%s to have RoleRef.Name=%q", ns, rbName, role.ClusterRoleName) g.Expect(rb.Subjects).ToNot(BeEmpty(), "expected RoleBinding %s/%s to have at least one subject", ns, rbName) g.Expect(rb.Subjects).To(ConsistOf(role.Subjects), "expected RoleBinding %s/%s to have exact subjects", ns, rb.Name, ) } } }).WithTimeout(30 * time.Second).WithPolling(500 * time.Millisecond).Should(Succeed()) } func normalizeOwners(in rbac.OwnerStatusListSpec) rbac.OwnerStatusListSpec { // copy to avoid mutating the original out := make(rbac.OwnerStatusListSpec, len(in)) copy(out, in) // sort outer slice by kind+name sort.Sort(rbac.GetByKindAndName(out)) // sort roles inside each owner so role order doesn't matter for i := range out { sort.Strings(out[i].ClusterRoles) } return out } func EnsureServiceAccount(ctx context.Context, c client.Client, name string, namespace string) { sa := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, } err := c.Create(ctx, sa) if err != nil && !apierrors.IsAlreadyExists(err) { Expect(err).ToNot(HaveOccurred()) } } func EnsureRoleAndBindingForNamespaces(ctx context.Context, c client.Client, saName string, saNamespace string, namespaces []string) { for _, ns := range namespaces { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ Name: saName + "-" + saNamespace, Namespace: ns, }, Rules: []rbacv1.PolicyRule{ { APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}, }, }, } err := c.Create(ctx, role) if err != nil && !apierrors.IsAlreadyExists(err) { Expect(err).ToNot(HaveOccurred()) } rb := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: saName + "-" + saNamespace, Namespace: ns, }, Subjects: []rbacv1.Subject{ { Kind: "ServiceAccount", Name: saName, Namespace: saNamespace, }, }, RoleRef: rbacv1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: saName + "-" + saNamespace, }, } err = c.Create(ctx, rb) if err != nil && !apierrors.IsAlreadyExists(err) { Expect(err).ToNot(HaveOccurred()) } } } func GetKubernetesVersion() *versionUtil.Version { var serverVersion *version.Info var err error var cs kubernetes.Interface var ver *versionUtil.Version cs, err = kubernetes.NewForConfig(cfg) Expect(err).ToNot(HaveOccurred()) serverVersion, err = cs.Discovery().ServerVersion() Expect(err).ToNot(HaveOccurred()) ver, err = versionUtil.ParseGeneric(serverVersion.String()) Expect(err).ToNot(HaveOccurred()) return ver } func GrantEphemeralContainersUpdate(ns string, username string) (cleanup func()) { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ Name: "e2e-ephemeralcontainers", Namespace: ns, }, Rules: []rbacv1.PolicyRule{ { APIGroups: []string{""}, Resources: []string{"pods/ephemeralcontainers"}, Verbs: []string{"update", "patch"}, }, // Optional but often useful for the test flow: { APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}, }, }, } rb := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: "e2e-ephemeralcontainers", Namespace: ns, }, Subjects: []rbacv1.Subject{ { Kind: rbacv1.UserKind, Name: username, APIGroup: rbacv1.GroupName, }, }, RoleRef: rbacv1.RoleRef{ APIGroup: rbacv1.GroupName, Kind: "Role", Name: role.Name, }, } // Create-or-update (simple) EventuallyCreation(func() error { _ = k8sClient.Delete(context.Background(), rb) _ = k8sClient.Delete(context.Background(), role) if err := k8sClient.Create(context.Background(), role); err != nil && !apierrors.IsAlreadyExists(err) { return err } if err := k8sClient.Create(context.Background(), rb); err != nil && !apierrors.IsAlreadyExists(err) { return err } return nil }).Should(Succeed()) // Give RBAC a moment to propagate in the apiserver authorizer cache Eventually(func() error { cs := ownerClient(rbac.UserSpec{Name: username, Kind: "User"}) _, err := cs.CoreV1().Pods(ns).List(context.Background(), metav1.ListOptions{Limit: 1}) return err }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) return func() { // Best-effort cleanup _ = k8sClient.Delete(context.Background(), rb) _ = k8sClient.Delete(context.Background(), role) } } func DeepCompare(expected, actual interface{}) (bool, string) { expVal := reflect.ValueOf(expected) actVal := reflect.ValueOf(actual) // If the kinds differ, they are not equal. if expVal.Kind() != actVal.Kind() { return false, fmt.Sprintf("kind mismatch: %v vs %v", expVal.Kind(), actVal.Kind()) } switch expVal.Kind() { case reflect.Slice, reflect.Array: // Convert slices to []interface{} for ElementsMatch. expSlice := make([]interface{}, expVal.Len()) actSlice := make([]interface{}, actVal.Len()) for i := 0; i < expVal.Len(); i++ { expSlice[i] = expVal.Index(i).Interface() } for i := 0; i < actVal.Len(); i++ { actSlice[i] = actVal.Index(i).Interface() } // Use a dummy tester to capture error messages. dummy := &dummyT{} if !assert.ElementsMatch(dummy, expSlice, actSlice) { return false, fmt.Sprintf("slice mismatch: %v", dummy.errors) } return true, "" case reflect.Struct: // Iterate over fields and compare recursively. for i := 0; i < expVal.NumField(); i++ { fieldName := expVal.Type().Field(i).Name ok, msg := DeepCompare(expVal.Field(i).Interface(), actVal.Field(i).Interface()) if !ok { return false, fmt.Sprintf("field %s mismatch: %s", fieldName, msg) } } return true, "" default: // Fallback to reflect.DeepEqual for other types. if !reflect.DeepEqual(expected, actual) { return false, fmt.Sprintf("expected %v but got %v", expected, actual) } return true, "" } } // dummyT implements a minimal TestingT for testify. type dummyT struct { errors []string } func (d *dummyT) Errorf(format string, args ...interface{}) { d.errors = append(d.errors, fmt.Sprintf(format, args...)) } func MakePod(namespace, name string, labels map[string]string, annotations map[string]string, image string, cpuRequest string, emptyDirSize string) *corev1.Pod { pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, Labels: labels, Annotations: annotations, }, Spec: corev1.PodSpec{ SecurityContext: nobodyPodSecurityContext(), Containers: []corev1.Container{ { Name: "main", Image: image, SecurityContext: restrictedContainerSecurityContext(), }, }, RestartPolicy: corev1.RestartPolicyAlways, }, } if cpuRequest != "" { pod.Spec.Containers[0].Resources.Requests = corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(cpuRequest), } } if emptyDirSize != "" { pod.Spec.Volumes = []corev1.Volume{ { Name: "cache", VolumeSource: corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{ SizeLimit: ptr.To(resource.MustParse(emptyDirSize)), }, }, }, } pod.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{ { Name: "cache", MountPath: "/cache", }, } } return pod } func MakeDeployment(namespace, name string, replicas int32, labels map[string]string, cpuRequest string) *appsv1.Deployment { dep := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, Spec: appsv1.DeploymentSpec{ Replicas: ptr.To(replicas), Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "app": name, }, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: mergeMaps(map[string]string{"app": name}, labels), }, Spec: corev1.PodSpec{ SecurityContext: nobodyPodSecurityContext(), Containers: []corev1.Container{ { Name: "nginx", Image: "nginx:1.27.0", SecurityContext: restrictedContainerSecurityContext(), }, }, }, }, }, } if cpuRequest != "" { dep.Spec.Template.Spec.Containers[0].Resources.Requests = corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(cpuRequest), } } return dep } func ExpectPodsForDeployment(ctx context.Context, namespace, app string, expected int) { Eventually(func(g Gomega) { pods := &corev1.PodList{} g.Expect(k8sClient.List(ctx, pods, client.InNamespace(namespace), client.MatchingLabels{ "app": app, }, )).To(Succeed()) g.Expect(len(pods.Items)).To(Equal(expected), "unexpected pod count for deployment app=%q in namespace %s", app, namespace, ) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func nobodyPodSecurityContext() *corev1.PodSecurityContext { return &corev1.PodSecurityContext{ RunAsNonRoot: ptr.To(true), RunAsUser: ptr.To[int64](65534), RunAsGroup: ptr.To[int64](65534), SeccompProfile: &corev1.SeccompProfile{ Type: corev1.SeccompProfileTypeRuntimeDefault, }, } } func restrictedContainerSecurityContext() *corev1.SecurityContext { return &corev1.SecurityContext{ AllowPrivilegeEscalation: ptr.To(false), Capabilities: &corev1.Capabilities{ Drop: []corev1.Capability{ "ALL", }, }, } } func MakePVC(namespace, name, size string) *corev1.PersistentVolumeClaim { return &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, Spec: corev1.PersistentVolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceStorage: resource.MustParse(size), }, }, }, } } func ScaleDeployment(ctx context.Context, namespace, name string, replicas int32) { Eventually(func() error { dep := &appsv1.Deployment{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, dep); err != nil { return err } dep.Spec.Replicas = ptr.To(replicas) return k8sClient.Update(ctx, dep) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func UpdatePodLabels(ctx context.Context, namespace, name string, labels map[string]string) { Eventually(func() error { pod := &corev1.Pod{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, pod); err != nil { return err } pod.Labels = labels return k8sClient.Update(ctx, pod) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func UpdatePodImage(ctx context.Context, namespace, name, image string) { Eventually(func() error { pod := &corev1.Pod{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, pod); err != nil { return err } pod.Spec.Containers[0].Image = image return k8sClient.Update(ctx, pod) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } func TenantReadyTrue(tnt *capsulev1beta2.Tenant) { TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) } func TenantReadyFalse(tnt *capsulev1beta2.Tenant) { TenantReady(tnt, metav1.ConditionFalse, defaultTimeoutInterval) } func TenantReady( tnt *capsulev1beta2.Tenant, expected metav1.ConditionStatus, timeoutInterval time.Duration, ) { Eventually(func(g Gomega) { current := &capsulev1beta2.Tenant{} err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: tnt.GetName()}, current) g.Expect(err).NotTo(HaveOccurred()) condition := current.Status.Conditions.GetConditionByType(meta.ReadyCondition) g.Expect(condition).NotTo(BeNil(), "expected Tenant %q to have Ready condition", tnt.GetName()) g.Expect(condition.Status).To( Equal(expected), "expected Tenant %q Ready condition to be %s, got %s: reason=%q message=%q", tnt.GetName(), expected, condition.Status, condition.Reason, condition.Message, ) for _, owner := range current.Spec.Owners { g.Expect(current.Status.Owners).To( ContainElement(owner.CoreOwnerSpec), "expected Tenant %q status.owners to contain spec owner %+v; current status.owners=%+v", tnt.GetName(), owner.CoreOwnerSpec, current.Status.Owners, ) } g.Expect(current.Status.ObservedGeneration).To( Equal(current.GetGeneration()), "expected Tenant %q status.observedGeneration (%d) to equal metadata.generation (%d)", tnt.GetName(), current.Status.ObservedGeneration, current.GetGeneration(), ) }, timeoutInterval, defaultPollInterval).Should(Succeed()) } func EnsureRuntimeClass(ctx context.Context, rtc *nodev1.RuntimeClass) { Eventually(func() error { desired := rtc.DeepCopy() desired.ResourceVersion = "" _, err := controllerutil.CreateOrUpdate(ctx, k8sClient, desired, func() error { desired.Handler = rtc.Handler desired.Overhead = rtc.Overhead desired.Scheduling = rtc.Scheduling labels := desired.GetLabels() if labels == nil { labels = map[string]string{} } for key, value := range rtc.GetLabels() { labels[key] = value } labels["env"] = "e2e" desired.SetLabels(labels) annotations := desired.GetAnnotations() if annotations == nil { annotations = map[string]string{} } for key, value := range rtc.GetAnnotations() { annotations[key] = value } desired.SetAnnotations(annotations) return nil }) return err }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } const namespaceTerminationHoldFinalizer = "e2e.projectcapsule.dev/hold-termination" func holdNamespaceTerminating(ctx context.Context, name string) func() { Eventually(func() error { ns := &corev1.Namespace{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns); err != nil { return err } if controllerutil.AddFinalizer(ns, namespaceTerminationHoldFinalizer) { return k8sClient.Update(ctx, ns) } return nil }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) Eventually(func() error { ns := &corev1.Namespace{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns); err != nil { return err } if ns.DeletionTimestamp != nil { return nil } return k8sClient.Delete(ctx, ns) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) Eventually(func(g Gomega) { ns := &corev1.Namespace{} g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns)).To(Succeed()) g.Expect(ns.DeletionTimestamp).ToNot(BeNil()) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) return func() { Eventually(func() error { ns := &corev1.Namespace{} if err := k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns); err != nil { if apierrors.IsNotFound(err) { return nil } return err } if controllerutil.RemoveFinalizer(ns, namespaceTerminationHoldFinalizer) { return k8sClient.Update(ctx, ns) } return nil }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } } func hasTenantOwnerReferenceByNameAndUID( obj metav1.Object, tenantName string, tenantUID types.UID, ) bool { if obj == nil { return false } for _, ref := range obj.GetOwnerReferences() { if ref.APIVersion != capsulev1beta2.GroupVersion.String() { continue } if ref.Kind != "Tenant" { continue } if ref.Name != tenantName { continue } if ref.UID != tenantUID { continue } return true } return false }