Repository: projectcalico/calico
Pull request #10127: Reject policy name with invalid format
Author: login: MichalFupso
Labels: name: docs-not-required
description: Docs not required for this change
name: release-note-not-required
description: Change has no user-facing impact
Created: 2025-04-02T19:49:12Z | Merged: 2025-04-03T18:00:36Z

## Description
Follow up for https://github.com/projectcalico/calico/pull/9615

Updates libcalico-go to reject policies in non default tier that are not in format of tier.name. This ensures that two policies do not end up with the same name. This change is only for v3 policies. 


<!-- A few sentences describing the overall goals of the pull request's commits.
Please include
- the type of fix - (e.g. bug fix, new feature, documentation)
- some details on _why_ this PR should be merged
- the details of the testing you've done on it (both manual and automated)
- which components are affected by this PR
- links to issues that this PR addresses
-->

## Related issues/PRs

<!-- If appropriate, include a link to the issue this fixes.
fixes <ISSUE LINK>

If appropriate, add links to any number of PRs documented by this PR
documents <PR LINK>
-->

## Todos

- [ ] Tests
- [ ] Documentation
- [ ] Release note

## Release Note

<!-- Writing a release note:
- By default, no release note action is required.
- If you're unsure whether or not your PR needs a note, ask your reviewer for guidance.
- If this PR requires a release note, update the block below to include a concise note describing
  the change and any important impacts this PR may have.
-->

```release-note
TBD
```

## Reminder for the reviewer

Make sure that this PR has the correct labels and milestone set.

Every PR needs one `docs-*` label.

- `docs-pr-required`: This change requires a change to the documentation that has not been completed yet.
- `docs-completed`: This change has all necessary documentation completed.
- `docs-not-required`: This change has no user-facing impact and requires no docs.

Every PR needs one `release-note-*` label.

- `release-note-required`: This PR has user-facing changes. Most PRs should have this label.
- `release-note-not-required`: This PR has no user-facing changes.

Other optional labels:

- `cherry-pick-candidate`: This PR should be cherry-picked to an earlier release. For bug fixes only.
- `needs-operator-pr`: This PR is related to install and requires a corresponding change to the operator.



