Skip to content

Commit 2a7d1d6

Browse files
committed
internal/mod/modresolve: new package
This provides the heart of the CUE_REGISTRY routing logic and will later be plumbed into an OCI client implementation. Signed-off-by: Roger Peppe <[email protected]> Change-Id: Ia7e9a30efff4cd5e5878d27dc482a06fcc6521ed Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1170811 Unity-Result: CUE porcuepine <[email protected]> Reviewed-by: Daniel Martí <[email protected]> TryBot-Result: CUEcueckoo <[email protected]>
1 parent c6da768 commit 2a7d1d6

File tree

2 files changed

+396
-0
lines changed

2 files changed

+396
-0
lines changed

internal/mod/modresolve/resolve.go

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package modresolve
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"strings"
7+
8+
"cuelabs.dev/go/oci/ociregistry/ociref"
9+
10+
"cuelang.org/go/internal/mod/module"
11+
)
12+
13+
// Resolve resolves a module path (a.k.a. OCI repository name) to the
14+
// location for that path. Invalid paths will map to the default location.
15+
type Resolver interface {
16+
Resolve(path string) Location
17+
}
18+
19+
// Location represents the location for a given path.
20+
type Location struct {
21+
// Host holds the host or host:port of the registry.
22+
Host string
23+
// Prefix holds a prefix to be added to the path.
24+
Prefix string
25+
// Insecure holds whether an insecure connection
26+
// should be used when connecting to the registry.
27+
Insecure bool
28+
}
29+
30+
// ParseCUERegistry parses a registry routing specification that
31+
// maps module prefixes to the registry that should be used to
32+
// fetch that module.
33+
//
34+
// The specification consists of an order-independent, comma-separated list.
35+
//
36+
// Each element either maps a module prefix to the registry that will be used
37+
// for all modules that have that prefix (prefix=registry), or a catch-all registry to be used
38+
// for modules that do not match any prefix (registry).
39+
//
40+
// For example:
41+
//
42+
// myorg.com=myregistry.com/m,catchallregistry.example.org
43+
//
44+
// Any module with a matching prefix will be routed to the given registry.
45+
// A prefix only matches whole path elements.
46+
// In the above example, module myorg.com/foo/bar@v0 will be looked up
47+
// in myregistry.com in the repository m/myorg.com/foo/bar,
48+
// whereas github.com/x/y will be looked up in catchallregistry.example.com.
49+
//
50+
// The registry part is syntactically similar to a [docker reference]
51+
// except that the repository is optional and no tag or digest is allowed.
52+
// Additionally, a +secure or +insecure suffix may be used to indicate
53+
// whether to use a secure or insecure connection. Without that,
54+
// localhost, 127.0.0.1 and [::1] will default to insecure, and anything
55+
// else to secure.
56+
//
57+
// If s does not declare a catch-all registry location, catchAllDefault is
58+
// used. It is an error if s fails to declares a catch-all registry location
59+
// and no catchAllDefault is provided.
60+
//
61+
// [docker reference]: https://pkg.go.dev/github.com/distribution/reference
62+
func ParseCUERegistry(s string, catchAllDefault string) (Resolver, error) {
63+
if s == "" && catchAllDefault == "" {
64+
return nil, fmt.Errorf("no catch-all registry or default")
65+
}
66+
locs := make(map[string]Location)
67+
if s == "" {
68+
s = catchAllDefault
69+
}
70+
parts := strings.Split(s, ",")
71+
for _, part := range parts {
72+
key, val, ok := strings.Cut(part, "=")
73+
if !ok {
74+
if part == "" {
75+
// TODO or just ignore it?
76+
return nil, fmt.Errorf("empty registry part")
77+
}
78+
if _, ok := locs[""]; ok {
79+
return nil, fmt.Errorf("duplicate catch-all registry")
80+
}
81+
key, val = "", part
82+
} else {
83+
if key == "" {
84+
return nil, fmt.Errorf("empty module prefix")
85+
}
86+
if val == "" {
87+
return nil, fmt.Errorf("empty registry reference")
88+
}
89+
if err := module.CheckPathWithoutVersion(key); err != nil {
90+
return nil, fmt.Errorf("invalid module path %q: %v", key, err)
91+
}
92+
if _, ok := locs[key]; ok {
93+
return nil, fmt.Errorf("duplicate module prefix %q", key)
94+
}
95+
}
96+
loc, err := parseRegistry(val)
97+
if err != nil {
98+
return nil, fmt.Errorf("invalid registry %q: %v", val, err)
99+
}
100+
locs[key] = loc
101+
}
102+
if _, ok := locs[""]; !ok {
103+
if catchAllDefault == "" {
104+
return nil, fmt.Errorf("no default catch-all registry provided")
105+
}
106+
loc, err := parseRegistry(catchAllDefault)
107+
if err != nil {
108+
return nil, fmt.Errorf("invalid catch-all registry %q: %v", catchAllDefault, err)
109+
}
110+
locs[""] = loc
111+
}
112+
return &resolver{
113+
locs: locs,
114+
}, nil
115+
}
116+
117+
type resolver struct {
118+
locs map[string]Location
119+
}
120+
121+
func (r *resolver) Resolve(path string) Location {
122+
if path == "" {
123+
return r.locs[""]
124+
}
125+
bestMatch := ""
126+
// Note: there's always a wildcard match.
127+
bestMatchLoc := r.locs[""]
128+
for pat, loc := range r.locs {
129+
if pat == path {
130+
return loc
131+
}
132+
if !strings.HasPrefix(path, pat) {
133+
continue
134+
}
135+
if len(bestMatch) > len(pat) {
136+
// We've already found a more specific match.
137+
continue
138+
}
139+
if path[len(pat)] != '/' {
140+
// The path doesn't have a separator at the end of
141+
// the prefix, which means that it doesn't match.
142+
// For example, foo.com/bar does not match foo.com/ba.
143+
continue
144+
}
145+
// It's a possible match but not necessarily the longest one.
146+
bestMatch, bestMatchLoc = pat, loc
147+
}
148+
return bestMatchLoc
149+
}
150+
151+
func parseRegistry(env string) (Location, error) {
152+
var suffix string
153+
if i := strings.LastIndex(env, "+"); i > 0 {
154+
suffix = env[i:]
155+
env = env[:i]
156+
}
157+
var r ociref.Reference
158+
if !strings.Contains(env, "/") {
159+
// OCI references don't allow a host name on its own without a repo,
160+
// but we do.
161+
r.Host = env
162+
if !ociref.IsValidHost(r.Host) {
163+
return Location{}, fmt.Errorf("invalid host name %q in registry", r.Host)
164+
}
165+
} else {
166+
var err error
167+
r, err = ociref.Parse(env)
168+
if err != nil {
169+
return Location{}, err
170+
}
171+
if r.Tag != "" || r.Digest != "" {
172+
return Location{}, fmt.Errorf("cannot have an associated tag or digest")
173+
}
174+
}
175+
if suffix == "" {
176+
if isInsecureHost(r.Host) {
177+
suffix = "+insecure"
178+
} else {
179+
suffix = "+secure"
180+
}
181+
}
182+
insecure := false
183+
switch suffix {
184+
case "+insecure":
185+
insecure = true
186+
case "+secure":
187+
default:
188+
return Location{}, fmt.Errorf("unknown suffix (%q), need +insecure, +secure or no suffix)", suffix)
189+
}
190+
return Location{
191+
Host: r.Host,
192+
Prefix: r.Repository,
193+
Insecure: insecure,
194+
}, nil
195+
}
196+
197+
func isInsecureHost(hostPort string) bool {
198+
host, _, err := net.SplitHostPort(hostPort)
199+
if err != nil {
200+
host = hostPort
201+
}
202+
switch host {
203+
case "localhost",
204+
"127.0.0.1",
205+
"::1", "[::1]":
206+
return true
207+
}
208+
// TODO other clients have logic for RFC1918 too, amongst other
209+
// things. Maybe we should do that too.
210+
return false
211+
}
+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package modresolve
2+
3+
import (
4+
"testing"
5+
6+
"github.com/go-quicktest/qt"
7+
)
8+
9+
func TestResolver(t *testing.T) {
10+
testCases := []struct {
11+
testName string
12+
in string
13+
catchAllDefault string
14+
err string
15+
lookups map[string]Location
16+
}{{
17+
testName: "MultipleFallbacks",
18+
in: "registry.somewhere,registry.other",
19+
err: "duplicate catch-all registry",
20+
}, {
21+
testName: "NoRegistryOrDefault",
22+
catchAllDefault: "",
23+
err: "no catch-all registry or default",
24+
}, {
25+
testName: "InvalidRegistry",
26+
in: "$#foo",
27+
err: `invalid registry "\$#foo": invalid host name "\$#foo" in registry`,
28+
}, {
29+
testName: "InvalidSecuritySuffix",
30+
in: "foo.com+bogus",
31+
err: `invalid registry "foo.com\+bogus": unknown suffix \("\+bogus"\), need \+insecure, \+secure or no suffix\)`,
32+
}, {
33+
testName: "IPV6AddrWithoutBrackets",
34+
in: "::1",
35+
err: `invalid registry "::1": invalid host name "::1" in registry`,
36+
}, {
37+
testName: "EmptyElement",
38+
in: "foo.com,",
39+
err: `empty registry part`,
40+
}, {
41+
testName: "MissingPrefix",
42+
in: "=foo.com",
43+
err: `empty module prefix`,
44+
}, {
45+
testName: "MissingRegistry",
46+
in: "x.com=",
47+
err: `empty registry reference`,
48+
}, {
49+
testName: "InvalidModulePrefix",
50+
in: "foo#=foo.com",
51+
err: `invalid module path "foo#": invalid char '#'`,
52+
}, {
53+
testName: "DuplicateModulePrefix",
54+
in: "x.com=r.org,x.com=q.org",
55+
err: `duplicate module prefix "x.com"`,
56+
}, {
57+
testName: "NoDefaultCatchAll",
58+
in: "x.com=r.org",
59+
err: `no default catch-all registry provided`,
60+
}, {
61+
testName: "InvalidCatchAll",
62+
in: "x.com=r.org",
63+
catchAllDefault: "bogus",
64+
err: `invalid catch-all registry "bogus": invalid host name "bogus" in registry`,
65+
}, {
66+
testName: "InvalidRegistryRef",
67+
in: "foo.com//bar",
68+
err: `invalid registry "foo.com//bar": invalid reference syntax \("foo.com//bar"\)`,
69+
}, {
70+
testName: "RegistryRefWithDigest",
71+
in: "foo.com/bar@sha256:f3c16f525a1b7c204fc953d6d7db7168d84ebf4902f83c3a37d113b18c28981f",
72+
err: `invalid registry "foo.com/bar@sha256:f3c16f525a1b7c204fc953d6d7db7168d84ebf4902f83c3a37d113b18c28981f": cannot have an associated tag or digest`,
73+
}, {
74+
testName: "RegistryRefWithTag",
75+
in: "foo.com/bar:sometag",
76+
err: `invalid registry "foo.com/bar:sometag": cannot have an associated tag or digest`,
77+
}, {
78+
testName: "SingleCatchAll",
79+
catchAllDefault: "registry.somewhere",
80+
lookups: map[string]Location{
81+
"fruit.com/apple": {
82+
Host: "registry.somewhere",
83+
},
84+
},
85+
}, {
86+
testName: "CatchAllWithNoDefault",
87+
in: "registry.somewhere",
88+
lookups: map[string]Location{
89+
"fruit.com/apple": {
90+
Host: "registry.somewhere",
91+
},
92+
},
93+
}, {
94+
testName: "CatchAllWithDefault",
95+
in: "registry.somewhere",
96+
catchAllDefault: "other.cue.somewhere",
97+
lookups: map[string]Location{
98+
"fruit.com/apple": {
99+
Host: "registry.somewhere",
100+
},
101+
"": {
102+
Host: "registry.somewhere",
103+
},
104+
},
105+
}, {
106+
testName: "PrefixWithCatchAllNoDefault",
107+
in: "example.com=registry.example.com/offset,registry.somewhere",
108+
lookups: map[string]Location{
109+
"fruit.com/apple": {
110+
Host: "registry.somewhere",
111+
},
112+
"example.com/blah": {
113+
Host: "registry.example.com",
114+
Prefix: "offset",
115+
},
116+
"example.com": {
117+
Host: "registry.example.com",
118+
Prefix: "offset",
119+
},
120+
},
121+
}, {
122+
testName: "PrefixWithCatchAllDefault",
123+
in: "example.com=registry.example.com/offset",
124+
catchAllDefault: "registry.somewhere",
125+
lookups: map[string]Location{
126+
"fruit.com/apple": {
127+
Host: "registry.somewhere",
128+
},
129+
"example.com/blah": {
130+
Host: "registry.example.com",
131+
Prefix: "offset",
132+
},
133+
},
134+
}, {
135+
testName: "LocalhostIsInsecure",
136+
in: "localhost:5000",
137+
lookups: map[string]Location{
138+
"fruit.com/apple": {
139+
Host: "localhost:5000",
140+
Insecure: true,
141+
},
142+
},
143+
}, {
144+
testName: "SecureLocalhost",
145+
in: "localhost:1234+secure",
146+
lookups: map[string]Location{
147+
"fruit.com/apple": {
148+
Host: "localhost:1234",
149+
},
150+
},
151+
}, {
152+
testName: "127.0.0.1IsInsecure",
153+
in: "127.0.0.1",
154+
lookups: map[string]Location{
155+
"fruit.com/apple": {
156+
Host: "127.0.0.1",
157+
Insecure: true,
158+
},
159+
},
160+
}, {
161+
testName: "[::1]IsInsecure",
162+
in: "[::1]",
163+
lookups: map[string]Location{
164+
"fruit.com/apple": {
165+
Host: "[::1]",
166+
Insecure: true,
167+
},
168+
},
169+
}}
170+
171+
for _, tc := range testCases {
172+
t.Run(tc.testName, func(t *testing.T) {
173+
r, err := ParseCUERegistry(tc.in, tc.catchAllDefault)
174+
if tc.err != "" {
175+
qt.Assert(t, qt.ErrorMatches(err, tc.err))
176+
return
177+
}
178+
qt.Assert(t, qt.IsNil(err))
179+
for prefix, want := range tc.lookups {
180+
got := r.Resolve(prefix)
181+
qt.Assert(t, qt.Equals(got, want), qt.Commentf("prefix %q", prefix))
182+
}
183+
})
184+
}
185+
}

0 commit comments

Comments
 (0)