diff --git a/pkg/testhelper/kube/kube.go b/pkg/testhelper/kube/kube.go index 6c613b9..1af2046 100644 --- a/pkg/testhelper/kube/kube.go +++ b/pkg/testhelper/kube/kube.go @@ -13,14 +13,22 @@ import ( . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apix "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" apiv1 "github.com/1Password/onepassword-operator/api/v1" + "github.com/1Password/onepassword-operator/pkg/testhelper/defaults" ) type TestConfig struct { @@ -32,11 +40,13 @@ type Config struct { Namespace string ManifestsDir string TestConfig *TestConfig + CRDs []string } type Kube struct { Config *Config Client client.Client + Mapper meta.RESTMapper } func NewKubeClient(config *Config) *Kube { @@ -49,12 +59,26 @@ func NewKubeClient(config *Config) *Kube { restConfig, err := clientcmd.BuildConfigFromFlags("", kubeconfig) Expect(err).NotTo(HaveOccurred()) + // Install CRDs first (so discovery sees them) + installCRDs(context.Background(), restConfig, config.CRDs) + + // Build an http.Client from restConfig + httpClient, err := rest.HTTPClientFor(restConfig) + Expect(err).NotTo(HaveOccurred()) + + // Create a Dynamic RESTMapper that uses restConfig + rm, err := apiutil.NewDynamicRESTMapper(restConfig, httpClient) + Expect(err).NotTo(HaveOccurred()) + scheme := runtime.NewScheme() utilruntime.Must(corev1.AddToScheme(scheme)) utilruntime.Must(appsv1.AddToScheme(scheme)) - utilruntime.Must(apiv1.AddToScheme(scheme)) + utilruntime.Must(apiv1.AddToScheme(scheme)) // add OnePasswordItem to scheme - kubernetesClient, err := client.New(restConfig, client.Options{Scheme: scheme}) + kubernetesClient, err := client.New(restConfig, client.Options{ + Scheme: scheme, + Mapper: rm, + }) Expect(err).NotTo(HaveOccurred()) // update the current context’s namespace in kubeconfig @@ -75,6 +99,7 @@ func NewKubeClient(config *Config) *Kube { return &Kube{ Config: config, Client: kubernetesClient, + Mapper: rm, } } @@ -113,19 +138,79 @@ func (k *Kube) ApplyOnePasswordItem(ctx context.Context, fileName string) { data, err := os.ReadFile(k.Config.ManifestsDir + "/" + fileName) Expect(err).NotTo(HaveOccurred()) - item := &apiv1.OnePasswordItem{} - err = yaml.Unmarshal(data, item) + // Decode YAML -> JSON -> unstructured.Unstructured + jsonBytes, err := yaml.ToJSON(data) Expect(err).NotTo(HaveOccurred()) - if item.Namespace == "" { - item.Namespace = k.Config.Namespace + var obj unstructured.Unstructured + Expect(obj.UnmarshalJSON(jsonBytes)).To(Succeed()) + + // Default namespace for namespaced resources if not set in YAML + if obj.GetNamespace() == "" && k.Config.Namespace != "" { + gvk := obj.GroupVersionKind() + mapping, mapErr := k.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if mapErr == nil && mapping.Scope.Name() == meta.RESTScopeNameNamespace { + obj.SetNamespace(k.Config.Namespace) + } } - err = k.Client.Get(c, client.ObjectKey{Name: item.Name, Namespace: k.Config.Namespace}, item) - if errors.IsNotFound(err) { - err = k.Client.Create(c, item) - } else { - err = k.Client.Update(c, item) + // Server-Side Apply (create or update) + patchOpts := []client.PatchOption{ + client.FieldOwner("onepassword-e2e"), + client.ForceOwnership, // to force-take conflicting fields } - Expect(err).NotTo(HaveOccurred()) + Expect(k.Client.Patch(c, &obj, client.Apply, patchOpts...)).To(Succeed()) +} + +func installCRDs(ctx context.Context, restConfig *rest.Config, crdFiles []string) { + apixClient, err := apix.NewForConfig(restConfig) + Expect(err).NotTo(HaveOccurred()) + + for _, f := range crdFiles { + By("Installing CRD " + f) + b, err := os.ReadFile(filepath.Clean(f)) + Expect(err).NotTo(HaveOccurred()) + + var crd apiextv1.CustomResourceDefinition + err = yaml.Unmarshal(b, &crd) + Expect(err).NotTo(HaveOccurred()) + + // Create or Update + _, err = apixClient.ApiextensionsV1().CustomResourceDefinitions().Create(ctx, &crd, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, getErr := apixClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crd.Name, metav1.GetOptions{}) + Expect(getErr).NotTo(HaveOccurred()) + + crd.ResourceVersion = existing.ResourceVersion + _, err = apixClient.ApiextensionsV1().CustomResourceDefinitions().Update(ctx, &crd, metav1.UpdateOptions{}) + } + Expect(err).NotTo(HaveOccurred()) + + waitCRDEstablished(ctx, apixClient, crd.Name) + } +} + +// waitCRDEstablished Wait until the CRD reaches Established=True, retrying until the suite timeout. +func waitCRDEstablished(ctx context.Context, apixClient *apix.Clientset, name string) { + By("Waiting for CRD " + name + " to be Established") + + Eventually(func(g Gomega) { + // Short per-attempt timeout so a single Get can't hang the whole Eventually loop. + attemptCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + crd, err := apixClient.ApiextensionsV1(). + CustomResourceDefinitions(). + Get(attemptCtx, name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + + established := false + for _, c := range crd.Status.Conditions { + if c.Type == apiextv1.Established && c.Status == apiextv1.ConditionTrue { + established = true + break + } + } + g.Expect(established).To(BeTrue(), "CRD %q is not Established yet", name) + }, defaults.E2ETimeout, defaults.E2EInterval).Should(Succeed()) } diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index dbe4a5a..8208353 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -17,6 +17,7 @@ import ( "github.com/1Password/onepassword-operator/pkg/testhelper/kube" "github.com/1Password/onepassword-operator/pkg/testhelper/op" "github.com/1Password/onepassword-operator/pkg/testhelper/operator" + "github.com/1Password/onepassword-operator/pkg/testhelper/system" ) const ( @@ -30,6 +31,9 @@ var _ = Describe("Onepassword Operator e2e", Ordered, func() { ctx := context.Background() BeforeAll(func() { + rootDir, err := system.GetProjectRoot() + Expect(err).NotTo(HaveOccurred()) + kubeClient = kube.NewKubeClient(&kube.Config{ Namespace: "default", ManifestsDir: filepath.Join("manifests"), @@ -37,6 +41,9 @@ var _ = Describe("Onepassword Operator e2e", Ordered, func() { Timeout: defaults.E2ETimeout, Interval: defaults.E2EInterval, }, + CRDs: []string{ + filepath.Join(rootDir, "config", "crd", "bases", "onepassword.com_onepassworditems.yaml"), + }, }) operator.BuildOperatorImage()