diff --git a/go.mod b/go.mod index 37416f46..cc4fb465 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,8 @@ require ( sigs.k8s.io/controller-runtime v0.13.1 ) +require github.com/kr/pretty v0.3.1 // indirect + require ( github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect diff --git a/go.sum b/go.sum index ac079503..b0dc9a34 100644 --- a/go.sum +++ b/go.sum @@ -438,8 +438,9 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -585,8 +586,9 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rollbar/rollbar-go v1.4.2 h1:UzxjFgg9CFE0Vb3grGPpZHCnbKzNd8RYFtFHEKovauU= github.com/rollbar/rollbar-go v1.4.2/go.mod h1:kLQ9gP3WCRGrvJmF0ueO3wK9xWocej8GRX98D8sa39w= github.com/rollbar/rollbar-go/errors v0.0.0-20210929193720-32947096267e/go.mod h1:Ie0xEc1Cyj+T4XMO8s0Vf7pMfvSAAy1sb4AYc8aJsao= diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go index b265327a..1fcacca1 100644 --- a/internal/e2e/e2e_test.go +++ b/internal/e2e/e2e_test.go @@ -51,7 +51,7 @@ func TestHegel_EC2Frontend(t *testing.T) { Name: "StaticRoute", Endpoint: "/2009-04-04", Expect: `meta-data/ -user-data/`, +user-data`, }, { Name: "DynamicRoute", diff --git a/internal/frontend/ec2/frontend.go b/internal/frontend/ec2/frontend.go index d3640811..e5554a15 100644 --- a/internal/frontend/ec2/frontend.go +++ b/internal/frontend/ec2/frontend.go @@ -4,10 +4,10 @@ import ( "context" "errors" "net/http" - "sort" "strings" "github.com/gin-gonic/gin" + "github.com/tinkerbell/hegel/internal/frontend/ec2/internal/staticroute" "github.com/tinkerbell/hegel/internal/ginutil" "github.com/tinkerbell/hegel/internal/http/httperror" "github.com/tinkerbell/hegel/internal/http/request" @@ -40,10 +40,11 @@ func New(client Client) Frontend { // // TODO(chrisdoherty4) Document unimplemented endpoints. func (f Frontend) Configure(router gin.IRouter) { - // Setup the 2009-04-04 API path prefix. + // Setup the 2009-04-04 API path prefix and use a trailing slash route helper to patch + // equivalent trailing slash routes. v20090404 := ginutil.TrailingSlashRouteHelper{IRouter: router.Group("/2009-04-04")} - dynamicEndpointBinder := func(router gin.IRouter, endpoint string, filter filterFunc) { + dataEndpointBinder := func(router gin.IRouter, endpoint string, filter filterFunc) { router.GET(endpoint, func(ctx *gin.Context) { instance, err := f.getInstance(ctx, ctx.Request) if err != nil { @@ -63,10 +64,15 @@ func (f Frontend) Configure(router gin.IRouter) { }) } + // Create a static route builder that we can add all data routes to which are the basis for + // all static routes. + staticRoutes := staticroute.NewBuilder() + // Configure all dynamic routes. Dynamic routes are anything that requires retrieving a specific // instance and returning data from it. - for _, route := range dynamicRoutes { - dynamicEndpointBinder(v20090404, route.Endpoint, route.Filter) + for _, r := range dataRoutes { + dataEndpointBinder(v20090404, r.Endpoint, r.Filter) + staticRoutes.FromEndpoint(r.Endpoint) } staticEndpointBinder := func(router gin.IRouter, endpoint string, childEndpoints []string) { @@ -75,11 +81,8 @@ func (f Frontend) Configure(router gin.IRouter) { }) } - for _, route := range staticRoutes { - children := make([]string, len(route.ChildEndpoints)) - copy(children, route.ChildEndpoints) - sort.Strings(children) - staticEndpointBinder(v20090404, route.Endpoint, children) + for _, r := range staticRoutes.Build() { + staticEndpointBinder(v20090404, r.Endpoint, r.Children) } } diff --git a/internal/frontend/ec2/frontend_test.go b/internal/frontend/ec2/frontend_test.go index 78b62c3e..01fee026 100644 --- a/internal/frontend/ec2/frontend_test.go +++ b/internal/frontend/ec2/frontend_test.go @@ -238,7 +238,7 @@ func TestFrontendStaticEndpoints(t *testing.T) { Name: "Root", Endpoint: "/2009-04-04", Expect: `meta-data/ -user-data/`, +user-data`, }, { Name: "Metadata", @@ -302,11 +302,11 @@ func validate(t *testing.T, router *gin.Engine, endpoint string, expect string) router.ServeHTTP(w, r) if w.Code != http.StatusOK { - t.Fatalf("Expected: 200; Received: %d", w.Code) + t.Fatalf("\nEndpoint=%s\nExpected status: 200; Received status: %d; ", endpoint, w.Code) } if w.Body.String() != expect { - t.Fatalf("Expected: %s;\nReceived: %s", expect, w.Body.String()) + t.Fatalf("\nExpected: %s;\nReceived: %s;\n(Endpoint=%s)", expect, w.Body.String(), endpoint) } } diff --git a/internal/frontend/ec2/internal/staticroute/set.go b/internal/frontend/ec2/internal/staticroute/set.go new file mode 100644 index 00000000..483e7f56 --- /dev/null +++ b/internal/frontend/ec2/internal/staticroute/set.go @@ -0,0 +1,20 @@ +package staticroute + +// unorderedSet is a utility data structure that behaves as a traditional unorderedSet. Its elements are unordered. +type unorderedSet map[string]struct{} + +func newUnorderedSet() unorderedSet { + return make(unorderedSet) +} + +// Insert adds v to s. +func (s unorderedSet) Insert(v string) { + s[v] = struct{}{} +} + +// Range iterates over the elements in s and calls fn for each element. +func (s unorderedSet) Range(fn func(v string)) { + for k := range s { + fn(k) + } +} diff --git a/internal/frontend/ec2/internal/staticroute/sortable.go b/internal/frontend/ec2/internal/staticroute/sortable.go new file mode 100644 index 00000000..92160cd8 --- /dev/null +++ b/internal/frontend/ec2/internal/staticroute/sortable.go @@ -0,0 +1,9 @@ +package staticroute + +type sortableRoutes []Route + +func (r sortableRoutes) Len() int { return len(r) } +func (r sortableRoutes) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r sortableRoutes) Less(i, j int) bool { + return r[i].Endpoint < r[j].Endpoint +} diff --git a/internal/frontend/ec2/internal/staticroute/staticroute.go b/internal/frontend/ec2/internal/staticroute/staticroute.go new file mode 100644 index 00000000..b3e239c3 --- /dev/null +++ b/internal/frontend/ec2/internal/staticroute/staticroute.go @@ -0,0 +1,86 @@ +/* +Package staticroute provides tools for building EC2 Instance Metadata static routes from +the set of data endpoints. A data endpoint is an one that serves instance specific data. +*/ +package staticroute + +import ( + "sort" + "strings" +) + +// Builder constructs a set of Route objects. Endpoints added via FromEndpoint will be result +// in a static route for each level of endpoint nesting. The root route is always an empty string. +// Endpoints that are descendable will be appended with a slash. For example, adding the endpoint +// "/foo/bar/baz" will result in the following routes: +// +// "/foo/bar" -> baz +// "/foo" -> bar/ +// "" -> foo/ +type Builder map[string]unorderedSet + +// NewBuilder returns a new Builder instance. +func NewBuilder() Builder { + return make(map[string]unorderedSet) +} + +// FromEndpoint adds endpoint to b. endpoint should be of URL path form such as "/foo/bar". +// FromEndpoint can be called multiple times. +func (b Builder) FromEndpoint(endpoint string) { + // Ensure our endpoint begins with a `/` so we can add to the root route for endpoint. + if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + + // Split the endpoint into its components so we can build the pieces we need. + split := strings.Split(endpoint, "/") + + // Iterate over the components in reverse order so we can build parent paths for every + // level of path nesting and track the child part. + for i := len(split) - 1; i > 0; i-- { + concat := strings.Join(split[:i], "/") + if _, ok := b[concat]; !ok { + b[concat] = newUnorderedSet() + } + b[concat].Insert(split[i]) + } +} + +// Build returns a slice of Route objects containing an Endpoint and its associated child +// elements for the response body. The root route is identified by an empty string for the +// Endpoint field of Route. +func (b Builder) Build() []Route { + var routes sortableRoutes + + for parent, children := range b { + r := Route{Endpoint: parent} + + // Add children to the route prepending a slash for any child that is also a parent. + children.Range(func(child string) { + asParent := strings.Join([]string{parent, child}, "/") + + // If the child is also a parent, append a slash so the consumer knows it is a + // descendable directory. + if _, ok := b[asParent]; ok { + child += "/" + } + + r.Children = append(r.Children, child) + }) + + sort.Strings(r.Children) + + routes = append(routes, r) + } + + // Sort for determinism, no other reason. + sort.Sort(routes) + + return routes +} + +// Route is an endpoint and its associated child elements. +type Route struct { + Endpoint string + Children []string +} diff --git a/internal/frontend/ec2/internal/staticroute/staticroute_test.go b/internal/frontend/ec2/internal/staticroute/staticroute_test.go new file mode 100644 index 00000000..9c14cd9b --- /dev/null +++ b/internal/frontend/ec2/internal/staticroute/staticroute_test.go @@ -0,0 +1,124 @@ +package staticroute_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + . "github.com/tinkerbell/hegel/internal/frontend/ec2/internal/staticroute" +) + +func TestBuilder(t *testing.T) { + cases := []struct { + Name string + Endpoints []string + Routes []Route + }{ + { + Name: "NoEndpoints", + Endpoints: []string{}, + Routes: nil, + }, + { + Name: "MissingLeadingSlash", + Endpoints: []string{"foo/bar"}, + Routes: []Route{ + { + Endpoint: "", + Children: []string{"foo/"}, + }, + { + Endpoint: "/foo", + Children: []string{"bar"}, + }, + }, + }, + { + Name: "SingleEndpoint", + Endpoints: []string{"/foo/bar"}, + Routes: []Route{ + { + Endpoint: "", + Children: []string{"foo/"}, + }, + { + Endpoint: "/foo", + Children: []string{"bar"}, + }, + }, + }, + { + Name: "NestedEndpoints", + Endpoints: []string{"/foo/bar", "/foo/bar/baz"}, + Routes: []Route{ + { + Endpoint: "", + Children: []string{"foo/"}, + }, + { + Endpoint: "/foo", + Children: []string{"bar/"}, + }, + { + Endpoint: "/foo/bar", + Children: []string{"baz"}, + }, + }, + }, + + { + Name: "DeepNestedEndpoints", + Endpoints: []string{"/foo/bar/baz/qux"}, + Routes: []Route{ + { + Endpoint: "", + Children: []string{"foo/"}, + }, + { + Endpoint: "/foo", + Children: []string{"bar/"}, + }, + { + Endpoint: "/foo/bar", + Children: []string{"baz/"}, + }, + { + Endpoint: "/foo/bar/baz", + Children: []string{"qux"}, + }, + }, + }, + { + Name: "MultipleDifferentiatedEndpoints", + Endpoints: []string{"/foo/bar", "/baz/qux"}, + Routes: []Route{ + { + Endpoint: "", + Children: []string{"baz/", "foo/"}, + }, + { + Endpoint: "/baz", + Children: []string{"qux"}, + }, + { + Endpoint: "/foo", + Children: []string{"bar"}, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + builder := NewBuilder() + for _, ep := range tc.Endpoints { + builder.FromEndpoint(ep) + } + + routes := builder.Build() + + if !cmp.Equal(tc.Routes, routes) { + t.Fatalf("Unexpected routes: %s", cmp.Diff(tc.Routes, routes)) + } + }) + } +} diff --git a/internal/frontend/ec2/routes.go b/internal/frontend/ec2/routes.go index 9f47c0ba..51deabdc 100644 --- a/internal/frontend/ec2/routes.go +++ b/internal/frontend/ec2/routes.go @@ -6,51 +6,7 @@ package ec2 type filterFunc func(i Instance) string -var staticRoutes = []struct { - Endpoint string - ChildEndpoints []string -}{ - { - Endpoint: "/", - ChildEndpoints: []string{ - "user-data/", - "meta-data/", - }, - }, - { - Endpoint: "/meta-data", - ChildEndpoints: []string{ - "instance-id", - "hostname", - "local-hostname", - "iqn", - "plan", - "facility", - "tags", - "public-ipv4", - "public-ipv6", - "local-ipv4", - "public-keys", - "operating-system/", - }, - }, - { - Endpoint: "/meta-data/operating-system", - ChildEndpoints: []string{ - "slug", - "distro", - "version", - "image_tag", - "license_activation/", - }, - }, - { - Endpoint: "/meta-data/operating-system/license_activation", - ChildEndpoints: []string{"state"}, - }, -} - -var dynamicRoutes = []struct { +var dataRoutes = []struct { Endpoint string Filter filterFunc }{