diff --git a/go.mod b/go.mod index 42163af5..57625302 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,8 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.7 - github.com/metal-toolbox/fleetdb v1.19.5-0.20240913163810-6a9703ca4111 - github.com/metal-toolbox/rivets v1.3.7 + github.com/metal-toolbox/fleetdb v1.19.5 + github.com/metal-toolbox/rivets v1.3.9 github.com/nats-io/nats-server/v2 v2.10.12 github.com/nats-io/nats.go v1.36.0 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index b72ba51c..ed232acc 100644 --- a/go.sum +++ b/go.sum @@ -540,10 +540,10 @@ github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/metal-toolbox/fleetdb v1.19.5-0.20240913163810-6a9703ca4111 h1:WX236DysSYXrlHueyk8WMxUj/vReFUPumXjulZAhHgo= -github.com/metal-toolbox/fleetdb v1.19.5-0.20240913163810-6a9703ca4111/go.mod h1:jaKeC1iiYjXhEPFoUTWtOM5Ni7+5+XZWIXnHIiBdq94= -github.com/metal-toolbox/rivets v1.3.7 h1:ZM6AbX1xASS91FWi/2i2wh9twVOPJTzpD3c7fcllhBk= -github.com/metal-toolbox/rivets v1.3.7/go.mod h1:8irU6eXgOa3QkjdcGi/aY4vqoMqCkbwVz7iVTYYPCX8= +github.com/metal-toolbox/fleetdb v1.19.5 h1:ERgdFAUtWnT/AeVhCGclsENmwPhU88JUcgOZAdxWKYI= +github.com/metal-toolbox/fleetdb v1.19.5/go.mod h1:k9MZXQsJX4NfBoANst6g1468papSs0tzsSyzN3gGWuQ= +github.com/metal-toolbox/rivets v1.3.9 h1:xiBxEVvZNsw3IsE0NVnaMWR5iXNuc7m2QhohmzwaVvg= +github.com/metal-toolbox/rivets v1.3.9/go.mod h1:yxvMwsGL8LsEWL5eBq17ViEvULVOojl+vIcGcz+YTzE= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= diff --git a/pkg/api/v1/conditions/client/client.go b/pkg/api/v1/conditions/client/client.go index 04d51076..332bad56 100644 --- a/pkg/api/v1/conditions/client/client.go +++ b/pkg/api/v1/conditions/client/client.go @@ -85,6 +85,13 @@ func (c *Client) ServerFirmwareInstall(ctx context.Context, return c.post(ctx, path, params) } +func (c *Client) ServerBiosControl(ctx context.Context, + params *rctypes.BiosControlTaskParameters) (*v1types.ServerResponse, error) { + path := fmt.Sprintf("servers/%s/biosControl", params.AssetID.String()) + + return c.post(ctx, path, params) +} + func (c *Client) ServerConditionCreate(ctx context.Context, serverID uuid.UUID, conditionKind rctypes.Kind, conditionCreate v1types.ConditionCreate) (*v1types.ServerResponse, error) { path := fmt.Sprintf("servers/%s/condition/%s", serverID.String(), conditionKind) diff --git a/pkg/api/v1/conditions/client/client_test.go b/pkg/api/v1/conditions/client/client_test.go index e1985b64..a839d658 100644 --- a/pkg/api/v1/conditions/client/client_test.go +++ b/pkg/api/v1/conditions/client/client_test.go @@ -361,7 +361,6 @@ func TestFirmwareInstall(t *testing.T) { mockStore: func(r *store.MockRepository) { r.On("Create", mock.Anything, serverID, "facility", mock.Anything, mock.Anything). Return(nil).Once() - }, mockFleetDB: func(r *fleetdb.MockFleetDB) { fwset.ComponentFirmware = append(fwset.ComponentFirmware, oobfw) @@ -845,3 +844,118 @@ func TestServerDeleteInvalidUUID(t *testing.T) { }) } } + +func TestServerBiosControl(t *testing.T) { + serverID := uuid.New() + + validParams := rctypes.BiosControlTaskParameters{ + AssetID: serverID, + Action: rctypes.ResetConfig, + } + + testcases := []struct { + name string + payload *rctypes.BiosControlTaskParameters + mockStore func(r *store.MockRepository) + mockFleetDB func(r *fleetdb.MockFleetDB) + expectResponse func() *v1types.ServerResponse + expectErrorContains string + expectPublish bool + }{ + { + name: "success case", + payload: &validParams, + mockStore: func(r *store.MockRepository) { + r.On("Create", mock.Anything, serverID, "facility", mock.Anything, mock.Anything). + Return(nil).Once() + }, + mockFleetDB: func(r *fleetdb.MockFleetDB) { + r.On("GetServer", mock.Anything, mock.Anything). + Return(&model.Server{FacilityCode: "facility"}, nil).Once() + }, + expectResponse: func() *v1types.ServerResponse { + return &v1types.ServerResponse{ + StatusCode: 200, + Message: "condition set", + } + }, + expectErrorContains: "", + expectPublish: true, + }, + { + name: "no server", + payload: &validParams, + mockFleetDB: func(r *fleetdb.MockFleetDB) { + r.On("GetServer", mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("no server")).Once() + }, + expectResponse: func() *v1types.ServerResponse { + return &v1types.ServerResponse{ + StatusCode: 500, + Message: "server facility: no server", + } + }, + expectErrorContains: "", + expectPublish: false, + }, + { + name: "active condition error", + payload: &validParams, + mockStore: func(r *store.MockRepository) { + r.On("Create", mock.Anything, serverID, "facility", mock.Anything, mock.Anything). + Return(fmt.Errorf("%w:%s", store.ErrActiveCondition, "pound sand")).Once() + }, + mockFleetDB: func(r *fleetdb.MockFleetDB) { + r.On("GetServer", mock.Anything, mock.Anything). + Return(&model.Server{FacilityCode: "facility"}, nil).Once() + }, + expectResponse: func() *v1types.ServerResponse { + return &v1types.ServerResponse{ + StatusCode: 500, + Message: "server has an active condition", + } + }, + expectErrorContains: "", + expectPublish: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tester := newTester(t) + + if tc.mockStore != nil { + tc.mockStore(tester.repository) + } + + if tc.mockFleetDB != nil { + tc.mockFleetDB(tester.fleetDB) + } + + if tc.expectPublish { + tester.stream.On( + "Publish", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(nil).Times(1) + } + + got, err := tester.client.ServerBiosControl(context.TODO(), tc.payload) + if err != nil { + t.Error(err) + } + + if err != nil { + require.Contains(t, err.Error(), tc.expectErrorContains) + } + + if tc.expectErrorContains != "" && err == nil { + t.Error("expected error, got nil") + } + + require.Equal(t, tc.expectResponse().StatusCode, got.StatusCode, "bad status code") + require.Contains(t, got.Message, tc.expectResponse().Message, "bad message") + }) + } +} diff --git a/pkg/api/v1/conditions/routes/handlers.go b/pkg/api/v1/conditions/routes/handlers.go index c75c66c0..02b6e752 100644 --- a/pkg/api/v1/conditions/routes/handlers.go +++ b/pkg/api/v1/conditions/routes/handlers.go @@ -516,6 +516,66 @@ func (r *Routes) firmwareInstallComposite( return sc } +// @Summary Bios Control +// @Tag Conditions +// @Description Controls the BIOS of the server +// @Param uuid path string true "Server ID" +// @Param data body rctypes.BiosControlTaskParameters true "bios control options" +// @Accept json +// @Produce json +// @Success 200 {object} v1types.ServerResponse +// Failure 400 {object} v1types.ServerResponse +// Failure 500 {object} v1types.ServerResponse +// Failure 503 {object} v1types.ServerResponse +// @Router /servers/{uuid}/biosControl [post] +func (r *Routes) biosControl(c *gin.Context) (int, *v1types.ServerResponse) { + id := c.Param("uuid") + otelCtx, span := otel.Tracer(pkgName).Start(c.Request.Context(), "Routes.biosControl") + span.SetAttributes(attribute.KeyValue{Key: "serverId", Value: attribute.StringValue(id)}) + defer span.End() + + serverID, err := uuid.Parse(id) + if err != nil { + r.logger.WithError(err).WithField("serverID", id).Warn("bad serverID") + + return http.StatusBadRequest, &v1types.ServerResponse{ + Message: "server id: " + err.Error(), + } + } + + facilityCode, err := r.serverFacilityCode(otelCtx, serverID) + if err != nil { + return http.StatusInternalServerError, &v1types.ServerResponse{ + Message: "server facility: " + err.Error(), + } + } + + var bctp rctypes.BiosControlTaskParameters + if err = c.ShouldBindJSON(&bctp); err != nil { + r.logger.WithError(err).Warn("unmarshal biosCotnrol payload") + + return http.StatusBadRequest, &v1types.ServerResponse{ + Message: "invalid biosCotnrol payload: " + err.Error(), + } + } + + createTime := time.Now() + traceID := trace.SpanFromContext(otelCtx).SpanContext().TraceID().String() + spanID := trace.SpanFromContext(otelCtx).SpanContext().SpanID().String() + + biosControlCondition := &rctypes.Condition{ + Kind: rctypes.BiosControl, + Parameters: bctp.MustJSON(), + State: rctypes.Pending, + CreatedAt: createTime, + TraceID: traceID, + SpanID: spanID, + Client: ginjwt.GetUser(c), + } + + return r.conditionCreate(otelCtx, biosControlCondition, serverID, facilityCode) +} + func (r *Routes) conditionCreate(otelCtx context.Context, newCondition *rctypes.Condition, serverID uuid.UUID, facilityCode string) (int, *v1types.ServerResponse) { // Create the new condition err := r.repository.Create(otelCtx, serverID, facilityCode, newCondition) diff --git a/pkg/api/v1/conditions/routes/routes.go b/pkg/api/v1/conditions/routes/routes.go index f1870a6c..fcf39642 100644 --- a/pkg/api/v1/conditions/routes/routes.go +++ b/pkg/api/v1/conditions/routes/routes.go @@ -157,6 +157,10 @@ func (r *Routes) Routes(g *gin.RouterGroup) { servers.POST("/firmwareInstall", r.composeAuthHandler(createScopes("condition")), wrapAPICall(r.firmwareInstall)) + // BIOS + servers.POST("/biosControl", r.composeAuthHandler(createScopes("condition")), + wrapAPICall(r.biosControl)) + // Generalized API for any condition status (for cases where some server work // has multiple conditions involved and the caller doesn't know what they might be) servers.GET("/status", r.composeAuthHandler(readScopes("condition")),