Skip to content

Commit

Permalink
Add authenticated refresh endpoints for metadata and userdata
Browse files Browse the repository at this point in the history
These endpoints are intended for admin/operators to be able to force a
refresh of metadata or userdata for troubleshooting purposes. Regardless
of whether the data is in the local database, these endpoints invoke the
lookup client to pull and save the latest data from the upstream source.

Also includes misc cleanups to make the linter happy.

Signed-off-by: Scott Garman <[email protected]>
  • Loading branch information
ScottGarman committed Oct 2, 2024
1 parent 5fc59a3 commit 910dca2
Show file tree
Hide file tree
Showing 18 changed files with 128 additions and 72 deletions.
4 changes: 2 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ linters:

# additional linters
- bodyclose
- err113
- gocritic
- gocyclo
- goerr113
- gofmt
# - gofumpt
- goimports
- gomnd
- govet
- misspell
- mnd
- noctx
- revive
- stylecheck
Expand Down
4 changes: 2 additions & 2 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const (
var serveCmd = &cobra.Command{
Use: "serve",
Short: "starts the metadata server",
Run: func(cmd *cobra.Command, args []string) {
Run: func(cmd *cobra.Command, _ []string) {
serve(cmd.Context())
},
}
Expand Down Expand Up @@ -83,7 +83,7 @@ func init() {
viperBindFlag("oidc.claims.username", serveCmd.Flags().Lookup("oidc-username-claim"))

// Lookup Service Flags
serveCmd.Flags().Bool("lookup-enabled", false, "Use the lookup client to attempt to fetch metadata or userdata from an upstream source when it is not cached locall for the instance")
serveCmd.Flags().Bool("lookup-enabled", false, "Use the lookup client to attempt to fetch metadata or userdata from an upstream source when it is not cached locally for the instance")
viperBindFlag("lookup.enabled", serveCmd.Flags().Lookup("lookup-enabled"))

serveCmd.Flags().String("lookup-service-url", "", "URL to the metadata lookup service (like 'https://metadata-lookup-service.tld/api/v1/') to use when fetching metadata or userdata from an upstream source")
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
Expand Down
58 changes: 2 additions & 56 deletions go.sum

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions internal/dbtools/testtools.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func DatabaseTest(t *testing.T) *sqlx.DB {

t.Cleanup(func() {
cleanDB()

err := addFixtures()
require.NoError(t, err, "Unexpected error setting up fixture data")
})
Expand Down
7 changes: 4 additions & 3 deletions internal/lookup/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,25 @@ import (
)

func lookupMetadataServerMock(instance testInstance) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := instance.MetadataResponse()

_ = json.NewEncoder(w).Encode(resp)
}))
}

func lookupUserdataServerMock(instance testInstance) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := instance.UserdataResponse()

_ = json.NewEncoder(w).Encode(resp)
}))
}

func lookupServerWithStatusMock(status int, body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)

if len(body) > 0 {
fmt.Fprint(w, body)
}
Expand Down
1 change: 1 addition & 0 deletions internal/middleware/identify_instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func TestIdentifyInstanceByIP(t *testing.T) {
assert.Equal(t, nil, instanceIDValue)
assert.False(t, found)
}

c.JSON(http.StatusOK, "ok")
})

Expand Down
24 changes: 24 additions & 0 deletions pkg/api/v1/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ const (
// endpoint used for retrieving the stored metadata for an instance
InternalUserdataWithIDURI = "/device-userdata/:instance-id"

// InternalMetadataRefreshWithURI is the path to the internal (authenticated)
// endpoint used for forcing a refresh of the metadata for an instance
InternalMetadataRefreshWithURI = "/device-metadata/refresh/:instance-id"

// InternalUserdataRefreshWithURI is the path to the internal (authenticated)
// endpoint used for forcing a refresh of the userdata for an instance
InternalUserdataRefreshWithURI = "/device-userdata/refresh/:instance-id"

scopePrefix = "metadata"
)

