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
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 4 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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=<some-registry>/<project-name-bundle>:<tag>)
Expand Down
25 changes: 20 additions & 5 deletions internal/controller/ingress/converters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 27 additions & 6 deletions internal/controller/tunnelconfig/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,20 +249,41 @@ 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

for _, source := range c.Sources {
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
Expand Down
43 changes: 43 additions & 0 deletions internal/controller/tunnelconfig/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}