Skip to content

Commit 16312f5

Browse files
authored
feat: Add experimental "smart" sort plugin (#90)
* feat: Add experimental "smart" sort plugin * Add tests and ci for tests * fix main test error
1 parent cce8e52 commit 16312f5

File tree

5 files changed

+215
-6
lines changed

5 files changed

+215
-6
lines changed

.github/workflows/tests.yml

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Go
2+
3+
on:
4+
push:
5+
branches: [ master ]
6+
pull_request:
7+
8+
jobs:
9+
build-and-test:
10+
name: Build and Test
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v2
14+
15+
- name: Set up Go
16+
uses: actions/setup-go@v2
17+
with:
18+
go-version: 1.16
19+
20+
- name: Build
21+
run: go build -v ./...
22+
23+
- name: Test
24+
run: go test -v ./...

cmd/caddy/main.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func main() {
4646
}
4747

4848
// get client to access the kubernetes service api
49-
kubeClient, err := createApiserverClient(logger)
49+
kubeClient, _, err := createApiserverClient(logger)
5050
if err != nil {
5151
logger.Fatalf("Could not establish a connection to the Kubernetes API Server. %v", err)
5252
}
@@ -71,10 +71,10 @@ func main() {
7171

7272
// createApiserverClient creates a new Kubernetes REST client. We assume the
7373
// controller runs inside Kubernetes and use the in-cluster config.
74-
func createApiserverClient(logger *zap.SugaredLogger) (*kubernetes.Clientset, error) {
74+
func createApiserverClient(logger *zap.SugaredLogger) (*kubernetes.Clientset, *version.Info, error) {
7575
cfg, err := clientcmd.BuildConfigFromFlags("", "")
7676
if err != nil {
77-
return nil, err
77+
return nil, nil, err
7878
}
7979

8080
logger.Infof("Creating API client for %s", cfg.Host)
@@ -84,7 +84,7 @@ func createApiserverClient(logger *zap.SugaredLogger) (*kubernetes.Clientset, er
8484
cfg.ContentType = "application/vnd.kubernetes.protobuf"
8585
client, err := kubernetes.NewForConfig(cfg)
8686
if err != nil {
87-
return nil, err
87+
return nil, nil, err
8888
}
8989

9090
// The client may fail to connect to the API server on the first request
@@ -113,12 +113,12 @@ func createApiserverClient(logger *zap.SugaredLogger) (*kubernetes.Clientset, er
113113

114114
// err is returned in case of timeout in the exponential backoff (ErrWaitTimeout)
115115
if err != nil {
116-
return nil, lastErr
116+
return nil, nil, lastErr
117117
}
118118

119119
if retries > 0 {
120120
logger.Warnf("Initial connection to the Kubernetes API server was retried %d times.", retries)
121121
}
122122

123-
return client, nil
123+
return client, v, nil
124124
}

internal/caddy/global/ingress_sort.go

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package global
2+
3+
import (
4+
"encoding/json"
5+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
6+
"github.com/caddyserver/ingress/pkg/converter"
7+
"github.com/caddyserver/ingress/pkg/store"
8+
"sort"
9+
"strings"
10+
)
11+
12+
type IngressSortPlugin struct{}
13+
14+
func (p IngressSortPlugin) IngressPlugin() converter.PluginInfo {
15+
return converter.PluginInfo{
16+
Name: "ingress_sort",
17+
// Must go after ingress are configured
18+
Priority: -2,
19+
New: func() converter.Plugin { return new(IngressSortPlugin) },
20+
}
21+
}
22+
23+
func init() {
24+
converter.RegisterPlugin(IngressSortPlugin{})
25+
}
26+
27+
func getFirstItemFromJSON(data json.RawMessage) string {
28+
var arr []string
29+
err := json.Unmarshal(data, &arr)
30+
if err != nil {
31+
return ""
32+
}
33+
return arr[0]
34+
}
35+
36+
func sortRoutes(routes caddyhttp.RouteList) {
37+
sort.SliceStable(routes, func(i, j int) bool {
38+
iPath := getFirstItemFromJSON(routes[i].MatcherSetsRaw[0]["path"])
39+
jPath := getFirstItemFromJSON(routes[j].MatcherSetsRaw[0]["path"])
40+
iPrefixed := strings.HasSuffix(iPath, "*")
41+
jPrefixed := strings.HasSuffix(jPath, "*")
42+
43+
// If both same type check by length
44+
if iPrefixed == jPrefixed {
45+
return len(jPath) < len(iPath)
46+
}
47+
// Empty path will be moved last
48+
if jPath == "" || iPath == "" {
49+
return jPath == ""
50+
}
51+
// j path is exact so should go first
52+
return jPrefixed
53+
})
54+
}
55+
56+
// GlobalHandler in IngressSortPlugin tries to sort routes to have the less conflict.
57+
//
58+
// It only supports basic conflicts for now. It doesn't support multiple matchers in the same route
59+
// nor multiple path/host in the matcher. It shouldn't be an issue with the ingress.matcher plugin.
60+
// Sort will prioritize exact paths then prefix paths and finally empty paths.
61+
// When 2 exacts paths or 2 prefixed paths are on the same host, we choose the longer first.
62+
func (p IngressSortPlugin) GlobalHandler(config *converter.Config, store *store.Store) error {
63+
if !store.ConfigMap.ExperimentalSmartSort {
64+
return nil
65+
}
66+
67+
routes := config.GetHTTPServer().Routes
68+
69+
sortRoutes(routes)
70+
return nil
71+
}
72+
73+
// Interface guards
74+
var (
75+
_ = converter.GlobalMiddleware(IngressSortPlugin{})
76+
)
+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package global
2+
3+
import (
4+
"encoding/json"
5+
"github.com/caddyserver/caddy/v2"
6+
"github.com/caddyserver/caddy/v2/caddyconfig"
7+
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
8+
"reflect"
9+
"testing"
10+
)
11+
12+
func TestIngressSort(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
routes []struct {
16+
id int
17+
path string
18+
}
19+
expect []int
20+
}{
21+
22+
{
23+
name: "multiple exact paths",
24+
routes: []struct {
25+
id int
26+
path string
27+
}{
28+
{id: 0, path: "/path/a"},
29+
{id: 1, path: "/path/"},
30+
{id: 2, path: "/other"},
31+
},
32+
expect: []int{0, 1, 2},
33+
},
34+
{
35+
name: "multiple prefix paths",
36+
routes: []struct {
37+
id int
38+
path string
39+
}{
40+
{id: 0, path: "/path/*"},
41+
{id: 1, path: "/path/auth/*"},
42+
{id: 2, path: "/other/*"},
43+
{id: 3, path: "/login/*"},
44+
},
45+
expect: []int{1, 2, 3, 0},
46+
},
47+
{
48+
name: "mixed exact and prefixed",
49+
routes: []struct {
50+
id int
51+
path string
52+
}{
53+
{id: 0, path: "/path/*"},
54+
{id: 1, path: "/path/auth/"},
55+
{id: 2, path: "/path/v2/*"},
56+
{id: 3, path: "/path/new"},
57+
},
58+
expect: []int{1, 3, 2, 0},
59+
},
60+
{
61+
name: "mixed exact, prefix and empty",
62+
routes: []struct {
63+
id int
64+
path string
65+
}{
66+
{id: 0, path: "/path/*"},
67+
{id: 1, path: ""},
68+
{id: 2, path: "/path/v2/*"},
69+
{id: 3, path: "/path/new"},
70+
{id: 4, path: ""},
71+
},
72+
expect: []int{3, 2, 0, 1, 4},
73+
},
74+
}
75+
for _, test := range tests {
76+
t.Run(test.name, func(t *testing.T) {
77+
routes := []caddyhttp.Route{}
78+
79+
for _, route := range test.routes {
80+
match := caddy.ModuleMap{}
81+
match["id"] = caddyconfig.JSON(route.id, nil)
82+
83+
if route.path != "" {
84+
match["path"] = caddyconfig.JSON(caddyhttp.MatchPath{route.path}, nil)
85+
}
86+
87+
r := caddyhttp.Route{MatcherSetsRaw: []caddy.ModuleMap{match}}
88+
routes = append(routes, r)
89+
}
90+
91+
sortRoutes(routes)
92+
93+
var got []int
94+
for i := range test.expect {
95+
var currentId int
96+
err := json.Unmarshal(routes[i].MatcherSetsRaw[0]["id"], &currentId)
97+
if err != nil {
98+
t.Fatalf("error unmarshaling id for i %v, %v", i, err)
99+
}
100+
got = append(got, currentId)
101+
}
102+
103+
if !reflect.DeepEqual(test.expect, got) {
104+
t.Errorf("expected order to match: got %v, expected %v, %s", got, test.expect, routes[1].MatcherSetsRaw)
105+
}
106+
})
107+
}
108+
}

pkg/store/configmap_parser.go

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type ConfigMapOptions struct {
1414
Debug bool `json:"debug,omitempty"`
1515
AcmeCA string `json:"acmeCA,omitempty"`
1616
Email string `json:"email,omitempty"`
17+
ExperimentalSmartSort bool `json:"experimentalSmartSort,omitempty"`
1718
ProxyProtocol bool `json:"proxyProtocol,omitempty"`
1819
Metrics bool `json:"metrics,omitempty"`
1920
OnDemandTLS bool `json:"onDemandTLS,omitempty"`

0 commit comments

Comments
 (0)