Expand Down Expand Up @@ -82,18 +90,33 @@ type Router struct {
func (r *Router) Routes(rg *gin.RouterGroup) {
setupValidator()

// Unauthenticated endpoints that users can use to fetch metadata and userdata.
// These lookups are done based on the originating IP of the request.
rg.GET(MetadataURI, middleware.IdentifyInstanceByIP(r.Logger, r.DB), r.instanceMetadataGet)
rg.GET(UserdataURI, middleware.IdentifyInstanceByIP(r.Logger, r.DB), r.instanceUserdataGet)

// Authenticated endpoints
authMw := r.AuthMW

// Used to write metadata or userdata for an instance
rg.POST(InternalMetadataURI, authMw.AuthRequired(), authMw.RequiredScopes(upsertScopes("metadata")), r.instanceMetadataSet)
rg.POST(InternalUserdataURI, authMw.AuthRequired(), authMw.RequiredScopes(upsertScopes("userdata")), r.instanceUserdataSet)

// Check whether metadata or userdata exists for an instance, without triggering
// a refresh if not found in the DB
rg.HEAD(InternalMetadataWithIDURI, authMw.AuthRequired(), authMw.RequiredScopes(readScopes("metadata")), r.instanceMetadataExistsInternal)
rg.HEAD(InternalUserdataWithIDURI, authMw.AuthRequired(), authMw.RequiredScopes(readScopes("userdata")), r.instanceUserdataExistsInternal)

// Force a refresh of metadata or userdata for an instance by looking it up via the lookup client
rg.POST(InternalMetadataRefreshWithURI, authMw.AuthRequired(), authMw.RequiredScopes(upsertScopes("metadata")), r.instanceMetadataRefreshInternal)
rg.POST(InternalUserdataRefreshWithURI, authMw.AuthRequired(), authMw.RequiredScopes(upsertScopes("userdata")), r.instanceUserdataRefreshInternal)

// Retrieve metadata or userdata for an instance by looking it up with the
// instance ID (instead of the originating IP)
rg.GET(InternalMetadataWithIDURI, authMw.AuthRequired(), authMw.RequiredScopes(readScopes("metadata")), r.instanceMetadataGetInternal)
rg.GET(InternalUserdataWithIDURI, authMw.AuthRequired(), authMw.RequiredScopes(readScopes("userdata")), r.instanceUserdataGetInternal)

// Delete metadata or userdata for an instance
rg.DELETE(InternalMetadataWithIDURI, authMw.AuthRequired(), authMw.RequiredScopes(deleteScopes("metadata")), r.instanceMetadataDelete)
rg.DELETE(InternalUserdataWithIDURI, authMw.AuthRequired(), authMw.RequiredScopes(deleteScopes("userdata")), r.instanceUserdataDelete)
}
Expand Down Expand Up @@ -270,6 +293,7 @@ func setupValidator() {
if name == "-" {
return ""
}

return name
})
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/v1/router_instance_ec2_metadata_lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func TestGetEc2MetadataLookupByIP(t *testing.T) {
for _, testcase := range testCases {
t.Run(testcase.testName, func(t *testing.T) {
lookupClient.setResponse(testcase.instanceIP, testcase.lookupResponse)

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, v1api.GetEc2MetadataPath(), nil)
Expand Down Expand Up @@ -126,6 +127,7 @@ func TestGetEc2MetadataItemLookupByIP(t *testing.T) {
for _, testcase := range testCases {
t.Run(testcase.testName, func(t *testing.T) {
lookupClient.setResponse(testcase.instanceIP, testcase.lookupResponse)

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, v1api.GetEc2MetadataItemPath("hostname"), nil)
Expand Down
1 change: 1 addition & 0 deletions pkg/api/v1/router_instance_ec2_userdata_lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func TestGetEc2UserdataLookupByIP(t *testing.T) {
for _, testcase := range testCases {
t.Run(testcase.testName, func(t *testing.T) {
lookupClient.setResponse(testcase.instanceIP, testcase.lookupResponse)

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, v1api.GetEc2UserdataPath(), nil)
Expand Down
83 changes: 83 additions & 0 deletions pkg/api/v1/router_instance_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/volatiletech/null/v8"
"github.com/volatiletech/sqlboiler/v4/types"

"go.hollow.sh/metadataservice/internal/lookup"
"go.hollow.sh/metadataservice/internal/middleware"
"go.hollow.sh/metadataservice/internal/models"
"go.hollow.sh/metadataservice/internal/upserter"
Expand Down Expand Up @@ -121,6 +122,88 @@ func (r *Router) instanceMetadataGetInternal(c *gin.Context) {
}
}

// instanceMetadataRefreshInternal retrieves the requested instance ID from the
// path and performs a lookup to refresh the metadata from an external system.
// If the metadata is successfully refreshed, it returns a 200. If not, it
// returns a 500. This can be used by an authenticated external system to
// trigger a refresh of metadata for a specific instance.
func (r *Router) instanceMetadataRefreshInternal(c *gin.Context) {
instanceID, err := getUUIDParam(c, "instance-id")

if err != nil {
invalidUUIDResponse(c, err)
return
}

// Perform a lookup from the upstream lookup service to retrieve the current metadata.
if !r.LookupEnabled || r.LookupClient == nil {
r.Logger.Sugar().Errorf("LookupClient is not configured or enabled, cannot refresh metadata for instance %s", instanceID)
c.Status(http.StatusInternalServerError)

return
}

// Save the metadata to the database if found, otherwise return a 404.
metadata, err := lookup.MetadataSyncByID(c.Request.Context(), r.DB, r.Logger, r.LookupClient, instanceID)
if err != nil && errors.Is(err, lookup.ErrNotFound) {
r.Logger.Sugar().Warnf("Metadata not found from upstream during refresh for instance %s", instanceID)
c.Status(http.StatusNotFound)

return
}

r.Logger.Sugar().Infof("Metadata successfully refreshed for instance %s", instanceID)

augmentedMetadata, err := addTemplateFields(metadata.Metadata, r.TemplateFields)
if err != nil {
r.Logger.Sugar().Warnf("Error adding additional templated fields to refreshed metadata for instance %s", metadata.ID, "error", err)

// Since we couldn't add the templated fields, just return the metadata as-is
c.JSON(http.StatusOK, metadata.Metadata)
} else {
c.JSON(http.StatusOK, augmentedMetadata)
}
}

// instanceUserdataRefreshInternal retrieves the requested instance ID from the
// path and performs a lookup to refresh the userdata from an external system.
// If the userdata is successfully refreshed, it returns a 200. If not, it
// returns a 500. This can be used by an authenticated external system to
// trigger a refresh of userdata for a specific instance.
func (r *Router) instanceUserdataRefreshInternal(c *gin.Context) {
instanceID, err := getUUIDParam(c, "instance-id")

if err != nil {
invalidUUIDResponse(c, err)
return
}

// Perform a lookup from the upstream lookup service to retrieve the current userdata.
if !r.LookupEnabled || r.LookupClient == nil {
r.Logger.Sugar().Errorf("LookupClient is not configured or enabled, cannot refresh userdata for instance %s", instanceID)
c.Status(http.StatusInternalServerError)

return
}

// Save the userdata to the database if found, otherwise return a 404.
userdata, err := lookup.UserdataSyncByID(c.Request.Context(), r.DB, r.Logger, r.LookupClient, instanceID)
if err != nil && errors.Is(err, lookup.ErrNotFound) {
r.Logger.Sugar().Warnf("Userdata not found from upstream during refresh for instance %s", instanceID)
c.Status(http.StatusNotFound)

return
}

r.Logger.Sugar().Infof("Userdata successfully refreshed for instance %s", instanceID)

if userdata != nil {
c.String(http.StatusOK, string(userdata.Userdata.Bytes))
}

c.Status(http.StatusOK)
}

// instanceMetadataExistsInternal retrieves the requested instance ID from the
// path and looks to see if the database has metadata recorded for that ID.
// If so, it returns a 200. If not, it returns a 404. This can be used by an
Expand Down
1 change: 1 addition & 0 deletions pkg/api/v1/router_instance_metadata_lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func TestGetMetadataLookupByIP(t *testing.T) {
for _, testcase := range testCases {
t.Run(testcase.testName, func(t *testing.T) {
lookupClient.setResponse(testcase.instanceIP, testcase.lookupResponse)

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, v1api.GetMetadataPath(), nil)
Expand Down
1 change: 1 addition & 0 deletions pkg/api/v1/router_instance_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ func TestSetMetadataRequestValidations(t *testing.T) {
if err != nil {
t.Fatal(err)
}

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, v1api.GetInternalMetadataPath(), bytes.NewReader(reqBody))
Expand Down
1 change: 1 addition & 0 deletions pkg/api/v1/router_instance_userdata_lookup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func TestGetUserdataLookupByIP(t *testing.T) {
for _, testcase := range testCases {
t.Run(testcase.testName, func(t *testing.T) {
lookupClient.setResponse(testcase.instanceIP, testcase.lookupResponse)

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, v1api.GetUserdataPath(), nil)
Expand Down
1 change: 1 addition & 0 deletions pkg/api/v1/router_instance_userdata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func TestSetUserdataRequestValidations(t *testing.T) {
if err != nil {
t.Fatal(err)
}

w := httptest.NewRecorder()

req, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, v1api.GetInternalUserdataPath(), bytes.NewReader(reqBody))
Expand Down
2 changes: 0 additions & 2 deletions quickstart-auth.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: "3.9"

services:
metadataservice:
environment:
Expand Down
2 changes: 0 additions & 2 deletions quickstart-dev.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: "3.9"

services:
metadataservice:
build:
Expand Down
6 changes: 2 additions & 4 deletions quickstart.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
version: "3.9"

services:
metadataservice:
image: ghcr.io/metal-toolbox/hollow-metadaaservice:v0.0.27
image: ghcr.io/metal-toolbox/hollow-metadaaservice:v0.0.29
depends_on:
crdb:
condition: service_healthy
Expand All @@ -18,7 +16,7 @@ services:
- metadataservice

metadataservice-migrate:
image: ghcr.io/metal-toolbox/hollow-metadataservice:v0.0.27
image: ghcr.io/metal-toolbox/hollow-metadataservice:v0.0.29
command:
migrate up
depends_on:
Expand Down

0 comments on commit 910dca2

Please sign in to comment.