diff --git a/pkg/cluster-handler/controller/multigrescluster/constants.go b/pkg/cluster-handler/controller/multigrescluster/constants.go deleted file mode 100644 index fc993295..00000000 --- a/pkg/cluster-handler/controller/multigrescluster/constants.go +++ /dev/null @@ -1,23 +0,0 @@ -package multigrescluster - -// NOTE: We may want to consider moving this to different module/package before implementing the Mutating Webhook. -// This separation is critical to prevent circular dependencies between the Webhook and Controller packages -// and ensures that the "Level 4" defaulting logic is reusable as a Single Source of Truth for both the reconciliation loop -// and admission requests. - -const ( - // DefaultEtcdReplicas is the default number of replicas for the managed Etcd cluster if not specified. - DefaultEtcdReplicas int32 = 3 - - // DefaultAdminReplicas is the default number of replicas for the MultiAdmin deployment if not specified. - DefaultAdminReplicas int32 = 1 - - // FallbackCoreTemplate is the name of the template to look for if no specific template is referenced. - FallbackCoreTemplate = "default" - - // FallbackCellTemplate is the name of the template to look for if no specific template is referenced. - FallbackCellTemplate = "default" - - // FallbackShardTemplate is the name of the template to look for if no specific template is referenced. - FallbackShardTemplate = "default" -) diff --git a/pkg/cluster-handler/controller/multigrescluster/doc.go b/pkg/cluster-handler/controller/multigrescluster/doc.go new file mode 100644 index 00000000..6c3dfe13 --- /dev/null +++ b/pkg/cluster-handler/controller/multigrescluster/doc.go @@ -0,0 +1,28 @@ +// Package multigrescluster implements the controller for the root MultigresCluster resource. +// +// The MultigresCluster controller acts as the central orchestrator for the database system. +// It is responsible for translating the high-level user intent into specific child resources. +// Its primary responsibilities include: +// +// 1. Global Component Management: +// It directly manages singleton resources defined at the cluster level, such as the +// Global TopoServer (via a child TopoServer CR) and the MultiAdmin deployment. +// +// 2. Resource Fan-Out (Child CR Management): +// It projects the configuration defined in the MultigresCluster spec (Cells and Databases) +// into discrete child Custom Resources (Cell and TableGroup). These child resources are +// then reconciled by their own respective controllers. +// +// 3. Template Resolution: +// It leverages the 'pkg/resolver' module to fetch CoreTemplates, CellTemplates, and +// ShardTemplates, merging them with user-defined overrides to produce the final +// specifications for child resources. +// +// 4. Status Aggregation: +// It continually observes the status of its child resources to produce a high-level +// summary of the cluster's health (e.g., "All cells ready", "Database X has Y/Z shards ready"). +// +// 5. Lifecycle Management: +// It utilizes finalizers to ensure that all child resources are gracefully terminated +// and cleaned up before the parent MultigresCluster is removed. +package multigrescluster diff --git a/pkg/cluster-handler/controller/multigrescluster/integration_test.go b/pkg/cluster-handler/controller/multigrescluster/integration_test.go index 442ec512..28383238 100644 --- a/pkg/cluster-handler/controller/multigrescluster/integration_test.go +++ b/pkg/cluster-handler/controller/multigrescluster/integration_test.go @@ -19,6 +19,7 @@ import ( multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" "github.com/numtide/multigres-operator/pkg/cluster-handler/controller/multigrescluster" + "github.com/numtide/multigres-operator/pkg/resolver" "github.com/numtide/multigres-operator/pkg/testutil" ) @@ -124,8 +125,10 @@ func TestMultigresClusterReconciliation(t *testing.T) { }, Spec: multigresv1alpha1.TopoServerSpec{ Etcd: &multigresv1alpha1.EtcdSpec{ - Image: "etcd:latest", - Replicas: ptr.To(int32(3)), // Default from logic + Image: "etcd:latest", + Replicas: ptr.To(int32(3)), // Default from logic + Storage: multigresv1alpha1.StorageSpec{Size: resolver.DefaultEtcdStorageSize}, + Resources: resolver.DefaultResourcesEtcd(), }, }, }, @@ -149,8 +152,9 @@ func TestMultigresClusterReconciliation(t *testing.T) { Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "multiadmin", - Image: "admin:latest", + Name: "multiadmin", + Image: "admin:latest", + Resources: resolver.DefaultResourcesAdmin(), }, }, }, @@ -278,18 +282,45 @@ func TestMultigresClusterReconciliation(t *testing.T) { t.Fatalf("Failed to create controller, %v", err) } - // 4. Create the Input + // 4. Create Defaults (Templates) + // The controller expects "default" templates to exist when TemplateDefaults are not specified. + // We create empty templates so the resolution succeeds and falls back to hardcoded defaults or inline specs. + emptyCore := &multigresv1alpha1.CoreTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.CoreTemplateSpec{}, + } + if err := k8sClient.Create(ctx, emptyCore); client.IgnoreAlreadyExists(err) != nil { + t.Fatalf("Failed to create default core template: %v", err) + } + + emptyCell := &multigresv1alpha1.CellTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.CellTemplateSpec{}, + } + if err := k8sClient.Create(ctx, emptyCell); client.IgnoreAlreadyExists(err) != nil { + t.Fatalf("Failed to create default cell template: %v", err) + } + + emptyShard := &multigresv1alpha1.ShardTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.ShardTemplateSpec{}, + } + if err := k8sClient.Create(ctx, emptyShard); client.IgnoreAlreadyExists(err) != nil { + t.Fatalf("Failed to create default shard template: %v", err) + } + + // 5. Create the Input if err := k8sClient.Create(ctx, tc.cluster); err != nil { t.Fatalf("Failed to create the initial cluster, %v", err) } - // 5. Assert Logic: Wait for Children + // 6. Assert Logic: Wait for Children // This ensures the controller has run and reconciled at least once successfully if err := watcher.WaitForMatch(tc.wantResources...); err != nil { t.Errorf("Resources mismatch:\n%v", err) } - // 6. Verify Parent Finalizer (Manual Check) + // 7. Verify Parent Finalizer (Manual Check) // We check this manually to avoid fighting with status/spec diffs in the watcher fetchedCluster := &multigresv1alpha1.MultigresCluster{} if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(tc.cluster), fetchedCluster); err != nil { diff --git a/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller.go b/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller.go index fbc045ae..50178040 100644 --- a/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller.go +++ b/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller.go @@ -19,6 +19,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + "github.com/numtide/multigres-operator/pkg/resolver" ) const ( @@ -67,23 +68,22 @@ func (r *MultigresClusterReconciler) Reconcile( return ctrl.Result{}, nil } - resolver := &TemplateResolver{ - Client: r.Client, - Namespace: cluster.Namespace, - Defaults: cluster.Spec.TemplateDefaults, - } + res := resolver.NewResolver(r.Client, cluster.Namespace, cluster.Spec.TemplateDefaults) + + // Apply defaults (in-memory) to ensure we have images/configs even if webhook didn't run + res.PopulateClusterDefaults(cluster) - if err := r.reconcileGlobalComponents(ctx, cluster, resolver); err != nil { + if err := r.reconcileGlobalComponents(ctx, cluster, res); err != nil { l.Error(err, "Failed to reconcile global components") return ctrl.Result{}, err } - if err := r.reconcileCells(ctx, cluster, resolver); err != nil { + if err := r.reconcileCells(ctx, cluster, res); err != nil { l.Error(err, "Failed to reconcile cells") return ctrl.Result{}, err } - if err := r.reconcileDatabases(ctx, cluster, resolver); err != nil { + if err := r.reconcileDatabases(ctx, cluster, res); err != nil { l.Error(err, "Failed to reconcile databases") return ctrl.Result{}, err } @@ -146,12 +146,12 @@ func (r *MultigresClusterReconciler) checkChildrenDeleted( func (r *MultigresClusterReconciler) reconcileGlobalComponents( ctx context.Context, cluster *multigresv1alpha1.MultigresCluster, - resolver *TemplateResolver, + res *resolver.Resolver, ) error { - if err := r.reconcileGlobalTopoServer(ctx, cluster, resolver); err != nil { + if err := r.reconcileGlobalTopoServer(ctx, cluster, res); err != nil { return err } - if err := r.reconcileMultiAdmin(ctx, cluster, resolver); err != nil { + if err := r.reconcileMultiAdmin(ctx, cluster, res); err != nil { return err } return nil @@ -160,19 +160,21 @@ func (r *MultigresClusterReconciler) reconcileGlobalComponents( func (r *MultigresClusterReconciler) reconcileGlobalTopoServer( ctx context.Context, cluster *multigresv1alpha1.MultigresCluster, - resolver *TemplateResolver, + res *resolver.Resolver, ) error { tplName := cluster.Spec.TemplateDefaults.CoreTemplate if cluster.Spec.GlobalTopoServer.TemplateRef != "" { tplName = cluster.Spec.GlobalTopoServer.TemplateRef } - tpl, err := resolver.ResolveCoreTemplate(ctx, tplName) + tpl, err := res.ResolveCoreTemplate(ctx, tplName) if err != nil { return fmt.Errorf("failed to resolve topo template: %w", err) } - spec := ResolveGlobalTopo(&cluster.Spec.GlobalTopoServer, tpl) + spec := resolver.ResolveGlobalTopo(&cluster.Spec.GlobalTopoServer, tpl) + // If Etcd is nil, it means we are using External topology (or invalid config handled by validations). + // We only create a TopoServer CR if we are managing Etcd. if spec.Etcd != nil { ts := &multigresv1alpha1.TopoServer{ ObjectMeta: metav1.ObjectMeta{ @@ -182,14 +184,10 @@ func (r *MultigresClusterReconciler) reconcileGlobalTopoServer( }, } if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ts, func() error { - replicas := DefaultEtcdReplicas - if spec.Etcd.Replicas != nil { - replicas = *spec.Etcd.Replicas - } - + // resolver.ResolveGlobalTopo guarantees spec.Etcd.Replicas is set ts.Spec.Etcd = &multigresv1alpha1.EtcdSpec{ Image: spec.Etcd.Image, - Replicas: &replicas, + Replicas: spec.Etcd.Replicas, Storage: spec.Etcd.Storage, Resources: spec.Etcd.Resources, } @@ -204,74 +202,72 @@ func (r *MultigresClusterReconciler) reconcileGlobalTopoServer( func (r *MultigresClusterReconciler) reconcileMultiAdmin( ctx context.Context, cluster *multigresv1alpha1.MultigresCluster, - resolver *TemplateResolver, + res *resolver.Resolver, ) error { tplName := cluster.Spec.TemplateDefaults.CoreTemplate if cluster.Spec.MultiAdmin.TemplateRef != "" { tplName = cluster.Spec.MultiAdmin.TemplateRef } - tpl, err := resolver.ResolveCoreTemplate(ctx, tplName) + tpl, err := res.ResolveCoreTemplate(ctx, tplName) if err != nil { return fmt.Errorf("failed to resolve admin template: %w", err) } - spec := ResolveMultiAdmin(&cluster.Spec.MultiAdmin, tpl) - if spec != nil { - deploy := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: cluster.Name + "-multiadmin", - Namespace: cluster.Namespace, - Labels: map[string]string{ - "multigres.com/cluster": cluster.Name, - "app": "multiadmin", - }, + // resolver.ResolveMultiAdmin guarantees a non-nil spec with defaults applied + spec := resolver.ResolveMultiAdmin(&cluster.Spec.MultiAdmin, tpl) + + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-multiadmin", + Namespace: cluster.Namespace, + Labels: map[string]string{ + "multigres.com/cluster": cluster.Name, + "app": "multiadmin", }, + }, + } + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error { + // resolver.ResolveMultiAdmin guarantees Replicas is set + deploy.Spec.Replicas = spec.Replicas + deploy.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "multiadmin", "multigres.com/cluster": cluster.Name}, } - if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error { - replicas := DefaultAdminReplicas - if spec.Replicas != nil { - replicas = *spec.Replicas - } - deploy.Spec.Replicas = &replicas - deploy.Spec.Selector = &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "multiadmin", "multigres.com/cluster": cluster.Name}, - } - deploy.Spec.Template = corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "multiadmin", "multigres.com/cluster": cluster.Name}, - }, - Spec: corev1.PodSpec{ - ImagePullSecrets: cluster.Spec.Images.ImagePullSecrets, - Containers: []corev1.Container{ - { - Name: "multiadmin", - Image: cluster.Spec.Images.MultiAdmin, - Resources: spec.Resources, - }, + deploy.Spec.Template = corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "multiadmin", "multigres.com/cluster": cluster.Name}, + }, + Spec: corev1.PodSpec{ + ImagePullSecrets: cluster.Spec.Images.ImagePullSecrets, + Containers: []corev1.Container{ + { + Name: "multiadmin", + Image: cluster.Spec.Images.MultiAdmin, + Resources: spec.Resources, }, - Affinity: spec.Affinity, }, - } - return controllerutil.SetControllerReference(cluster, deploy, r.Scheme) - }); err != nil { - return fmt.Errorf("failed to create/update multiadmin: %w", err) + Affinity: spec.Affinity, + }, } + return controllerutil.SetControllerReference(cluster, deploy, r.Scheme) + }); err != nil { + return fmt.Errorf("failed to create/update multiadmin: %w", err) } + return nil } func (r *MultigresClusterReconciler) reconcileCells( ctx context.Context, cluster *multigresv1alpha1.MultigresCluster, - resolver *TemplateResolver, + res *resolver.Resolver, ) error { existingCells := &multigresv1alpha1.CellList{} if err := r.List(ctx, existingCells, client.InNamespace(cluster.Namespace), client.MatchingLabels{"multigres.com/cluster": cluster.Name}); err != nil { return fmt.Errorf("failed to list existing cells: %w", err) } - globalTopoRef, err := r.getGlobalTopoRef(ctx, cluster, resolver) + globalTopoRef, err := r.getGlobalTopoRef(ctx, cluster, res) if err != nil { return fmt.Errorf("failed to get global topo ref: %w", err) } @@ -286,12 +282,12 @@ func (r *MultigresClusterReconciler) reconcileCells( for _, cellCfg := range cluster.Spec.Cells { activeCellNames[cellCfg.Name] = true - tpl, err := resolver.ResolveCellTemplate(ctx, cellCfg.CellTemplate) + tpl, err := res.ResolveCellTemplate(ctx, cellCfg.CellTemplate) if err != nil { return fmt.Errorf("failed to resolve cell template '%s': %w", cellCfg.CellTemplate, err) } - gatewaySpec, localTopoSpec := MergeCellConfig(tpl, cellCfg.Overrides, cellCfg.Spec) + gatewaySpec, localTopoSpec := resolver.MergeCellConfig(tpl, cellCfg.Overrides, cellCfg.Spec) cellCR := &multigresv1alpha1.Cell{ ObjectMeta: metav1.ObjectMeta{ @@ -309,7 +305,7 @@ func (r *MultigresClusterReconciler) reconcileCells( cellCR.Spec.Zone = cellCfg.Zone cellCR.Spec.Region = cellCfg.Region cellCR.Spec.MultiGatewayImage = cluster.Spec.Images.MultiGateway - cellCR.Spec.MultiGateway = gatewaySpec + cellCR.Spec.MultiGateway = *gatewaySpec cellCR.Spec.AllCells = allCellNames cellCR.Spec.GlobalTopoServer = globalTopoRef @@ -341,14 +337,14 @@ func (r *MultigresClusterReconciler) reconcileCells( func (r *MultigresClusterReconciler) reconcileDatabases( ctx context.Context, cluster *multigresv1alpha1.MultigresCluster, - resolver *TemplateResolver, + res *resolver.Resolver, ) error { existingTGs := &multigresv1alpha1.TableGroupList{} if err := r.List(ctx, existingTGs, client.InNamespace(cluster.Namespace), client.MatchingLabels{"multigres.com/cluster": cluster.Name}); err != nil { return fmt.Errorf("failed to list existing tablegroups: %w", err) } - globalTopoRef, err := r.getGlobalTopoRef(ctx, cluster, resolver) + globalTopoRef, err := r.getGlobalTopoRef(ctx, cluster, res) if err != nil { return fmt.Errorf("failed to get global topo ref: %w", err) } @@ -370,7 +366,7 @@ func (r *MultigresClusterReconciler) reconcileDatabases( resolvedShards := []multigresv1alpha1.ShardResolvedSpec{} for _, shard := range tg.Shards { - tpl, err := resolver.ResolveShardTemplate(ctx, shard.ShardTemplate) + tpl, err := res.ResolveShardTemplate(ctx, shard.ShardTemplate) if err != nil { return fmt.Errorf( "failed to resolve shard template '%s': %w", @@ -379,7 +375,7 @@ func (r *MultigresClusterReconciler) reconcileDatabases( ) } - orch, pools := MergeShardConfig(tpl, shard.Overrides, shard.Spec) + orch, pools := resolver.MergeShardConfig(tpl, shard.Overrides, shard.Spec) // Default MultiOrch Cells if empty (Consensus safety) // If 'cells' is empty, it defaults to all cells where pools are defined. @@ -451,14 +447,14 @@ func (r *MultigresClusterReconciler) reconcileDatabases( func (r *MultigresClusterReconciler) getGlobalTopoRef( ctx context.Context, cluster *multigresv1alpha1.MultigresCluster, - resolver *TemplateResolver, + res *resolver.Resolver, ) (multigresv1alpha1.GlobalTopoServerRef, error) { topoTplName := cluster.Spec.TemplateDefaults.CoreTemplate if cluster.Spec.GlobalTopoServer.TemplateRef != "" { topoTplName = cluster.Spec.GlobalTopoServer.TemplateRef } - topoTpl, err := resolver.ResolveCoreTemplate(ctx, topoTplName) + topoTpl, err := res.ResolveCoreTemplate(ctx, topoTplName) if err != nil { return multigresv1alpha1.GlobalTopoServerRef{}, fmt.Errorf( "failed to resolve global topo template: %w", @@ -466,7 +462,7 @@ func (r *MultigresClusterReconciler) getGlobalTopoRef( ) } - topoSpec := ResolveGlobalTopo(&cluster.Spec.GlobalTopoServer, topoTpl) + topoSpec := resolver.ResolveGlobalTopo(&cluster.Spec.GlobalTopoServer, topoTpl) address := "" if topoSpec.Etcd != nil { diff --git a/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller_test.go b/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller_test.go index 611d3fdd..8c245222 100644 --- a/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller_test.go +++ b/pkg/cluster-handler/controller/multigrescluster/multigrescluster_controller_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -252,7 +251,7 @@ func TestMultigresClusterReconciler_Reconcile_Success(t *testing.T) { }, } c.Spec.MultiAdmin = multigresv1alpha1.MultiAdminConfig{TemplateRef: "default-core"} - c.Spec.TemplateDefaults.CoreTemplate = "" + // FIX: Do NOT clear TemplateDefaults.CoreTemplate, so GlobalTopo can resolve 'default-core' }, validate: func(t testing.TB, c client.Client) { ctx := t.Context() @@ -341,7 +340,7 @@ func TestMultigresClusterReconciler_Reconcile_Success(t *testing.T) { c.Spec.GlobalTopoServer = multigresv1alpha1.GlobalTopoServerSpec{ Etcd: &multigresv1alpha1.EtcdSpec{Image: "etcd:inline"}, } - c.Spec.TemplateDefaults.CoreTemplate = "" + // FIX: Do NOT clear TemplateDefaults.CoreTemplate }, validate: func(t testing.TB, c client.Client) { ctx := t.Context() @@ -354,75 +353,39 @@ func TestMultigresClusterReconciler_Reconcile_Success(t *testing.T) { } }, }, - "Create: Defaults and Optional Components": { + "Create: External Topo Integration": { + // Ensures that when External is used, no TopoServer CR is created, and address is correct preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { - c.Spec.TemplateDefaults.CoreTemplate = "minimal-core" - c.Spec.GlobalTopoServer = multigresv1alpha1.GlobalTopoServerSpec{} // Use defaults - // Remove MultiAdmin to test skip logic - c.Spec.MultiAdmin = multigresv1alpha1.MultiAdminConfig{} - }, - existingObjects: []client.Object{ - &multigresv1alpha1.CoreTemplate{ - ObjectMeta: metav1.ObjectMeta{Name: "minimal-core", Namespace: namespace}, - Spec: multigresv1alpha1.CoreTemplateSpec{ - GlobalTopoServer: &multigresv1alpha1.TopoServerSpec{ - Etcd: &multigresv1alpha1.EtcdSpec{Image: "etcd:v1"}, // Replicas nil - }, + // FIX: Do NOT clear TemplateDefaults.CoreTemplate + c.Spec.GlobalTopoServer = multigresv1alpha1.GlobalTopoServerSpec{ + External: &multigresv1alpha1.ExternalTopoServerSpec{ + Endpoints: []multigresv1alpha1.EndpointUrl{"http://external-etcd:2379"}, }, - }, - cellTpl, shardTpl, + } }, + existingObjects: []client.Object{coreTpl, cellTpl, shardTpl}, validate: func(t testing.TB, c client.Client) { ctx := t.Context() - // Verify TopoServer created with default replicas (3) + // 1. Verify TopoServer CR does NOT exist ts := &multigresv1alpha1.TopoServer{} - if err := c.Get(ctx, types.NamespacedName{Name: clusterName + "-global-topo", Namespace: namespace}, ts); err != nil { - t.Fatal("Global TopoServer not created") - } - if got, want := *ts.Spec.Etcd.Replicas, DefaultEtcdReplicas; got != want { - t.Errorf("Expected default replicas mismatch got %d, want %d", got, want) - } - // Verify MultiAdmin NOT created - deploy := &appsv1.Deployment{} - if err := c.Get(ctx, types.NamespacedName{Name: clusterName + "-multiadmin", Namespace: namespace}, deploy); !apierrors.IsNotFound( + if err := c.Get(ctx, types.NamespacedName{Name: clusterName + "-global-topo", Namespace: namespace}, ts); !apierrors.IsNotFound( err, ) { - t.Error("MultiAdmin should not have been created") + t.Fatal("Global TopoServer should NOT be created for External mode") } - }, - }, - "Create: Cell with Local Topo in Template": { - preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { - c.Spec.Cells[0].CellTemplate = "local-topo-cell" - }, - existingObjects: []client.Object{ - coreTpl, shardTpl, - &multigresv1alpha1.CellTemplate{ - ObjectMeta: metav1.ObjectMeta{Name: "local-topo-cell", Namespace: namespace}, - Spec: multigresv1alpha1.CellTemplateSpec{ - MultiGateway: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, - LocalTopoServer: &multigresv1alpha1.LocalTopoServerSpec{ - Etcd: &multigresv1alpha1.EtcdSpec{Image: "local-etcd:v1"}, - }, - }, - }, - }, - validate: func(t testing.TB, c client.Client) { - ctx := t.Context() + // 2. Verify Cell config points to external address cell := &multigresv1alpha1.Cell{} if err := c.Get(ctx, types.NamespacedName{Name: clusterName + "-zone-a", Namespace: namespace}, cell); err != nil { t.Fatal(err) } - // Updated to handle pointer dereference safety - if cell.Spec.TopoServer == nil || cell.Spec.TopoServer.Etcd == nil || - cell.Spec.TopoServer.Etcd.Image != "local-etcd:v1" { - t.Error("LocalTopoServer not propagated to Cell") + if got, want := cell.Spec.GlobalTopoServer.Address, "http://external-etcd:2379"; got != want { + t.Errorf("External address mismatch got %q, want %q", got, want) } }, }, "Create: External Topo with Empty Endpoints": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { - c.Spec.TemplateDefaults.CoreTemplate = "" + // FIX: Do NOT clear TemplateDefaults.CoreTemplate c.Spec.GlobalTopoServer = multigresv1alpha1.GlobalTopoServerSpec{ External: &multigresv1alpha1.ExternalTopoServerSpec{ Endpoints: []multigresv1alpha1.EndpointUrl{}, @@ -459,9 +422,13 @@ func TestMultigresClusterReconciler_Reconcile_Success(t *testing.T) { StatelessSpec: multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(3))}, }, } - c.Spec.TemplateDefaults = multigresv1alpha1.TemplateDefaults{} + // FIX: Do NOT clear TemplateDefaults.CoreTemplate }, - existingObjects: []client.Object{}, + existingObjects: []client.Object{ + coreTpl, + cellTpl, + shardTpl, + }, // Add all templates so defaults work validate: func(t testing.TB, c client.Client) { ctx := t.Context() cell := &multigresv1alpha1.Cell{} @@ -644,7 +611,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { failureConfig *testutil.FailureConfig preReconcileUpdate func(testing.TB, *multigresv1alpha1.MultigresCluster) skipClusterCreation bool - validate func(testing.TB, client.Client) + wantErrMsg string // Optional: assert specific error message }{ "Delete: Block Finalization if Cells Exist": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -661,6 +628,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { }, }, }, + wantErrMsg: "cells still exist", }, "Delete: Block Finalization if TableGroups Exist": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -677,6 +645,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { }, }, }, + wantErrMsg: "tablegroups still exist", }, "Delete: Block Finalization if TopoServer Exists": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -693,31 +662,38 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { }, }, }, + wantErrMsg: "toposervers still exist", }, - "Error: Explicit Template Missing (Should Fail)": { + "Error: Explicit Core Template Missing (Should Fail)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { c.Spec.TemplateDefaults.CoreTemplate = "non-existent-template" }, existingObjects: []client.Object{}, // No templates exist failureConfig: nil, // No API failure, just logical failure + wantErrMsg: "failed to resolve topo template", }, "Error: Explicit Cell Template Missing": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { c.Spec.Cells[0].CellTemplate = "missing-cell-tpl" }, - existingObjects: []client.Object{coreTpl, shardTpl}, // Missing cellTpl + // Ensure core and shard templates exist so reconciliation proceeds to Cells + existingObjects: []client.Object{coreTpl, shardTpl}, + wantErrMsg: "failed to resolve cell template", }, "Error: Explicit Shard Template Missing": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { c.Spec.Databases[0].TableGroups[0].Shards[0].ShardTemplate = "missing-shard-tpl" }, - existingObjects: []client.Object{coreTpl, cellTpl}, // Missing shardTpl + // Ensure core and cell templates exist so reconciliation proceeds to Databases + existingObjects: []client.Object{coreTpl, cellTpl}, + wantErrMsg: "failed to resolve shard template", }, "Error: Fetch Cluster Failed": { existingObjects: []client.Object{}, failureConfig: &testutil.FailureConfig{ OnGet: testutil.FailOnKeyName(clusterName, errSimulated), }, + wantErrMsg: "failed to get MultigresCluster", }, "Error: Add Finalizer Failed": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -727,6 +703,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { failureConfig: &testutil.FailureConfig{ OnUpdate: testutil.FailOnObjectName(clusterName, errSimulated), }, + wantErrMsg: "failed to add finalizer", }, "Error: Remove Finalizer Failed": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -738,6 +715,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { failureConfig: &testutil.FailureConfig{ OnUpdate: testutil.FailOnObjectName(clusterName, errSimulated), }, + wantErrMsg: "failed to remove finalizer", }, "Error: CheckChildrenDeleted (List Cells Failed)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -754,6 +732,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { return nil }, }, + wantErrMsg: "failed to list cells", }, "Error: CheckChildrenDeleted (List TableGroups Failed)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -770,6 +749,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { return nil }, }, + wantErrMsg: "failed to list tablegroups", }, "Error: CheckChildrenDeleted (List TopoServers Failed)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -786,12 +766,14 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { return nil }, }, + wantErrMsg: "failed to list toposervers", }, "Error: Resolve CoreTemplate Failed": { existingObjects: []client.Object{coreTpl}, failureConfig: &testutil.FailureConfig{ OnGet: testutil.FailOnKeyName("default-core", errSimulated), }, + wantErrMsg: "failed to resolve topo template", }, "Error: Resolve Admin Template Failed (Second Call)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -810,23 +792,29 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { failureConfig: &testutil.FailureConfig{ OnGet: testutil.FailOnKeyName("admin-core-fail", errSimulated), }, + wantErrMsg: "failed to resolve admin template", }, "Error: Create GlobalTopo Failed": { failureConfig: &testutil.FailureConfig{ OnCreate: testutil.FailOnObjectName(clusterName+"-global-topo", errSimulated), }, + wantErrMsg: "failed to create/update global topo", }, "Error: Create MultiAdmin Failed": { failureConfig: &testutil.FailureConfig{ OnCreate: testutil.FailOnObjectName(clusterName+"-multiadmin", errSimulated), }, + wantErrMsg: "failed to create/update multiadmin", }, "Error: Resolve CellTemplate Failed": { failureConfig: &testutil.FailureConfig{ OnGet: testutil.FailOnKeyName("default-cell", errSimulated), }, + wantErrMsg: "failed to resolve cell template", }, "Error: List Existing Cells Failed (Reconcile Loop)": { + // Important: We must populate existingObjects so early checks pass and execution reaches List() + existingObjects: []client.Object{coreTpl, cellTpl, shardTpl}, failureConfig: &testutil.FailureConfig{ OnList: func(list client.ObjectList) error { if _, ok := list.(*multigresv1alpha1.CellList); ok { @@ -835,11 +823,13 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { return nil }, }, + wantErrMsg: "failed to list existing cells", }, "Error: Create Cell Failed": { failureConfig: &testutil.FailureConfig{ OnCreate: testutil.FailOnObjectName(clusterName+"-zone-a", errSimulated), }, + wantErrMsg: "failed to create/update cell", }, "Error: Prune Cell Failed": { existingObjects: []client.Object{ @@ -855,8 +845,10 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { failureConfig: &testutil.FailureConfig{ OnDelete: testutil.FailOnObjectName(clusterName+"-zone-b", errSimulated), }, + wantErrMsg: "failed to delete orphaned cell", }, "Error: List Existing TableGroups Failed": { + existingObjects: []client.Object{coreTpl, cellTpl, shardTpl}, failureConfig: &testutil.FailureConfig{ OnList: func(list client.ObjectList) error { if _, ok := list.(*multigresv1alpha1.TableGroupList); ok { @@ -865,16 +857,19 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { return nil }, }, + wantErrMsg: "failed to list existing tablegroups", }, "Error: Resolve ShardTemplate Failed": { failureConfig: &testutil.FailureConfig{ OnGet: testutil.FailOnKeyName("default-shard", errSimulated), }, + wantErrMsg: "failed to resolve shard template", }, "Error: Create TableGroup Failed": { failureConfig: &testutil.FailureConfig{ OnCreate: testutil.FailOnObjectName(clusterName+"-db1-tg1", errSimulated), }, + wantErrMsg: "failed to create/update tablegroup", }, "Error: Prune TableGroup Failed": { existingObjects: []client.Object{ @@ -890,6 +885,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { failureConfig: &testutil.FailureConfig{ OnDelete: testutil.FailOnObjectName(clusterName+"-orphan-tg", errSimulated), }, + wantErrMsg: "failed to delete orphaned tablegroup", }, "Error: UpdateStatus (List Cells Failed)": { failureConfig: &testutil.FailureConfig{ @@ -906,6 +902,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { } }(), }, + wantErrMsg: "failed to list cells for status", }, "Error: UpdateStatus (List TableGroups Failed)": { failureConfig: &testutil.FailureConfig{ @@ -922,11 +919,13 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { } }(), }, + wantErrMsg: "failed to list tablegroups for status", }, "Error: Update Status Failed (API Error)": { failureConfig: &testutil.FailureConfig{ OnStatusUpdate: testutil.FailOnObjectName(clusterName, errSimulated), }, + wantErrMsg: "failed to update cluster status", }, "Error: Global Topo Resolution Failed (During Cell Reconcile)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -941,6 +940,11 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "topo-fail-cells", Namespace: namespace}, Spec: multigresv1alpha1.CoreTemplateSpec{}, }, + // FIX: Added default template so reconcileMultiAdmin succeeds (it defaults to "default") + &multigresv1alpha1.CoreTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.CoreTemplateSpec{}, + }, }, failureConfig: &testutil.FailureConfig{ OnGet: func() func(client.ObjectKey) error { @@ -958,6 +962,8 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { } }(), }, + // This specifically validates we hit the error inside reconcileCells calling getGlobalTopoRef + wantErrMsg: "failed to get global topo ref", }, "Error: Global Topo Resolution Failed (During Database Reconcile)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -972,6 +978,11 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "topo-fail-db", Namespace: namespace}, Spec: multigresv1alpha1.CoreTemplateSpec{}, }, + // FIX: Added default template so reconcileMultiAdmin succeeds + &multigresv1alpha1.CoreTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.CoreTemplateSpec{}, + }, }, failureConfig: &testutil.FailureConfig{ OnGet: func() func(client.ObjectKey) error { @@ -990,6 +1001,8 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { } }(), }, + // This specifically validates we hit the error inside reconcileDatabases calling getGlobalTopoRef + wantErrMsg: "failed to get global topo ref", }, "Create: Long Names (Truncation Check)": { preReconcileUpdate: func(t testing.TB, c *multigresv1alpha1.MultigresCluster) { @@ -997,6 +1010,7 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { c.Spec.Databases[0].Name = longName c.Spec.Databases[0].TableGroups[0].Name = longName }, + wantErrMsg: "exceeds 50 characters", }, } @@ -1082,10 +1096,8 @@ func TestMultigresClusterReconciler_Reconcile_Failure(t *testing.T) { _, err := reconciler.Reconcile(t.Context(), req) if err == nil { t.Error("Expected error from Reconcile, got nil") - } - - if tc.validate != nil { - tc.validate(t, baseClient) + } else if tc.wantErrMsg != "" && !strings.Contains(err.Error(), tc.wantErrMsg) { + t.Errorf("Error mismatch. Expected substring %q, got %q", tc.wantErrMsg, err.Error()) } }) } @@ -1117,254 +1129,6 @@ func TestSetupWithManager_Coverage(t *testing.T) { }) } -func TestTemplateLogic_Unit(t *testing.T) { - t.Parallel() - - t.Run("MergeCellConfig", func(t *testing.T) { - t.Parallel() - - tpl := &multigresv1alpha1.CellTemplate{ - Spec: multigresv1alpha1.CellTemplateSpec{ - MultiGateway: &multigresv1alpha1.StatelessSpec{ - Replicas: ptr.To(int32(1)), - PodAnnotations: map[string]string{"foo": "bar"}, - PodLabels: map[string]string{"l1": "v1"}, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("100m")}, - }, - Affinity: &corev1.Affinity{ - NodeAffinity: &corev1.NodeAffinity{}, - }, - }, - LocalTopoServer: &multigresv1alpha1.LocalTopoServerSpec{ - Etcd: &multigresv1alpha1.EtcdSpec{Image: "base"}, - }, - }, - } - overrides := &multigresv1alpha1.CellOverrides{ - MultiGateway: &multigresv1alpha1.StatelessSpec{ - Replicas: ptr.To(int32(2)), - PodAnnotations: map[string]string{"baz": "qux"}, - PodLabels: map[string]string{"l2": "v2"}, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceMemory: parseQty("1Gi")}, - }, - Affinity: &corev1.Affinity{ - PodAntiAffinity: &corev1.PodAntiAffinity{}, - }, - }, - } - - gw, topo := MergeCellConfig(tpl, overrides, nil) - - wantGw := multigresv1alpha1.StatelessSpec{ - Replicas: ptr.To(int32(2)), - PodAnnotations: map[string]string{"foo": "bar", "baz": "qux"}, - PodLabels: map[string]string{"l1": "v1", "l2": "v2"}, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceMemory: parseQty("1Gi")}, - }, - Affinity: &corev1.Affinity{ - PodAntiAffinity: &corev1.PodAntiAffinity{}, - }, - } - - // Use IgnoreUnexported to handle resource.Quantity fields - if diff := cmp.Diff(wantGw, gw, cmpopts.IgnoreUnexported(resource.Quantity{})); diff != "" { - t.Errorf("MergeCellConfig gateway mismatch (-want +got):\n%s", diff) - } - - wantTopo := &multigresv1alpha1.LocalTopoServerSpec{ - Etcd: &multigresv1alpha1.EtcdSpec{Image: "base"}, - } - if diff := cmp.Diff(wantTopo, topo); diff != "" { - t.Errorf("MergeCellConfig topo mismatch (-want +got):\n%s", diff) - } - - inline := &multigresv1alpha1.CellInlineSpec{ - MultiGateway: multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(99))}, - } - gw, _ = MergeCellConfig(tpl, overrides, inline) - if got, want := *gw.Replicas, int32(99); got != want { - t.Errorf("MergeCellConfig inline priority mismatch got %d, want %d", got, want) - } - - gw, _ = MergeCellConfig(nil, overrides, nil) - if got, want := *gw.Replicas, int32(2); got != want { - t.Errorf("MergeCellConfig nil template mismatch got %d, want %d", got, want) - } - - tplNil := &multigresv1alpha1.CellTemplate{ - Spec: multigresv1alpha1.CellTemplateSpec{ - MultiGateway: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, - }, - } - gw, _ = MergeCellConfig(tplNil, overrides, nil) - if got, want := gw.PodAnnotations["baz"], "qux"; got != want { - t.Errorf("MergeCellConfig nil map init mismatch got %q, want %q", got, want) - } - }) - - t.Run("MergeShardConfig", func(t *testing.T) { - t.Parallel() - - tpl := &multigresv1alpha1.ShardTemplate{ - Spec: multigresv1alpha1.ShardTemplateSpec{ - MultiOrch: &multigresv1alpha1.MultiOrchSpec{ - StatelessSpec: multigresv1alpha1.StatelessSpec{ - Replicas: ptr.To(int32(1)), - }, - Cells: []multigresv1alpha1.CellName{"a"}, - }, - Pools: map[string]multigresv1alpha1.PoolSpec{ - "p1": { - Type: "readOnly", - ReplicasPerCell: ptr.To(int32(1)), - Storage: multigresv1alpha1.StorageSpec{Size: "1Gi"}, - Postgres: multigresv1alpha1.ContainerConfig{ - Resources: corev1.ResourceRequirements{}, - }, - }, - }, - }, - } - - overrides := &multigresv1alpha1.ShardOverrides{ - MultiOrch: &multigresv1alpha1.MultiOrchSpec{ - Cells: []multigresv1alpha1.CellName{"b"}, - }, - Pools: map[string]multigresv1alpha1.PoolSpec{ - "p1": { - Type: "readWrite", // Added Type override here to hit coverage - ReplicasPerCell: ptr.To(int32(2)), - Storage: multigresv1alpha1.StorageSpec{Size: "10Gi"}, - Postgres: multigresv1alpha1.ContainerConfig{ - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, - }, - }, - Multipooler: multigresv1alpha1.ContainerConfig{ - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, - }, - }, - Affinity: &corev1.Affinity{PodAntiAffinity: &corev1.PodAntiAffinity{}}, - Cells: []multigresv1alpha1.CellName{"c2"}, - }, - "p2": {Type: "write"}, - }, - } - - orch, pools := MergeShardConfig(tpl, overrides, nil) - - wantOrchCells := []multigresv1alpha1.CellName{"b"} - if diff := cmp.Diff(wantOrchCells, orch.Cells); diff != "" { - t.Errorf("MergeShardConfig MultiOrch cells mismatch (-want +got):\n%s", diff) - } - - p1 := pools["p1"] - wantP1 := multigresv1alpha1.PoolSpec{ - Type: "readWrite", - ReplicasPerCell: ptr.To(int32(2)), - Storage: multigresv1alpha1.StorageSpec{Size: "10Gi"}, - Postgres: multigresv1alpha1.ContainerConfig{ - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, - }, - }, - Multipooler: multigresv1alpha1.ContainerConfig{ - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, - }, - }, - Affinity: &corev1.Affinity{PodAntiAffinity: &corev1.PodAntiAffinity{}}, - Cells: []multigresv1alpha1.CellName{"c2"}, - } - - if diff := cmp.Diff(wantP1, p1, cmpopts.IgnoreUnexported(resource.Quantity{})); diff != "" { - t.Errorf("MergeShardConfig Pool p1 mismatch (-want +got):\n%s", diff) - } - - inline := &multigresv1alpha1.ShardInlineSpec{ - MultiOrch: multigresv1alpha1.MultiOrchSpec{ - Cells: []multigresv1alpha1.CellName{"inline"}, - }, - } - orch, _ = MergeShardConfig(tpl, overrides, inline) - wantInlineCells := []multigresv1alpha1.CellName{"inline"} - if diff := cmp.Diff(wantInlineCells, orch.Cells); diff != "" { - t.Errorf("MergeShardConfig inline priority mismatch (-want +got):\n%s", diff) - } - }) - - t.Run("ResolveGlobalTopo", func(t *testing.T) { - t.Parallel() - - spec := &multigresv1alpha1.GlobalTopoServerSpec{TemplateRef: "t1"} - core := &multigresv1alpha1.CoreTemplate{ - Spec: multigresv1alpha1.CoreTemplateSpec{ - GlobalTopoServer: &multigresv1alpha1.TopoServerSpec{ - Etcd: &multigresv1alpha1.EtcdSpec{Image: "resolved"}, - }, - }, - } - res := ResolveGlobalTopo(spec, core) - if got, want := res.Etcd.Image, "resolved"; got != want { - t.Errorf("ResolveGlobalTopo template mismatch got %q, want %q", got, want) - } - - spec2 := &multigresv1alpha1.GlobalTopoServerSpec{ - TemplateRef: "t1", - Etcd: &multigresv1alpha1.EtcdSpec{Image: "inline"}, - } - res2 := ResolveGlobalTopo(spec2, nil) - if got, want := res2.Etcd.Image, "inline"; got != want { - t.Errorf("ResolveGlobalTopo inline fallback mismatch got %q, want %q", got, want) - } - - spec4 := &multigresv1alpha1.GlobalTopoServerSpec{} - res4 := ResolveGlobalTopo(spec4, nil) - if diff := cmp.Diff(spec4, res4); diff != "" { - t.Errorf("ResolveGlobalTopo no-op mismatch (-want +got):\n%s", diff) - } - }) - - t.Run("ResolveMultiAdmin", func(t *testing.T) { - t.Parallel() - - spec := &multigresv1alpha1.MultiAdminConfig{TemplateRef: "t1"} - core := &multigresv1alpha1.CoreTemplate{ - Spec: multigresv1alpha1.CoreTemplateSpec{ - MultiAdmin: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(10))}, - }, - } - res := ResolveMultiAdmin(spec, core) - if got, want := *res.Replicas, int32(10); got != want { - t.Errorf("ResolveMultiAdmin template mismatch got %d, want %d", got, want) - } - - spec2 := &multigresv1alpha1.MultiAdminConfig{ - TemplateRef: "t1", - Spec: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(5))}, - } - res2 := ResolveMultiAdmin(spec2, nil) - if got, want := *res2.Replicas, int32(5); got != want { - t.Errorf("ResolveMultiAdmin inline fallback mismatch got %d, want %d", got, want) - } - - res3 := ResolveMultiAdmin(&multigresv1alpha1.MultiAdminConfig{}, nil) - if res3 != nil { - t.Error("ResolveMultiAdmin expected nil for empty config") - } - - spec5 := &multigresv1alpha1.MultiAdminConfig{} - res5 := ResolveMultiAdmin(spec5, nil) - if res5 != nil { - t.Error("ResolveMultiAdmin expected nil when no config and no template") - } - }) -} - func parseQty(s string) resource.Quantity { return resource.MustParse(s) } diff --git a/pkg/cluster-handler/controller/multigrescluster/template_logic.go b/pkg/cluster-handler/controller/multigrescluster/template_logic.go deleted file mode 100644 index c585ca92..00000000 --- a/pkg/cluster-handler/controller/multigrescluster/template_logic.go +++ /dev/null @@ -1,302 +0,0 @@ -package multigrescluster - -import ( - "context" - "fmt" - "reflect" - - multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// NOTE: We may want to consider move this to different module/package before implementing the Mutating Webhook. -// This separation is critical to prevent circular dependencies between the Webhook and Controller packages -// and ensures that the "Level 4" defaulting logic is reusable as a Single Source of Truth for both the reconciliation loop -// and admission requests. - -// TemplateResolver handles the logic for fetching and merging templates. -type TemplateResolver struct { - // Client is the kubernetes client used to fetch templates. - Client client.Client - // Namespace is the namespace where templates are expected to exist. - Namespace string - // Defaults contains the cluster-level template references to use when explicit ones are missing. - Defaults multigresv1alpha1.TemplateDefaults -} - -// ResolveCoreTemplate determines the target CoreTemplate name and fetches it. -// -// If templateName is empty, it uses the following precedence: -// 1. The cluster-level default defined in TemplateDefaults. -// 2. A CoreTemplate named "default" found in the same namespace where MultigresCluster is deployed. -// -// If an explicit template (param or cluster default) is not found, it returns an error. -// If the implicit "default" template is not found, it returns an empty object (safe fallback). -// In this case the default would be applied by the operator via mutating webhook. -func (r *TemplateResolver) ResolveCoreTemplate( - ctx context.Context, - templateName string, -) (*multigresv1alpha1.CoreTemplate, error) { - name := templateName - isImplicitFallback := false - - if name == "" { - name = r.Defaults.CoreTemplate - } - if name == "" { - name = FallbackCoreTemplate - isImplicitFallback = true - } - - tpl := &multigresv1alpha1.CoreTemplate{} - err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: r.Namespace}, tpl) - if err != nil { - if errors.IsNotFound(err) { - if isImplicitFallback { - return &multigresv1alpha1.CoreTemplate{}, nil - } - return nil, fmt.Errorf("referenced CoreTemplate '%s' not found", name) - } - return nil, fmt.Errorf("failed to get CoreTemplate: %w", err) - } - return tpl, nil -} - -// ResolveCellTemplate fetches and resolves a CellTemplate by name, handling defaults. -func (r *TemplateResolver) ResolveCellTemplate( - ctx context.Context, - templateName string, -) (*multigresv1alpha1.CellTemplate, error) { - name := templateName - isImplicitFallback := false - - if name == "" { - name = r.Defaults.CellTemplate - } - if name == "" { - name = FallbackCellTemplate - isImplicitFallback = true - } - - tpl := &multigresv1alpha1.CellTemplate{} - err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: r.Namespace}, tpl) - if err != nil { - if errors.IsNotFound(err) { - if isImplicitFallback { - return &multigresv1alpha1.CellTemplate{}, nil - } - return nil, fmt.Errorf("referenced CellTemplate '%s' not found", name) - } - return nil, fmt.Errorf("failed to get CellTemplate: %w", err) - } - return tpl, nil -} - -// ResolveShardTemplate fetches and resolves a ShardTemplate by name, handling defaults. -func (r *TemplateResolver) ResolveShardTemplate( - ctx context.Context, - templateName string, -) (*multigresv1alpha1.ShardTemplate, error) { - name := templateName - isImplicitFallback := false - - if name == "" { - name = r.Defaults.ShardTemplate - } - if name == "" { - name = FallbackShardTemplate - isImplicitFallback = true - } - - tpl := &multigresv1alpha1.ShardTemplate{} - err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: r.Namespace}, tpl) - if err != nil { - if errors.IsNotFound(err) { - if isImplicitFallback { - return &multigresv1alpha1.ShardTemplate{}, nil - } - return nil, fmt.Errorf("referenced ShardTemplate '%s' not found", name) - } - return nil, fmt.Errorf("failed to get ShardTemplate: %w", err) - } - return tpl, nil -} - -// MergeCellConfig merges a template spec with overrides and an inline spec to produce the final configuration. -func MergeCellConfig( - template *multigresv1alpha1.CellTemplate, - overrides *multigresv1alpha1.CellOverrides, - inline *multigresv1alpha1.CellInlineSpec, -) (multigresv1alpha1.StatelessSpec, *multigresv1alpha1.LocalTopoServerSpec) { - var gateway multigresv1alpha1.StatelessSpec - var localTopo *multigresv1alpha1.LocalTopoServerSpec - - if template != nil { - if template.Spec.MultiGateway != nil { - gateway = *template.Spec.MultiGateway.DeepCopy() - } - if template.Spec.LocalTopoServer != nil { - localTopo = template.Spec.LocalTopoServer.DeepCopy() - } - } - - if overrides != nil { - if overrides.MultiGateway != nil { - mergeStatelessSpec(&gateway, overrides.MultiGateway) - } - } - - if inline != nil { - return inline.MultiGateway, inline.LocalTopoServer - } - - return gateway, localTopo -} - -// MergeShardConfig merges a template spec with overrides and an inline spec to produce the final configuration. -func MergeShardConfig( - template *multigresv1alpha1.ShardTemplate, - overrides *multigresv1alpha1.ShardOverrides, - inline *multigresv1alpha1.ShardInlineSpec, -) (multigresv1alpha1.MultiOrchSpec, map[string]multigresv1alpha1.PoolSpec) { - if inline != nil { - return inline.MultiOrch, inline.Pools - } - - var multiOrch multigresv1alpha1.MultiOrchSpec - pools := make(map[string]multigresv1alpha1.PoolSpec) - - if template != nil { - if template.Spec.MultiOrch != nil { - multiOrch = *template.Spec.MultiOrch.DeepCopy() - } - for k, v := range template.Spec.Pools { - pools[k] = *v.DeepCopy() - } - } - - if overrides != nil { - if overrides.MultiOrch != nil { - mergeMultiOrchSpec(&multiOrch, overrides.MultiOrch) - } - - for k, v := range overrides.Pools { - if existingPool, exists := pools[k]; exists { - mergedPool := mergePoolSpec(existingPool, v) - pools[k] = mergedPool - } else { - pools[k] = v - } - } - } - - return multiOrch, pools -} - -func mergeStatelessSpec( - base *multigresv1alpha1.StatelessSpec, - override *multigresv1alpha1.StatelessSpec, -) { - if override.Replicas != nil { - base.Replicas = override.Replicas - } - if !reflect.DeepEqual(override.Resources, corev1.ResourceRequirements{}) { - base.Resources = override.Resources - } - if override.Affinity != nil { - base.Affinity = override.Affinity - } - - for k, v := range override.PodAnnotations { - if base.PodAnnotations == nil { - base.PodAnnotations = make(map[string]string) - } - base.PodAnnotations[k] = v - } - for k, v := range override.PodLabels { - if base.PodLabels == nil { - base.PodLabels = make(map[string]string) - } - base.PodLabels[k] = v - } -} - -func mergeMultiOrchSpec( - base *multigresv1alpha1.MultiOrchSpec, - override *multigresv1alpha1.MultiOrchSpec, -) { - mergeStatelessSpec(&base.StatelessSpec, &override.StatelessSpec) - if len(override.Cells) > 0 { - base.Cells = override.Cells - } -} - -func mergePoolSpec( - base multigresv1alpha1.PoolSpec, - override multigresv1alpha1.PoolSpec, -) multigresv1alpha1.PoolSpec { - out := base - if override.Type != "" { - out.Type = override.Type - } - if len(override.Cells) > 0 { - out.Cells = override.Cells - } - if override.ReplicasPerCell != nil { - out.ReplicasPerCell = override.ReplicasPerCell - } - if override.Storage.Size != "" { - out.Storage = override.Storage - } - if !reflect.DeepEqual(override.Postgres.Resources, corev1.ResourceRequirements{}) { - out.Postgres.Resources = override.Postgres.Resources - } - if !reflect.DeepEqual(override.Multipooler.Resources, corev1.ResourceRequirements{}) { - out.Multipooler.Resources = override.Multipooler.Resources - } - if override.Affinity != nil { - out.Affinity = override.Affinity - } - return out -} - -// ResolveGlobalTopo determines the final GlobalTopoServer configuration by preferring inline config over templates. -func ResolveGlobalTopo( - spec *multigresv1alpha1.GlobalTopoServerSpec, - coreTemplate *multigresv1alpha1.CoreTemplate, -) *multigresv1alpha1.GlobalTopoServerSpec { - // If inline config is present, use it. - if spec.Etcd != nil || spec.External != nil { - return spec - } - - // Otherwise, use the template (loaded by caller based on TemplateRef or Defaults) - if coreTemplate != nil && coreTemplate.Spec.GlobalTopoServer != nil { - return &multigresv1alpha1.GlobalTopoServerSpec{ - Etcd: coreTemplate.Spec.GlobalTopoServer.Etcd, - } - } - - return spec -} - -// ResolveMultiAdmin determines the final MultiAdmin configuration by preferring inline config over templates. -func ResolveMultiAdmin( - spec *multigresv1alpha1.MultiAdminConfig, - coreTemplate *multigresv1alpha1.CoreTemplate, -) *multigresv1alpha1.StatelessSpec { - // If inline spec is present, use it. - if spec.Spec != nil { - return spec.Spec - } - - // Otherwise, use the template (loaded by caller based on TemplateRef or Defaults) - if coreTemplate != nil && coreTemplate.Spec.MultiAdmin != nil { - return coreTemplate.Spec.MultiAdmin - } - - return nil -} diff --git a/pkg/cluster-handler/controller/tablegroup/doc.go b/pkg/cluster-handler/controller/tablegroup/doc.go new file mode 100644 index 00000000..64b8d2c2 --- /dev/null +++ b/pkg/cluster-handler/controller/tablegroup/doc.go @@ -0,0 +1,23 @@ +// Package tablegroup implements the controller for the TableGroup resource. +// +// The TableGroup controller acts as a "middle-manager" in the Multigres hierarchy, +// sitting between the root MultigresCluster and the leaf Shard resources. +// +// Responsibilities: +// +// 1. Shard Lifecycle Management: +// It watches TableGroup resources and creates, updates, or deletes (prunes) +// child Shard resources to match the list of fully resolved shard specifications +// provided in the TableGroup spec. +// +// 2. Status Aggregation: +// It monitors the status of all owned Shards and aggregates them into the +// TableGroup's status (e.g., ReadyShards / TotalShards), providing a summarized +// view for the parent MultigresCluster controller. +// +// Design Note: +// This controller is intentionally "dumb" regarding configuration logic. It does not +// perform template resolution or defaulting. It expects fully resolved specs to be +// pushed down from the parent MultigresCluster controller, and its only job is to +// enforce that state on the child Shards. +package tablegroup diff --git a/pkg/cluster-handler/go.mod b/pkg/cluster-handler/go.mod index 82d86b55..607f9beb 100644 --- a/pkg/cluster-handler/go.mod +++ b/pkg/cluster-handler/go.mod @@ -4,11 +4,12 @@ go 1.25.0 require ( github.com/google/go-cmp v0.7.0 - github.com/numtide/multigres-operator/api v0.0.0-20251222211535-b3e1d4ecd958 + github.com/numtide/multigres-operator/api v0.0.0-20251229104516-c9d07419a88e + github.com/numtide/multigres-operator/pkg/resolver v0.0.0-20251231093010-3fca1a2e579a github.com/numtide/multigres-operator/pkg/testutil v0.0.0-20251214105213-458b940d04bd - k8s.io/api v0.34.3 - k8s.io/apimachinery v0.34.3 - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 sigs.k8s.io/controller-runtime v0.22.4 ) @@ -16,54 +17,59 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.25.3 // indirect - github.com/onsi/gomega v1.38.3 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.9.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect - k8s.io/client-go v0.34.3 // indirect + k8s.io/apiextensions-apiserver v0.34.3 // indirect + k8s.io/client-go v0.35.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/pkg/cluster-handler/go.sum b/pkg/cluster-handler/go.sum index a2593f6d..2ab54340 100644 --- a/pkg/cluster-handler/go.sum +++ b/pkg/cluster-handler/go.sum @@ -4,12 +4,11 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -22,22 +21,48 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -47,25 +72,18 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -74,55 +92,50 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/numtide/multigres-operator/api v0.0.0-20251222211535-b3e1d4ecd958 h1:dqHU/SmEy5CUlWMRHwIIii4wPUZ1Cj4HDUxwqKvCOsM= -github.com/numtide/multigres-operator/api v0.0.0-20251222211535-b3e1d4ecd958/go.mod h1:A1bBmTxHr+362dGZ5G6u2S4xsP6enbgdUS/UJUOmKbc= +github.com/numtide/multigres-operator/api v0.0.0-20251229104516-c9d07419a88e h1:dA5W+VZMTnSx4DtxjX/1Xw2IFY7uAhHBcMNA92NivgI= +github.com/numtide/multigres-operator/api v0.0.0-20251229104516-c9d07419a88e/go.mod h1:A1bBmTxHr+362dGZ5G6u2S4xsP6enbgdUS/UJUOmKbc= +github.com/numtide/multigres-operator/pkg/resolver v0.0.0-20251231093010-3fca1a2e579a h1:8qomtoiYiF4uCXdiPQ94C1/qV9MGU7qIg5MwsjL6dl8= +github.com/numtide/multigres-operator/pkg/resolver v0.0.0-20251231093010-3fca1a2e579a/go.mod h1:sM5pJBpBfMmU4IY8prQLBPilNtVk9XVlkn5iphJ0TxA= github.com/numtide/multigres-operator/pkg/testutil v0.0.0-20251214105213-458b940d04bd h1:gp55gShKenPt4r9K1EC3SKKeOMDDreypivBWzAD6XjQ= github.com/numtide/multigres-operator/pkg/testutil v0.0.0-20251214105213-458b940d04bd/go.mod h1:+NQa7dSvQqxhBOE9XcE9RWXLvOvNaw0keCc29Y7pjyQ= -github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw= -github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= +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/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -130,77 +143,78 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= -golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= 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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 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.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -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/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +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/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -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/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= +k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE= +k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/resolver/cell.go b/pkg/resolver/cell.go new file mode 100644 index 00000000..ac00550c --- /dev/null +++ b/pkg/resolver/cell.go @@ -0,0 +1,77 @@ +package resolver + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +// ResolveCellTemplate fetches and resolves a CellTemplate by name, handling defaults. +func (r *Resolver) ResolveCellTemplate( + ctx context.Context, + templateName string, +) (*multigresv1alpha1.CellTemplate, error) { + name := templateName + isImplicitFallback := false + + if name == "" { + name = r.TemplateDefaults.CellTemplate + } + if name == "" { + name = FallbackCellTemplate + isImplicitFallback = true + } + + tpl := &multigresv1alpha1.CellTemplate{} + err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: r.Namespace}, tpl) + if err != nil { + if errors.IsNotFound(err) { + if isImplicitFallback { + return &multigresv1alpha1.CellTemplate{}, nil + } + return nil, fmt.Errorf("referenced CellTemplate '%s' not found: %w", name, err) + } + return nil, fmt.Errorf("failed to get CellTemplate: %w", err) + } + return tpl, nil +} + +// MergeCellConfig merges a template spec with overrides and an inline spec to produce the final configuration. +func MergeCellConfig( + template *multigresv1alpha1.CellTemplate, + overrides *multigresv1alpha1.CellOverrides, + inline *multigresv1alpha1.CellInlineSpec, +) (*multigresv1alpha1.StatelessSpec, *multigresv1alpha1.LocalTopoServerSpec) { + gateway := &multigresv1alpha1.StatelessSpec{} + var localTopo *multigresv1alpha1.LocalTopoServerSpec + + if template != nil { + if template.Spec.MultiGateway != nil { + gateway = template.Spec.MultiGateway.DeepCopy() + } + if template.Spec.LocalTopoServer != nil { + localTopo = template.Spec.LocalTopoServer.DeepCopy() + } + } + + if overrides != nil { + if overrides.MultiGateway != nil { + mergeStatelessSpec(gateway, overrides.MultiGateway) + } + } + + if inline != nil { + gw := inline.MultiGateway.DeepCopy() + var topo *multigresv1alpha1.LocalTopoServerSpec + if inline.LocalTopoServer != nil { + topo = inline.LocalTopoServer.DeepCopy() + } + return gw, topo + } + + return gateway, localTopo +} diff --git a/pkg/resolver/cell_test.go b/pkg/resolver/cell_test.go new file mode 100644 index 00000000..ff60d617 --- /dev/null +++ b/pkg/resolver/cell_test.go @@ -0,0 +1,263 @@ +package resolver + +import ( + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestResolver_ResolveCellTemplate(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + + _, cellTpl, _, ns := setupFixtures(t) + customCell := cellTpl.DeepCopy() + customCell.Name = "custom-cell" + + tests := map[string]struct { + existingObjects []client.Object + defaults multigresv1alpha1.TemplateDefaults + reqName string + wantErr bool + errContains string + wantFound bool + wantResName string + }{ + "Explicit Found": { + existingObjects: []client.Object{customCell}, + reqName: "custom-cell", + wantFound: true, + wantResName: "custom-cell", + }, + "Explicit Not Found (Error)": { + existingObjects: []client.Object{}, + reqName: "missing-cell", + wantErr: true, + errContains: "referenced CellTemplate 'missing-cell' not found", + }, + "Implicit Fallback Found": { + existingObjects: []client.Object{cellTpl}, + defaults: multigresv1alpha1.TemplateDefaults{}, + reqName: "", + wantFound: true, + wantResName: "default", + }, + "Implicit Fallback Not Found (Safe Empty Return)": { + existingObjects: []client.Object{}, + defaults: multigresv1alpha1.TemplateDefaults{}, + reqName: "", + wantFound: false, + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.existingObjects...). + Build() + r := NewResolver(c, ns, tc.defaults) + + res, err := r.ResolveCellTemplate(t.Context(), tc.reqName) + if tc.wantErr { + if err == nil { + t.Fatal("Expected error, got nil") + } + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Errorf( + "Error message mismatch: got %q, want substring %q", + err.Error(), + tc.errContains, + ) + } + return + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !tc.wantFound { + if res == nil { + t.Fatal( + "Expected non-nil result structure even for not-found implicit fallback", + ) + } + if res.GetName() != "" { + t.Errorf("Expected empty result, got object with name %q", res.GetName()) + } + return + } + + if got, want := res.GetName(), tc.wantResName; got != want { + t.Errorf("Result name mismatch: got %q, want %q", got, want) + } + }) + } +} + +func TestMergeCellConfig(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + tpl *multigresv1alpha1.CellTemplate + overrides *multigresv1alpha1.CellOverrides + inline *multigresv1alpha1.CellInlineSpec + wantGw *multigresv1alpha1.StatelessSpec + wantTopo *multigresv1alpha1.LocalTopoServerSpec + }{ + "Full Merge With Resources and Affinity Overrides": { + tpl: &multigresv1alpha1.CellTemplate{ + Spec: multigresv1alpha1.CellTemplateSpec{ + MultiGateway: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + PodAnnotations: map[string]string{"foo": "bar"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("100m")}, + }, + }, + LocalTopoServer: &multigresv1alpha1.LocalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "base"}, + }, + }, + }, + overrides: &multigresv1alpha1.CellOverrides{ + MultiGateway: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(2)), + PodAnnotations: map[string]string{"baz": "qux"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("200m")}, + }, + Affinity: &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{}, + }, + }, + }, + wantGw: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(2)), + PodAnnotations: map[string]string{"foo": "bar", "baz": "qux"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("200m")}, + }, + Affinity: &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{}, + }, + }, + wantTopo: &multigresv1alpha1.LocalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "base"}, + }, + }, + "Template Only (Nil Overrides)": { + tpl: &multigresv1alpha1.CellTemplate{ + Spec: multigresv1alpha1.CellTemplateSpec{ + MultiGateway: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + }, + overrides: nil, + wantGw: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + "Preserve Base (Empty Override)": { + tpl: &multigresv1alpha1.CellTemplate{ + Spec: multigresv1alpha1.CellTemplateSpec{ + MultiGateway: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + PodAnnotations: map[string]string{"foo": "bar"}, + }, + }, + }, + overrides: &multigresv1alpha1.CellOverrides{ + MultiGateway: &multigresv1alpha1.StatelessSpec{}, + }, + wantGw: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + PodAnnotations: map[string]string{"foo": "bar"}, + }, + }, + "Map Init (Nil Base)": { + tpl: &multigresv1alpha1.CellTemplate{ + Spec: multigresv1alpha1.CellTemplateSpec{ + MultiGateway: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + }, + overrides: &multigresv1alpha1.CellOverrides{ + MultiGateway: &multigresv1alpha1.StatelessSpec{ + PodAnnotations: map[string]string{"a": "b"}, + PodLabels: map[string]string{"c": "d"}, + }, + }, + wantGw: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + PodAnnotations: map[string]string{"a": "b"}, + PodLabels: map[string]string{"c": "d"}, + }, + }, + "Inline Priority": { + tpl: &multigresv1alpha1.CellTemplate{ + Spec: multigresv1alpha1.CellTemplateSpec{ + MultiGateway: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + }, + inline: &multigresv1alpha1.CellInlineSpec{ + MultiGateway: multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(99))}, + LocalTopoServer: &multigresv1alpha1.LocalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "inline-etcd"}, + }, + }, + wantGw: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(99))}, + wantTopo: &multigresv1alpha1.LocalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "inline-etcd"}, + }, + }, + "Nil Template (Override Only)": { + tpl: nil, + overrides: &multigresv1alpha1.CellOverrides{ + MultiGateway: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(2))}, + }, + wantGw: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(2))}, + }, + "Nil Everything": { + tpl: nil, + wantGw: &multigresv1alpha1.StatelessSpec{}, + wantTopo: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + gw, topo := MergeCellConfig(tc.tpl, tc.overrides, tc.inline) + + if diff := cmp.Diff(tc.wantGw, gw, cmpopts.IgnoreUnexported(resource.Quantity{}), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Gateway mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.wantTopo, topo); diff != "" { + t.Errorf("Topo mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolver_ClientErrors_Cell(t *testing.T) { + t.Parallel() + errSimulated := errors.New("simulated database connection error") + mc := &mockClient{failGet: true, err: errSimulated} + r := NewResolver(mc, "default", multigresv1alpha1.TemplateDefaults{}) + + _, err := r.ResolveCellTemplate(t.Context(), "any") + if err == nil || + err.Error() != "failed to get CellTemplate: simulated database connection error" { + t.Errorf("Error mismatch: got %v, want simulated error", err) + } +} diff --git a/pkg/resolver/cluster.go b/pkg/resolver/cluster.go new file mode 100644 index 00000000..d0bdca40 --- /dev/null +++ b/pkg/resolver/cluster.go @@ -0,0 +1,218 @@ +package resolver + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +// PopulateClusterDefaults applies static defaults to the Cluster Spec. +// This is safe for the Mutating Webhook because it DOES NOT fetch external templates. +// It ensures that "invisible defaults" (Images, default template names) are made visible +// and applies safety limits to any inline configurations provided by the user. +func (r *Resolver) PopulateClusterDefaults(cluster *multigresv1alpha1.MultigresCluster) { + // 1. Default Images + if cluster.Spec.Images.Postgres == "" { + cluster.Spec.Images.Postgres = DefaultPostgresImage + } + if cluster.Spec.Images.MultiAdmin == "" { + cluster.Spec.Images.MultiAdmin = DefaultMultiAdminImage + } + if cluster.Spec.Images.MultiOrch == "" { + cluster.Spec.Images.MultiOrch = DefaultMultiOrchImage + } + if cluster.Spec.Images.MultiPooler == "" { + cluster.Spec.Images.MultiPooler = DefaultMultiPoolerImage + } + if cluster.Spec.Images.MultiGateway == "" { + cluster.Spec.Images.MultiGateway = DefaultMultiGatewayImage + } + if cluster.Spec.Images.ImagePullPolicy == "" { + cluster.Spec.Images.ImagePullPolicy = DefaultImagePullPolicy + } + + // 2. Default Template Refs (Strings only) + if cluster.Spec.TemplateDefaults.CoreTemplate == "" { + cluster.Spec.TemplateDefaults.CoreTemplate = FallbackCoreTemplate + } + if cluster.Spec.TemplateDefaults.CellTemplate == "" { + cluster.Spec.TemplateDefaults.CellTemplate = FallbackCellTemplate + } + if cluster.Spec.TemplateDefaults.ShardTemplate == "" { + cluster.Spec.TemplateDefaults.ShardTemplate = FallbackShardTemplate + } + + // 3. Default Inline Configs (Deep Defaulting) + // We ONLY default these if the user explicitly provided the block (Inline). + // We DO NOT fetch templates here, adhering to the "Non-Goal" of the design doc. + + // GlobalTopoServer: If user provided 'etcd: {}', fill in the details. + if cluster.Spec.GlobalTopoServer.Etcd != nil { + defaultEtcdSpec(cluster.Spec.GlobalTopoServer.Etcd) + } + + // MultiAdmin: If user provided 'spec: {}', fill in the details. + if cluster.Spec.MultiAdmin.Spec != nil { + defaultStatelessSpec( + cluster.Spec.MultiAdmin.Spec, + DefaultResourcesAdmin(), + DefaultAdminReplicas, + ) + } + + // Cells: Default inline specs + for i := range cluster.Spec.Cells { + if cluster.Spec.Cells[i].Spec != nil { + defaultStatelessSpec( + &cluster.Spec.Cells[i].Spec.MultiGateway, + // Note: You might want to define specific defaults for Gateway if they differ from Admin. + // For now using the same pattern or generic defaults. + // Assuming you might add DefaultResourcesGateway later, but using valid struct defaults here. + corev1.ResourceRequirements{}, // Placeholder or define specific constant if needed + 1, // Default replicas + ) + } + } +} + +// defaultEtcdSpec applies hardcoded safety defaults to an inline Etcd spec. +func defaultEtcdSpec(spec *multigresv1alpha1.EtcdSpec) { + if spec.Image == "" { + spec.Image = DefaultEtcdImage + } + if spec.Storage.Size == "" { + spec.Storage.Size = DefaultEtcdStorageSize + } + if spec.Replicas == nil { + r := DefaultEtcdReplicas + spec.Replicas = &r + } + // Use isResourcesZero to ensure we respect overrides that only have Claims + if isResourcesZero(spec.Resources) { + // Safety: DefaultResourcesEtcd() returns a fresh struct, so no DeepCopy needed. + spec.Resources = DefaultResourcesEtcd() + } +} + +// defaultStatelessSpec applies hardcoded safety defaults to any stateless spec. +func defaultStatelessSpec( + spec *multigresv1alpha1.StatelessSpec, + defaultRes corev1.ResourceRequirements, + defaultReplicas int32, +) { + if spec.Replicas == nil { + spec.Replicas = &defaultReplicas + } + // Use isResourcesZero to ensure we respect overrides that only have Claims + if isResourcesZero(spec.Resources) { + // Safety: We assume defaultRes is passed by value (a fresh copy from the default function). + // We perform a DeepCopy to ensure spec.Resources owns its own maps, independent of the input defaultRes. + spec.Resources = *defaultRes.DeepCopy() + } +} + +// isResourcesZero checks if the resource requirements are strictly the zero value (nil maps). +// This mimics reflect.DeepEqual(res, corev1.ResourceRequirements{}) but is safer and faster. +// It is used for merging logic where we want to distinguish "inherit" (nil) from "empty" (set to empty). +func isResourcesZero(res corev1.ResourceRequirements) bool { + return res.Requests == nil && res.Limits == nil && res.Claims == nil +} + +// ResolveCoreTemplate determines the target CoreTemplate name and fetches it. +// +// If templateName is empty, it uses the following precedence: +// 1. The cluster-level default defined in TemplateDefaults. +// 2. A CoreTemplate named "default" found in the same namespace where MultigresCluster is deployed. +// +// If an explicit template (param or cluster default) is not found, it returns an error. +// If the implicit "default" template is not found, it returns an empty object (safe fallback). +func (r *Resolver) ResolveCoreTemplate( + ctx context.Context, + templateName string, +) (*multigresv1alpha1.CoreTemplate, error) { + name := templateName + isImplicitFallback := false + + if name == "" { + name = r.TemplateDefaults.CoreTemplate + } + if name == "" { + name = FallbackCoreTemplate + isImplicitFallback = true + } + + tpl := &multigresv1alpha1.CoreTemplate{} + err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: r.Namespace}, tpl) + if err != nil { + if errors.IsNotFound(err) { + if isImplicitFallback { + return &multigresv1alpha1.CoreTemplate{}, nil + } + return nil, fmt.Errorf("referenced CoreTemplate '%s' not found: %w", name, err) + } + return nil, fmt.Errorf("failed to get CoreTemplate: %w", err) + } + return tpl, nil +} + +// ResolveGlobalTopo determines the final GlobalTopoServer configuration. +// It prioritizes Inline Config > Template > Implicit Default (Managed Etcd). +// It applies deep defaults (safety limits) to the final result. +func ResolveGlobalTopo( + spec *multigresv1alpha1.GlobalTopoServerSpec, + coreTemplate *multigresv1alpha1.CoreTemplate, +) *multigresv1alpha1.GlobalTopoServerSpec { + var finalSpec *multigresv1alpha1.GlobalTopoServerSpec + + // 1. Determine base config + if spec.Etcd != nil || spec.External != nil { + finalSpec = spec.DeepCopy() + } else if coreTemplate != nil && coreTemplate.Spec.GlobalTopoServer != nil { + // Copy from template + finalSpec = &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: coreTemplate.Spec.GlobalTopoServer.Etcd.DeepCopy(), + } + } else { + // Fallback: Default to an empty Etcd spec if nothing found. + finalSpec = &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{}, + } + } + + // 2. Apply Deep Defaults to Etcd if present + if finalSpec.Etcd != nil { + defaultEtcdSpec(finalSpec.Etcd) + } + + return finalSpec +} + +// ResolveMultiAdmin determines the final MultiAdmin configuration. +// It prioritizes Inline Config > Template > Implicit Default. +// It applies deep defaults (safety limits) to the final result. +func ResolveMultiAdmin( + spec *multigresv1alpha1.MultiAdminConfig, + coreTemplate *multigresv1alpha1.CoreTemplate, +) *multigresv1alpha1.StatelessSpec { + var finalSpec *multigresv1alpha1.StatelessSpec + + // 1. Determine base config + if spec.Spec != nil { + finalSpec = spec.Spec.DeepCopy() + } else if coreTemplate != nil && coreTemplate.Spec.MultiAdmin != nil { + finalSpec = coreTemplate.Spec.MultiAdmin.DeepCopy() + } else { + // Fallback to empty spec so we can apply defaults + finalSpec = &multigresv1alpha1.StatelessSpec{} + } + + // 2. Apply Deep Defaults + defaultStatelessSpec(finalSpec, DefaultResourcesAdmin(), DefaultAdminReplicas) + + return finalSpec +} diff --git a/pkg/resolver/cluster_test.go b/pkg/resolver/cluster_test.go new file mode 100644 index 00000000..cf18a749 --- /dev/null +++ b/pkg/resolver/cluster_test.go @@ -0,0 +1,498 @@ +package resolver + +import ( + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestResolver_PopulateClusterDefaults(t *testing.T) { + t.Parallel() + + r := NewResolver( + fake.NewClientBuilder().Build(), + "default", + multigresv1alpha1.TemplateDefaults{}, + ) + + tests := map[string]struct { + input *multigresv1alpha1.MultigresCluster + want *multigresv1alpha1.MultigresCluster + }{ + "Empty Cluster: Applies All Static Defaults": { + input: &multigresv1alpha1.MultigresCluster{}, + want: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Images: multigresv1alpha1.ClusterImages{ + Postgres: DefaultPostgresImage, + MultiAdmin: DefaultMultiAdminImage, + MultiOrch: DefaultMultiOrchImage, + MultiPooler: DefaultMultiPoolerImage, + MultiGateway: DefaultMultiGatewayImage, + ImagePullPolicy: DefaultImagePullPolicy, + }, + TemplateDefaults: multigresv1alpha1.TemplateDefaults{ + CoreTemplate: FallbackCoreTemplate, + CellTemplate: FallbackCellTemplate, + ShardTemplate: FallbackShardTemplate, + }, + }, + }, + }, + "Preserve Existing Values: Does Not Overwrite": { + input: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Images: multigresv1alpha1.ClusterImages{ + Postgres: "custom-postgres", + MultiAdmin: "custom-admin", + MultiOrch: "custom-orch", + MultiPooler: "custom-pooler", + MultiGateway: "custom-gateway", + ImagePullPolicy: corev1.PullAlways, + }, + TemplateDefaults: multigresv1alpha1.TemplateDefaults{ + CoreTemplate: "custom-core", + CellTemplate: "custom-cell", + ShardTemplate: "custom-shard", + }, + }, + }, + want: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Images: multigresv1alpha1.ClusterImages{ + Postgres: "custom-postgres", + MultiAdmin: "custom-admin", + MultiOrch: "custom-orch", + MultiPooler: "custom-pooler", + MultiGateway: "custom-gateway", + ImagePullPolicy: corev1.PullAlways, + }, + TemplateDefaults: multigresv1alpha1.TemplateDefaults{ + CoreTemplate: "custom-core", + CellTemplate: "custom-cell", + ShardTemplate: "custom-shard", + }, + }, + }, + }, + "Inline Configs: Deep Defaulting (Etcd & Admin)": { + input: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + GlobalTopoServer: multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{}, + }, + MultiAdmin: multigresv1alpha1.MultiAdminConfig{ + Spec: &multigresv1alpha1.StatelessSpec{}, + }, + }, + }, + want: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Images: multigresv1alpha1.ClusterImages{ + Postgres: DefaultPostgresImage, + MultiAdmin: DefaultMultiAdminImage, + MultiOrch: DefaultMultiOrchImage, + MultiPooler: DefaultMultiPoolerImage, + MultiGateway: DefaultMultiGatewayImage, + ImagePullPolicy: DefaultImagePullPolicy, + }, + TemplateDefaults: multigresv1alpha1.TemplateDefaults{ + CoreTemplate: FallbackCoreTemplate, + CellTemplate: FallbackCellTemplate, + ShardTemplate: FallbackShardTemplate, + }, + GlobalTopoServer: multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: DefaultEtcdImage, + Replicas: ptr.To(DefaultEtcdReplicas), + Resources: DefaultResourcesEtcd(), + Storage: multigresv1alpha1.StorageSpec{Size: DefaultEtcdStorageSize}, + }, + }, + MultiAdmin: multigresv1alpha1.MultiAdminConfig{ + Spec: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(DefaultAdminReplicas), + Resources: DefaultResourcesAdmin(), + }, + }, + }, + }, + }, + "Inline Configs: Deep Defaulting (Cells)": { + input: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Cells: []multigresv1alpha1.CellConfig{ + { + Name: "cell-1", + Spec: &multigresv1alpha1.CellInlineSpec{ + MultiGateway: multigresv1alpha1.StatelessSpec{}, + }, + }, + }, + }, + }, + want: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Images: multigresv1alpha1.ClusterImages{ + Postgres: DefaultPostgresImage, + MultiAdmin: DefaultMultiAdminImage, + MultiOrch: DefaultMultiOrchImage, + MultiPooler: DefaultMultiPoolerImage, + MultiGateway: DefaultMultiGatewayImage, + ImagePullPolicy: DefaultImagePullPolicy, + }, + TemplateDefaults: multigresv1alpha1.TemplateDefaults{ + CoreTemplate: FallbackCoreTemplate, + CellTemplate: FallbackCellTemplate, + ShardTemplate: FallbackShardTemplate, + }, + Cells: []multigresv1alpha1.CellConfig{ + { + Name: "cell-1", + Spec: &multigresv1alpha1.CellInlineSpec{ + MultiGateway: multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + Resources: corev1.ResourceRequirements{}, + }, + }, + }, + }, + }, + }, + }, + "Inline Configs: Partial Overrides Preserved": { + input: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + GlobalTopoServer: multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: "custom-etcd", + }, + }, + }, + }, + want: &multigresv1alpha1.MultigresCluster{ + Spec: multigresv1alpha1.MultigresClusterSpec{ + Images: multigresv1alpha1.ClusterImages{ + Postgres: DefaultPostgresImage, + MultiAdmin: DefaultMultiAdminImage, + MultiOrch: DefaultMultiOrchImage, + MultiPooler: DefaultMultiPoolerImage, + MultiGateway: DefaultMultiGatewayImage, + ImagePullPolicy: DefaultImagePullPolicy, + }, + TemplateDefaults: multigresv1alpha1.TemplateDefaults{ + CoreTemplate: FallbackCoreTemplate, + CellTemplate: FallbackCellTemplate, + ShardTemplate: FallbackShardTemplate, + }, + GlobalTopoServer: multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: "custom-etcd", + Replicas: ptr.To(DefaultEtcdReplicas), + Resources: DefaultResourcesEtcd(), + Storage: multigresv1alpha1.StorageSpec{Size: DefaultEtcdStorageSize}, + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + got := tc.input.DeepCopy() + r.PopulateClusterDefaults(got) + + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(resource.Quantity{}), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Cluster defaults mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolver_ResolveCoreTemplate(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + + coreTpl, _, _, ns := setupFixtures(t) + customCore := coreTpl.DeepCopy() + customCore.Name = "custom-core" + + tests := map[string]struct { + existingObjects []client.Object + defaults multigresv1alpha1.TemplateDefaults + reqName string + wantErr bool + errContains string + wantFound bool + wantResName string + }{ + "Explicit Found": { + existingObjects: []client.Object{customCore}, + reqName: "custom-core", + wantFound: true, + wantResName: "custom-core", + }, + "Explicit Not Found (Error)": { + existingObjects: []client.Object{}, + reqName: "missing-core", + wantErr: true, + errContains: "referenced CoreTemplate 'missing-core' not found", + }, + "Default from Config Found": { + existingObjects: []client.Object{customCore}, + defaults: multigresv1alpha1.TemplateDefaults{CoreTemplate: "custom-core"}, + reqName: "", + wantFound: true, + wantResName: "custom-core", + }, + "Default from Config Not Found (Error)": { + existingObjects: []client.Object{}, + defaults: multigresv1alpha1.TemplateDefaults{CoreTemplate: "missing"}, + reqName: "", + wantErr: true, + errContains: "referenced CoreTemplate 'missing' not found", + }, + "Implicit Fallback Found": { + existingObjects: []client.Object{coreTpl}, + defaults: multigresv1alpha1.TemplateDefaults{}, + reqName: "", + wantFound: true, + wantResName: "default", + }, + "Implicit Fallback Not Found (Safe Empty Return)": { + existingObjects: []client.Object{}, + defaults: multigresv1alpha1.TemplateDefaults{}, + reqName: "", + wantFound: false, + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.existingObjects...). + Build() + r := NewResolver(c, ns, tc.defaults) + + res, err := r.ResolveCoreTemplate(t.Context(), tc.reqName) + if tc.wantErr { + if err == nil { + t.Fatal("Expected error, got nil") + } + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Errorf( + "Error message mismatch: got %q, want substring %q", + err.Error(), + tc.errContains, + ) + } + return + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !tc.wantFound { + if res == nil { + t.Fatal( + "Expected non-nil result structure even for not-found implicit fallback", + ) + } + if res.GetName() != "" { + t.Errorf("Expected empty result, got object with name %q", res.GetName()) + } + return + } + + if got, want := res.GetName(), tc.wantResName; got != want { + t.Errorf("Result name mismatch: got %q, want %q", got, want) + } + }) + } +} + +func TestResolveGlobalTopo(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + spec *multigresv1alpha1.GlobalTopoServerSpec + tpl *multigresv1alpha1.CoreTemplate + want *multigresv1alpha1.GlobalTopoServerSpec + }{ + "Inline Priority (Etcd)": { + spec: &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "inline"}, + }, + tpl: nil, + want: &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: "inline", + Replicas: ptr.To(DefaultEtcdReplicas), + Resources: DefaultResourcesEtcd(), + Storage: multigresv1alpha1.StorageSpec{Size: DefaultEtcdStorageSize}, + }, + }, + }, + "Inline Priority (External)": { + spec: &multigresv1alpha1.GlobalTopoServerSpec{ + External: &multigresv1alpha1.ExternalTopoServerSpec{ + Endpoints: []multigresv1alpha1.EndpointUrl{"http://foo"}, + }, + }, + tpl: nil, + want: &multigresv1alpha1.GlobalTopoServerSpec{ + External: &multigresv1alpha1.ExternalTopoServerSpec{ + Endpoints: []multigresv1alpha1.EndpointUrl{"http://foo"}, + }, + }, + }, + "Template Fallback": { + spec: &multigresv1alpha1.GlobalTopoServerSpec{}, + tpl: &multigresv1alpha1.CoreTemplate{ + Spec: multigresv1alpha1.CoreTemplateSpec{ + GlobalTopoServer: &multigresv1alpha1.TopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "template"}, + }, + }, + }, + want: &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: "template", + Replicas: ptr.To(DefaultEtcdReplicas), + Resources: DefaultResourcesEtcd(), + Storage: multigresv1alpha1.StorageSpec{Size: DefaultEtcdStorageSize}, + }, + }, + }, + "Template Found but Nil Content": { + spec: &multigresv1alpha1.GlobalTopoServerSpec{}, + tpl: &multigresv1alpha1.CoreTemplate{ + Spec: multigresv1alpha1.CoreTemplateSpec{ + GlobalTopoServer: nil, + }, + }, + want: &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: DefaultEtcdImage, + Replicas: ptr.To(DefaultEtcdReplicas), + Resources: DefaultResourcesEtcd(), + Storage: multigresv1alpha1.StorageSpec{Size: DefaultEtcdStorageSize}, + }, + }, + }, + "No-op (Nil Template)": { + spec: &multigresv1alpha1.GlobalTopoServerSpec{}, + tpl: nil, + want: &multigresv1alpha1.GlobalTopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{ + Image: DefaultEtcdImage, + Replicas: ptr.To(DefaultEtcdReplicas), + Resources: DefaultResourcesEtcd(), + Storage: multigresv1alpha1.StorageSpec{Size: DefaultEtcdStorageSize}, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + got := ResolveGlobalTopo(tc.spec, tc.tpl) + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(resource.Quantity{}), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("ResolveGlobalTopo mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolveMultiAdmin(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + spec *multigresv1alpha1.MultiAdminConfig + tpl *multigresv1alpha1.CoreTemplate + want *multigresv1alpha1.StatelessSpec + }{ + "Inline Priority": { + spec: &multigresv1alpha1.MultiAdminConfig{ + Spec: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(5))}, + }, + tpl: nil, + want: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(5)), + Resources: DefaultResourcesAdmin(), + }, + }, + "Template Fallback": { + spec: &multigresv1alpha1.MultiAdminConfig{}, + tpl: &multigresv1alpha1.CoreTemplate{ + Spec: multigresv1alpha1.CoreTemplateSpec{ + MultiAdmin: &multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(3))}, + }, + }, + want: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(3)), + Resources: DefaultResourcesAdmin(), + }, + }, + "Template Found but Nil Content": { + spec: &multigresv1alpha1.MultiAdminConfig{}, + tpl: &multigresv1alpha1.CoreTemplate{ + Spec: multigresv1alpha1.CoreTemplateSpec{ + MultiAdmin: nil, + }, + }, + want: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(DefaultAdminReplicas), + Resources: DefaultResourcesAdmin(), + }, + }, + "No-op (Nil Template)": { + spec: &multigresv1alpha1.MultiAdminConfig{}, + tpl: nil, + want: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(DefaultAdminReplicas), + Resources: DefaultResourcesAdmin(), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + got := ResolveMultiAdmin(tc.spec, tc.tpl) + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreUnexported(resource.Quantity{}), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("ResolveMultiAdmin mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolver_ClientErrors_Core(t *testing.T) { + t.Parallel() + errSimulated := errors.New("simulated database connection error") + mc := &mockClient{failGet: true, err: errSimulated} + r := NewResolver(mc, "default", multigresv1alpha1.TemplateDefaults{}) + + _, err := r.ResolveCoreTemplate(t.Context(), "any") + if err == nil || + err.Error() != "failed to get CoreTemplate: simulated database connection error" { + t.Errorf("Error mismatch: got %v, want simulated error", err) + } +} diff --git a/pkg/resolver/defaults.go b/pkg/resolver/defaults.go new file mode 100644 index 00000000..e7811cae --- /dev/null +++ b/pkg/resolver/defaults.go @@ -0,0 +1,75 @@ +package resolver + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +const ( + // DefaultEtcdReplicas is the default number of replicas for the managed Etcd cluster if not specified. + DefaultEtcdReplicas int32 = 3 + + // DefaultAdminReplicas is the default number of replicas for the MultiAdmin deployment if not specified. + DefaultAdminReplicas int32 = 1 + + // FallbackCoreTemplate is the name of the template to look for if no specific CoreTemplate is referenced. + FallbackCoreTemplate = "default" + + // FallbackCellTemplate is the name of the template to look for if no specific CellTemplate is referenced. + FallbackCellTemplate = "default" + + // FallbackShardTemplate is the name of the template to look for if no specific ShardTemplate is referenced. + FallbackShardTemplate = "default" + + // DefaultPostgresImage is the default container image used for PostgreSQL instances. + DefaultPostgresImage = "postgres:15-alpine" + + // DefaultEtcdImage is the default container image used for the managed Etcd cluster. + DefaultEtcdImage = "registry.k8s.io/etcd:3.5.12-0" + + // DefaultMultiAdminImage is the default container image used for the MultiAdmin component. + DefaultMultiAdminImage = "ghcr.io/multigres/multigres:main" + + // DefaultMultiOrchImage is the default container image used for the MultiOrch component. + DefaultMultiOrchImage = "ghcr.io/multigres/multigres:main" + + // DefaultMultiPoolerImage is the default container image used for the MultiPooler component. + DefaultMultiPoolerImage = "ghcr.io/multigres/multigres:main" + + // DefaultMultiGatewayImage is the default container image used for the MultiGateway component. + DefaultMultiGatewayImage = "ghcr.io/multigres/multigres:main" + + // DefaultImagePullPolicy is the default image pull policy used for all components if not specified. + DefaultImagePullPolicy = corev1.PullIfNotPresent + + // DefaultEtcdStorageSize is the default PVC size for the managed Etcd cluster if not specified. + DefaultEtcdStorageSize = "1Gi" +) + +// DefaultResourcesAdmin returns the default resource requests and limits for the MultiAdmin deployment. +// It requests 100m CPU and 128Mi memory, with a limit of 256Mi memory. +func DefaultResourcesAdmin() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + } +} + +// DefaultResourcesEtcd returns the default resource requests and limits for the managed Etcd cluster. +// It requests 100m CPU and 256Mi memory, with a limit of 512Mi memory. +func DefaultResourcesEtcd() corev1.ResourceRequirements { + return corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("512Mi"), + }, + } +} diff --git a/pkg/resolver/doc.go b/pkg/resolver/doc.go new file mode 100644 index 00000000..554fc175 --- /dev/null +++ b/pkg/resolver/doc.go @@ -0,0 +1,47 @@ +// Package resolver provides the central logic for calculating the "Effective Specification" of a MultigresCluster. +// +// In the Multigres Operator, a cluster's configuration can come from multiple sources: +// 1. Inline Configurations (defined directly in the MultigresCluster CR). +// 2. Template References (pointing to CoreTemplate, CellTemplate, or ShardTemplate CRs). +// 3. Overrides (partial patches applied on top of a template). +// 4. Hardcoded Defaults (fallback values for safety). +// +// The Resolver is the single source of truth for merging these sources. It ensures that +// the Reconciler and the Webhook always agree on what the final configuration should be. +// +// # Logic Hierarchy +// +// When calculating the final configuration for a component, the Resolver applies the +// following precedence (highest to lowest): +// +// 1. Inline Spec (if provided, it ignores templates entirely). +// 2. Overrides (patches specific fields of the template). +// 3. Template Spec (the base configuration from the referenced CR). +// 4. Global Defaults (hardcoded constants like default images or replicas). +// +// # Dual Usage +// +// This package is designed to be used in two distinct phases: +// +// 1. Mutation (Webhook): +// The 'PopulateClusterDefaults' method is safe to call during admission. It applies +// static defaults (images, explicit template names) to the API object itself, +// solving the "Invisible Defaults" problem without making external API calls. +// +// 2. Reconciliation (Controller): +// The 'Resolve...' and 'Merge...' methods are used during reconciliation. They +// fetch external templates from the API server and merge them to determine the +// actual state the child resources (StatefulSets, Services) should match. +// +// Usage: +// +// // Create a resolver +// res := resolver.NewResolver(client, namespace, cluster.Spec.TemplateDefaults) +// +// // Webhook: Apply static defaults to the object +// res.PopulateClusterDefaults(cluster) +// +// // Controller: Calculate final config for a specific component +// template, err := res.ResolveShardTemplate(ctx, "my-shard-template") +// finalConfig := resolver.MergeShardConfig(template, overrides, nil) +package resolver diff --git a/pkg/resolver/go.mod b/pkg/resolver/go.mod new file mode 100644 index 00000000..e00a7c35 --- /dev/null +++ b/pkg/resolver/go.mod @@ -0,0 +1,68 @@ +module github.com/numtide/multigres-operator/pkg/resolver + +go 1.25.0 + +require ( + github.com/google/go-cmp v0.7.0 + github.com/numtide/multigres-operator/api v0.0.0-20251224124005-355869230728 + k8s.io/api v0.34.3 + k8s.io/apimachinery v0.34.3 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.4 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/onsi/ginkgo/v2 v2.25.3 // indirect + github.com/onsi/gomega v1.38.3 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.34.3 // indirect + k8s.io/client-go v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/pkg/resolver/go.sum b/pkg/resolver/go.sum new file mode 100644 index 00000000..c1b3cd8e --- /dev/null +++ b/pkg/resolver/go.sum @@ -0,0 +1,196 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= +github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/numtide/multigres-operator/api v0.0.0-20251224124005-355869230728 h1:a+iM4L0nC7kUgcAD1P+wuNJg4x0rjcy6rVZ3+vF52Cc= +github.com/numtide/multigres-operator/api v0.0.0-20251224124005-355869230728/go.mod h1:A1bBmTxHr+362dGZ5G6u2S4xsP6enbgdUS/UJUOmKbc= +github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw= +github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.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/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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +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= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= +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/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= +k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/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= +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/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/resolver/resolver.go b/pkg/resolver/resolver.go new file mode 100644 index 00000000..e01e5ba5 --- /dev/null +++ b/pkg/resolver/resolver.go @@ -0,0 +1,72 @@ +// Package resolver provides the central logic for resolving MultigresCluster +// defaults, templates, and configurations. +// +// It serves as the single source of truth for merging user inputs, +// cluster-level defaults, and external templates into a final resource specification. +package resolver + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +// Resolver handles the logic for fetching templates and calculating defaults. +// It serves as the single source of truth for defaulting logic across the operator. +type Resolver struct { + // Client is the kubernetes client used to fetch templates or other cluster resources. + Client client.Client + // Namespace is the namespace where templates/resources are expected to exist. + Namespace string + // TemplateDefaults contains the cluster-level template references. + TemplateDefaults multigresv1alpha1.TemplateDefaults +} + +// NewResolver creates a new defaults.Resolver. +func NewResolver( + c client.Client, + namespace string, + tplDefaults multigresv1alpha1.TemplateDefaults, +) *Resolver { + return &Resolver{ + Client: c, + Namespace: namespace, + TemplateDefaults: tplDefaults, + } +} + +// ============================================================================ +// Shared Merge Helpers +// ============================================================================ + +func mergeStatelessSpec( + base *multigresv1alpha1.StatelessSpec, + override *multigresv1alpha1.StatelessSpec, +) { + if override.Replicas != nil { + base.Replicas = override.Replicas + } + + // Safety: Use DeepCopy to ensure we don't share mutable map references (Requests/Limits) + if !isResourcesZero(override.Resources) { + base.Resources = *override.Resources.DeepCopy() + } + + // Safety: DeepCopy Affinity to avoid sharing pointers + if override.Affinity != nil { + base.Affinity = override.Affinity.DeepCopy() + } + + for k, v := range override.PodAnnotations { + if base.PodAnnotations == nil { + base.PodAnnotations = make(map[string]string) + } + base.PodAnnotations[k] = v + } + for k, v := range override.PodLabels { + if base.PodLabels == nil { + base.PodLabels = make(map[string]string) + } + base.PodLabels[k] = v + } +} diff --git a/pkg/resolver/resolver_test.go b/pkg/resolver/resolver_test.go new file mode 100644 index 00000000..20870a89 --- /dev/null +++ b/pkg/resolver/resolver_test.go @@ -0,0 +1,96 @@ +package resolver + +import ( + "context" + "testing" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// setupFixtures helper returns a fresh set of test objects. +func setupFixtures(t testing.TB) ( + *multigresv1alpha1.CoreTemplate, + *multigresv1alpha1.CellTemplate, + *multigresv1alpha1.ShardTemplate, + string, +) { + t.Helper() + namespace := "default" + + coreTpl := &multigresv1alpha1.CoreTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.CoreTemplateSpec{ + GlobalTopoServer: &multigresv1alpha1.TopoServerSpec{ + Etcd: &multigresv1alpha1.EtcdSpec{Image: "core-default"}, + }, + }, + } + + cellTpl := &multigresv1alpha1.CellTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.CellTemplateSpec{ + MultiGateway: &multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + }, + }, + } + + shardTpl := &multigresv1alpha1.ShardTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: namespace}, + Spec: multigresv1alpha1.ShardTemplateSpec{ + MultiOrch: &multigresv1alpha1.MultiOrchSpec{ + StatelessSpec: multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + }, + } + + return coreTpl, cellTpl, shardTpl, namespace +} + +func TestNewResolver(t *testing.T) { + t.Parallel() + + c := fake.NewClientBuilder().Build() + defaults := multigresv1alpha1.TemplateDefaults{CoreTemplate: "foo"} + r := NewResolver(c, "ns", defaults) + + if got, want := r.Client, c; got != want { + t.Errorf("Client mismatch: got %v, want %v", got, want) + } + if got, want := r.Namespace, "ns"; got != want { + t.Errorf("Namespace mismatch: got %q, want %q", got, want) + } + if got, want := r.TemplateDefaults.CoreTemplate, "foo"; got != want { + t.Errorf("Defaults mismatch: got %q, want %q", got, want) + } +} + +// mockClient is a partial implementation of client.Client to force errors. +type mockClient struct { + client.Client + failGet bool + err error +} + +func (m *mockClient) Get( + ctx context.Context, + key client.ObjectKey, + obj client.Object, + opts ...client.GetOption, +) error { + if m.failGet { + return m.err + } + return apierrors.NewNotFound(schema.GroupResource{}, key.Name) +} + +func parseQty(s string) resource.Quantity { + return resource.MustParse(s) +} diff --git a/pkg/resolver/shard.go b/pkg/resolver/shard.go new file mode 100644 index 00000000..be1766fc --- /dev/null +++ b/pkg/resolver/shard.go @@ -0,0 +1,126 @@ +package resolver + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" +) + +// ResolveShardTemplate fetches and resolves a ShardTemplate by name, handling defaults. +func (r *Resolver) ResolveShardTemplate( + ctx context.Context, + templateName string, +) (*multigresv1alpha1.ShardTemplate, error) { + name := templateName + isImplicitFallback := false + + if name == "" { + name = r.TemplateDefaults.ShardTemplate + } + if name == "" { + name = FallbackShardTemplate + isImplicitFallback = true + } + + tpl := &multigresv1alpha1.ShardTemplate{} + err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: r.Namespace}, tpl) + if err != nil { + if errors.IsNotFound(err) { + if isImplicitFallback { + return &multigresv1alpha1.ShardTemplate{}, nil + } + return nil, fmt.Errorf("referenced ShardTemplate '%s' not found: %w", name, err) + } + return nil, fmt.Errorf("failed to get ShardTemplate: %w", err) + } + return tpl, nil +} + +// MergeShardConfig merges a template spec with overrides and an inline spec to produce the final configuration. +func MergeShardConfig( + template *multigresv1alpha1.ShardTemplate, + overrides *multigresv1alpha1.ShardOverrides, + inline *multigresv1alpha1.ShardInlineSpec, +) (multigresv1alpha1.MultiOrchSpec, map[string]multigresv1alpha1.PoolSpec) { + if inline != nil { + orch := *inline.MultiOrch.DeepCopy() + pools := make(map[string]multigresv1alpha1.PoolSpec) + for k, v := range inline.Pools { + pools[k] = *v.DeepCopy() + } + return orch, pools + } + + var multiOrch multigresv1alpha1.MultiOrchSpec + pools := make(map[string]multigresv1alpha1.PoolSpec) + + if template != nil { + if template.Spec.MultiOrch != nil { + multiOrch = *template.Spec.MultiOrch.DeepCopy() + } + for k, v := range template.Spec.Pools { + pools[k] = *v.DeepCopy() + } + } + + if overrides != nil { + if overrides.MultiOrch != nil { + mergeMultiOrchSpec(&multiOrch, overrides.MultiOrch) + } + + for k, v := range overrides.Pools { + if existingPool, exists := pools[k]; exists { + mergedPool := mergePoolSpec(existingPool, v) + pools[k] = mergedPool + } else { + pools[k] = v + } + } + } + + return multiOrch, pools +} + +func mergeMultiOrchSpec( + base *multigresv1alpha1.MultiOrchSpec, + override *multigresv1alpha1.MultiOrchSpec, +) { + mergeStatelessSpec(&base.StatelessSpec, &override.StatelessSpec) + if len(override.Cells) > 0 { + base.Cells = override.Cells + } +} + +func mergePoolSpec( + base multigresv1alpha1.PoolSpec, + override multigresv1alpha1.PoolSpec, +) multigresv1alpha1.PoolSpec { + out := base + if override.Type != "" { + out.Type = override.Type + } + if len(override.Cells) > 0 { + out.Cells = override.Cells + } + if override.ReplicasPerCell != nil { + out.ReplicasPerCell = override.ReplicasPerCell + } + if override.Storage.Size != "" { + out.Storage = override.Storage + } + // Safety: Use DeepCopy to avoid sharing pointers to maps within ResourceRequirements + if !isResourcesZero(override.Postgres.Resources) { + out.Postgres.Resources = *override.Postgres.Resources.DeepCopy() + } + if !isResourcesZero(override.Multipooler.Resources) { + out.Multipooler.Resources = *override.Multipooler.Resources.DeepCopy() + } + if override.Affinity != nil { + out.Affinity = override.Affinity.DeepCopy() + } + return out +} diff --git a/pkg/resolver/shard_test.go b/pkg/resolver/shard_test.go new file mode 100644 index 00000000..11e28d61 --- /dev/null +++ b/pkg/resolver/shard_test.go @@ -0,0 +1,317 @@ +package resolver + +import ( + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + multigresv1alpha1 "github.com/numtide/multigres-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestResolver_ResolveShardTemplate(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + _ = multigresv1alpha1.AddToScheme(scheme) + + _, _, shardTpl, ns := setupFixtures(t) + customShard := shardTpl.DeepCopy() + customShard.Name = "custom-shard" + + tests := map[string]struct { + existingObjects []client.Object + defaults multigresv1alpha1.TemplateDefaults + reqName string + wantErr bool + errContains string + wantFound bool + wantResName string + }{ + "Explicit Found": { + existingObjects: []client.Object{customShard}, + reqName: "custom-shard", + wantFound: true, + wantResName: "custom-shard", + }, + "Explicit Not Found (Error)": { + existingObjects: []client.Object{}, + reqName: "missing-shard", + wantErr: true, + errContains: "referenced ShardTemplate 'missing-shard' not found", + }, + "Implicit Fallback Found": { + existingObjects: []client.Object{shardTpl}, + defaults: multigresv1alpha1.TemplateDefaults{}, + reqName: "", + wantFound: true, + wantResName: "default", + }, + "Implicit Fallback Not Found (Safe Empty Return)": { + existingObjects: []client.Object{}, + defaults: multigresv1alpha1.TemplateDefaults{}, + reqName: "", + wantFound: false, + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.existingObjects...). + Build() + r := NewResolver(c, ns, tc.defaults) + + res, err := r.ResolveShardTemplate(t.Context(), tc.reqName) + if tc.wantErr { + if err == nil { + t.Fatal("Expected error, got nil") + } + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Errorf( + "Error message mismatch: got %q, want substring %q", + err.Error(), + tc.errContains, + ) + } + return + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !tc.wantFound { + if res == nil { + t.Fatal( + "Expected non-nil result structure even for not-found implicit fallback", + ) + } + if res.GetName() != "" { + t.Errorf("Expected empty result, got object with name %q", res.GetName()) + } + return + } + + if got, want := res.GetName(), tc.wantResName; got != want { + t.Errorf("Result name mismatch: got %q, want %q", got, want) + } + }) + } +} + +func TestMergeShardConfig(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + tpl *multigresv1alpha1.ShardTemplate + overrides *multigresv1alpha1.ShardOverrides + inline *multigresv1alpha1.ShardInlineSpec + wantOrch multigresv1alpha1.MultiOrchSpec + wantPools map[string]multigresv1alpha1.PoolSpec + }{ + "Full Merge with MultiOrch Overrides": { + tpl: &multigresv1alpha1.ShardTemplate{ + Spec: multigresv1alpha1.ShardTemplateSpec{ + MultiOrch: &multigresv1alpha1.MultiOrchSpec{ + StatelessSpec: multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceMemory: parseQty("1Gi"), + }, + }, + }, + Cells: []multigresv1alpha1.CellName{"a"}, + }, + Pools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {Type: "read"}, + }, + }, + }, + overrides: &multigresv1alpha1.ShardOverrides{ + MultiOrch: &multigresv1alpha1.MultiOrchSpec{ + StatelessSpec: multigresv1alpha1.StatelessSpec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: parseQty("2Gi")}, + }, + Affinity: &corev1.Affinity{ + PodAntiAffinity: &corev1.PodAntiAffinity{}, + }, + }, + Cells: []multigresv1alpha1.CellName{"b"}, + }, + Pools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {Type: "write"}, + "p2": {Type: "internal"}, + }, + }, + wantOrch: multigresv1alpha1.MultiOrchSpec{ + StatelessSpec: multigresv1alpha1.StatelessSpec{ + Replicas: ptr.To(int32(1)), + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceMemory: parseQty("2Gi")}, + }, + Affinity: &corev1.Affinity{ + PodAntiAffinity: &corev1.PodAntiAffinity{}, + }, + }, + Cells: []multigresv1alpha1.CellName{"b"}, + }, + wantPools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {Type: "write"}, + "p2": {Type: "internal"}, + }, + }, + "Template Only (Nil Overrides)": { + tpl: &multigresv1alpha1.ShardTemplate{ + Spec: multigresv1alpha1.ShardTemplateSpec{ + MultiOrch: &multigresv1alpha1.MultiOrchSpec{ + StatelessSpec: multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + }, + }, + overrides: nil, + wantOrch: multigresv1alpha1.MultiOrchSpec{ + StatelessSpec: multigresv1alpha1.StatelessSpec{Replicas: ptr.To(int32(1))}, + }, + wantPools: map[string]multigresv1alpha1.PoolSpec{}, + }, + "Pool Deep Merge": { + tpl: &multigresv1alpha1.ShardTemplate{ + Spec: multigresv1alpha1.ShardTemplateSpec{ + Pools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {Type: "read"}, + }, + }, + }, + overrides: &multigresv1alpha1.ShardOverrides{ + Pools: map[string]multigresv1alpha1.PoolSpec{ + "p1": { + Type: "write", + Cells: []multigresv1alpha1.CellName{"zone-a"}, + ReplicasPerCell: ptr.To(int32(5)), + Storage: multigresv1alpha1.StorageSpec{Size: "10Gi"}, + Postgres: multigresv1alpha1.ContainerConfig{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, + }, + }, + Multipooler: multigresv1alpha1.ContainerConfig{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, + }, + }, + Affinity: &corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{}}, + }, + }, + }, + wantOrch: multigresv1alpha1.MultiOrchSpec{}, + wantPools: map[string]multigresv1alpha1.PoolSpec{ + "p1": { + Type: "write", + Cells: []multigresv1alpha1.CellName{"zone-a"}, + ReplicasPerCell: ptr.To(int32(5)), + Storage: multigresv1alpha1.StorageSpec{Size: "10Gi"}, + Postgres: multigresv1alpha1.ContainerConfig{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, + }, + }, + Multipooler: multigresv1alpha1.ContainerConfig{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: parseQty("1")}, + }, + }, + Affinity: &corev1.Affinity{NodeAffinity: &corev1.NodeAffinity{}}, + }, + }, + }, + "Preserve Base Pool (Empty Override)": { + tpl: &multigresv1alpha1.ShardTemplate{ + Spec: multigresv1alpha1.ShardTemplateSpec{ + Pools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {Type: "read", ReplicasPerCell: ptr.To(int32(1))}, + }, + }, + }, + overrides: &multigresv1alpha1.ShardOverrides{ + Pools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {}, + }, + }, + wantOrch: multigresv1alpha1.MultiOrchSpec{}, + wantPools: map[string]multigresv1alpha1.PoolSpec{ + "p1": {Type: "read", ReplicasPerCell: ptr.To(int32(1))}, + }, + }, + "Inline Priority": { + tpl: &multigresv1alpha1.ShardTemplate{ + Spec: multigresv1alpha1.ShardTemplateSpec{ + MultiOrch: &multigresv1alpha1.MultiOrchSpec{ + Cells: []multigresv1alpha1.CellName{"a"}, + }, + }, + }, + inline: &multigresv1alpha1.ShardInlineSpec{ + MultiOrch: multigresv1alpha1.MultiOrchSpec{ + Cells: []multigresv1alpha1.CellName{"inline"}, + }, + Pools: map[string]multigresv1alpha1.PoolSpec{ + "inline-pool": {Type: "read"}, + }, + }, + wantOrch: multigresv1alpha1.MultiOrchSpec{ + Cells: []multigresv1alpha1.CellName{"inline"}, + }, + wantPools: map[string]multigresv1alpha1.PoolSpec{ + "inline-pool": {Type: "read"}, + }, + }, + "Nil Template": { + tpl: nil, + overrides: &multigresv1alpha1.ShardOverrides{ + MultiOrch: &multigresv1alpha1.MultiOrchSpec{ + Cells: []multigresv1alpha1.CellName{"b"}, + }, + }, + wantOrch: multigresv1alpha1.MultiOrchSpec{Cells: []multigresv1alpha1.CellName{"b"}}, + wantPools: map[string]multigresv1alpha1.PoolSpec{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + orch, pools := MergeShardConfig(tc.tpl, tc.overrides, tc.inline) + + if diff := cmp.Diff(tc.wantOrch, orch, cmpopts.IgnoreUnexported(resource.Quantity{})); diff != "" { + t.Errorf("Orch mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.wantPools, pools, cmpopts.IgnoreUnexported(resource.Quantity{}), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("Pools mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestResolver_ClientErrors_Shard(t *testing.T) { + t.Parallel() + errSimulated := errors.New("simulated database connection error") + mc := &mockClient{failGet: true, err: errSimulated} + r := NewResolver(mc, "default", multigresv1alpha1.TemplateDefaults{}) + + _, err := r.ResolveShardTemplate(t.Context(), "any") + if err == nil || + err.Error() != "failed to get ShardTemplate: simulated database connection error" { + t.Errorf("Error mismatch: got %v, want simulated error", err) + } +}