diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index 51da75363..07591e656 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -4,294 +4,144 @@ import ( "errors" "fmt" "net/http" - "strconv" "time" "github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore" "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" - v1 "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/v1" "github.com/metal-stack/metal-api/cmd/metal-api/internal/utils" "github.com/prometheus/client_golang/prometheus" "go.uber.org/zap" - restfulspec "github.com/emicklei/go-restful-openapi/v2" - restful "github.com/emicklei/go-restful/v3" - "github.com/metal-stack/metal-lib/httperrors" "github.com/metal-stack/metal-lib/zapup" ) -type imageResource struct { - webResource +type ImageService struct { + ds *datastore.RethinkStore } -// NewImage returns a webservice for image specific endpoints. -func NewImage(ds *datastore.RethinkStore) *restful.WebService { - ir := imageResource{ - webResource: webResource{ - ds: ds, - }, +// NewImageService returns an image service. +func NewImageService(ds *datastore.RethinkStore) *ImageService { + return &ImageService{ + ds: ds, } - iuc := imageUsageCollector{ir: &ir} - err := prometheus.Register(iuc) - if err != nil { - zapup.MustRootLogger().Error("Failed to register prometheus", zap.Error(err)) - } - return ir.webService() } -func (ir imageResource) webService() *restful.WebService { - ws := new(restful.WebService) - ws. - Path(BasePath + "v1/image"). - Consumes(restful.MIME_JSON). - Produces(restful.MIME_JSON) - - tags := []string{"image"} - - ws.Route(ws.GET("/{id}"). - To(ir.findImage). - Operation("findImage"). - Doc("get image by id"). - Param(ws.PathParameter("id", "identifier of the image").DataType("string")). - Metadata(restfulspec.KeyOpenAPITags, tags). - Writes(v1.ImageResponse{}). - Returns(http.StatusOK, "OK", v1.ImageResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - ws.Route(ws.GET("/{id}/query"). - To(ir.queryImages). - Operation("queryImages by id"). - Doc("query all images which match at least id"). - Param(ws.PathParameter("id", "identifier of the image").DataType("string")). - Metadata(restfulspec.KeyOpenAPITags, tags). - Writes([]v1.ImageResponse{}). - Returns(http.StatusOK, "OK", []v1.ImageResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - ws.Route(ws.GET("/{id}/latest"). - To(ir.findLatestImage). - Operation("findLatestImage"). - Doc("find latest image by id"). - Param(ws.PathParameter("id", "identifier of the image").DataType("string")). - Metadata(restfulspec.KeyOpenAPITags, tags). - Writes(v1.ImageResponse{}). - Returns(http.StatusOK, "OK", v1.ImageResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - ws.Route(ws.GET("/"). - To(ir.listImages). - Operation("listImages"). - Doc("get all images"). - Metadata(restfulspec.KeyOpenAPITags, tags). - Param(ws.QueryParameter("show-usage", "include image usage into response").DataType("bool").DefaultValue("false")). - Writes([]v1.ImageResponse{}). - Returns(http.StatusOK, "OK", []v1.ImageResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - ws.Route(ws.DELETE("/{id}"). - To(admin(ir.deleteImage)). - Operation("deleteImage"). - Doc("deletes an image and returns the deleted entity"). - Param(ws.PathParameter("id", "identifier of the image").DataType("string")). - Metadata(restfulspec.KeyOpenAPITags, tags). - Writes(v1.ImageResponse{}). - Returns(http.StatusOK, "OK", v1.ImageResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - ws.Route(ws.PUT("/"). - To(admin(ir.createImage)). - Operation("createImage"). - Doc("create an image. if the given ID already exists a conflict is returned"). - Metadata(restfulspec.KeyOpenAPITags, tags). - Reads(v1.ImageCreateRequest{}). - Returns(http.StatusCreated, "Created", v1.ImageResponse{}). - Returns(http.StatusConflict, "Conflict", httperrors.HTTPErrorResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - ws.Route(ws.POST("/"). - To(admin(ir.updateImage)). - Operation("updateImage"). - Doc("updates an image. if the image was changed since this one was read, a conflict is returned"). - Metadata(restfulspec.KeyOpenAPITags, tags). - Reads(v1.ImageUpdateRequest{}). - Returns(http.StatusOK, "OK", v1.ImageResponse{}). - Returns(http.StatusConflict, "Conflict", httperrors.HTTPErrorResponse{}). - DefaultReturns("Error", httperrors.HTTPErrorResponse{})) - - return ws -} - -func (ir imageResource) findImage(request *restful.Request, response *restful.Response) { - id := request.PathParameter("id") - +func (ir ImageService) Get(id string) (*metal.Image, error) { img, err := ir.ds.GetImage(id) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return nil, err } -} -func (ir imageResource) queryImages(request *restful.Request, response *restful.Response) { - id := request.PathParameter("id") - - img, err := ir.ds.FindImages(id) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - result := []*v1.ImageResponse{} + return img, nil +} - for i := range img { - result = append(result, v1.NewImageResponse(&img[i])) - } - err = response.WriteHeaderAndEntity(http.StatusOK, result) +func (ir ImageService) Find(id string) ([]metal.Image, error) { + imgs, err := ir.ds.FindImages(id) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return nil, err } -} -func (ir imageResource) findLatestImage(request *restful.Request, response *restful.Response) { - id := request.PathParameter("id") + return imgs, nil +} +func (ir ImageService) FindLatest(id string) (*metal.Image, error) { img, err := ir.ds.FindImage(id) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return nil, err } + + return img, nil } -func (ir imageResource) listImages(request *restful.Request, response *restful.Response) { +func (ir ImageService) List() ([]metal.Image, error) { imgs, err := ir.ds.ListImages() - if checkError(request, response, utils.CurrentFuncName(), err) { - return + if err != nil { + return nil, err } - ms := metal.Machines{} - showUsage := false - if request.QueryParameter("show-usage") != "" { - showUsage, err = strconv.ParseBool(request.QueryParameter("show-usage")) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - if showUsage { - ms, err = ir.ds.ListMachines() - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - } - } + return imgs, nil +} - result := []*v1.ImageResponse{} - for i := range imgs { - img := v1.NewImageResponse(&imgs[i]) - if showUsage { - machines := ir.machinesByImage(ms, imgs[i].ID) - if len(machines) > 0 { - img.UsedBy = machines - } - } - result = append(result, img) +func (ir ImageService) Create(img *metal.Image) error { + defaultImage(img) + + err := validateImage(img) + if err != nil { + return err } - err = response.WriteHeaderAndEntity(http.StatusOK, result) + + err = ir.ds.CreateImage(img) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return err } + + return nil } -func (ir imageResource) createImage(request *restful.Request, response *restful.Response) { - var requestPayload v1.ImageCreateRequest - err := request.ReadEntity(&requestPayload) - if checkError(request, response, utils.CurrentFuncName(), err) { - return +func defaultImage(img *metal.Image) { + if img.Classification == "" { + img.Classification = metal.ClassificationPreview } - if requestPayload.ID == "" { - if checkError(request, response, utils.CurrentFuncName(), errors.New("id should not be empty")) { - return - } + if img.ExpirationDate.IsZero() { + img.ExpirationDate = time.Now().Add(metal.DefaultImageExpiration) } - if requestPayload.URL == "" { - if checkError(request, response, utils.CurrentFuncName(), errors.New("url should not be empty")) { - return + os, v, err := utils.GetOsAndSemverFromImage(img.ID) + if err == nil { + if img.OS == "" { + img.OS = os + } + if img.Version == "" { + img.Version = v.String() } } +} - var name string - if requestPayload.Name != nil { - name = *requestPayload.Name +func validateImage(img *metal.Image) error { + if img.ID == "" { + return errors.New("id should not be empty") } - var description string - if requestPayload.Description != nil { - description = *requestPayload.Description + + if img.URL == "" { + return errors.New("url should not be empty") } - features := make(map[metal.ImageFeatureType]bool) - for _, f := range requestPayload.Features { - ft, err := metal.ImageFeatureTypeFrom(f) - if checkError(request, response, utils.CurrentFuncName(), err) { - return + for f := range img.Features { + _, err := metal.ImageFeatureTypeFrom(string(f)) + if err != nil { + return err } - features[ft] = true } - os, v, err := utils.GetOsAndSemverFromImage(requestPayload.ID) - if checkError(request, response, utils.CurrentFuncName(), err) { - return + os, v, err := utils.GetOsAndSemverFromImage(img.ID) + if err != nil { + return err } - expirationDate := time.Now().Add(metal.DefaultImageExpiration) - if requestPayload.ExpirationDate != nil && !requestPayload.ExpirationDate.IsZero() { - expirationDate = *requestPayload.ExpirationDate + if img.OS != os { + return fmt.Errorf("os must be derived from image id: %s", img.OS) } - vc := metal.ClassificationPreview - if requestPayload.Classification != nil { - vc, err = metal.VersionClassificationFrom(*requestPayload.Classification) - if err != nil { - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - } + if img.Version != v.String() { + return fmt.Errorf("version must be derived from image id: %s", img.Version) } - err = checkImageURL(requestPayload.ID, requestPayload.URL) - if checkError(request, response, utils.CurrentFuncName(), err) { - return + if img.ExpirationDate.IsZero() { + img.ExpirationDate = time.Now().Add(metal.DefaultImageExpiration) } - img := &metal.Image{ - Base: metal.Base{ - ID: requestPayload.ID, - Name: name, - Description: description, - }, - URL: requestPayload.URL, - Features: features, - OS: os, - Version: v.String(), - ExpirationDate: expirationDate, - Classification: vc, + _, err = metal.VersionClassificationFrom(string(img.Classification)) + if err != nil { + return err } - err = ir.ds.CreateImage(img) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - err = response.WriteHeaderAndEntity(http.StatusCreated, v1.NewImageResponse(img)) + err = checkImageURL(img.ID, img.URL) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return err } + + return nil } func checkImageURL(id, url string) error { @@ -306,117 +156,86 @@ func checkImageURL(id, url string) error { return nil } -func (ir imageResource) deleteImage(request *restful.Request, response *restful.Response) { - id := request.PathParameter("id") - +func (ir ImageService) Delete(id string) (*metal.Image, error) { img, err := ir.ds.GetImage(id) - if checkError(request, response, utils.CurrentFuncName(), err) { - return + if err != nil { + return nil, err } - ms, err := ir.ds.ListMachines() - if checkError(request, response, utils.CurrentFuncName(), err) { - return + var ms metal.Machines + err = ir.ds.SearchMachines(&datastore.MachineSearchQuery{ + AllocationImageID: &img.ID, + }, &ms) + if err != nil { + return nil, err } - machines := ir.machinesByImage(ms, img.ID) - if len(machines) > 0 { - if checkError(request, response, utils.CurrentFuncName(), fmt.Errorf("image %s is in use by machines:%v", img.ID, machines)) { - return - } + if len(ms) > 0 { + return nil, fmt.Errorf("image %s is in use by %d machines", img.ID, len(ms)) } err = ir.ds.DeleteImage(img) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return nil, err } -} -func (ir imageResource) updateImage(request *restful.Request, response *restful.Response) { - var requestPayload v1.ImageUpdateRequest - err := request.ReadEntity(&requestPayload) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } + return img, nil +} - oldImage, err := ir.ds.GetImage(requestPayload.ID) - if checkError(request, response, utils.CurrentFuncName(), err) { - return +func (ir ImageService) Update(img *metal.Image) (*metal.Image, error) { + oldImage, err := ir.ds.GetImage(img.ID) + if err != nil { + return nil, err } newImage := *oldImage - if requestPayload.Name != nil { - newImage.Name = *requestPayload.Name + if img.Name != "" { + newImage.Name = img.Name } - if requestPayload.Description != nil { - newImage.Description = *requestPayload.Description + if img.Description != "" { + newImage.Description = img.Description } - if requestPayload.URL != nil { - err = checkImageURL(requestPayload.ID, *requestPayload.URL) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - newImage.URL = *requestPayload.URL + if img.URL != "" { + newImage.URL = img.URL } - features := make(map[metal.ImageFeatureType]bool) - for _, f := range requestPayload.Features { - ft, err := metal.ImageFeatureTypeFrom(f) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - features[ft] = true + if len(img.Features) > 0 { + newImage.Features = img.Features } - if len(features) > 0 { - newImage.Features = features + if img.Classification != "" { + newImage.Classification = img.Classification } - - if requestPayload.Classification != nil { - vc, err := metal.VersionClassificationFrom(*requestPayload.Classification) - if err != nil { - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - } - newImage.Classification = vc + if !img.ExpirationDate.IsZero() { + newImage.ExpirationDate = img.ExpirationDate } - if requestPayload.ExpirationDate != nil { - newImage.ExpirationDate = *requestPayload.ExpirationDate + err = validateImage(&newImage) + if err != nil { + return nil, err } err = ir.ds.UpdateImage(oldImage, &newImage) - if checkError(request, response, utils.CurrentFuncName(), err) { - return - } - err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(&newImage)) if err != nil { - zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) - return + return nil, err } -} -func (ir imageResource) machinesByImage(machines metal.Machines, imageID string) []string { - var machinesByImage []string - for _, m := range machines { - if m.Allocation == nil { - continue - } - if m.Allocation.ImageID == imageID { - machinesByImage = append(machinesByImage, m.ID) - } - } - return machinesByImage + return &newImage, nil } // networkUsageCollector implements the prometheus collector interface. type imageUsageCollector struct { - ir *imageResource + ir *ImageService +} + +func RegisterImageUsageCollector(ds *datastore.RethinkStore) error { + iuc := imageUsageCollector{ir: NewImageService(ds)} + + err := prometheus.Register(iuc) + if err != nil { + return fmt.Errorf("failed to register prometheus: %w", err) + } + + return nil } var usedImageDesc = prometheus.NewDesc( diff --git a/cmd/metal-api/internal/service/v1/services/common_test.go b/cmd/metal-api/internal/service/v1/services/common_test.go new file mode 100644 index 000000000..9a5cb78f4 --- /dev/null +++ b/cmd/metal-api/internal/service/v1/services/common_test.go @@ -0,0 +1,96 @@ +package services + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + restful "github.com/emicklei/go-restful/v3" + "github.com/metal-stack/metal-lib/zapup" + "github.com/metal-stack/security" + "github.com/stretchr/testify/require" +) + +type emptyPublisher struct { + doPublish func(topic string, data interface{}) error +} + +func (p *emptyPublisher) Publish(topic string, data interface{}) error { + if p.doPublish != nil { + return p.doPublish(topic, data) + } + return nil +} + +func (p *emptyPublisher) CreateTopic(topic string) error { + return nil +} + +func (p *emptyPublisher) Stop() {} + +//nolint:deadcode,unused +type emptyBody struct{} + +func webRequestPut(t *testing.T, service *restful.WebService, user *security.User, request interface{}, path string, response interface{}) int { + return webRequest(t, http.MethodPut, service, user, request, path, response) +} + +func webRequestPost(t *testing.T, service *restful.WebService, user *security.User, request interface{}, path string, response interface{}) int { + return webRequest(t, http.MethodPost, service, user, request, path, response) +} + +func webRequestDelete(t *testing.T, service *restful.WebService, user *security.User, request interface{}, path string, response interface{}) int { + return webRequest(t, http.MethodDelete, service, user, request, path, response) +} + +func webRequestGet(t *testing.T, service *restful.WebService, user *security.User, request interface{}, path string, response interface{}) int { + return webRequest(t, http.MethodGet, service, user, request, path, response) +} + +func webRequest(t *testing.T, method string, service *restful.WebService, user *security.User, request interface{}, path string, response interface{}) int { + container := restful.NewContainer().Add(service) + + jsonBody, err := json.Marshal(request) + require.NoError(t, err) + body := io.NopCloser(strings.NewReader(string(jsonBody))) + createReq := httptest.NewRequest(method, path, body) + createReq.Header.Set("Content-Type", "application/json") + + container.Filter(MockAuth(user)) + + w := httptest.NewRecorder() + container.ServeHTTP(w, createReq) + + resp := w.Result() + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(response) + require.NoError(t, err) + return resp.StatusCode +} + +func MockAuth(user *security.User) restful.FilterFunction { + return func(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) { + log := zapup.RequestLogger(req.Request) + rq := req.Request + ctx := security.PutUserInContext(zapup.PutLogger(rq.Context(), log), user) + req.Request = rq.WithContext(ctx) + chain.ProcessFilter(req, resp) + } +} + +type NopPublisher struct { +} + +func (p NopPublisher) Publish(topic string, data interface{}) error { + return nil +} + +func (p NopPublisher) CreateTopic(topic string) error { + return nil +} + +func (p NopPublisher) Stop() {} diff --git a/cmd/metal-api/internal/service/v1/services/image.go b/cmd/metal-api/internal/service/v1/services/image.go new file mode 100644 index 000000000..909fdd5cd --- /dev/null +++ b/cmd/metal-api/internal/service/v1/services/image.go @@ -0,0 +1,351 @@ +package services + +import ( + "net/http" + "strconv" + + "github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/service" + v1 "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/v1" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/utils" + "go.uber.org/zap" + + restfulspec "github.com/emicklei/go-restful-openapi/v2" + restful "github.com/emicklei/go-restful/v3" + "github.com/metal-stack/metal-lib/httperrors" + "github.com/metal-stack/metal-lib/zapup" +) + +type imageResource struct { + images *service.ImageService + ds *datastore.RethinkStore // TODO: to be removed when migrated all services +} + +// NewImage returns a webservice for image specific endpoints. +func NewImage(ds *datastore.RethinkStore) *restful.WebService { + ir := imageResource{ + images: service.NewImageService(ds), + ds: ds, + } + + return ir.webService() +} + +func (ir imageResource) webService() *restful.WebService { + ws := new(restful.WebService) + ws. + Path(BasePath + "v1/image"). + Consumes(restful.MIME_JSON). + Produces(restful.MIME_JSON) + + tags := []string{"image"} + + ws.Route(ws.GET("/{id}"). + To(ir.findImage). + Operation("findImage"). + Doc("get image by id"). + Param(ws.PathParameter("id", "identifier of the image").DataType("string")). + Metadata(restfulspec.KeyOpenAPITags, tags). + Writes(v1.ImageResponse{}). + Returns(http.StatusOK, "OK", v1.ImageResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + ws.Route(ws.GET("/{id}/query"). + To(ir.queryImages). + Operation("queryImages by id"). + Doc("query all images which match at least id"). + Param(ws.PathParameter("id", "identifier of the image").DataType("string")). + Metadata(restfulspec.KeyOpenAPITags, tags). + Writes([]v1.ImageResponse{}). + Returns(http.StatusOK, "OK", []v1.ImageResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + ws.Route(ws.GET("/{id}/latest"). + To(ir.findLatestImage). + Operation("findLatestImage"). + Doc("find latest image by id"). + Param(ws.PathParameter("id", "identifier of the image").DataType("string")). + Metadata(restfulspec.KeyOpenAPITags, tags). + Writes(v1.ImageResponse{}). + Returns(http.StatusOK, "OK", v1.ImageResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + ws.Route(ws.GET("/"). + To(ir.listImages). + Operation("listImages"). + Doc("get all images"). + Metadata(restfulspec.KeyOpenAPITags, tags). + Param(ws.QueryParameter("show-usage", "include image usage into response").DataType("bool").DefaultValue("false")). + Writes([]v1.ImageResponse{}). + Returns(http.StatusOK, "OK", []v1.ImageResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + ws.Route(ws.DELETE("/{id}"). + To(admin(ir.deleteImage)). + Operation("deleteImage"). + Doc("deletes an image and returns the deleted entity"). + Param(ws.PathParameter("id", "identifier of the image").DataType("string")). + Metadata(restfulspec.KeyOpenAPITags, tags). + Writes(v1.ImageResponse{}). + Returns(http.StatusOK, "OK", v1.ImageResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + ws.Route(ws.PUT("/"). + To(admin(ir.createImage)). + Operation("createImage"). + Doc("create an image. if the given ID already exists a conflict is returned"). + Metadata(restfulspec.KeyOpenAPITags, tags). + Reads(v1.ImageCreateRequest{}). + Returns(http.StatusCreated, "Created", v1.ImageResponse{}). + Returns(http.StatusConflict, "Conflict", httperrors.HTTPErrorResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + ws.Route(ws.POST("/"). + To(admin(ir.updateImage)). + Operation("updateImage"). + Doc("updates an image. if the image was changed since this one was read, a conflict is returned"). + Metadata(restfulspec.KeyOpenAPITags, tags). + Reads(v1.ImageUpdateRequest{}). + Returns(http.StatusOK, "OK", v1.ImageResponse{}). + Returns(http.StatusConflict, "Conflict", httperrors.HTTPErrorResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + + return ws +} + +func (ir imageResource) findImage(request *restful.Request, response *restful.Response) { + id := request.PathParameter("id") + + img, err := ir.images.Get(id) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) queryImages(request *restful.Request, response *restful.Response) { + id := request.PathParameter("id") + + img, err := ir.images.Find(id) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + result := []*v1.ImageResponse{} + + for i := range img { + result = append(result, v1.NewImageResponse(&img[i])) + } + err = response.WriteHeaderAndEntity(http.StatusOK, result) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) findLatestImage(request *restful.Request, response *restful.Response) { + id := request.PathParameter("id") + + img, err := ir.images.FindLatest(id) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) listImages(request *restful.Request, response *restful.Response) { + imgs, err := ir.images.List() + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + + ms := metal.Machines{} + showUsage := false + if request.QueryParameter("show-usage") != "" { + showUsage, err = strconv.ParseBool(request.QueryParameter("show-usage")) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + if showUsage { + ms, err = ir.ds.ListMachines() + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + } + } + + result := []*v1.ImageResponse{} + for i := range imgs { + img := v1.NewImageResponse(&imgs[i]) + if showUsage { + machines := ir.machinesByImage(ms, imgs[i].ID) + if len(machines) > 0 { + img.UsedBy = machines + } + } + result = append(result, img) + } + err = response.WriteHeaderAndEntity(http.StatusOK, result) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) createImage(request *restful.Request, response *restful.Response) { + var requestPayload v1.ImageCreateRequest + err := request.ReadEntity(&requestPayload) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + + features := make(map[metal.ImageFeatureType]bool) + for _, f := range requestPayload.Features { + ft, err := metal.ImageFeatureTypeFrom(f) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + features[ft] = true + } + + img := &metal.Image{ + Base: metal.Base{ + ID: requestPayload.ID, + }, + URL: requestPayload.URL, + Features: features, + } + + if requestPayload.Name != nil { + img.Name = *requestPayload.Name + } + if requestPayload.Description != nil { + img.Description = *requestPayload.Description + } + if requestPayload.ExpirationDate != nil && !requestPayload.ExpirationDate.IsZero() { + img.ExpirationDate = *requestPayload.ExpirationDate + } + if requestPayload.Classification != nil { + vc, err := metal.VersionClassificationFrom(*requestPayload.Classification) + if err != nil { + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + } + img.Classification = vc + } + + err = ir.images.Create(img) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + err = response.WriteHeaderAndEntity(http.StatusCreated, v1.NewImageResponse(img)) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) deleteImage(request *restful.Request, response *restful.Response) { + id := request.PathParameter("id") + + img, err := ir.images.Delete(id) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + + err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) updateImage(request *restful.Request, response *restful.Response) { + var requestPayload v1.ImageUpdateRequest + err := request.ReadEntity(&requestPayload) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + + img := &metal.Image{ + Base: metal.Base{ + ID: requestPayload.ID, + }, + } + + if requestPayload.Name != nil { + img.Name = *requestPayload.Name + } + if requestPayload.Description != nil { + img.Description = *requestPayload.Description + } + if requestPayload.URL != nil { + img.URL = *requestPayload.URL + } + if requestPayload.ExpirationDate != nil && !requestPayload.ExpirationDate.IsZero() { + img.ExpirationDate = *requestPayload.ExpirationDate + } + if requestPayload.Classification != nil { + vc, err := metal.VersionClassificationFrom(*requestPayload.Classification) + if err != nil { + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + } + img.Classification = vc + } + + features := make(map[metal.ImageFeatureType]bool) + for _, f := range requestPayload.Features { + ft, err := metal.ImageFeatureTypeFrom(f) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + features[ft] = true + } + if len(features) > 0 { + img.Features = features + } + + if requestPayload.Classification != nil { + vc, err := metal.VersionClassificationFrom(*requestPayload.Classification) + if err != nil { + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + } + img.Classification = vc + } + + img, err = ir.images.Update(img) + if checkError(request, response, utils.CurrentFuncName(), err) { + return + } + err = response.WriteHeaderAndEntity(http.StatusOK, v1.NewImageResponse(img)) + if err != nil { + zapup.MustRootLogger().Error("Failed to send response", zap.Error(err)) + return + } +} + +func (ir imageResource) machinesByImage(machines metal.Machines, imageID string) []string { + var machinesByImage []string + for _, m := range machines { + if m.Allocation == nil { + continue + } + if m.Allocation.ImageID == imageID { + machinesByImage = append(machinesByImage, m.ID) + } + } + return machinesByImage +} diff --git a/cmd/metal-api/internal/service/image-service_integration_test.go b/cmd/metal-api/internal/service/v1/services/image_integration_test.go similarity index 98% rename from cmd/metal-api/internal/service/image-service_integration_test.go rename to cmd/metal-api/internal/service/v1/services/image_integration_test.go index 7b91f727c..07e999cbb 100644 --- a/cmd/metal-api/internal/service/image-service_integration_test.go +++ b/cmd/metal-api/internal/service/v1/services/image_integration_test.go @@ -1,6 +1,7 @@ +//go:build integration // +build integration -package service +package services import ( "context" diff --git a/cmd/metal-api/internal/service/image-service_test.go b/cmd/metal-api/internal/service/v1/services/image_test.go similarity index 99% rename from cmd/metal-api/internal/service/image-service_test.go rename to cmd/metal-api/internal/service/v1/services/image_test.go index 2ebe5d307..95198237c 100644 --- a/cmd/metal-api/internal/service/image-service_test.go +++ b/cmd/metal-api/internal/service/v1/services/image_test.go @@ -1,4 +1,4 @@ -package service +package services import ( "bytes" diff --git a/cmd/metal-api/internal/service/integration_test.go b/cmd/metal-api/internal/service/v1/services/integration_test.go similarity index 95% rename from cmd/metal-api/internal/service/integration_test.go rename to cmd/metal-api/internal/service/v1/services/integration_test.go index 31d09949c..a4661a4d1 100644 --- a/cmd/metal-api/internal/service/integration_test.go +++ b/cmd/metal-api/internal/service/v1/services/integration_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package service +package services import ( "context" @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/keepalive" metalgrpc "github.com/metal-stack/metal-api/cmd/metal-api/internal/grpc" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/service" "github.com/metal-stack/metal-api/test" "github.com/metal-stack/metal-lib/bus" "github.com/metal-stack/security" @@ -98,15 +99,15 @@ func createTestEnvironment(t *testing.T) testEnv { hma := security.NewHMACAuth(testUserDirectory.admin.Name, []byte{1, 2, 3}, security.WithUser(testUserDirectory.admin)) usergetter := security.NewCreds(security.WithHMAC(hma)) - machineService, err := NewMachine(ds, &emptyPublisher{}, bus.DirectEndpoints(), ipamer, mdc, grpcServer, nil, usergetter, 0) + machineService, err := service.NewMachine(ds, &emptyPublisher{}, bus.DirectEndpoints(), ipamer, mdc, grpcServer, nil, usergetter, 0) require.NoError(t, err) imageService := NewImage(ds) - switchService := NewSwitch(ds) - sizeService := NewSize(ds) - sizeImageConstraintService := NewSizeImageConstraint(ds) - networkService := NewNetwork(ds, ipamer, mdc) - partitionService := NewPartition(ds, &emptyPublisher{}) - ipService, err := NewIP(ds, bus.DirectEndpoints(), ipamer, mdc) + switchService := service.NewSwitch(ds) + sizeService := service.NewSize(ds) + sizeImageConstraintService := service.NewSizeImageConstraint(ds) + networkService := service.NewNetwork(ds, ipamer, mdc) + partitionService := service.NewPartition(ds, &emptyPublisher{}) + ipService, err := service.NewIP(ds, bus.DirectEndpoints(), ipamer, mdc) require.NoError(t, err) te := testEnv{ diff --git a/cmd/metal-api/internal/service/machine-service_allocation_test.go b/cmd/metal-api/internal/service/v1/services/machine-service_allocation_test.go similarity index 98% rename from cmd/metal-api/internal/service/machine-service_allocation_test.go rename to cmd/metal-api/internal/service/v1/services/machine-service_allocation_test.go index c26077ad5..b6bda7b3d 100644 --- a/cmd/metal-api/internal/service/machine-service_allocation_test.go +++ b/cmd/metal-api/internal/service/v1/services/machine-service_allocation_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package service +package services import ( "bytes" @@ -24,6 +24,7 @@ import ( "github.com/metal-stack/metal-api/cmd/metal-api/internal/grpc" "github.com/metal-stack/metal-api/cmd/metal-api/internal/ipam" "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/service" v1 "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/v1" "github.com/metal-stack/metal-api/test" "github.com/metal-stack/metal-lib/bus" @@ -331,7 +332,7 @@ func setupTestEnvironment(machineCount int, t *testing.T) (*datastore.RethinkSto createTestdata(machineCount, rs, ipamer, t) usergetter := security.NewCreds(security.WithHMAC(hma)) - ms, err := NewMachine(rs, &emptyPublisher{}, bus.DirectEndpoints(), ipam.New(ipamer), mdc, ws, nil, usergetter, 0) + ms, err := service.NewMachine(rs, &emptyPublisher{}, bus.DirectEndpoints(), ipam.New(ipamer), mdc, ws, nil, usergetter, 0) require.NoError(t, err) container := restful.NewContainer().Add(ms) container.Filter(rest.UserAuth(usergetter)) diff --git a/cmd/metal-api/internal/service/machine-service_integration_test.go b/cmd/metal-api/internal/service/v1/services/machine-service_integration_test.go similarity index 97% rename from cmd/metal-api/internal/service/machine-service_integration_test.go rename to cmd/metal-api/internal/service/v1/services/machine-service_integration_test.go index 1c4082298..5908c3f37 100644 --- a/cmd/metal-api/internal/service/machine-service_integration_test.go +++ b/cmd/metal-api/internal/service/v1/services/machine-service_integration_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package service +package services import ( "net" @@ -9,6 +9,7 @@ import ( "testing" "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/service" v1 "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/v1" "github.com/stretchr/testify/assert" @@ -108,7 +109,7 @@ func TestMachineAllocationIntegrationFullCycle(t *testing.T) { assert.NotEmpty(t, allocatedMachine.Allocation.MachineNetworks[0].Vrf) assert.GreaterOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].Vrf, te.ds.VRFPoolRangeMin) assert.LessOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].Vrf, te.ds.VRFPoolRangeMax) - assert.GreaterOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].ASN, int64(ASNBase)) + assert.GreaterOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].ASN, int64(service.ASNBase)) assert.Len(t, allocatedMachine.Allocation.MachineNetworks[0].IPs, 1) _, ipnet, _ := net.ParseCIDR(te.privateNetwork.Prefixes[0]) ip := net.ParseIP(allocatedMachine.Allocation.MachineNetworks[0].IPs[0]) @@ -147,7 +148,7 @@ func TestMachineAllocationIntegrationFullCycle(t *testing.T) { assert.NotEmpty(t, allocatedMachine.Allocation.MachineNetworks[0].Vrf) assert.GreaterOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].Vrf, te.ds.VRFPoolRangeMin) assert.LessOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].Vrf, te.ds.VRFPoolRangeMax) - assert.GreaterOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].ASN, int64(ASNBase)) + assert.GreaterOrEqual(t, allocatedMachine.Allocation.MachineNetworks[0].ASN, int64(service.ASNBase)) assert.Len(t, allocatedMachine.Allocation.MachineNetworks[0].IPs, 1) _, ipnet, _ = net.ParseCIDR(te.privateNetwork.Prefixes[0]) ip = net.ParseIP(allocatedMachine.Allocation.MachineNetworks[0].IPs[0]) diff --git a/cmd/metal-api/internal/service/v1/services/service.go b/cmd/metal-api/internal/service/v1/services/service.go new file mode 100644 index 000000000..048fc8f9c --- /dev/null +++ b/cmd/metal-api/internal/service/v1/services/service.go @@ -0,0 +1,199 @@ +package services + +import ( + "fmt" + "net/http" + "strings" + + mdmv1 "github.com/metal-stack/masterdata-api/api/v1" + "github.com/metal-stack/metal-lib/jwt/sec" + + "github.com/go-stack/stack" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/utils" + "github.com/metal-stack/metal-lib/httperrors" + + "github.com/emicklei/go-restful/v3" + "github.com/metal-stack/security" + "go.uber.org/zap" +) + +const ( + viewUserEmail = "metal-view@metal-stack.io" + editUserEmail = "metal-edit@metal-stack.io" + adminUserEmail = "metal-admin@metal-stack.io" +) + +// BasePath is the URL base path for the metal-api +var BasePath = "/" + +// UserDirectory is the directory of users +type UserDirectory struct { + viewer security.User + edit security.User + admin security.User + + metalUsers map[string]security.User +} + +// NewUserDirectory creates a new user directory with default users +func NewUserDirectory(providerTenant string) *UserDirectory { + ud := &UserDirectory{} + + // User.Name is used as AuthType for HMAC + ud.viewer = security.User{ + EMail: viewUserEmail, + Name: "Metal-View", + Groups: sec.MergeResourceAccess(metal.ViewGroups), + Tenant: providerTenant, + } + ud.edit = security.User{ + EMail: editUserEmail, + Name: "Metal-Edit", + Groups: sec.MergeResourceAccess(metal.EditGroups), + Tenant: providerTenant, + } + ud.admin = security.User{ + EMail: adminUserEmail, + Name: "Metal-Admin", + Groups: sec.MergeResourceAccess(metal.AdminGroups), + Tenant: providerTenant, + } + ud.metalUsers = map[string]security.User{ + "view": ud.viewer, + "edit": ud.edit, + "admin": ud.admin, + } + + return ud +} + +// UserNames returns the list of user names in the directory. +func (ud *UserDirectory) UserNames() []string { + keys := make([]string, 0, len(ud.metalUsers)) + for k := range ud.metalUsers { + keys = append(keys, k) + } + return keys +} + +// Get a user by its user name. +func (ud *UserDirectory) Get(user string) security.User { + return ud.metalUsers[user] +} + +func sendError(log *zap.Logger, rsp *restful.Response, opname string, errRsp *httperrors.HTTPErrorResponse) { + sendErrorImpl(log, rsp, opname, errRsp, 1) +} + +func sendErrorImpl(log *zap.Logger, rsp *restful.Response, opname string, errRsp *httperrors.HTTPErrorResponse, stackup int) { + s := stack.Caller(stackup) + log.Error("service error", zap.String("operation", opname), zap.Int("status", errRsp.StatusCode), zap.String("error", errRsp.Message), zap.Stringer("service-caller", s), zap.String("resp", errRsp.Error())) + err := rsp.WriteHeaderAndEntity(errRsp.StatusCode, errRsp) + if err != nil { + log.Error("Failed to send response", zap.Error(err)) + return + } +} + +func checkError(rq *restful.Request, rsp *restful.Response, opname string, err error) bool { + log := utils.Logger(rq) + if err != nil { + if metal.IsNotFound(err) { + sendErrorImpl(log, rsp, opname, httperrors.NotFound(err), 2) + return true + } + if metal.IsConflict(err) { + sendErrorImpl(log, rsp, opname, httperrors.Conflict(err), 2) + return true + } + if metal.IsInternal(err) { + sendErrorImpl(log, rsp, opname, httperrors.InternalServerError(err), 2) + return true + } + if mdmv1.IsNotFound(err) { + sendErrorImpl(log, rsp, opname, httperrors.NotFound(err), 2) + return true + } + if mdmv1.IsConflict(err) { + sendErrorImpl(log, rsp, opname, httperrors.Conflict(err), 2) + return true + } + if mdmv1.IsInternal(err) { + sendErrorImpl(log, rsp, opname, httperrors.InternalServerError(err), 2) + return true + } + sendErrorImpl(log, rsp, opname, httperrors.NewHTTPError(http.StatusUnprocessableEntity, err), 2) + return true + } + return false +} + +func admin(rf restful.RouteFunction) restful.RouteFunction { + return oneOf(rf, metal.AdminAccess...) +} + +func oneOf(rf restful.RouteFunction, acc ...security.ResourceAccess) restful.RouteFunction { + return func(request *restful.Request, response *restful.Response) { + log := utils.Logger(request) + lg := log.Sugar() + usr := security.GetUser(request.Request) + if !usr.HasGroup(acc...) { + err := fmt.Errorf("you are not member in one of %+v", acc) + lg.Infow("missing group", "user", usr, "required-group", acc) + sendError(log, response, utils.CurrentFuncName(), httperrors.NewHTTPError(http.StatusForbidden, err)) + return + } + rf(request, response) + } +} + +func tenant(request *restful.Request) string { + return security.GetUser(request.Request).Tenant +} + +// TenantEnsurer holds allowed tenants and a list of path suffixes that +type TenantEnsurer struct { + allowedTenants map[string]bool + excludedPathSuffixes []string +} + +// NewTenantEnsurer creates a new ensurer with the given tenants. +func NewTenantEnsurer(tenants, excludedPathSuffixes []string) TenantEnsurer { + result := TenantEnsurer{ + allowedTenants: make(map[string]bool), + excludedPathSuffixes: excludedPathSuffixes, + } + for _, t := range tenants { + result.allowedTenants[strings.ToLower(t)] = true + } + return result +} + +// EnsureAllowedTenantFilter checks if the tenant of the user is allowed. +func (e *TenantEnsurer) EnsureAllowedTenantFilter(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) { + p := req.Request.URL.Path + + // securing health checks would break monitoring tools + // preventing liveliness would break status of machines + for _, suffix := range e.excludedPathSuffixes { + if strings.HasSuffix(p, suffix) { + chain.ProcessFilter(req, resp) + return + } + } + + // enforce tenant check otherwise + tenantID := tenant(req) + if !e.allowed(tenantID) { + err := fmt.Errorf("tenant %s not allowed", tenantID) + sendError(utils.Logger(req), resp, utils.CurrentFuncName(), httperrors.NewHTTPError(http.StatusForbidden, err)) + return + } + chain.ProcessFilter(req, resp) +} + +// allowed checks if the given tenant is allowed (case insensitive) +func (e *TenantEnsurer) allowed(tenant string) bool { + return e.allowedTenants[strings.ToLower(tenant)] +} diff --git a/cmd/metal-api/internal/service/v1/services/service_test.go b/cmd/metal-api/internal/service/v1/services/service_test.go new file mode 100644 index 000000000..dcf8408ed --- /dev/null +++ b/cmd/metal-api/internal/service/v1/services/service_test.go @@ -0,0 +1,95 @@ +package services + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/emicklei/go-restful/v3" + "github.com/metal-stack/metal-lib/httperrors" + "github.com/metal-stack/metal-lib/rest" + "github.com/metal-stack/security" +) + +var testUserDirectory = NewUserDirectory("") + +func injectAdmin(container *restful.Container, rq *http.Request) *restful.Container { + return injectUser(testUserDirectory.admin, container, rq) +} + +func injectUser(u security.User, container *restful.Container, rq *http.Request) *restful.Container { + hma := security.NewHMACAuth(u.Name, []byte{1, 2, 3}, security.WithUser(u)) + usergetter := security.NewCreds(security.WithHMAC(hma)) + container.Filter(rest.UserAuth(usergetter)) + var body []byte + if rq.Body != nil { + data, _ := io.ReadAll(rq.Body) + body = data + rq.Body.Close() + rq.Body = io.NopCloser(bytes.NewReader(data)) + } + hma.AddAuth(rq, time.Now(), body) + return container +} + +func TestTenantEnsurer(t *testing.T) { + e := NewTenantEnsurer([]string{"pvdr", "Pv", "pv-DR"}, nil) + require.True(t, e.allowed("pvdr")) + require.True(t, e.allowed("Pv")) + require.True(t, e.allowed("pv")) + require.True(t, e.allowed("pv-DR")) + require.True(t, e.allowed("PV-DR")) + require.True(t, e.allowed("PV-dr")) + require.False(t, e.allowed("")) + require.False(t, e.allowed("abc")) +} + +func TestAllowedPathSuffixes(t *testing.T) { + foo := func(req *restful.Request, resp *restful.Response) { + _ = resp.WriteHeaderAndEntity(http.StatusOK, nil) + } + + e := NewTenantEnsurer([]string{"a", "b", "c"}, []string{"health", "liveliness"}) + ws := new(restful.WebService).Path("/").Consumes(restful.MIME_JSON).Produces(restful.MIME_JSON) + ws.Filter(e.EnsureAllowedTenantFilter) + health := ws.GET("health").To(foo).Returns(http.StatusOK, "OK", nil).DefaultReturns("Error", httperrors.HTTPErrorResponse{}) + liveliness := ws.GET("liveliness").To(foo).Returns(http.StatusOK, "OK", nil).DefaultReturns("Error", httperrors.HTTPErrorResponse{}) + machine := ws.GET("machine").To(foo).Returns(http.StatusOK, "OK", nil).DefaultReturns("Error", httperrors.HTTPErrorResponse{}) + ws.Route(health) + ws.Route(liveliness) + ws.Route(machine) + restful.DefaultContainer.Add(ws) + + // health must be allowed without tenant check + httpRequest, _ := http.NewRequestWithContext(context.TODO(), "GET", "http://localhost/health", nil) + httpRequest.Header.Set("Accept", "application/json") + httpWriter := httptest.NewRecorder() + + restful.DefaultContainer.Dispatch(httpWriter, httpRequest) + + require.Equal(t, http.StatusOK, httpWriter.Code) + + // liveliness must be allowed without tenant check + httpRequest, _ = http.NewRequestWithContext(context.TODO(), "GET", "http://localhost/liveliness", nil) + httpRequest.Header.Set("Accept", "application/json") + httpWriter = httptest.NewRecorder() + + restful.DefaultContainer.Dispatch(httpWriter, httpRequest) + + require.Equal(t, http.StatusOK, httpWriter.Code) + + // machine must not be allowed without tenant check + httpRequest, _ = http.NewRequestWithContext(context.TODO(), "GET", "http://localhost/machine", nil) + httpRequest.Header.Set("Accept", "application/json") + httpWriter = httptest.NewRecorder() + + restful.DefaultContainer.Dispatch(httpWriter, httpRequest) + + require.Equal(t, http.StatusForbidden, httpWriter.Code) +} diff --git a/cmd/metal-api/internal/testdata/testdata.go b/cmd/metal-api/internal/testdata/testdata.go index 296fdaa13..7845ab9ac 100644 --- a/cmd/metal-api/internal/testdata/testdata.go +++ b/cmd/metal-api/internal/testdata/testdata.go @@ -819,6 +819,7 @@ func InitMockDBData(mock *r.Mock) { mock.On(r.DB("mockdb").Table("machine").Get("8")).Return(M8, nil) mock.On(r.DB("mockdb").Table("machine").Get("404")).Return(nil, errors.New("Test Error")) mock.On(r.DB("mockdb").Table("machine").Get("999")).Return(nil, nil) + mock.On(r.DB("mockdb").Table("machine").Filter(func(var_13 r.Term) r.Term { return var_13.Field("allocation").Field("imageid").Eq("image-3") })) mock.On(r.DB("mockdb").Table("machine").Filter(func(var_1 r.Term) r.Term { return var_1.Field("partitionid").Eq(Partition1.ID) })).Return(metal.Machines{M1, M2, M3, M4, M5, M7, M8}, nil) diff --git a/cmd/metal-api/main.go b/cmd/metal-api/main.go index 8086e229c..c32380bd9 100644 --- a/cmd/metal-api/main.go +++ b/cmd/metal-api/main.go @@ -15,6 +15,7 @@ import ( v1 "github.com/metal-stack/masterdata-api/api/v1" "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/s3client" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/v1/services" "google.golang.org/protobuf/types/known/wrapperspb" "github.com/go-logr/zapr" @@ -707,7 +708,7 @@ func initRestServices(withauth bool) *restfulspec.Config { } restful.DefaultContainer.Add(service.NewPartition(ds, nsqer)) - restful.DefaultContainer.Add(service.NewImage(ds)) + restful.DefaultContainer.Add(services.NewImage(ds)) restful.DefaultContainer.Add(service.NewSize(ds)) restful.DefaultContainer.Add(service.NewSizeImageConstraint(ds)) restful.DefaultContainer.Add(service.NewNetwork(ds, ipamer, mdc)) @@ -733,6 +734,11 @@ func initRestServices(withauth bool) *restfulspec.Config { restful.DefaultContainer.Filter(ensurer.EnsureAllowedTenantFilter) } + err = service.RegisterImageUsageCollector(ds) + if err != nil { + logger.Fatal(err) + } + config := restfulspec.Config{ WebServices: restful.RegisteredWebServices(), // you control what services are visible APIPath: service.BasePath + "apidocs.json", diff --git a/pkg/api/v1/supwd.pb.go b/pkg/api/v1/supwd.pb.go index 316f62d4f..e1596f3a3 100644 --- a/pkg/api/v1/supwd.pb.go +++ b/pkg/api/v1/supwd.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.19.4 +// protoc v3.20.0 // source: api/v1/supwd.proto package v1 diff --git a/pkg/api/v1/wait.pb.go b/pkg/api/v1/wait.pb.go index a2e500502..eb8965600 100644 --- a/pkg/api/v1/wait.pb.go +++ b/pkg/api/v1/wait.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.19.4 +// protoc v3.20.0 // source: api/v1/wait.proto package v1