Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 94 additions & 6 deletions charts/fleet-crd/templates/crds.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2461,17 +2461,30 @@ spec:
description: ServiceAccount which will be used to perform this deployment.
nullable: true
type: string
targetCustomizationMode:
default: FirstMatch
description: 'TargetCustomizationMode controls how targetCustomizations
from fleet.yaml

are evaluated. "FirstMatch" (default) stops at the first matching
entry.

"AllMatches" applies all matching entries in order, merging them.'
enum:
- FirstMatch
- AllMatches
type: string
targetRestrictions:
description: TargetRestrictions is an allow list, which controls
if a bundledeployment is created for a target.
items:
description: 'BundleTargetRestriction is used internally by Fleet
and should not be modified.

It acts as an allow list, to prevent the creation of BundleDeployments
from
It acts as an allow list, restricting BundleDeployment creation
to only those clusters

Targets created by TargetCustomizations in fleet.yaml.'
that are explicitly listed in the GitRepo targets.'
properties:
clusterGroup:
nullable: true
Expand Down Expand Up @@ -3198,6 +3211,37 @@ spec:
this deployment.
nullable: true
type: string
source:
description: 'Source indicates the origin of this target.

"customization" - target comes from fleet.yaml targetCustomizations

"gitrepo" - target comes from GitRepo.Spec.Targets (via
targets file)

"helmop" - target comes from a HelmOp resource


If empty, provenance is determined by position in the Targets
array:

- First N targets are customizations where N = len(Targets)
- len(TargetRestrictions)

- Remaining targets are GitRepo targets


This field enables explicit provenance tracking for better
maintainability

while maintaining backward compatibility with Bundles created
before this field existed.'
enum:
- customization
- gitrepo
- helmop
- ''
type: string
yaml:
description: 'YAML options, if using raw YAML these are names
that map to
Expand Down Expand Up @@ -8708,17 +8752,30 @@ spec:
description: ServiceAccount which will be used to perform this deployment.
nullable: true
type: string
targetCustomizationMode:
default: FirstMatch
description: 'TargetCustomizationMode controls how targetCustomizations
from fleet.yaml

are evaluated. "FirstMatch" (default) stops at the first matching
entry.

"AllMatches" applies all matching entries in order, merging them.'
enum:
- FirstMatch
- AllMatches
type: string
targetRestrictions:
description: TargetRestrictions is an allow list, which controls
if a bundledeployment is created for a target.
items:
description: 'BundleTargetRestriction is used internally by Fleet
and should not be modified.

It acts as an allow list, to prevent the creation of BundleDeployments
from
It acts as an allow list, restricting BundleDeployment creation
to only those clusters

Targets created by TargetCustomizations in fleet.yaml.'
that are explicitly listed in the GitRepo targets.'
properties:
clusterGroup:
nullable: true
Expand Down Expand Up @@ -9445,6 +9502,37 @@ spec:
this deployment.
nullable: true
type: string
source:
description: 'Source indicates the origin of this target.

"customization" - target comes from fleet.yaml targetCustomizations

"gitrepo" - target comes from GitRepo.Spec.Targets (via
targets file)

"helmop" - target comes from a HelmOp resource


If empty, provenance is determined by position in the Targets
array:

- First N targets are customizations where N = len(Targets)
- len(TargetRestrictions)

- Remaining targets are GitRepo targets


This field enables explicit provenance tracking for better
maintainability

