From 6f424fc8bee9642ab4ab8bca1437035362241e49 Mon Sep 17 00:00:00 2001 From: Doctor Vince Date: Fri, 16 Aug 2024 17:06:14 -0400 Subject: [PATCH] client function and api endpoint for firmware validation --- internal/dbtools/fixtures.go | 25 ++++++++ pkg/api/v1/firmware_set.go | 6 ++ pkg/api/v1/requests.go | 7 ++ pkg/api/v1/router.go | 25 ++++++-- pkg/api/v1/router_firmware_set.go | 89 ++++++++++++++++++++++++++ pkg/api/v1/router_firmware_set_test.go | 38 ++++++++++- pkg/api/v1/router_server_test.go | 15 +++-- pkg/api/v1/server_service.go | 16 +++++ 8 files changed, 208 insertions(+), 13 deletions(-) diff --git a/internal/dbtools/fixtures.go b/internal/dbtools/fixtures.go index 7ba93a5..3324a66 100644 --- a/internal/dbtools/fixtures.go +++ b/internal/dbtools/fixtures.go @@ -101,6 +101,9 @@ var ( FixtureEventHistoryServer *models.Server FixtureEventHistoryRelatedID uuid.UUID FixtureEventHistories []*models.EventHistory + + FixtureFWValidationServer *models.Server + FixtureFWValidationSet *models.ComponentFirmwareSet ) func addFixtures(t *testing.T) error { @@ -183,6 +186,10 @@ func addFixtures(t *testing.T) error { return err } + if err := setupFWValidationFixtures(ctx, testDB); err != nil { + return err + } + // excluding Chuckles here since that server is deleted FixtureServers = models.ServerSlice{FixtureNemo, FixtureDory, FixtureMarlin} FixtureDeletedServers = models.ServerSlice{FixtureChuckles} @@ -886,3 +893,21 @@ func setupConfigSet(ctx context.Context, db *sqlx.DB) error { return nil } + +func setupFWValidationFixtures(ctx context.Context, db *sqlx.DB) error { + FixtureFWValidationServer = &models.Server{ + Name: null.StringFrom("firmware-validation"), + FacilityCode: null.StringFrom("tf2"), + } + + if err := FixtureFWValidationServer.Insert(ctx, db, boil.Infer()); err != nil { + return errors.Wrap(err, "firmware validation server fixture") + } + + FixtureFWValidationSet = &models.ComponentFirmwareSet{Name: "firmware-validation"} + if err := FixtureFWValidationSet.Insert(ctx, db, boil.Infer()); err != nil { + return errors.Wrap(err, "firmware validation set fixture") + } + + return nil +} diff --git a/pkg/api/v1/firmware_set.go b/pkg/api/v1/firmware_set.go index 4ad724d..c6474f4 100644 --- a/pkg/api/v1/firmware_set.go +++ b/pkg/api/v1/firmware_set.go @@ -101,3 +101,9 @@ func (sc *ComponentFirmwareSetRequest) toDBModelFirmwareSet() (*models.Component return s, nil } + +type FirmwareSetValidation struct { + TargetServer uuid.UUID `json:"target_server" binding:"required"` + FirmwareSet uuid.UUID `json:"firmware_set" binding:"required"` + PerformedOn time.Time `json:"performed_on" binding:"required"` +} diff --git a/pkg/api/v1/requests.go b/pkg/api/v1/requests.go index b9c8922..77c6b4e 100644 --- a/pkg/api/v1/requests.go +++ b/pkg/api/v1/requests.go @@ -99,6 +99,13 @@ func (c *Client) do(req *http.Request, result interface{}) error { defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusNoContent, http.StatusResetContent: + // these statuses are not allowed to have body content + return nil + default: + } + data, err := io.ReadAll(resp.Body) if err != nil { return err diff --git a/pkg/api/v1/router.go b/pkg/api/v1/router.go index d9e136d..2cfd3f5 100644 --- a/pkg/api/v1/router.go +++ b/pkg/api/v1/router.go @@ -107,12 +107,25 @@ func (r *Router) Routes(rg *gin.RouterGroup) { // /server-component-firmware-sets srvCmpntFwSets := rg.Group("/server-component-firmware-sets") { - srvCmpntFwSets.GET("", amw.AuthRequired(readScopes("server-component-firmware-sets")), r.serverComponentFirmwareSetList) - srvCmpntFwSets.POST("", amw.AuthRequired(createScopes("server-component-firmware-sets")), r.serverComponentFirmwareSetCreate) - srvCmpntFwSets.GET("/:uuid", amw.AuthRequired(readScopes("server-component-firmware-sets")), r.serverComponentFirmwareSetGet) - srvCmpntFwSets.PUT("/:uuid", amw.AuthRequired(updateScopes("server-component-firmware-sets")), r.serverComponentFirmwareSetUpdate) - srvCmpntFwSets.DELETE("/:uuid", amw.AuthRequired(deleteScopes("server-component-firmware-sets")), r.serverComponentFirmwareSetDelete) - srvCmpntFwSets.POST("/:uuid/remove-firmware", amw.AuthRequired(deleteScopes("server-component-firmware-sets")), r.serverComponentFirmwareSetRemoveFirmware) + createScopeMiddleware := amw.AuthRequired(createScopes("server-component-firmware-sets")) + readScopeMiddleware := amw.AuthRequired(readScopes("server-component-firmware-sets")) + updateScopeMiddleware := amw.AuthRequired(updateScopes("server-component-firmware-sets")) + deleteScopeMiddleware := amw.AuthRequired(deleteScopes("server-component-firmware-sets")) + + // list all sets + srvCmpntFwSets.GET("", readScopeMiddleware, r.serverComponentFirmwareSetList) + + // create/read/update/delete individual firmware sets + srvCmpntFwSets.POST("", createScopeMiddleware, r.serverComponentFirmwareSetCreate) + srvCmpntFwSets.GET("/:uuid", readScopeMiddleware, r.serverComponentFirmwareSetGet) + srvCmpntFwSets.PUT("/:uuid", updateScopeMiddleware, r.serverComponentFirmwareSetUpdate) + srvCmpntFwSets.DELETE("/:uuid", deleteScopeMiddleware, r.serverComponentFirmwareSetDelete) + + // remove a component firmware from the set + srvCmpntFwSets.POST("/:uuid/remove-firmware", deleteScopeMiddleware, r.serverComponentFirmwareSetRemoveFirmware) + + // mark the set as validated + srvCmpntFwSets.POST("/validate-firmware-set", updateScopeMiddleware, r.validateFirmwareSet) } // /bill-of-materials diff --git a/pkg/api/v1/router_firmware_set.go b/pkg/api/v1/router_firmware_set.go index 1c871d7..6cec4bc 100644 --- a/pkg/api/v1/router_firmware_set.go +++ b/pkg/api/v1/router_firmware_set.go @@ -3,6 +3,7 @@ package fleetdbapi import ( "context" "database/sql" + "net/http" "fmt" "strings" @@ -13,7 +14,9 @@ import ( "github.com/volatiletech/null/v8" "github.com/volatiletech/sqlboiler/v4/boil" "github.com/volatiletech/sqlboiler/v4/queries/qm" + "go.uber.org/zap" + "github.com/metal-toolbox/fleetdb/internal/metrics" "github.com/metal-toolbox/fleetdb/internal/models" ) @@ -698,3 +701,89 @@ func (r *Router) firmwareSetDeleteMappingTx(ctx context.Context, _ *models.Compo return tx.Commit() } + +// We allow multiple calls for the same firmware-set and server-id because firmware sets are mutable. +// We will update any record in place, or create a new one as needed. +func (r *Router) validateFirmwareSet(c *gin.Context) { + var payload FirmwareSetValidation + if err := c.ShouldBindJSON(&payload); err != nil { + badRequestResponse(c, "invalid validation payload", err) + return + } + ctx := c.Request.Context() + txn, err := r.DB.BeginTx(ctx, nil) + if err != nil { + dbErrorResponse2(c, "starting transaction", err) + return + } + + doRollback := false + rollbackFn := func() { + if doRollback { + if rbErr := txn.Rollback(); rbErr != nil { + r.Logger.With( + zap.Error(rbErr), + ).Warn("rollback error on firmware validation") + metrics.DBError("rollback firmware validation") + } + } + } + defer rollbackFn() + + fact := models.FirmwareSetValidationFact{ + TargetServerID: payload.TargetServer.String(), + FirmwareSetID: payload.FirmwareSet.String(), + PerformedOn: payload.PerformedOn, + } + + existing, err := models.FirmwareSetValidationFacts( + models.FirmwareSetValidationFactWhere.FirmwareSetID.EQ(payload.FirmwareSet.String()), + ).One(ctx, txn) + + switch err { + case nil: + fact.ID = existing.ID + _, updErr := fact.Update(ctx, txn, boil.Infer()) + if updErr != nil { + r.Logger.With( + zap.Error(updErr), + zap.String("firmware.set", payload.FirmwareSet.String()), + zap.String("target.server", payload.TargetServer.String()), + ).Warn("updating existing firmware validation record") + metrics.DBError("update firmware validation") + doRollback = true + dbErrorResponse2(c, "update firmware validation", updErr) + return + } + case sql.ErrNoRows: + writeErr := fact.Insert(ctx, txn, boil.Infer()) + if writeErr != nil { + r.Logger.With( + zap.Error(writeErr), + zap.String("firmware.set", payload.FirmwareSet.String()), + zap.String("target.server", payload.TargetServer.String()), + ).Warn("inserting existing firmware validation record") + metrics.DBError("insert firmware validation") + doRollback = true + dbErrorResponse2(c, "insert firmware validation", writeErr) + return + } + default: + dbErrorResponse2(c, "checking database for existing", err) + return + } + + if txErr := txn.Commit(); txErr != nil { + r.Logger.With( + zap.Error(txErr), + zap.String("firmware.set", payload.FirmwareSet.String()), + zap.String("target.server", payload.TargetServer.String()), + ).Warn("commit firmware validation record") + doRollback = true + metrics.DBError("commit firmware validation transaction") + dbErrorResponse2(c, "commit firmware validation transaction", txErr) + return + } + + c.JSON(http.StatusNoContent, nil) +} diff --git a/pkg/api/v1/router_firmware_set_test.go b/pkg/api/v1/router_firmware_set_test.go index 3a9db1e..f705342 100644 --- a/pkg/api/v1/router_firmware_set_test.go +++ b/pkg/api/v1/router_firmware_set_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -610,7 +611,7 @@ func TestIntegrationServerComponentFirmwareSetList(t *testing.T) { }, nil, nil, - 5, + 6, 2, false, "", @@ -979,3 +980,38 @@ func TestIntegrationServerComponentFirmwareSetRemoveFirmware(t *testing.T) { }) } } + +func TestIntegrationValidateFirmwareSet(t *testing.T) { + s := serverTest(t) + + t.Logf("validation set fixture: %v", dbtools.FixtureFWValidationSet) + setID, err := uuid.Parse(dbtools.FixtureFWValidationSet.ID) + require.NoError(t, err) + + srvID, err := uuid.Parse(dbtools.FixtureFWValidationServer.ID) + require.NoError(t, err) + + realClientTests(t, func(ctx context.Context, authToken string, respCode int, expectError bool) error { + s.Client.SetToken(authToken) + + on := time.Now() + + err := s.Client.ValidateFirmwareSet(ctx, srvID, setID, on) + if !expectError { + require.NoError(t, err) + } + return err + }) + + // Updating an existing validation is kosher. + err = s.Client.ValidateFirmwareSet(context.TODO(), srvID, setID, time.Now()) + require.NoError(t, err, "update with fixture server id failed") + + // Validate the same firmware with a non-fixture server id -- expect success + err = s.Client.ValidateFirmwareSet(context.TODO(), uuid.New(), setID, time.Now()) + require.NoError(t, err, "update with unregistered server id failed") + + // bogus firmware set id + err = s.Client.ValidateFirmwareSet(context.TODO(), uuid.New(), uuid.New(), time.Now()) + require.Error(t, err, "update with bogus firmware set id succeeded") +} diff --git a/pkg/api/v1/router_server_test.go b/pkg/api/v1/router_server_test.go index 44caf12..55ed912 100644 --- a/pkg/api/v1/router_server_test.go +++ b/pkg/api/v1/router_server_test.go @@ -24,11 +24,11 @@ func TestIntegrationServerList(t *testing.T) { r, resp, err := s.Client.List(ctx, nil) if !expectError { require.NoError(t, err) - assert.Len(t, r, 5) + assert.Len(t, r, 6) - assert.EqualValues(t, 5, resp.PageCount) + assert.EqualValues(t, 6, resp.PageCount) assert.EqualValues(t, 1, resp.TotalPages) - assert.EqualValues(t, 5, resp.TotalRecordCount) + assert.EqualValues(t, 6, resp.TotalRecordCount) // We returned everything, so we shouldnt have a next page info assert.Nil(t, resp.Links.Next) assert.Nil(t, resp.Links.Previous) @@ -145,6 +145,7 @@ func TestIntegrationServerList(t *testing.T) { "empty search filter", nil, []string{ + dbtools.FixtureFWValidationServer.ID, dbtools.FixtureEventHistoryServer.ID, dbtools.FixtureInventoryServer.ID, dbtools.FixtureNemo.ID, @@ -443,6 +444,7 @@ func TestIntegrationServerList(t *testing.T) { "search for server without IncludeDeleted defined", &fleetdbapi.ServerListParams{}, []string{ + dbtools.FixtureFWValidationServer.ID, dbtools.FixtureEventHistoryServer.ID, dbtools.FixtureInventoryServer.ID, dbtools.FixtureNemo.ID, @@ -456,6 +458,7 @@ func TestIntegrationServerList(t *testing.T) { "search for server with IncludeDeleted defined", &fleetdbapi.ServerListParams{IncludeDeleted: true}, []string{ + dbtools.FixtureFWValidationServer.ID, dbtools.FixtureEventHistoryServer.ID, dbtools.FixtureInventoryServer.ID, dbtools.FixtureNemo.ID, @@ -535,7 +538,7 @@ func TestIntegrationServerListPagination(t *testing.T) { assert.EqualValues(t, 2, resp.PageCount) assert.EqualValues(t, 3, resp.TotalPages) - assert.EqualValues(t, 5, resp.TotalRecordCount) + assert.EqualValues(t, 6, resp.TotalRecordCount) // Since we have a next page let's make sure all the links are set assert.NotNil(t, resp.Links.Next) assert.Nil(t, resp.Links.Previous) @@ -554,13 +557,13 @@ func TestIntegrationServerListPagination(t *testing.T) { // get the last page resp, err = s.Client.NextPage(context.TODO(), *resp, &r) assert.NoError(t, err) - assert.Len(t, r, 1) + assert.Len(t, r, 2) // we should have followed the cursor so first/previous/next/last links shouldn't be set // but there is another page so we should have a next cursor link. Total counts are not includes // cursor pages assert.EqualValues(t, 3, resp.TotalPages) - assert.EqualValues(t, 5, resp.TotalRecordCount) + assert.EqualValues(t, 6, resp.TotalRecordCount) assert.NotNil(t, resp.Links.First) assert.NotNil(t, resp.Links.Previous) assert.Nil(t, resp.Links.Next) diff --git a/pkg/api/v1/server_service.go b/pkg/api/v1/server_service.go index 78ecf4d..2badb92 100644 --- a/pkg/api/v1/server_service.go +++ b/pkg/api/v1/server_service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "path" + "time" "github.com/google/uuid" rivets "github.com/metal-toolbox/rivets/types" @@ -63,6 +64,7 @@ type ClientInterface interface { ListServerComponentFirmwareSet(context.Context, *ComponentFirmwareSetListParams) ([]ComponentFirmwareSet, *ServerResponse, error) ListFirmwareSets(context.Context, *ComponentFirmwareSetListParams) ([]ComponentFirmwareSet, *ServerResponse, error) DeleteServerComponentFirmwareSet(context.Context, uuid.UUID) (*ServerResponse, error) + ValidateFirmwareSet(context.Context, uuid.UUID, uuid.UUID, time.Time) error GetCredential(context.Context, uuid.UUID, string) (*ServerCredential, *ServerResponse, error) SetCredential(context.Context, uuid.UUID, string, string) (*ServerResponse, error) @@ -372,6 +374,20 @@ func (c *Client) RemoveServerComponentFirmwareSetFirmware(ctx context.Context, f return c.post(ctx, path, firmwareSet) } +// ValidateFirmwareSet inserts or updates a record containing facts about the validation of this +// particular firmware set. On a successful execution the API returns 204 (http.StatusNoContent), so +// there is nothing useful to put into a ServerResponse. +func (c *Client) ValidateFirmwareSet(ctx context.Context, srvID, fwSetID uuid.UUID, on time.Time) error { + path := fmt.Sprintf("%s/validate-firmware-set", serverComponentFirmwareSetsEndpoint) + facts := FirmwareSetValidation{ + TargetServer: srvID, + FirmwareSet: fwSetID, + PerformedOn: on, + } + _, err := c.post(ctx, path, facts) + return err +} + // GetCredential will return the secret for the secret type for the given server UUID func (c *Client) GetCredential(ctx context.Context, srvUUID uuid.UUID, secretSlug string) (*ServerCredential, *ServerResponse, error) { p := path.Join(serversEndpoint, srvUUID.String(), serverCredentialsEndpoint, secretSlug)