diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml index 7cc6cd2cea..56c67b504a 100644 --- a/charts/fleet-crd/templates/crds.yaml +++ b/charts/fleet-crd/templates/crds.yaml @@ -2226,6 +2226,18 @@ spec: default: 200' nullable: true type: integer + maxNew: + description: 'MaxNew is the maximum number of new BundleDeployments + that can be created + + in a single reconciliation. This limits the rate at which + new deployments + + are staged when a bundle is first applied to many clusters. + + default: 50' + nullable: true + type: integer maxUnavailable: anyOf: - type: integer @@ -3281,12 +3293,6 @@ spec: nullable: true type: string type: object - maxNew: - description: 'MaxNew is always 50. A bundle change can only stage - 50 - - bundledeployments at a time.' - type: integer maxUnavailable: description: 'MaxUnavailable is the maximum number of unavailable deployments. See @@ -8453,6 +8459,18 @@ spec: default: 200' nullable: true type: integer + maxNew: + description: 'MaxNew is the maximum number of new BundleDeployments + that can be created + + in a single reconciliation. This limits the rate at which + new deployments + + are staged when a bundle is first applied to many clusters. + + default: 50' + nullable: true + type: integer maxUnavailable: anyOf: - type: integer diff --git a/internal/cmd/cli/target.go b/internal/cmd/cli/target.go index 962ac8c898..aa9451f713 100644 --- a/internal/cmd/cli/target.go +++ b/internal/cmd/cli/target.go @@ -152,11 +152,7 @@ func (t *Target) Run(cmd *cobra.Command, args []string) error { cmd.Println("---") cmd.Println(string(b)) - // Needs to be set to print all targets. UpdatePartitions will only - // create this many deployments if the bundle is new. - bundle.Status.MaxNew = len(matchedTargets) - - if err := target.UpdatePartitions(&bundle.Status, matchedTargets); err != nil { + if err := stageAllTargets(bundle, matchedTargets); err != nil { return err } for _, target := range matchedTargets { @@ -177,3 +173,20 @@ func (t *Target) Run(cmd *cobra.Command, args []string) error { return nil } + +// stageAllTargets overrides maxNew to the total number of matched targets so +// that UpdatePartitions stages a deployment for every target, not just the +// first target.DefaultMaxNew. +// +// NOTE: mutating bundle.Spec.RolloutStrategy is visible to UpdatePartitions +// because target.Manager.Targets() stores the same bundle pointer in every +// target's Bundle field, and UpdatePartitions reads MaxNew from +// targets[0].Bundle.Spec.RolloutStrategy. +func stageAllTargets(bundle *v1alpha1.Bundle, matchedTargets []*target.Target) error { + count := len(matchedTargets) + if bundle.Spec.RolloutStrategy == nil { + bundle.Spec.RolloutStrategy = &v1alpha1.RolloutStrategy{} + } + bundle.Spec.RolloutStrategy.MaxNew = &count + return target.UpdatePartitions(&bundle.Status, matchedTargets) +} diff --git a/internal/cmd/cli/target_test.go b/internal/cmd/cli/target_test.go new file mode 100644 index 0000000000..3c6e3c2825 --- /dev/null +++ b/internal/cmd/cli/target_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "strconv" + "testing" + + "github.com/rancher/fleet/internal/cmd/controller/target" + fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// newTargetsWithoutDeployments creates targets that share a single bundle +// pointer, matching what the real target builder produces. +func newTargetsWithoutDeployments(bundle *fleet.Bundle, count int) []*target.Target { + targets := make([]*target.Target, count) + for i := range count { + targets[i] = &target.Target{ + Cluster: &fleet.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "cluster-" + strconv.Itoa(i+1), + }, + }, + Bundle: bundle, + DeploymentID: "deployment-" + strconv.Itoa(i+1), + } + } + return targets +} + +func Test_stageAllTargets(t *testing.T) { + tests := []struct { + name string + targetCount int + }{ + { + name: "stages all targets when count exceeds default maxNew", + targetCount: 100, + }, + { + name: "stages all targets when count is below default maxNew", + targetCount: 10, + }, + { + name: "no targets produces no deployments", + targetCount: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bundle := &fleet.Bundle{ObjectMeta: metav1.ObjectMeta{Name: "bundle-1"}} + targets := newTargetsWithoutDeployments(bundle, tt.targetCount) + + if err := stageAllTargets(bundle, targets); err != nil { + t.Fatalf("stageAllTargets() failed: %v", err) + } + + deploymentCount := 0 + for _, tgt := range targets { + if tgt.Deployment != nil { + deploymentCount++ + } + } + if deploymentCount != tt.targetCount { + t.Errorf("staged %d deployments, want %d", deploymentCount, tt.targetCount) + } + }) + } +} + +func Test_stageAllTargets_overwritesExistingMaxNew(t *testing.T) { + // MaxNew=2 is less than the target count (5); if stageAllTargets does not + // overwrite it, UpdatePartitions would only stage 2 deployments. + two := 2 + bundle := &fleet.Bundle{ObjectMeta: metav1.ObjectMeta{Name: "bundle-1"}} + bundle.Spec.RolloutStrategy = &fleet.RolloutStrategy{MaxNew: &two} + targets := newTargetsWithoutDeployments(bundle, 5) + + if err := stageAllTargets(bundle, targets); err != nil { + t.Fatalf("stageAllTargets() failed: %v", err) + } + + deploymentCount := 0 + for _, tgt := range targets { + if tgt.Deployment != nil { + deploymentCount++ + } + } + if deploymentCount != 5 { + t.Errorf("staged %d deployments, want 5", deploymentCount) + } +} + +func Test_stageAllTargets_preservesExistingRolloutStrategy(t *testing.T) { + ten := 10 + existing := &fleet.RolloutStrategy{ + AutoPartitionThreshold: &ten, + } + bundle := &fleet.Bundle{ObjectMeta: metav1.ObjectMeta{Name: "bundle-1"}} + bundle.Spec.RolloutStrategy = existing + targets := newTargetsWithoutDeployments(bundle, 5) + + if err := stageAllTargets(bundle, targets); err != nil { + t.Fatalf("stageAllTargets() failed: %v", err) + } + + if bundle.Spec.RolloutStrategy.AutoPartitionThreshold == nil || *bundle.Spec.RolloutStrategy.AutoPartitionThreshold != 10 { + t.Error("existing rollout strategy fields were overwritten") + } +} diff --git a/internal/cmd/controller/reconciler/bundle_status.go b/internal/cmd/controller/reconciler/bundle_status.go index 703380f1e8..43b010b19f 100644 --- a/internal/cmd/controller/reconciler/bundle_status.go +++ b/internal/cmd/controller/reconciler/bundle_status.go @@ -8,12 +8,7 @@ import ( fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1" ) -const ( - maxNew = 50 -) - func resetStatus(status *fleet.BundleStatus, allTargets []*target.Target) (err error) { - status.MaxNew = maxNew status.Summary = fleet.BundleSummary{} status.PartitionStatus = nil status.Unavailable = 0 diff --git a/internal/cmd/controller/target/partition.go b/internal/cmd/controller/target/partition.go index 85044172bb..88532c8dcd 100644 --- a/internal/cmd/controller/target/partition.go +++ b/internal/cmd/controller/target/partition.go @@ -17,6 +17,12 @@ type partition struct { // It creates Deployments in allTargets if they are missing. // It updates Deployments in allTargets if they are out of sync (DeploymentID != StagedDeploymentID). func UpdatePartitions(status *fleet.BundleStatus, allTargets []*Target) (err error) { + rollout := getRollout(allTargets) + maxNew := DefaultMaxNew + if rollout.MaxNew != nil { + maxNew = *rollout.MaxNew + } + partitions, err := partitions(allTargets) if err != nil { return err @@ -30,8 +36,8 @@ func UpdatePartitions(status *fleet.BundleStatus, allTargets []*Target) (err err for _, partition := range partitions { for _, target := range partition.Targets { - // for a new bundledeployment, only stage the first maxNew (50) targets - if target.Deployment == nil && status.NewlyCreated < status.MaxNew { + // for a new bundledeployment, only stage the first maxNew targets + if target.Deployment == nil && status.NewlyCreated < maxNew { status.NewlyCreated++ target.Deployment = &fleet.BundleDeployment{ ObjectMeta: metav1.ObjectMeta{ diff --git a/internal/cmd/controller/target/rollout_test.go b/internal/cmd/controller/target/rollout_test.go index f416c738fe..6feac06acb 100644 --- a/internal/cmd/controller/target/rollout_test.go +++ b/internal/cmd/controller/target/rollout_test.go @@ -141,6 +141,29 @@ func intPtr(i int) *int { return &i } +// createNewTargets creates targets without existing Deployments, simulating +// clusters that do not yet have a BundleDeployment. +func createNewTargets(count int) []*Target { + targets := make([]*Target, count) + for i := range count { + targets[i] = &Target{ + Cluster: &fleet.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "cluster-" + strconv.Itoa(i+1), + }, + }, + Bundle: &fleet.Bundle{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bundle-1", + }, + }, + DeploymentID: "deployment-" + strconv.Itoa(i+1), + } + } + return targets +} + func Test_autoPartition(t *testing.T) { tests := []struct { name string @@ -476,3 +499,60 @@ func Test_manualPartition(t *testing.T) { }) } } + +func Test_UpdatePartitions_MaxNew(t *testing.T) { + tests := []struct { + name string + maxNew *int + targetCount int + wantNewlyCreated int + }{ + { + name: "maxNew configured on rollout strategy limits newly created deployments", + maxNew: intPtr(10), + targetCount: 100, + wantNewlyCreated: 10, + }, + { + name: "maxNew larger than target count creates all deployments", + maxNew: intPtr(200), + targetCount: 20, + wantNewlyCreated: 20, + }, + { + name: "maxNew of 0 creates no new deployments", + maxNew: intPtr(0), + targetCount: 10, + wantNewlyCreated: 0, + }, + { + name: "nil maxNew uses default of 50", + maxNew: nil, + targetCount: 100, + wantNewlyCreated: DefaultMaxNew, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + targets := createNewTargets(tt.targetCount) + for _, tgt := range targets { + tgt.Bundle.Spec.RolloutStrategy = &fleet.RolloutStrategy{ + MaxNew: tt.maxNew, + } + } + if err := UpdatePartitions(&fleet.BundleStatus{}, targets); err != nil { + t.Fatalf("UpdatePartitions() failed: %v", err) + } + // Verify the right number of targets got a Deployment assigned + deploymentCount := 0 + for _, tgt := range targets { + if tgt.Deployment != nil { + deploymentCount++ + } + } + if deploymentCount != tt.wantNewlyCreated { + t.Errorf("targets with Deployment = %d, want %d", deploymentCount, tt.wantNewlyCreated) + } + }) + } +} diff --git a/internal/cmd/controller/target/target.go b/internal/cmd/controller/target/target.go index 7dc252c742..9b0a17de56 100644 --- a/internal/cmd/controller/target/target.go +++ b/internal/cmd/controller/target/target.go @@ -25,6 +25,11 @@ var ( defMaxUnavailablePartitions = intstr.FromInt(0) ) +const ( + // DefaultMaxNew is the default value for RolloutStrategy.MaxNew. + DefaultMaxNew = 50 +) + const ( maxTemplateRecursionDepth = 50 clusterLabelPrefix = "global.fleet.clusterLabels." diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go index aef17f9c4e..905f2e2e3f 100644 --- a/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go +++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundle_types.go @@ -200,6 +200,12 @@ type RolloutStrategy struct { // default: 200 // +nullable AutoPartitionThreshold *int `json:"autoPartitionThreshold,omitempty"` + // MaxNew is the maximum number of new BundleDeployments that can be created + // in a single reconciliation. This limits the rate at which new deployments + // are staged when a bundle is first applied to many clusters. + // default: 50 + // +nullable + MaxNew *int `json:"maxNew,omitempty"` // A list of definitions of partitions. If any target clusters do not match // the configuration they are added to partitions at the end following the // autoPartitionSize. @@ -383,9 +389,6 @@ type BundleStatus struct { // percentage of unavailable partitions. // +optional MaxUnavailablePartitions int `json:"maxUnavailablePartitions"` - // MaxNew is always 50. A bundle change can only stage 50 - // bundledeployments at a time. - MaxNew int `json:"maxNew,omitempty"` // PartitionStatus lists the status of each partition. PartitionStatus []PartitionStatus `json:"partitions,omitempty"` // Display contains the number of ready, desiredready clusters and a diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go index bbd4786e72..8c11984897 100644 --- a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated.deepcopy.go @@ -2353,6 +2353,11 @@ func (in *RolloutStrategy) DeepCopyInto(out *RolloutStrategy) { *out = new(int) **out = **in } + if in.MaxNew != nil { + in, out := &in.MaxNew, &out.MaxNew + *out = new(int) + **out = **in + } if in.Partitions != nil { in, out := &in.Partitions, &out.Partitions *out = make([]Partition, len(*in))