while maintaining backward compatibility with Bundles created
before this field existed.'
enum:
- customization
- gitrepo
- helmop
- ''
type: string
yaml:
description: 'YAML options, if using raw YAML these are names
that map to
Expand Down
1 change: 1 addition & 0 deletions integrationtests/cli/apply/apply_online_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ data:
{
Name: "default",
ClusterGroup: "default",
Source: "gitrepo",
},
},
},
Expand Down
4 changes: 2 additions & 2 deletions integrationtests/cli/apply/targetsfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ var _ = Describe("Fleet apply targets", func() {
)

BeforeEach(func() {
targets = []fleet.BundleTarget{{Name: "target1", ClusterName: "test1"}}
targets = []fleet.BundleTarget{{Name: "target1", ClusterName: "test1", Source: "gitrepo"}}
targetRestrictions = []fleet.BundleTargetRestriction{{Name: "target1", ClusterName: "test1"}}
file := createTargetsFile(targets, targetRestrictions)
options = apply.Options{TargetsFile: file.Name()}
Expand All @@ -81,7 +81,7 @@ var _ = Describe("Fleet apply targets", func() {
)

BeforeEach(func() {
targets = []fleet.BundleTarget{{Name: "target1", ClusterName: "test1"}}
targets = []fleet.BundleTarget{{Name: "target1", ClusterName: "test1", Source: "gitrepo"}}
targetRestrictions = []fleet.BundleTargetRestriction{{Name: "target1", ClusterName: "test1"}}
file := createTargetsFile(targets, targetRestrictions)
options = apply.Options{TargetsFile: file.Name()}
Expand Down
131 changes: 124 additions & 7 deletions integrationtests/controller/bundle/bundle_targets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,22 +459,23 @@ var _ = Describe("Bundle targets", Ordered, func() {
})
})

// Regression test for https://github.com/rancher/fleet/issues/3580:
// With FirstMatch semantics (the default), only the first matching targetCustomization is applied.
// When a broader-matching target appears before a doNotDeploy target in the list,
// the doNotDeploy target was previously bypassed due to first-match semantics.
// the broader match wins and the doNotDeploy entry is never evaluated.
// Users must order their targetCustomizations from specific to general to use doNotDeploy effectively.
When("a broader-matching targetCustomization appears before a doNotDeploy targetCustomization", func() {
BeforeEach(func() {
bundleName = "donot-deploy-after-broader-match"
bdLabels = map[string]string{
"fleet.cattle.io/bundle-name": bundleName,
"fleet.cattle.io/bundle-namespace": namespace,
}
expectedNumberOfBundleDeployments = 0
expectedNumberOfBundleDeployments = 1
// simulate targets in fleet.yaml:
// - first entry matches all clusters (broader match)
// - second entry matches cluster "one" with doNotDeploy=true
// With the old first-match logic, cluster "one" would match the first entry and
// the doNotDeploy entry would never be evaluated.
// With FirstMatch semantics, cluster "one" matches the first entry and gets deployed.
// The doNotDeploy entry is never evaluated because the first match wins.
targets = []v1alpha1.BundleTarget{
{
BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
Expand All @@ -483,25 +484,73 @@ var _ = Describe("Bundle targets", Ordered, func() {
},
},
ClusterGroup: "all",
Source: "customization",
},
{
ClusterGroup: "one",
DoNotDeploy: true,
Source: "customization",
},
}
// simulate targets in GitRepo: only cluster group "one" is targeted
targetsInGitRepo := []v1alpha1.BundleTarget{
{
ClusterGroup: "one",
Source: "gitrepo",
},
}
targetRestrictions = make([]v1alpha1.BundleTarget, len(targetsInGitRepo))
copy(targetRestrictions, targetsInGitRepo)
targets = append(targets, targetsInGitRepo...)
})

It("no BundleDeployments are created", func() {
waitForBundleToBeReady(bundleName)
It("creates a BundleDeployment because first match wins (using explicit Source field)", func() {
_ = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
})
})

// Same scenario as above, but without Source fields to test backward compatibility
// with bundles created before the Source field was added (position-based fallback).
When("a broader-matching targetCustomization appears before a doNotDeploy targetCustomization (position-based detection)", func() {
BeforeEach(func() {
bundleName = "donot-deploy-after-broader-match-position-based"
bdLabels = map[string]string{
"fleet.cattle.io/bundle-name": bundleName,
"fleet.cattle.io/bundle-namespace": namespace,
}
expectedNumberOfBundleDeployments = 1
// simulate targets in fleet.yaml:
// - first entry matches all clusters (broader match)
// - second entry matches cluster "one" with doNotDeploy=true
// With FirstMatch semantics, cluster "one" matches the first entry and gets deployed.
// The doNotDeploy entry is never evaluated because the first match wins.
// No Source fields are set, so position-based detection is used.
targets = []v1alpha1.BundleTarget{
{
BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
Helm: &v1alpha1.HelmOptions{
Values: &v1alpha1.GenericMap{Data: map[string]interface{}{"replicas": "1"}},
},
},
ClusterGroup: "all",
},
{
ClusterGroup: "one",
DoNotDeploy: true,
},
}
// simulate targets in GitRepo: only cluster group "one" is targeted
targetsInGitRepo := []v1alpha1.BundleTarget{
{
ClusterGroup: "one",
},
}
targetRestrictions = make([]v1alpha1.BundleTarget, len(targetsInGitRepo))
copy(targetRestrictions, targetsInGitRepo)
targets = append(targets, targetsInGitRepo...)
})

It("creates a BundleDeployment because first match wins (using position-based fallback)", func() {
_ = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
})
})
Expand Down Expand Up @@ -588,6 +637,73 @@ var _ = Describe("Bundle targets", Ordered, func() {
}
})
})

// AllMatches mode: every matching targetCustomization is applied in order,
// so a cluster that matches two customizations receives the merged options
// from both, whereas FirstMatch would stop at the first.
When("TargetCustomizationMode is AllMatches and two customizations match cluster one", func() {
BeforeEach(func() {
bundleName = "all-matches-mode"
bdLabels = map[string]string{
"fleet.cattle.io/bundle-name": bundleName,
"fleet.cattle.io/bundle-namespace": namespace,
}
expectedNumberOfBundleDeployments = 3

// Customization 1: only cluster group "one" → sets region
// Customization 2: all clusters → sets env
// GitRepo target: all clusters (no extra values)
targetsInGitRepo := []v1alpha1.BundleTarget{
{ClusterGroup: "all"},
}
targets = []v1alpha1.BundleTarget{
{
BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
Helm: &v1alpha1.HelmOptions{
Values: &v1alpha1.GenericMap{Data: map[string]interface{}{"region": "us-west"}},
},
},
ClusterGroup: "one",
},
{
BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
Helm: &v1alpha1.HelmOptions{
Values: &v1alpha1.GenericMap{Data: map[string]interface{}{"env": "prod"}},
},
},
ClusterGroup: "all",
},
}
targetRestrictions = make([]v1alpha1.BundleTarget, len(targetsInGitRepo))
copy(targetRestrictions, targetsInGitRepo)
targets = append(targets, targetsInGitRepo...)
})

JustBeforeEach(func() {
// The outer JustBeforeEach already created the bundle with FirstMatch
// (the default). Patch it to AllMatches so the reconciler re-evaluates.
mod := bundle.DeepCopy()
mod.Spec.TargetCustomizationMode = v1alpha1.TargetCustomizationModeAllMatches
Expect(k8sClient.Patch(ctx, mod, client.MergeFrom(bundle))).ToNot(HaveOccurred())
bundle = mod
})

It("merges all matching customizations into cluster one's BundleDeployment", func() {
bdList := verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
for _, bd := range bdList.Items {
values, _ := loadValues(bd)
if strings.Contains(bd.Namespace, "cluster-one") {
// cluster "one" matches both customizations: both keys must be present
Expect(values).To(HaveKeyWithValue("region", "us-west"), "cluster-one should have region from cust1")
Expect(values).To(HaveKeyWithValue("env", "prod"), "cluster-one should have env from cust2")
} else {
// other clusters match only cust2
Expect(values).ToNot(HaveKey("region"), "non-one clusters should not have region")
Expect(values).To(HaveKeyWithValue("env", "prod"), "non-one clusters should have env from cust2")
}
}
})
})
})

func verifyBundlesDeploymentsAreCreated(numBundleDeployments int, bdLabels map[string]string, bundleName string) *v1alpha1.BundleDeploymentList {
Expand All @@ -608,6 +724,7 @@ func verifyBundlesDeploymentsAreCreated(numBundleDeployments int, bdLabels map[s
}

func waitForBundleToBeReady(bundleName string) {
GinkgoHelper()
Eventually(func() bool {
bundle := &v1alpha1.Bundle{}
err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: bundleName}, bundle)
Expand Down
Loading