Diff:
diff --git a/libcalico-go/lib/clientv3/globalnetworkpolicy.go b/libcalico-go/lib/clientv3/globalnetworkpolicy.go
index 2a491d9d4b3..c989f82fc55 100644
--- a/libcalico-go/lib/clientv3/globalnetworkpolicy.go
+++ b/libcalico-go/lib/clientv3/globalnetworkpolicy.go
@@ -63,6 +63,10 @@ func (r globalNetworkPolicies) Create(ctx context.Context, res *apiv3.GlobalNetw
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	if tier != "default" {
@@ -96,6 +100,10 @@ func (r globalNetworkPolicies) Update(ctx context.Context, res *apiv3.GlobalNetw
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, res.Spec.Tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	tier := names.TierOrDefault(res.Spec.Tier)
diff --git a/libcalico-go/lib/clientv3/globalnetworkpolicy_e2e_test.go b/libcalico-go/lib/clientv3/globalnetworkpolicy_e2e_test.go
index 18bb74f4493..5fdd101dd36 100644
--- a/libcalico-go/lib/clientv3/globalnetworkpolicy_e2e_test.go
+++ b/libcalico-go/lib/clientv3/globalnetworkpolicy_e2e_test.go
@@ -404,6 +404,41 @@ var _ = testutils.E2eDatastoreDescribe("GlobalNetworkPolicy tests", testutils.Da
 		Entry("GlobalNetworkPolicy with default tier prefix", "default.netpol", "netpol"),
 	)
 
+	DescribeTable("GlobalNetworkPolicy name validation tests",
+		func(policyName string, tier string, expectError bool) {
+			if tier != "default" {
+				// Create the tier if required before running other tiered policy tests.
+				tierSpec := apiv3.TierSpec{Order: &tierOrder}
+				By("Creating the tier")
+				_, resErr := c.Tiers().Create(ctx, &apiv3.Tier{
+					ObjectMeta: metav1.ObjectMeta{Name: tier},
+					Spec:       tierSpec,
+				}, options.SetOptions{})
+				Expect(resErr).NotTo(HaveOccurred())
+			}
+
+			_, err := c.GlobalNetworkPolicies().Create(ctx,
+				&apiv3.GlobalNetworkPolicy{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: policyName},
+					Spec: apiv3.GlobalNetworkPolicySpec{
+						Tier: tier,
+					},
+				}, options.SetOptions{})
+
+			if expectError {
+				Expect(err).To(HaveOccurred())
+			} else {
+				Expect(err).ToNot(HaveOccurred())
+			}
+		},
+		Entry("GlobalNetworkPolicy in default tier without prefix", "netpol", "default", false),
+		Entry("GlobalNetworkPolicy in default tier with prefix", "default.netpol", "default", false),
+		Entry("GlobalNetworkPolicy in custom tier with correct prefix", "tier1.netpol", "tier1", false),
+		Entry("GlobalNetworkPolicy in custom tier without prefix", "netpol", "tier1", true),
+		Entry("GlobalNetworkPolicy in custom tier with incorrect prefix", "tier1.netpol", "tier2", true),
+	)
+
 	Describe("GlobalNetworkPolicy watch functionality", func() {
 		It("should handle watch events for different resource versions and event types", func() {
 			By("Listing GlobalNetworkPolicies with the latest resource version and checking for two results with name1/spec2 and name2/spec2")
diff --git a/libcalico-go/lib/clientv3/networkpolicy.go b/libcalico-go/lib/clientv3/networkpolicy.go
index 81a9ddc1907..054a551fdb6 100644
--- a/libcalico-go/lib/clientv3/networkpolicy.go
+++ b/libcalico-go/lib/clientv3/networkpolicy.go
@@ -63,6 +63,10 @@ func (r networkPolicies) Create(ctx context.Context, res *apiv3.NetworkPolicy, o
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	if tier != "default" {
@@ -96,6 +100,10 @@ func (r networkPolicies) Update(ctx context.Context, res *apiv3.NetworkPolicy, o
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, res.Spec.Tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	tier := names.TierOrDefault(res.Spec.Tier)
diff --git a/libcalico-go/lib/clientv3/networkpolicy_e2e_test.go b/libcalico-go/lib/clientv3/networkpolicy_e2e_test.go
index 1ab039926e0..2a5fa4223a4 100644
--- a/libcalico-go/lib/clientv3/networkpolicy_e2e_test.go
+++ b/libcalico-go/lib/clientv3/networkpolicy_e2e_test.go
@@ -427,6 +427,44 @@ var _ = testutils.E2eDatastoreDescribe("NetworkPolicy tests", testutils.Datastor
 		Entry("NetworkPolicy with default tier prefix", "default.netpol", "netpol"),
 	)
 
+	DescribeTable("NetworkPolicy name validation tests",
+		func(policyName string, tier string, expectError bool) {
+			namespace := "default"
+
+			if tier != "default" {
+				// Create the tier if required before running other tiered policy tests.
+				tierSpec := apiv3.TierSpec{Order: &tierOrder, DefaultAction: &actionPass}
+				By("Creating the tier")
+				_, resErr := c.Tiers().Create(ctx, &apiv3.Tier{
+					ObjectMeta: metav1.ObjectMeta{Name: tier},
+					Spec:       tierSpec,
+				}, options.SetOptions{})
+				Expect(resErr).NotTo(HaveOccurred())
+			}
+
+			_, err := c.NetworkPolicies().Create(ctx,
+				&apiv3.NetworkPolicy{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      policyName,
+						Namespace: namespace},
+					Spec: apiv3.NetworkPolicySpec{
+						Tier: tier,
+					},
+				}, options.SetOptions{})
+
+			if expectError {
+				Expect(err).To(HaveOccurred())
+			} else {
+				Expect(err).ToNot(HaveOccurred())
+			}
+		},
+		Entry("NetworkPolicy in default tier without prefix", "netpol", "default", false),
+		Entry("NetworkPolicy in default tier with prefix", "default.netpol", "default", false),
+		Entry("NetworkPolicy in custom tier with correct prefix", "tier1.netpol", "tier1", false),
+		Entry("NetworkPolicy in custom tier without prefix", "netpol", "tier1", true),
+		Entry("NetworkPolicy in custom tier with incorrect prefix", "tier1.netpol", "tier2", true),
+	)
+
 	Describe("NetworkPolicy watch functionality", func() {
 		It("should handle watch events for different resource versions and event types", func() {
 			By("Listing NetworkPolicies with the latest resource version and checking for two results with name1/spec2 and name2/spec2")
diff --git a/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy.go b/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy.go
index 736f50ae2e5..ea7ae43289a 100644
--- a/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy.go
+++ b/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy.go
@@ -67,6 +67,10 @@ func (r stagedGlobalNetworkPolicies) Create(ctx context.Context, res *apiv3.Stag
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	if tier != "default" {
@@ -104,6 +108,10 @@ func (r stagedGlobalNetworkPolicies) Update(ctx context.Context, res *apiv3.Stag
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, res.Spec.Tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	tier := names.TierOrDefault(res.Spec.Tier)
diff --git a/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy_e2e_test.go b/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy_e2e_test.go
index cb32080da28..4cdbdcbbb25 100644
--- a/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy_e2e_test.go
+++ b/libcalico-go/lib/clientv3/stagedglobalnetworkpolicy_e2e_test.go
@@ -398,6 +398,41 @@ var _ = testutils.E2eDatastoreDescribe("StagedGlobalNetworkPolicy tests", testut
 		Entry("StagedGlobalNetworkPolicy with default tier prefix", "default.netpol", "netpol"),
 	)
 
+	DescribeTable("StagedGlobalNetworkPolicy name validation tests",
+		func(policyName string, tier string, expectError bool) {
+			if tier != "default" {
+				// Create the tier if required before running other tiered policy tests.
+				tierSpec := apiv3.TierSpec{Order: &tierOrder}
+				By("Creating the tier")
+				_, resErr := c.Tiers().Create(ctx, &apiv3.Tier{
+					ObjectMeta: metav1.ObjectMeta{Name: tier},
+					Spec:       tierSpec,
+				}, options.SetOptions{})
+				Expect(resErr).NotTo(HaveOccurred())
+			}
+
+			_, err := c.StagedGlobalNetworkPolicies().Create(ctx,
+				&apiv3.StagedGlobalNetworkPolicy{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: policyName},
+					Spec: apiv3.StagedGlobalNetworkPolicySpec{
+						Tier: tier,
+					},
+				}, options.SetOptions{})
+
+			if expectError {
+				Expect(err).To(HaveOccurred())
+			} else {
+				Expect(err).ToNot(HaveOccurred())
+			}
+		},
+		Entry("StagedGlobalNetworkPolicy in default tier without prefix", "netpol", "default", false),
+		Entry("StagedGlobalNetworkPolicy in default tier with prefix", "default.netpol", "default", false),
+		Entry("StagedGlobalNetworkPolicy in custom tier with correct prefix", "tier1.netpol", "tier1", false),
+		Entry("StagedGlobalNetworkPolicy in custom tier without prefix", "netpol", "tier1", true),
+		Entry("StagedGlobalNetworkPolicy in custom tier with incorrect prefix", "tier1.netpol", "tier2", true),
+	)
+
 	Describe("StagedGlobalNetworkPolicy watch functionality", func() {
 		It("should handle watch events for different resource versions and event types", func() {
 			By("Listing StagedGlobalNetworkPolicies with the latest resource version and checking for two results with name1/spec2 and name2/spec2")
diff --git a/libcalico-go/lib/clientv3/stagednetworkpolicy.go b/libcalico-go/lib/clientv3/stagednetworkpolicy.go
index ffac87eb596..39c5f64911a 100644
--- a/libcalico-go/lib/clientv3/stagednetworkpolicy.go
+++ b/libcalico-go/lib/clientv3/stagednetworkpolicy.go
@@ -78,6 +78,10 @@ func (r stagedNetworkPolicies) Create(ctx context.Context, res *apiv3.StagedNetw
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	if tier != "default" {
@@ -125,6 +129,10 @@ func (r stagedNetworkPolicies) Update(ctx context.Context, res *apiv3.StagedNetw
 	if err := validator.Validate(res); err != nil {
 		return nil, err
 	}
+	err := names.ValidateTieredPolicyName(res.Name, res.Spec.Tier)
+	if err != nil {
+		return nil, err
+	}
 
 	// Add tier labels to policy for lookup.
 	tier := names.TierOrDefault(res.Spec.Tier)
diff --git a/libcalico-go/lib/clientv3/stagednetworkpolicy_e2e_test.go b/libcalico-go/lib/clientv3/stagednetworkpolicy_e2e_test.go
index ce6d6408c77..56d6b3dfb55 100644
--- a/libcalico-go/lib/clientv3/stagednetworkpolicy_e2e_test.go
+++ b/libcalico-go/lib/clientv3/stagednetworkpolicy_e2e_test.go
@@ -430,6 +430,44 @@ var _ = testutils.E2eDatastoreDescribe("StagedNetworkPolicy tests", testutils.Da
 		Entry("StagedNetworkPolicy with default tier prefix", "default.netpol", "netpol"),
 	)
 
+	DescribeTable("StagedNetworkPolicy name validation tests",
+		func(policyName string, tier string, expectError bool) {
+			namespace := "default"
+
+			if tier != "default" {
+				// Create the tier if required before running other tiered policy tests.
+				tierSpec := apiv3.TierSpec{Order: &tierOrder}
+				By("Creating the tier")
+				_, resErr := c.Tiers().Create(ctx, &apiv3.Tier{
+					ObjectMeta: metav1.ObjectMeta{Name: tier},
+					Spec:       tierSpec,
+				}, options.SetOptions{})
+				Expect(resErr).NotTo(HaveOccurred())
+			}
+
+			_, err := c.StagedNetworkPolicies().Create(ctx,
+				&apiv3.StagedNetworkPolicy{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      policyName,
+						Namespace: namespace},
+					Spec: apiv3.StagedNetworkPolicySpec{
+						Tier: tier,
+					},
+				}, options.SetOptions{})
+
+			if expectError {
+				Expect(err).To(HaveOccurred())
+			} else {
+				Expect(err).ToNot(HaveOccurred())
+			}
+		},
+		Entry("StagedNetworkPolicy in default tier without prefix", "netpol", "default", false),
+		Entry("StagedNetworkPolicy in default tier with prefix", "default.netpol", "default", false),
+		Entry("StagedNetworkPolicy in custom tier with correct prefix", "tier1.netpol", "tier1", false),
+		Entry("StagedNetworkPolicy in custom tier without prefix", "netpol", "tier1", true),
+		Entry("StagedNetworkPolicy in custom tier with incorrect prefix", "tier1.netpol", "tier2", true),
+	)
+
 	Describe("StagedNetworkPolicy watch functionality", func() {
 		It("should handle watch events for different resource versions and event types", func() {
 			By("Listing NetworkPolicies with the latest resource version and checking for two results with name1/spec2 and name2/spec2")
diff --git a/libcalico-go/lib/names/policy.go b/libcalico-go/lib/names/policy.go
index 09701219d2a..f18282001da 100644
--- a/libcalico-go/lib/names/policy.go
+++ b/libcalico-go/lib/names/policy.go
@@ -102,6 +102,33 @@ func validateBackendTieredPolicyName(policy, tier string) error {
 	return nil
 }
 
+// ValidateTieredPolicyName validates v3 policy name, policies in the default tier can be named without the default. prefix.
+// Policy names in non default tier have to be in a format of tier.name
+func ValidateTieredPolicyName(policy, tier string) error {
+	if policy == "" {
+		return errors.New("Policy name is empty")
+	}
+	if policyNameIsFormatted(policy) {
+		return nil
+	}
+
+	tier = TierOrDefault(tier)
+	parts := strings.SplitN(policy, ".", 2)
+
+	if len(parts) == 1 && tier == "default" {
+		// Policy in default tier, without the default. prefix
+		return nil
+	}
+
+	if len(parts) == 2 && strings.HasPrefix(policy, tier+".") {
+		// Policy in format of tier.name with tier matching the prefix
+		return nil
+	}
+
+	// If we reached here the policy name is invalid. Either incorrect prefix or with additional . in the name
+	return fmt.Errorf("Incorrectly formatted policy name %s", policy)
+}
+
 func TieredPolicyName(policy string) string {
 	if policy == "" {
 		return ""


Reviews:
user: login: caseydavenport
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T19:54:30Z
user: login: caseydavenport
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T19:56:43Z
user: login: caseydavenport
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T22:35:46Z
user: login: caseydavenport
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T15:31:24Z

Review comments:
diff_hunk: @@ -83,10 +83,10 @@ func TierFromPolicyName(name string) (string, error) {
 // (Staged)NetworkPolicy or a (Staged)GlobalNetworkPolicy resource.
 func BackendTieredPolicyName(policy, tier string) (string, error) {
 	tieredPolicy := TieredPolicyName(policy)
-	return tieredPolicy, validateBackendTieredPolicyName(tieredPolicy, tier)
+	return tieredPolicy, ValidateBackendTieredPolicyName(tieredPolicy, tier)
 }
 
-func validateBackendTieredPolicyName(policy, tier string) error {
+func ValidateBackendTieredPolicyName(policy, tier string) error {
path: libcalico-go/lib/names/policy.go
user: login: caseydavenport
body: I think this function should be renamed now that it is no longer backend-only.

```suggestion
func ValidateTieredPolicyName(policy, tier string) error {
```
created_at: 2025-04-02T19:54:29Z
author_association: MEMBER
diff_hunk: @@ -83,10 +83,10 @@ func TierFromPolicyName(name string) (string, error) {
 // (Staged)NetworkPolicy or a (Staged)GlobalNetworkPolicy resource.
 func BackendTieredPolicyName(policy, tier string) (string, error) {
 	tieredPolicy := TieredPolicyName(policy)
-	return tieredPolicy, validateBackendTieredPolicyName(tieredPolicy, tier)
+	return tieredPolicy, ValidateBackendTieredPolicyName(tieredPolicy, tier)
 }
 
-func validateBackendTieredPolicyName(policy, tier string) error {
+func ValidateBackendTieredPolicyName(policy, tier string) error {
path: libcalico-go/lib/names/policy.go
user: login: caseydavenport
body: Alternatively, we probably actually want two implementations... backend policy name validations are different than frontend / v3 client ones. 
created_at: 2025-04-02T19:56:42Z
author_association: MEMBER
diff_hunk: @@ -102,6 +102,24 @@ func validateBackendTieredPolicyName(policy, tier string) error {
 	return nil
 }
 
+// ValidateTieredPolicyName validates v3 policy name, policies in the default tier can be named without the default. prefix.
+// Policy names in non default tier have to be in a format of tier.name
+func ValidateTieredPolicyName(policy, tier string) error {
+	if policyNameIsFormatted(policy) {
+		return nil
+	}
+
+	tier = TierOrDefault(tier)
+	parts := strings.SplitN(policy, ".", 2)
+	if len(parts) == 1 && tier == "default" {
+		return nil
+	}
+	if len(parts) != 2 || !strings.HasPrefix(policy, tier+".") {
path: libcalico-go/lib/names/policy.go
user: login: caseydavenport
body: I am not sure this is correct?

It's possible to create a policy named something like this I think:

```
name: tier.some.name.with.dots
```

That would fail this `len(parts)` check wouldn't it? 
created_at: 2025-04-02T22:35:46Z
author_association: MEMBER

PR comments:
user: login: nelljerram
created_at: 2025-04-04T09:42:23Z
body: @MichalFupso @caseydavenport Can I ask why we have made this change?  There is nothing in the PR description that motivates it.  As a rule, I believe it's essential that PR descriptions always explain **why** they are making a change, as well as what the change is.

I am concerned in particular if this might accidentally break OpenStack operation.
author_association: MEMBER
user: login: caseydavenport
created_at: 2025-04-04T15:09:02Z
body: @nelljerram For tiered policy, we need this validation to ensure that policy names are unique across tiers. 

Historically, users could create NetworkPolicy only in the default tier - these policies could have any user-provided name, but were canonicalized to `default.v3name` in the backend. 

In v3.29, we introduced tiers, which broke a number of users for various reasons - mostly due to new name defaulting that added that `default.` on the v3 API as well. We needed to fix this, so after much discussion we came up with this:

- Policies in the default tier can have either `default.name` or simply `name` format, in order to support backwards compatibility.
- Policies _not_ in the default tier must have a `tier.name` format to ensure that two v3 policies in different tiers don't end up with the same name.

This was a bugfix for the original PR that implemented this. I'm afraid I'm not sure what the OpenStack interaction you're alluding to is - do we support tiered policies there?  
author_association: MEMBER
user: login: caseydavenport
created_at: 2025-04-04T15:12:45Z
body: For reference, this is the PR that aimed to fix the bugs introduced in v3.29 that should have had this logic in it: https://github.com/projectcalico/calico/pull/9615

This is the main user issue that 9615 (and this PR) was meant to fix: https://github.com/projectcalico/calico/issues/9437

If there's a bad openstack interaction here, sounds like maybe we're missing some unit tests to avoid regressing that? 
author_association: MEMBER
user: login: nelljerram
created_at: 2025-04-04T20:10:21Z
body: My specific OpenStack concern was that our Neutron driver (which maps from OpenStack API to Calico API) writes NetworkPolicy into the datastore with names like `ossg.default.<security group ID>`, and it's important that:
1. these are allowed, and flow through to Felix
2. these are interpreted as being in the default tier.

There was a recent fix for this workflow, following the addition of tiers to OSS: https://github.com/projectcalico/calico/pull/9777

In other words, it's all about inferring a tier from a policy name, which this PR appears to be concerned with also.  Conceptually, IIUC, this PR is imposing a constraint which would be wrong for these OpenStack-derived policies.  That said, I don't think this PR is actually breaking OpenStack because it only adds validation on the v3 Go API, which our OpenStack integration doesn't use; instead, the OpenStack integration is Python code and writes directly to etcd3.

In terms of testing - we do have an excellent (in theory) Calico/OpenStack ST suite, but it only runs post-merge, and it can easily be missed when it starts failing.  So yes, it would be good to add UTs to cover the OpenStack workflow, but I'm not sure where they would go - WDYT?

In terms of the implementation of this change, I wonder if more rationalization is possible, to reduce the chance of us allowing for special cases in some code paths but missing them in others.  We seem to have a lot of similarly named functions in policy.go that do similar but subtly different things, like `validateBackendTieredPolicyName` and `ValidateTieredPolicyName`.

Finally, you've now addressed my meta-point about recording the motivation for this PR, but I think it would still be worthwhile to add those links into the PR description, as well as them being in comments.
author_association: MEMBER
user: login: caseydavenport
created_at: 2025-04-04T23:33:43Z
body: @nelljerram I'd be happy to hear alternatives to how we could implement this requirement if you can think of any - I am drawing a blank on any other mechanism we could use to enforce policy name uniqueness given the need to be backwards compatible with the underlying `crd.projectcalico.org` "storage" representation used in previous releases.

regarding #9777 - sounds like this isn't a problem because those polices are actually in the default tier, and so this is actually totally aligned with the intentions of this PR - things in the "default" tier can have whatever name they want, and things in non-default tiers must adhere to additional validation. So I think we're compatible there? In other words - we're parsing policy names based on whether they are in a particular tier or not, and not deciding that a policy is in a tier based on its name! 

> In terms of testing - we do have an excellent (in theory) Calico/OpenStack ST suite, but it only runs post-merge, and it can easily be missed when it starts failing. So yes, it would be good to add UTs to cover the OpenStack workflow, but I'm not sure where they would go - WDYT?

Yeah, tricky. We could find a way to trigger this pre-commit if the right files change? But likely costly in terms of CI time and maybe hard to get it right. It sounds like we need some sort of unit test suite that interacts with policies in etcd via both our Golang code as well as the Python code and makes sure they are in dialog with each other? (without running a full cluster end-to-end)

> like validateBackendTieredPolicyName and ValidateTieredPolicyName.

Yeah, I think we could do better to refactor all of this and put v3 validation in the v3 package, and backend validation in the backend package. In truth, so much of "libcalico-go" needs a fresh look in terms of package structure, etc. to make it even close to approachable for anyone not intimately familiar with it. Hopefully someday soon :broken_heart: 


author_association: MEMBER