mirror of
https://github.com/1Password/onepassword-operator.git
synced 2025-10-21 15:08:06 +00:00
223 lines
6.4 KiB
Go
223 lines
6.4 KiB
Go
package kube
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"time"
|
||
|
||
//nolint:staticcheck // ST1001
|
||
. "github.com/onsi/ginkgo/v2"
|
||
//nolint:staticcheck // ST1001
|
||
. "github.com/onsi/gomega"
|
||
appsv1 "k8s.io/api/apps/v1"
|
||
corev1 "k8s.io/api/core/v1"
|
||
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/controller-runtime/pkg/client/apiutil"
|
||
|
||
"github.com/1Password/onepassword-operator/pkg/testhelper/defaults"
|
||
)
|
||
|
||
type TestConfig struct {
|
||
Timeout time.Duration
|
||
Interval time.Duration
|
||
}
|
||
|
||
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 {
|
||
By("Creating a kubernetes client")
|
||
kubeconfig := os.Getenv("KUBECONFIG")
|
||
if kubeconfig == "" {
|
||
home, _ := os.UserHomeDir()
|
||
kubeconfig = filepath.Join(home, ".kube", "config")
|
||
}
|
||
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))
|
||
|
||
kubernetesClient, err := client.New(restConfig, client.Options{
|
||
Scheme: scheme,
|
||
Mapper: rm,
|
||
})
|
||
Expect(err).NotTo(HaveOccurred())
|
||
|
||
// update the current context’s namespace in kubeconfig
|
||
pathOpts := clientcmd.NewDefaultPathOptions()
|
||
cfg, err := pathOpts.GetStartingConfig()
|
||
Expect(err).NotTo(HaveOccurred())
|
||
|
||
currentContext := cfg.CurrentContext
|
||
Expect(currentContext).NotTo(BeEmpty(), "no current kube context is set in kubeconfig")
|
||
|
||
ctx, ok := cfg.Contexts[currentContext]
|
||
Expect(ok).To(BeTrue(), fmt.Sprintf("current context %q not found in kubeconfig", currentContext))
|
||
|
||
ctx.Namespace = config.Namespace
|
||
err = clientcmd.ModifyConfig(pathOpts, *cfg, true)
|
||
Expect(err).NotTo(HaveOccurred())
|
||
|
||
return &Kube{
|
||
Config: config,
|
||
Client: kubernetesClient,
|
||
Mapper: rm,
|
||
}
|
||
}
|
||
|
||
func (k *Kube) Secret(name string) *Secret {
|
||
return &Secret{
|
||
client: k.Client,
|
||
config: k.Config,
|
||
name: name,
|
||
}
|
||
}
|
||
|
||
func (k *Kube) Deployment(name string) *Deployment {
|
||
return &Deployment{
|
||
client: k.Client,
|
||
config: k.Config,
|
||
name: name,
|
||
}
|
||
}
|
||
|
||
func (k *Kube) Pod(selector map[string]string) *Pod {
|
||
return &Pod{
|
||
client: k.Client,
|
||
config: k.Config,
|
||
selector: selector,
|
||
}
|
||
}
|
||
|
||
func (k *Kube) Namespace(name string) *Namespace {
|
||
return &Namespace{
|
||
client: k.Client,
|
||
config: k.Config,
|
||
name: name,
|
||
}
|
||
}
|
||
|
||
// ApplyOnePasswordItem applies a OnePasswordItem manifest.
|
||
func (k *Kube) ApplyOnePasswordItem(ctx context.Context, fileName string) {
|
||
By("Applying " + fileName)
|
||
|
||
// Derive a short-lived context so this API call won't hang indefinitely.
|
||
c, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
|
||
data, err := os.ReadFile(k.Config.ManifestsDir + "/" + fileName)
|
||
Expect(err).NotTo(HaveOccurred())
|
||
|
||
// Decode YAML -> JSON -> unstructured.Unstructured
|
||
jsonBytes, err := yaml.ToJSON(data)
|
||
Expect(err).NotTo(HaveOccurred())
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Server-Side Apply (create or update)
|
||
patchOpts := []client.PatchOption{
|
||
client.FieldOwner("onepassword-e2e"),
|
||
client.ForceOwnership, // to force-take conflicting fields
|
||
}
|
||
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())
|
||
}
|