diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30c2414..069d5ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,10 @@ permissions: env: REGISTRY: ghcr.io - IMAGE_NAME: stringke/cloudflare-operator + # Repository variable (Settings > Secrets and variables > Actions > Variables). + # Must be the lowercase GHCR image path, e.g. stringke/cloudflare-operator. + # Falls back to the upstream image name when the variable is not set. + IMAGE_NAME: ${{ vars.IMAGE_NAME || 'stringke/cloudflare-operator' }} jobs: # Build Docker images using matrix for native builds (faster than QEMU) diff --git a/Makefile b/Makefile index 8afbc0b..cb0f69f 100644 --- a/Makefile +++ b/Makefile @@ -24,12 +24,10 @@ BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) endif BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) -# IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images. -# This variable is used to construct full image tags for bundle and catalog images. -# -# For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both -# cfargotunnel.com/cloudflare-operator-bundle:$VERSION and cfargotunnel.com/cloudflare-operator-catalog:$VERSION. -IMAGE_TAG_BASE ?= ghcr.io/stringke/cloudflare-operator +# IMAGE_TAG_BASE defines the container registry namespace and image name for +# remote images. Use IMAGE_NAME when set (e.g. from CI), otherwise fall back to +# the upstream project. Override with IMAGE_TAG_BASE to customize. +IMAGE_TAG_BASE ?= ghcr.io/$(if $(IMAGE_NAME),$(IMAGE_NAME),stringke/cloudflare-operator) # BUNDLE_IMG defines the image:tag used for the bundle. # You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=/:) diff --git a/internal/controller/ingress/converters.go b/internal/controller/ingress/converters.go index 4468a16..07ddd8b 100644 --- a/internal/controller/ingress/converters.go +++ b/internal/controller/ingress/converters.go @@ -51,12 +51,27 @@ func (r *Reconciler) buildIngressRules( rules = append(rules, bindingRules...) } - // 3. Sort rules for deterministic config (by hostname, then path) - sort.Slice(rules, func(i, j int) bool { - if rules[i].Hostname != rules[j].Hostname { - return rules[i].Hostname < rules[j].Hostname + // 3. Sort rules for deterministic config. Cloudflare evaluates rules in + // order (first match wins), so path-specific rules must come before + // hostname-only catch-all rules, and longer paths before shorter paths. + sort.SliceStable(rules, func(i, j int) bool { + a, b := rules[i], rules[j] + + // Rules with a path are more specific than hostname catch-all rules. + if (a.Path == "") != (b.Path == "") { + return a.Path != "" } - return rules[i].Path < rules[j].Path + + // Longer paths are more specific and must be evaluated first. + if len(a.Path) != len(b.Path) { + return len(a.Path) > len(b.Path) + } + + if a.Hostname != b.Hostname { + return a.Hostname < b.Hostname + } + + return a.Path < b.Path }) // 4. Add fallback rule diff --git a/internal/controller/tunnelconfig/types.go b/internal/controller/tunnelconfig/types.go index 3c72c12..fcd258d 100644 --- a/internal/controller/tunnelconfig/types.go +++ b/internal/controller/tunnelconfig/types.go @@ -249,7 +249,12 @@ func (c *TunnelConfig) ComputeHash() string { } // AggregateRules aggregates all rules from all sources into a single list. -// Rules are sorted by priority and then by hostname. +// +// Cloudflare evaluates ingress rules in order (first match wins), so rules are +// ordered by source priority first, then by path specificity: +// - path-specific rules before hostname-only catch-all rules +// - longer paths before shorter paths (more specific first) +// - hostname/path as deterministic tie-breakers func (c *TunnelConfig) AggregateRules() []IngressRule { var allRules []IngressRule @@ -257,12 +262,28 @@ func (c *TunnelConfig) AggregateRules() []IngressRule { allRules = append(allRules, source.Rules...) } - // Sort by priority (lower first), then by hostname - sort.Slice(allRules, func(i, j int) bool { - if allRules[i].Priority != allRules[j].Priority { - return allRules[i].Priority < allRules[j].Priority + sort.SliceStable(allRules, func(i, j int) bool { + a, b := allRules[i], allRules[j] + + if a.Priority != b.Priority { + return a.Priority < b.Priority + } + + // Rules with a path are more specific than hostname catch-all rules. + if (a.Path == "") != (b.Path == "") { + return a.Path != "" + } + + // Longer paths are more specific and must be evaluated first. + if len(a.Path) != len(b.Path) { + return len(a.Path) > len(b.Path) + } + + if a.Hostname != b.Hostname { + return a.Hostname < b.Hostname } - return allRules[i].Hostname < allRules[j].Hostname + + return a.Path < b.Path }) return allRules diff --git a/internal/controller/tunnelconfig/types_test.go b/internal/controller/tunnelconfig/types_test.go new file mode 100644 index 0000000..3bc74a1 --- /dev/null +++ b/internal/controller/tunnelconfig/types_test.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025-2026 The Cloudflare Operator Authors + +package tunnelconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAggregateRulesOrdersByPathSpecificity(t *testing.T) { + config := &TunnelConfig{ + Sources: map[string]*SourceConfig{ + "Ingress/test/example": { + Kind: SourceKindIngress, + Namespace: "test", + Name: "example", + Rules: []IngressRule{ + {Hostname: "example.com", Path: "", Service: "http://root"}, + {Hostname: "example.com", Path: "/api", Service: "http://api"}, + {Hostname: "example.com", Path: "/api/v2", Service: "http://api-v2"}, + {Hostname: "example.com", Path: "/static", Service: "http://static"}, + {Hostname: "other.example.com", Path: "", Service: "http://other"}, + }, + }, + }, + } + + got := config.AggregateRules() + want := []string{ + "example.com/api/v2", + "example.com/static", + "example.com/api", + "example.com", + "other.example.com", + } + + assert.Len(t, got, len(want)) + for i := range want { + assert.Equal(t, want[i], got[i].Hostname+got[i].Path) + } +}