From 67294cc6691e47d652c9f73a65a84fb8c26d9c91 Mon Sep 17 00:00:00 2001 From: Bogdan Bivolaru <104334+bogdanbiv@users.noreply.github.com> Date: Fri, 30 Sep 2022 20:32:32 +0300 Subject: [PATCH] Add graphql query for retrieving imgSummary based on repo:tag image id. (#814) Refactor Image GqlResolver to better suit GetManifest. Changed GetManifest to also return digest. Signed-off-by: Bogdan BIVOLARU <104334+bogdanbiv@users.noreply.github.com> --- errors/errors.go | 5 +- pkg/extensions/search/common/common.go | 14 + pkg/extensions/search/common/common_test.go | 241 ++++++++++++------ pkg/extensions/search/common/model.go | 72 ++++++ pkg/extensions/search/common/oci_layout.go | 76 +----- pkg/extensions/search/digest/digest_test.go | 43 ++-- .../search/gql_generated/generated.go | 165 +++++++++++- pkg/extensions/search/resolver.go | 160 +++++++++--- pkg/extensions/search/resolver_test.go | 197 +++++++++++++- pkg/extensions/search/schema.graphql | 21 +- pkg/extensions/search/schema.resolvers.go | 29 ++- pkg/test/mocks/oci_mock.go | 9 + 12 files changed, 805 insertions(+), 227 deletions(-) create mode 100644 pkg/extensions/search/common/model.go diff --git a/errors/errors.go b/errors/errors.go index eb6e2b14..cf35eea1 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -18,7 +18,7 @@ var ( ErrBadBlobDigest = errors.New("blob: bad blob digest") ErrUnknownCode = errors.New("error: unknown error code") ErrBadCACert = errors.New("tls: invalid ca cert") - ErrBadUser = errors.New("ldap: non-existent user") + ErrBadUser = errors.New("auth: non-existent user") ErrEntriesExceeded = errors.New("ldap: too many entries returned") ErrLDAPEmptyPassphrase = errors.New("ldap: empty passphrase") ErrLDAPBadConn = errors.New("ldap: bad connection") @@ -32,7 +32,7 @@ var ( ErrInvalidArgs = errors.New("cli: Invalid Arguments") ErrInvalidFlagsCombination = errors.New("cli: Invalid combination of flags") ErrInvalidURL = errors.New("cli: invalid URL format") - ErrUnauthorizedAccess = errors.New("cli: unauthorized access. check credentials") + ErrUnauthorizedAccess = errors.New("auth: unauthorized access. check credentials") ErrCannotResetConfigKey = errors.New("cli: cannot reset given config key") ErrConfigNotFound = errors.New("cli: config with the given name does not exist") ErrNoURLProvided = errors.New("cli: no URL provided in argument or via config") @@ -58,4 +58,5 @@ var ( ErrBadType = errors.New("core: invalid type") ErrParsingHTTPHeader = errors.New("routes: invalid HTTP header") ErrBadRange = errors.New("storage: bad range") + ErrBadLayerCount = errors.New("manifest: layers count doesn't correspond to config history") ) diff --git a/pkg/extensions/search/common/common.go b/pkg/extensions/search/common/common.go index 709d2456..d50a4583 100644 --- a/pkg/extensions/search/common/common.go +++ b/pkg/extensions/search/common/common.go @@ -61,6 +61,20 @@ func GetRepo(image string) string { return image } +func GetImageDirAndTag(imageName string) (string, string) { + var imageDir string + + var imageTag string + + if strings.Contains(imageName, ":") { + imageDir, imageTag, _ = strings.Cut(imageName, ":") + } else { + imageDir = imageName + } + + return imageDir, imageTag +} + func GetFixedTags(allTags, vulnerableTags []TagInfo) []TagInfo { sort.Slice(allTags, func(i, j int) bool { return allTags[i].Timestamp.Before(allTags[j].Timestamp) diff --git a/pkg/extensions/search/common/common_test.go b/pkg/extensions/search/common/common_test.go index f0b62b70..808432b6 100644 --- a/pkg/extensions/search/common/common_test.go +++ b/pkg/extensions/search/common/common_test.go @@ -66,7 +66,7 @@ type ImageListResponse struct { } type ImageList struct { - SummaryList []ImageSummary `json:"imageList"` + SummaryList []common.ImageSummary `json:"imageList"` } type ExpandedRepoInfoResp struct { @@ -83,62 +83,9 @@ type GlobalSearchResult struct { GlobalSearch GlobalSearch `json:"globalSearch"` } type GlobalSearch struct { - Images []ImageSummary `json:"images"` - Repos []RepoSummary `json:"repos"` - Layers []LayerSummary `json:"layers"` -} - -type ImageSummary struct { - RepoName string `json:"repoName"` - Tag string `json:"tag"` - LastUpdated time.Time `json:"lastUpdated"` - Size string `json:"size"` - Platform OsArch `json:"platform"` - Vendor string `json:"vendor"` - Score int `json:"score"` - IsSigned bool `json:"isSigned"` - History []LayerHistory `json:"history"` - Layers []LayerSummary `json:"layers"` - Vulnerabilities ImageVulnerabilitySummary `json:"vulnerabilities"` -} - -type LayerHistory struct { - Layer LayerSummary `json:"layer"` - HistoryDescription HistoryDescription `json:"historyDescription"` -} - -type HistoryDescription struct { - Created time.Time `json:"created"` - CreatedBy string `json:"createdBy"` - Author string `json:"author"` - Comment string `json:"comment"` - EmptyLayer bool `json:"emptyLayer"` -} - -type ImageVulnerabilitySummary struct { - MaxSeverity string `json:"maxSeverity"` - Count int `json:"count"` -} - -type RepoSummary struct { - Name string `json:"name"` - LastUpdated time.Time `json:"lastUpdated"` - Size string `json:"size"` - Platforms []OsArch `json:"platforms"` - Vendors []string `json:"vendors"` - Score int `json:"score"` - NewestImage ImageSummary `json:"newestImage"` -} - -type LayerSummary struct { - Size string `json:"size"` - Digest string `json:"digest"` - Score int `json:"score"` -} - -type OsArch struct { - Os string `json:"os"` - Arch string `json:"arch"` + Images []common.ImageSummary `json:"images"` + Repos []common.RepoSummary `json:"repos"` + Layers []common.LayerSummary `json:"layers"` } type ExpandedRepoInfo struct { @@ -147,7 +94,7 @@ type ExpandedRepoInfo struct { //nolint:tagliatelle // graphQL schema type RepoListWithNewestImage struct { - Repos []RepoSummary `json:"RepoListWithNewestImage"` + Repos []common.RepoSummary `json:"RepoListWithNewestImage"` } type ErrorGQL struct { @@ -155,15 +102,12 @@ type ErrorGQL struct { Path []string `json:"path"` } -type ImageInfo struct { - RepoName string - Tag string - LastUpdated time.Time - Description string - Licenses string - Vendor string - Size string - Labels string +type SingleImageSummary struct { + ImageSummary common.ImageSummary `json:"Image"` //nolint:tagliatelle +} +type ImageSummaryResult struct { + SingleImageSummary SingleImageSummary `json:"data"` + Errors []ErrorGQL `json:"errors"` } func testSetup(t *testing.T, subpath string) error { @@ -1202,7 +1146,7 @@ func TestDerivedImageList(t *testing.T) { }, } - repoName := "test-repo" + repoName := "test-repo" //nolint:goconst err = UploadImage( Image{ @@ -1245,7 +1189,7 @@ func TestDerivedImageList(t *testing.T) { }, } - repoName = "same-layers" + repoName = "same-layers" //nolint:goconst err = UploadImage( Image{ @@ -1378,7 +1322,7 @@ func TestDerivedImageList(t *testing.T) { resp, err := resty.R().Get(baseURL + graphqlQueryPrefix + "?query=" + url.QueryEscape(query)) So(resp, ShouldNotBeNil) - So(strings.Contains(string(resp.Body()), "same-layers"), ShouldBeTrue) + So(strings.Contains(string(resp.Body()), "same-layers"), ShouldBeTrue) //nolint:goconst So(strings.Contains(string(resp.Body()), "missing-layers"), ShouldBeFalse) So(strings.Contains(string(resp.Body()), "more-layers"), ShouldBeTrue) So(err, ShouldBeNil) @@ -1497,7 +1441,7 @@ func TestGetImageManifest(t *testing.T) { } olu := common.NewBaseOciLayoutUtils(storeController, log.NewLogger("debug", "")) - _, err := olu.GetImageManifest("nonexistent-repo", "latest") + _, _, err := olu.GetImageManifest("nonexistent-repo", "latest") So(err, ShouldNotBeNil) }) @@ -1513,7 +1457,7 @@ func TestGetImageManifest(t *testing.T) { } olu := common.NewBaseOciLayoutUtils(storeController, log.NewLogger("debug", "")) - _, err := olu.GetImageManifest("test-repo", "latest") + _, _, err := olu.GetImageManifest("test-repo", "latest") //nolint:goconst So(err, ShouldNotBeNil) }) } @@ -1623,7 +1567,7 @@ func TestBaseImageList(t *testing.T) { }, } - repoName := "test-repo" + repoName := "test-repo" //nolint:goconst err = UploadImage( Image{ @@ -1671,7 +1615,7 @@ func TestBaseImageList(t *testing.T) { }, } - repoName = "same-layers" + repoName = "same-layers" //nolint:goconst err = UploadImage( Image{ @@ -1890,12 +1834,12 @@ func TestBaseImageList(t *testing.T) { resp, err := resty.R().Get(baseURL + graphqlQueryPrefix + "?query=" + url.QueryEscape(query)) So(resp, ShouldNotBeNil) - So(strings.Contains(string(resp.Body()), "same-layers"), ShouldBeTrue) + So(strings.Contains(string(resp.Body()), "same-layers"), ShouldBeTrue) //nolint:goconst So(strings.Contains(string(resp.Body()), "less-layers"), ShouldBeTrue) So(strings.Contains(string(resp.Body()), "less-layers-false"), ShouldBeFalse) So(strings.Contains(string(resp.Body()), "more-layers"), ShouldBeFalse) So(strings.Contains(string(resp.Body()), "diff-layers"), ShouldBeFalse) - So(strings.Contains(string(resp.Body()), "test-repo"), ShouldBeTrue) // should not list given image + So(strings.Contains(string(resp.Body()), "test-repo"), ShouldBeTrue) //nolint:goconst // should not list given image So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, 200) }) @@ -2172,7 +2116,7 @@ func TestGlobalSearch(t *testing.T) { t.Logf("returned layers: %v", responseStruct.GlobalSearchResult.GlobalSearch.Layers) So(len(responseStruct.GlobalSearchResult.GlobalSearch.Layers), ShouldNotBeEmpty) - newestImageMap := make(map[string]ImageSummary) + newestImageMap := make(map[string]common.ImageSummary) for _, image := range responseStruct.GlobalSearchResult.GlobalSearch.Images { // Make sure all returned results are supposed to be in the repo So(allExpectedTagMap[image.RepoName], ShouldContain, image.Tag) @@ -2395,7 +2339,7 @@ func TestGlobalSearch(t *testing.T) { t.Logf("returned layers: %v", responseStruct.GlobalSearchResult.GlobalSearch.Layers) So(len(responseStruct.GlobalSearchResult.GlobalSearch.Layers), ShouldNotBeEmpty) - newestImageMap := make(map[string]ImageSummary) + newestImageMap := make(map[string]common.ImageSummary) for _, image := range responseStruct.GlobalSearchResult.GlobalSearch.Images { // Make sure all returned results are supposed to be in the repo So(allExpectedTagMap[image.RepoName], ShouldContain, image.Tag) @@ -2740,7 +2684,10 @@ func TestBuildImageInfo(t *testing.T) { imageConfig, err := olu.GetImageConfigInfo(invalid, manifestDigest) So(err, ShouldBeNil) - imageSummary := search.BuildImageInfo(invalid, invalid, manifestDigest, manifest, imageConfig) + isSigned := false + + imageSummary := search.BuildImageInfo(invalid, invalid, manifestDigest, manifest, + imageConfig, isSigned) So(len(imageSummary.Layers), ShouldEqual, len(manifest.Layers)) imageSummaryLayerSize, err := strconv.Atoi(*imageSummary.Size) @@ -2883,7 +2830,7 @@ func TestSearchSize(t *testing.T) { { GlobalSearch(query:"test"){ Images { RepoName Tag LastUpdated Size Score } - Repos { + Repos { Name LastUpdated Size Vendors Score Platforms { Os @@ -2943,6 +2890,140 @@ func TestSearchSize(t *testing.T) { }) } +func TestImageSummary(t *testing.T) { + Convey("GraphQL query ImageSummary", t, func() { + port := GetFreePort() + baseURL := GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = t.TempDir() + + defaultVal := true + conf.Extensions = &extconf.ExtensionConfig{ + Search: &extconf.SearchConfig{Enable: &defaultVal}, + } + + conf.Extensions.Search.CVE = nil + + ctlr := api.NewController(conf) + + gqlQuery := ` + { + Image(image:"%s:%s"){ + RepoName, + Tag, + Digest, + ConfigDigest, + LastUpdated, + IsSigned, + Size + Layers { Digest Size } + } + }` + + gqlEndpoint := fmt.Sprintf("%s%s?query=", baseURL, graphqlQueryPrefix) + config, layers, manifest, err := GetImageComponents(100) + So(err, ShouldBeNil) + + configBlob, errConfig := json.Marshal(config) + configDigest := digest.FromBytes(configBlob) + So(errConfig, ShouldBeNil) // marshall success, config is valid JSON + go startServer(ctlr) + defer stopServer(ctlr) + WaitTillServerReady(baseURL) + + manifestBlob, errMarsal := json.Marshal(manifest) + So(errMarsal, ShouldBeNil) + So(manifestBlob, ShouldNotBeNil) + manifestDigest := digest.FromBytes(manifestBlob) + repoName := "test-repo" //nolint:goconst + + tagTarget := "latest" + err = UploadImage( + Image{ + Manifest: manifest, + Config: config, + Layers: layers, + Tag: tagTarget, + }, + baseURL, + repoName, + ) + So(err, ShouldBeNil) + var ( + imgSummaryResponse ImageSummaryResult + strQuery string + targetURL string + resp *resty.Response + ) + + t.Log("starting Test retrieve image based on image identifier") + // gql is parametrized with the repo. + strQuery = fmt.Sprintf(gqlQuery, repoName, tagTarget) + targetURL = fmt.Sprintf("%s%s", gqlEndpoint, url.QueryEscape(strQuery)) + + resp, err = resty.R().Get(targetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.SingleImageSummary, ShouldNotBeNil) + So(imgSummaryResponse.SingleImageSummary.ImageSummary, ShouldNotBeNil) + + imgSummary := imgSummaryResponse.SingleImageSummary.ImageSummary + So(imgSummary.RepoName, ShouldContainSubstring, repoName) + So(imgSummary.ConfigDigest, ShouldContainSubstring, configDigest.Hex()) + So(imgSummary.Digest, ShouldContainSubstring, manifestDigest.Hex()) + So(len(imgSummary.Layers), ShouldEqual, 1) + So(imgSummary.Layers[0].Digest, ShouldContainSubstring, + digest.FromBytes(layers[0]).Hex()) + + t.Log("starting Test retrieve duplicated image same layers based on image identifier") + // gqlEndpoint + strQuery = fmt.Sprintf(gqlQuery, "wrong-repo-does-not-exist", "latest") + targetURL = fmt.Sprintf("%s%s", gqlEndpoint, url.QueryEscape(strQuery)) + + resp, err = resty.R().Get(targetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.SingleImageSummary, ShouldNotBeNil) + So(imgSummaryResponse.SingleImageSummary.ImageSummary, ShouldNotBeNil) + + So(len(imgSummaryResponse.Errors), ShouldEqual, 1) + So(imgSummaryResponse.Errors[0].Message, + ShouldContainSubstring, "repository: not found") + + t.Log("starting Test retrieve image with bad tag") + // gql is parametrized with the repo. + strQuery = fmt.Sprintf(gqlQuery, repoName, "nonexisttag") + targetURL = fmt.Sprintf("%s%s", gqlEndpoint, url.QueryEscape(strQuery)) + + resp, err = resty.R().Get(targetURL) + So(resp, ShouldNotBeNil) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + So(resp.Body(), ShouldNotBeNil) + err = json.Unmarshal(resp.Body(), &imgSummaryResponse) + So(err, ShouldBeNil) + So(imgSummaryResponse, ShouldNotBeNil) + So(imgSummaryResponse.SingleImageSummary, ShouldNotBeNil) + So(imgSummaryResponse.SingleImageSummary.ImageSummary, ShouldNotBeNil) + + So(len(imgSummaryResponse.Errors), ShouldEqual, 1) + So(imgSummaryResponse.Errors[0].Message, + ShouldContainSubstring, "manifest: not found") + }) +} + func startServer(c *api.Controller) { // this blocks ctx := context.Background() diff --git a/pkg/extensions/search/common/model.go b/pkg/extensions/search/common/model.go new file mode 100644 index 00000000..55a20ac7 --- /dev/null +++ b/pkg/extensions/search/common/model.go @@ -0,0 +1,72 @@ +package common + +import ( + "time" +) + +type RepoInfo struct { + Summary RepoSummary + ImageSummaries []ImageSummary `json:"images"` +} + +type RepoSummary struct { + Name string `json:"name"` + LastUpdated time.Time `json:"lastUpdated"` + Size string `json:"size"` + Platforms []OsArch `json:"platforms"` + Vendors []string `json:"vendors"` + Score int `json:"score"` + NewestImage ImageSummary `json:"newestImage"` +} + +type ImageSummary struct { + RepoName string `json:"repoName"` + Tag string `json:"tag"` + Digest string `json:"digest"` + ConfigDigest string `json:"configDigest"` + LastUpdated time.Time `json:"lastUpdated"` + IsSigned bool `json:"isSigned"` + Size string `json:"size"` + Platform OsArch `json:"platform"` + Vendor string `json:"vendor"` + Score int `json:"score"` + DownloadCount int `json:"downloadCount"` + Description string `json:"description"` + Licenses string `json:"licenses"` + Labels string `json:"labels"` + Title string `json:"title"` + Source string `json:"source"` + Documentation string `json:"documentation"` + History []LayerHistory `json:"history"` + Layers []LayerSummary `json:"layers"` + Vulnerabilities ImageVulnerabilitySummary `json:"vulnerabilities"` +} + +type OsArch struct { + Os string `json:"os"` + Arch string `json:"arch"` +} + +type ImageVulnerabilitySummary struct { + MaxSeverity string `json:"maxSeverity"` + Count int `json:"count"` +} + +type LayerSummary struct { + Size string `json:"size"` + Digest string `json:"digest"` + Score int `json:"score"` +} + +type LayerHistory struct { + Layer LayerSummary `json:"layer"` + HistoryDescription HistoryDescription `json:"historyDescription"` +} + +type HistoryDescription struct { + Created time.Time `json:"created"` + CreatedBy string `json:"createdBy"` + Author string `json:"author"` + Comment string `json:"comment"` + EmptyLayer bool `json:"emptyLayer"` +} diff --git a/pkg/extensions/search/common/oci_layout.go b/pkg/extensions/search/common/oci_layout.go index f1fbab25..4814c01d 100644 --- a/pkg/extensions/search/common/oci_layout.go +++ b/pkg/extensions/search/common/oci_layout.go @@ -7,7 +7,6 @@ import ( "fmt" "path" "strconv" - "strings" "time" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -20,6 +19,7 @@ import ( ) type OciLayoutUtils interface { + GetImageManifest(repo string, reference string) (ispec.Manifest, string, error) GetImageManifests(image string) ([]ispec.Descriptor, error) GetImageBlobManifest(imageDir string, digest godigest.Digest) (v1.Manifest, error) GetImageInfo(imageDir string, hash v1.Hash) (ispec.Image, error) @@ -40,77 +40,31 @@ type BaseOciLayoutUtils struct { StoreController storage.StoreController } -type RepoInfo struct { - Summary RepoSummary - ImageSummaries []ImageSummary `json:"images"` -} - -type RepoSummary struct { - Name string `json:"name"` - LastUpdated time.Time `json:"lastUpdated"` - Size string `json:"size"` - Platforms []OsArch `json:"platforms"` - Vendors []string `json:"vendors"` - Score int `json:"score"` - NewestImage ImageSummary `json:"newestImage"` -} - -type ImageSummary struct { - RepoName string `json:"repoName"` - Tag string `json:"tag"` - Digest string `json:"digest"` - ConfigDigest string `json:"configDigest"` - LastUpdated time.Time `json:"lastUpdated"` - IsSigned bool `json:"isSigned"` - Size string `json:"size"` - Platform OsArch `json:"platform"` - Vendor string `json:"vendor"` - Score int `json:"score"` - DownloadCount int `json:"downloadCount"` - Description string `json:"description"` - Licenses string `json:"licenses"` - Labels string `json:"labels"` - Title string `json:"title"` - Source string `json:"source"` - Documentation string `json:"documentation"` - Layers []Layer `json:"layers"` -} - -type OsArch struct { - Os string `json:"os"` - Arch string `json:"arch"` -} - -type Layer struct { - Size string `json:"size"` - Digest string `json:"digest"` -} - // NewBaseOciLayoutUtils initializes a new OciLayoutUtils object. func NewBaseOciLayoutUtils(storeController storage.StoreController, log log.Logger) *BaseOciLayoutUtils { return &BaseOciLayoutUtils{Log: log, StoreController: storeController} } -func (olu BaseOciLayoutUtils) GetImageManifest(repo string, reference string) (ispec.Manifest, error) { +func (olu BaseOciLayoutUtils) GetImageManifest(repo string, reference string) (ispec.Manifest, string, error) { imageStore := olu.StoreController.GetImageStore(repo) if reference == "" { reference = "latest" } - buf, _, _, err := imageStore.GetImageManifest(repo, reference) + buf, dig, _, err := imageStore.GetImageManifest(repo, reference) if err != nil { - return ispec.Manifest{}, err + return ispec.Manifest{}, "", err } var manifest ispec.Manifest err = json.Unmarshal(buf, &manifest) if err != nil { - return ispec.Manifest{}, err + return ispec.Manifest{}, "", err } - return manifest, nil + return manifest, dig, nil } // Provide a list of repositories from all the available image stores. @@ -435,10 +389,10 @@ func (olu BaseOciLayoutUtils) GetExpandedRepoInfo(name string) (RepoInfo, error) repoPlatforms = append(repoPlatforms, osArch) - layers := make([]Layer, 0) + layers := make([]LayerSummary, 0) for _, layer := range manifest.Layers { - layerInfo := Layer{} + layerInfo := LayerSummary{} layerInfo.Digest = layer.Digest.Hex @@ -513,17 +467,3 @@ func (olu BaseOciLayoutUtils) GetExpandedRepoInfo(name string) (RepoInfo, error) return repo, nil } - -func GetImageDirAndTag(imageName string) (string, string) { - var imageDir string - - var imageTag string - - if strings.Contains(imageName, ":") { - imageDir, imageTag, _ = strings.Cut(imageName, ":") - } else { - imageDir = imageName - } - - return imageDir, imageTag -} diff --git a/pkg/extensions/search/digest/digest_test.go b/pkg/extensions/search/digest/digest_test.go index 809943da..59d0e5c9 100644 --- a/pkg/extensions/search/digest/digest_test.go +++ b/pkg/extensions/search/digest/digest_test.go @@ -7,6 +7,7 @@ package digestinfo_test import ( "context" "encoding/json" + "net/url" "os" "testing" "time" @@ -213,10 +214,13 @@ func TestDigestSearchHTTP(t *testing.T) { // Call should return {"data":{"ImageListForDigest":[{"Name":"zot-test","Tags":["0.0.1"]}]}} // "2bacca16" should match the manifest of 1 image - resp, err = resty.R().Get( - baseURL + constants.ExtSearchPrefix + `?query={ImageListForDigest(id:"2bacca16")` + - `{RepoName%20Tag%20Digest%20ConfigDigest%20Size%20Layers%20{%20Digest}}}`, - ) + + gqlQuery := url.QueryEscape(`{ImageListForDigest(id:"2bacca16") + {RepoName Tag Digest ConfigDigest Size Layers { Digest }}}`) + targetURL := baseURL + constants.ExtSearchPrefix + `?query=` + gqlQuery + + resp, err = resty.R().Get(targetURL) + So(string(resp.Body()), ShouldNotBeNil) So(resp, ShouldNotBeNil) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, 200) @@ -228,11 +232,13 @@ func TestDigestSearchHTTP(t *testing.T) { So(responseStruct.ImgListForDigest.Images[0].RepoName, ShouldEqual, "zot-test") So(responseStruct.ImgListForDigest.Images[0].Tag, ShouldEqual, "0.0.1") - // "adf3bb6c" should match the config of 1 image - resp, err = resty.R().Get( - baseURL + constants.ExtSearchPrefix + `?query={ImageListForDigest(id:"adf3bb6c")` + - `{RepoName%20Tag%20Digest%20ConfigDigest%20Size%20Layers%20{%20Digest}}}`, - ) + // "adf3bb6c" should match the config of 1 image. + gqlQuery = url.QueryEscape(`{ImageListForDigest(id:"adf3bb6c") + {RepoName Tag Digest ConfigDigest Size Layers { Digest }}}`) + + targetURL = baseURL + constants.ExtSearchPrefix + `?query=` + gqlQuery + resp, err = resty.R().Get(targetURL) + So(resp, ShouldNotBeNil) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, 200) @@ -246,20 +252,25 @@ func TestDigestSearchHTTP(t *testing.T) { // Call should return {"data":{"ImageListForDigest":[{"Name":"zot-cve-test","Tags":["0.0.1"]}]}} // "7a0437f0" should match the layer of 1 image + gqlQuery = url.QueryEscape(`{ImageListForDigest(id:"7a0437f0") + {RepoName Tag Digest ConfigDigest Size Layers { Digest }}}`) + targetURL = baseURL + constants.ExtSearchPrefix + `?query=` + gqlQuery + resp, err = resty.R().Get( - baseURL + constants.ExtSearchPrefix + `?query={ImageListForDigest(id:"7a0437f0")` + - `{RepoName%20Tag%20Digest%20ConfigDigest%20Size%20Layers%20{%20Digest}}}`, + targetURL, ) + So(resp, ShouldNotBeNil) So(err, ShouldBeNil) So(resp.StatusCode(), ShouldEqual, 200) + var responseStruct2 ImgResponseForDigest - err = json.Unmarshal(resp.Body(), &responseStruct) + err = json.Unmarshal(resp.Body(), &responseStruct2) So(err, ShouldBeNil) - So(len(responseStruct.Errors), ShouldEqual, 0) - So(len(responseStruct.ImgListForDigest.Images), ShouldEqual, 1) - So(responseStruct.ImgListForDigest.Images[0].RepoName, ShouldEqual, "zot-cve-test") - So(responseStruct.ImgListForDigest.Images[0].Tag, ShouldEqual, "0.0.1") + So(len(responseStruct2.Errors), ShouldEqual, 0) + So(len(responseStruct2.ImgListForDigest.Images), ShouldEqual, 1) + So(responseStruct2.ImgListForDigest.Images[0].RepoName, ShouldEqual, "zot-cve-test") + So(responseStruct2.ImgListForDigest.Images[0].Tag, ShouldEqual, "0.0.1") // Call should return {"data":{"ImageListForDigest":[]}} // "1111111" should match 0 images diff --git a/pkg/extensions/search/gql_generated/generated.go b/pkg/extensions/search/gql_generated/generated.go index da3b36c8..6af532e5 100644 --- a/pkg/extensions/search/gql_generated/generated.go +++ b/pkg/extensions/search/gql_generated/generated.go @@ -126,6 +126,7 @@ type ComplexityRoot struct { DerivedImageList func(childComplexity int, image string) int ExpandedRepoInfo func(childComplexity int, repo string) int GlobalSearch func(childComplexity int, query string) int + Image func(childComplexity int, image string) int ImageList func(childComplexity int, repo string) int ImageListForCve func(childComplexity int, id string) int ImageListForDigest func(childComplexity int, id string) int @@ -163,6 +164,7 @@ type QueryResolver interface { GlobalSearch(ctx context.Context, query string) (*GlobalSearchResult, error) DerivedImageList(ctx context.Context, image string) ([]*ImageSummary, error) BaseImageList(ctx context.Context, image string) ([]*ImageSummary, error) + Image(ctx context.Context, image string) (*ImageSummary, error) } type executableSchema struct { @@ -569,6 +571,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.GlobalSearch(childComplexity, args["query"].(string)), true + case "Query.Image": + if e.complexity.Query.Image == nil { + break + } + + args, err := ec.field_Query_Image_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.Image(childComplexity, args["image"].(string)), true + case "Query.ImageList": if e.complexity.Query.ImageList == nil { break @@ -763,12 +777,12 @@ var sources = []*ast.Source{ {Name: "../schema.graphql", Input: `scalar Time type CVEResultForImage { - Tag: String + Tag: String CVEList: [CVE] } type CVE { - Id: String + Id: String Title: String Description: String Severity: String @@ -776,9 +790,9 @@ type CVE { } type PackageInfo { - Name: String - InstalledVersion: String - FixedVersion: String + Name: String + InstalledVersion: String + FixedVersion: String } type RepoInfo { @@ -848,19 +862,19 @@ type LayerSummary { type HistoryDescription { Created: Time - """ + """ CreatedBy is the command which created the layer. """ - CreatedBy: String + CreatedBy: String """ Author is the author of the build point. """ - Author: String + Author: String """ Comment is a custom message set when creating the layer. """ Comment: String - """ + """ EmptyLayer is used to mark if the history item created a filesystem diff. """ EmptyLayer: Boolean @@ -887,7 +901,9 @@ type Query { GlobalSearch(query: String!): GlobalSearchResult! DerivedImageList(image: String!): [ImageSummary!] BaseImageList(image: String!): [ImageSummary!] -}`, BuiltIn: false}, + Image(image: String!): ImageSummary +} +`, BuiltIn: false}, } var parsedSchema = gqlparser.MustLoadSchema(sources...) @@ -1039,6 +1055,21 @@ func (ec *executionContext) field_Query_ImageList_args(ctx context.Context, rawA return args, nil } +func (ec *executionContext) field_Query_Image_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["image"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("image")) + arg0, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["image"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query___type_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -3972,6 +4003,100 @@ func (ec *executionContext) fieldContext_Query_BaseImageList(ctx context.Context return fc, nil } +func (ec *executionContext) _Query_Image(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_Image(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().Image(rctx, fc.Args["image"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*ImageSummary) + fc.Result = res + return ec.marshalOImageSummary2ᚖzotregistryᚗioᚋzotᚋpkgᚋextensionsᚋsearchᚋgql_generatedᚐImageSummary(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_Image(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "RepoName": + return ec.fieldContext_ImageSummary_RepoName(ctx, field) + case "Tag": + return ec.fieldContext_ImageSummary_Tag(ctx, field) + case "Digest": + return ec.fieldContext_ImageSummary_Digest(ctx, field) + case "ConfigDigest": + return ec.fieldContext_ImageSummary_ConfigDigest(ctx, field) + case "LastUpdated": + return ec.fieldContext_ImageSummary_LastUpdated(ctx, field) + case "IsSigned": + return ec.fieldContext_ImageSummary_IsSigned(ctx, field) + case "Size": + return ec.fieldContext_ImageSummary_Size(ctx, field) + case "Platform": + return ec.fieldContext_ImageSummary_Platform(ctx, field) + case "Vendor": + return ec.fieldContext_ImageSummary_Vendor(ctx, field) + case "Score": + return ec.fieldContext_ImageSummary_Score(ctx, field) + case "DownloadCount": + return ec.fieldContext_ImageSummary_DownloadCount(ctx, field) + case "Layers": + return ec.fieldContext_ImageSummary_Layers(ctx, field) + case "Description": + return ec.fieldContext_ImageSummary_Description(ctx, field) + case "Licenses": + return ec.fieldContext_ImageSummary_Licenses(ctx, field) + case "Labels": + return ec.fieldContext_ImageSummary_Labels(ctx, field) + case "Title": + return ec.fieldContext_ImageSummary_Title(ctx, field) + case "Source": + return ec.fieldContext_ImageSummary_Source(ctx, field) + case "Documentation": + return ec.fieldContext_ImageSummary_Documentation(ctx, field) + case "History": + return ec.fieldContext_ImageSummary_History(ctx, field) + case "Vulnerabilities": + return ec.fieldContext_ImageSummary_Vulnerabilities(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ImageSummary", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_Image_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query___type(ctx, field) if err != nil { @@ -7112,6 +7237,26 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr return ec.OperationContext.RootResolverMiddleware(ctx, innerFunc) } + out.Concurrently(i, func() graphql.Marshaler { + return rrm(innerCtx) + }) + case "Image": + field := field + + innerFunc := func(ctx context.Context) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_Image(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, innerFunc) + } + out.Concurrently(i, func() graphql.Marshaler { return rrm(innerCtx) }) diff --git a/pkg/extensions/search/resolver.go b/pkg/extensions/search/resolver.go index e8ee436b..f6e0ce25 100644 --- a/pkg/extensions/search/resolver.go +++ b/pkg/extensions/search/resolver.go @@ -6,11 +6,11 @@ package search import ( "context" - "errors" "fmt" "sort" "strconv" "strings" + "time" "github.com/99designs/gqlgen/graphql" glob "github.com/bmatcuk/doublestar/v4" // nolint:gci @@ -18,6 +18,7 @@ import ( godigest "github.com/opencontainers/go-digest" ispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/vektah/gqlparser/v2/gqlerror" + "zotregistry.io/zot/errors" "zotregistry.io/zot/pkg/extensions/search/common" cveinfo "zotregistry.io/zot/pkg/extensions/search/cve" digestinfo "zotregistry.io/zot/pkg/extensions/search/digest" @@ -35,11 +36,6 @@ type Resolver struct { log log.Logger } -var ( - ErrBadCtxFormat = errors.New("type assertion failed") - ErrBadLayerCount = errors.New("manifest: layers count doesn't correspond to config history") -) - // GetResolverConfig ... func GetResolverConfig(log log.Logger, storeController storage.StoreController, cveInfo cveinfo.CveInfo, ) gql_generated.Config { @@ -75,7 +71,9 @@ func (r *queryResolver) getImageListForDigest(repoList []string, digest string) return []*gql_generated.ImageSummary{}, err } - imageInfo := BuildImageInfo(repo, imageInfo.Tag, imageInfo.Digest, imageInfo.Manifest, imageConfig) + isSigned := olu.CheckManifestSignature(repo, imageInfo.Digest) + imageInfo := BuildImageInfo(repo, imageInfo.Tag, imageInfo.Digest, + imageInfo.Manifest, imageConfig, isSigned) imgResultForDigest = append(imgResultForDigest, imageInfo) } @@ -544,7 +542,9 @@ func (r *queryResolver) getImageList(store storage.ImageStore, imageName string) return results, err } - imageInfo := BuildImageInfo(repo, tag.Name, digest, manifest, imageConfig) + isSigned := layoutUtils.CheckManifestSignature(repo, digest) + imageInfo := BuildImageInfo(repo, tag.Name, digest, manifest, + imageConfig, isSigned) results = append(results, imageInfo) } @@ -559,16 +559,21 @@ func (r *queryResolver) getImageList(store storage.ImageStore, imageName string) } func BuildImageInfo(repo string, tag string, manifestDigest godigest.Digest, - manifest v1.Manifest, imageConfig ispec.Image, + manifest v1.Manifest, imageConfig ispec.Image, isSigned bool, ) *gql_generated.ImageSummary { layers := []*gql_generated.LayerSummary{} size := int64(0) - log := log.NewLogger("debug", "") - allHistory := []*gql_generated.LayerHistory{} - formattedManifestDigest := manifestDigest.Hex() + annotations := common.GetAnnotations(manifest.Annotations, imageConfig.Config.Labels) + + lastUpdated := imageConfig.Created + + if (lastUpdated == nil || *lastUpdated == (time.Time{})) && + len(imageConfig.History) > 0 { + lastUpdated = imageConfig.History[len(imageConfig.History)-1].Created + } history := imageConfig.History if len(history) == 0 { @@ -596,13 +601,26 @@ func BuildImageInfo(repo string, tag string, manifestDigest godigest.Digest, formattedSize := strconv.FormatInt(size, 10) imageInfo := &gql_generated.ImageSummary{ - RepoName: &repo, - Tag: &tag, - Digest: &formattedManifestDigest, - ConfigDigest: &manifest.Config.Digest.Hex, - Size: &formattedSize, - Layers: layers, - History: []*gql_generated.LayerHistory{}, + RepoName: &repo, + Tag: &tag, + Digest: &formattedManifestDigest, + ConfigDigest: &manifest.Config.Digest.Hex, + Size: &formattedSize, + Layers: layers, + History: allHistory, + Vendor: &annotations.Vendor, + Description: &annotations.Description, + Title: &annotations.Title, + Documentation: &annotations.Documentation, + Licenses: &annotations.Licenses, + Labels: &annotations.Labels, + Source: &annotations.Source, + LastUpdated: lastUpdated, + IsSigned: &isSigned, + Platform: &gql_generated.OsArch{ + Os: &imageConfig.OS, + Arch: &imageConfig.Architecture, + }, } return imageInfo @@ -629,16 +647,29 @@ func BuildImageInfo(repo string, tag string, manifestDigest godigest.Digest, if layersIterator+1 > len(manifest.Layers) { formattedSize := strconv.FormatInt(size, 10) - log.Error().Err(ErrBadLayerCount).Msg("error on creating layer history for ImageSummary") + log.Error().Err(errors.ErrBadLayerCount).Msg("error on creating layer history for ImageSummary") return &gql_generated.ImageSummary{ - RepoName: &repo, - Tag: &tag, - Digest: &formattedManifestDigest, - ConfigDigest: &manifest.Config.Digest.Hex, - Size: &formattedSize, - Layers: layers, - History: allHistory, + RepoName: &repo, + Tag: &tag, + Digest: &formattedManifestDigest, + ConfigDigest: &manifest.Config.Digest.Hex, + Size: &formattedSize, + Layers: layers, + History: allHistory, + Vendor: &annotations.Vendor, + Description: &annotations.Description, + Title: &annotations.Title, + Documentation: &annotations.Documentation, + Licenses: &annotations.Licenses, + Labels: &annotations.Labels, + Source: &annotations.Source, + LastUpdated: lastUpdated, + IsSigned: &isSigned, + Platform: &gql_generated.OsArch{ + Os: &imageConfig.OS, + Arch: &imageConfig.Architecture, + }, } } @@ -664,13 +695,26 @@ func BuildImageInfo(repo string, tag string, manifestDigest godigest.Digest, formattedSize := strconv.FormatInt(size, 10) imageInfo := &gql_generated.ImageSummary{ - RepoName: &repo, - Tag: &tag, - Digest: &formattedManifestDigest, - ConfigDigest: &manifest.Config.Digest.Hex, - Size: &formattedSize, - Layers: layers, - History: allHistory, + RepoName: &repo, + Tag: &tag, + Digest: &formattedManifestDigest, + ConfigDigest: &manifest.Config.Digest.Hex, + Size: &formattedSize, + Layers: layers, + History: allHistory, + Vendor: &annotations.Vendor, + Description: &annotations.Description, + Title: &annotations.Title, + Documentation: &annotations.Documentation, + Licenses: &annotations.Licenses, + Labels: &annotations.Labels, + Source: &annotations.Source, + LastUpdated: lastUpdated, + IsSigned: &isSigned, + Platform: &gql_generated.OsArch{ + Os: &imageConfig.OS, + Arch: &imageConfig.Architecture, + }, } return imageInfo @@ -703,7 +747,7 @@ func userAvailableRepos(ctx context.Context, repoList []string) ([]string, error if authCtx := ctx.Value(authzCtxKey); authCtx != nil { acCtx, ok := authCtx.(localCtx.AccessControlContext) if !ok { - err := ErrBadCtxFormat + err := errors.ErrBadType return []string{}, err } @@ -719,3 +763,49 @@ func userAvailableRepos(ctx context.Context, repoList []string) ([]string, error return availableRepos, nil } + +func extractImageDetails( + ctx context.Context, + layoutUtils common.OciLayoutUtils, + repo, tag string, + log log.Logger) ( + godigest.Digest, *v1.Manifest, *ispec.Image, error, +) { + validRepoList, err := userAvailableRepos(ctx, []string{repo}) + if err != nil { + log.Error().Err(err).Msg("unable to retrieve access token") + + return "", nil, nil, err + } + + if len(validRepoList) == 0 { + log.Error().Err(err).Msg("user is not authorized") + + return "", nil, nil, errors.ErrUnauthorizedAccess + } + + _, dig, err := layoutUtils.GetImageManifest(repo, tag) + if err != nil { + log.Error().Err(err).Msg("Could not retrieve image ispec manifest") + + return "", nil, nil, err + } + + digest := godigest.Digest(dig) + + manifest, err := layoutUtils.GetImageBlobManifest(repo, digest) + if err != nil { + log.Error().Err(err).Msg("Could not retrieve image godigest manifest") + + return "", nil, nil, err + } + + imageConfig, err := layoutUtils.GetImageConfigInfo(repo, digest) + if err != nil { + log.Error().Err(err).Msg("Could not retrieve image config") + + return "", nil, nil, err + } + + return digest, &manifest, &imageConfig, nil +} diff --git a/pkg/extensions/search/resolver_test.go b/pkg/extensions/search/resolver_test.go index 1dce5584..0bad576c 100644 --- a/pkg/extensions/search/resolver_test.go +++ b/pkg/extensions/search/resolver_test.go @@ -2,6 +2,7 @@ package search //nolint import ( "context" + "encoding/json" "errors" "os" "strings" @@ -157,7 +158,7 @@ func TestGlobalSearch(t *testing.T) { ImageSummaries: []common.ImageSummary{ { Tag: "latest", - Layers: []common.Layer{ + Layers: []common.LayerSummary{ { Size: "100", Digest: "sha256:855b1556a45637abf05c63407437f6f305b4627c4361fb965a78e5731999c0c7", @@ -313,3 +314,197 @@ func TestMatching(t *testing.T) { So(score, ShouldEqual, 12) }) } + +func TestExtractImageDetails(t *testing.T) { + Convey("repoListWithNewestImage", t, func() { + // log := log.Logger{Logger: zerolog.New(os.Stdout)} + content := []byte("this is a blob5") + testLogger := log.NewLogger("debug", "") + layerDigest := godigest.FromBytes(content) + config := ispec.Image{ + Architecture: "amd64", + OS: "linux", + RootFS: ispec.RootFS{ + Type: "layers", + DiffIDs: []godigest.Digest{}, + }, + Author: "some author", + } + + ctx := context.TODO() + authzCtxKey := localCtx.GetContextKey() + ctx = context.WithValue(ctx, authzCtxKey, + localCtx.AccessControlContext{ + GlobPatterns: map[string]bool{"*": true, "**": true}, + Username: "jane_doe", + }) + configBlobContent, _ := json.MarshalIndent(&config, "", "\t") + configDigest := godigest.FromBytes(configBlobContent) + + localTestManifest := ispec.Manifest{ + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: configDigest, + Size: int64(len(configBlobContent)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: layerDigest, + Size: int64(len(content)), + }, + }, + } + localTestDigestTry, _ := json.Marshal(localTestManifest) + localTestDigest := godigest.FromBytes(localTestDigestTry) + localTestManifestV1 := v1.Manifest{ + Config: v1.Descriptor{ + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: configDigest.Encoded(), + }, + }, + Layers: []v1.Descriptor{ + { + Size: 4, + Digest: v1.Hash{ + Algorithm: "sha256", + Hex: layerDigest.Encoded(), + }, + }, + }, + } + + Convey("extractImageDetails good workflow", func() { + mockOlum := mocks.OciLayoutUtilsMock{ + GetImageBlobManifestFn: func(imageDir string, digest godigest.Digest) (v1.Manifest, error) { + return localTestManifestV1, nil + }, + GetImageConfigInfoFn: func(repo string, digest godigest.Digest) ( + ispec.Image, error, + ) { + return config, nil + }, + GetImageManifestFn: func(repo string, tag string) ( + ispec.Manifest, string, error, + ) { + return localTestManifest, localTestDigest.String(), nil + }, + } + resDigest, resManifest, resIspecImage, resErr := extractImageDetails(ctx, + mockOlum, "zot-test", "latest", testLogger) + So(string(resDigest), ShouldContainSubstring, "sha256:d004018b9f") + So(resManifest.Config.Digest.String(), ShouldContainSubstring, configDigest.Encoded()) + + So(resIspecImage.Architecture, ShouldContainSubstring, "amd64") + So(resErr, ShouldBeNil) + }) + + Convey("extractImageDetails bad ispec.ImageManifest", func() { + mockOlum := mocks.OciLayoutUtilsMock{ + GetImageBlobManifestFn: func(imageDir string, digest godigest.Digest) (v1.Manifest, error) { + return localTestManifestV1, nil + }, + GetImageConfigInfoFn: func(repo string, digest godigest.Digest) ( + ispec.Image, error, + ) { + return config, nil + }, + GetImageManifestFn: func(repo string, tag string) ( + ispec.Manifest, string, error, + ) { + // localTestManifest = nil + return ispec.Manifest{}, localTestDigest.String() + "aaa", ErrTestError + }, + } + resDigest, resManifest, resIspecImage, resErr := extractImageDetails(ctx, + mockOlum, "zot-test", "latest", testLogger) + So(resErr, ShouldEqual, ErrTestError) + So(string(resDigest), ShouldEqual, "") + So(resManifest, ShouldBeNil) + + So(resIspecImage, ShouldBeNil) + }) + + Convey("extractImageDetails bad ImageBlobManifest", func() { + mockOlum := mocks.OciLayoutUtilsMock{ + GetImageBlobManifestFn: func(imageDir string, digest godigest.Digest) (v1.Manifest, error) { + return localTestManifestV1, ErrTestError + }, + GetImageConfigInfoFn: func(repo string, digest godigest.Digest) ( + ispec.Image, error, + ) { + return config, nil + }, + GetImageManifestFn: func(repo string, tag string) ( + ispec.Manifest, string, error, + ) { + return localTestManifest, localTestDigest.String(), nil + }, + } + resDigest, resManifest, resIspecImage, resErr := extractImageDetails(ctx, + mockOlum, "zot-test", "latest", testLogger) + So(string(resDigest), ShouldEqual, "") + So(resManifest, ShouldBeNil) + + So(resIspecImage, ShouldBeNil) + So(resErr, ShouldEqual, ErrTestError) + }) + + Convey("extractImageDetails bad imageConfig", func() { + mockOlum := mocks.OciLayoutUtilsMock{ + GetImageBlobManifestFn: func(imageDir string, digest godigest.Digest) (v1.Manifest, error) { + return localTestManifestV1, nil + }, + GetImageConfigInfoFn: func(repo string, digest godigest.Digest) ( + ispec.Image, error, + ) { + return config, nil + }, + GetImageManifestFn: func(repo string, tag string) ( + ispec.Manifest, string, error, + ) { + return localTestManifest, localTestDigest.String(), ErrTestError + }, + } + resDigest, resManifest, resIspecImage, resErr := extractImageDetails(ctx, + mockOlum, "zot-test", "latest", testLogger) + So(string(resDigest), ShouldEqual, "") + So(resManifest, ShouldBeNil) + + So(resIspecImage, ShouldBeNil) + So(resErr, ShouldEqual, ErrTestError) + }) + + Convey("extractImageDetails without proper authz", func() { + ctx = context.WithValue(ctx, authzCtxKey, + localCtx.AccessControlContext{ + GlobPatterns: map[string]bool{}, + Username: "jane_doe", + }) + mockOlum := mocks.OciLayoutUtilsMock{ + GetImageBlobManifestFn: func(imageDir string, digest godigest.Digest) (v1.Manifest, error) { + return localTestManifestV1, nil + }, + GetImageConfigInfoFn: func(repo string, digest godigest.Digest) ( + ispec.Image, error, + ) { + return config, nil + }, + GetImageManifestFn: func(repo string, tag string) ( + ispec.Manifest, string, error, + ) { + return localTestManifest, localTestDigest.String(), ErrTestError + }, + } + resDigest, resManifest, resIspecImage, resErr := extractImageDetails(ctx, + mockOlum, "zot-test", "latest", testLogger) + So(string(resDigest), ShouldEqual, "") + So(resManifest, ShouldBeNil) + + So(resIspecImage, ShouldBeNil) + So(resErr, ShouldNotBeNil) + So(strings.ToLower(resErr.Error()), ShouldContainSubstring, "unauthorized access") + }) + }) +} diff --git a/pkg/extensions/search/schema.graphql b/pkg/extensions/search/schema.graphql index 7b21d725..91479616 100644 --- a/pkg/extensions/search/schema.graphql +++ b/pkg/extensions/search/schema.graphql @@ -1,12 +1,12 @@ scalar Time type CVEResultForImage { - Tag: String + Tag: String CVEList: [CVE] } type CVE { - Id: String + Id: String Title: String Description: String Severity: String @@ -14,9 +14,9 @@ type CVE { } type PackageInfo { - Name: String - InstalledVersion: String - FixedVersion: String + Name: String + InstalledVersion: String + FixedVersion: String } type RepoInfo { @@ -86,19 +86,19 @@ type LayerSummary { type HistoryDescription { Created: Time - """ + """ CreatedBy is the command which created the layer. """ - CreatedBy: String + CreatedBy: String """ Author is the author of the build point. """ - Author: String + Author: String """ Comment is a custom message set when creating the layer. """ Comment: String - """ + """ EmptyLayer is used to mark if the history item created a filesystem diff. """ EmptyLayer: Boolean @@ -125,4 +125,5 @@ type Query { GlobalSearch(query: String!): GlobalSearchResult! DerivedImageList(image: String!): [ImageSummary!] BaseImageList(image: String!): [ImageSummary!] -} \ No newline at end of file + Image(image: String!): ImageSummary +} diff --git a/pkg/extensions/search/schema.resolvers.go b/pkg/extensions/search/schema.resolvers.go index 1e907c3e..52d9504a 100644 --- a/pkg/extensions/search/schema.resolvers.go +++ b/pkg/extensions/search/schema.resolvers.go @@ -60,7 +60,6 @@ func (r *queryResolver) CVEListForImage(ctx context.Context, image string) (*gql // ImageListForCve is the resolver for the ImageListForCVE field. func (r *queryResolver) ImageListForCve(ctx context.Context, id string) ([]*gql_generated.ImageSummary, error) { olu := common.NewBaseOciLayoutUtils(r.storeController, r.log) - affectedImages := []*gql_generated.ImageSummary{} r.log.Info().Msg("extracting repositories") @@ -90,7 +89,8 @@ func (r *queryResolver) ImageListForCve(ctx context.Context, id string) ([]*gql_ return affectedImages, err } - imageInfo := BuildImageInfo(repo, imageByCVE.Tag, imageByCVE.Digest, imageByCVE.Manifest, imageConfig) + isSigned := olu.CheckManifestSignature(repo, imageByCVE.Digest) + imageInfo := BuildImageInfo(repo, imageByCVE.Tag, imageByCVE.Digest, imageByCVE.Manifest, imageConfig, isSigned) affectedImages = append( affectedImages, @@ -129,7 +129,8 @@ func (r *queryResolver) ImageListWithCVEFixed(ctx context.Context, id string, im return []*gql_generated.ImageSummary{}, err } - imageInfo := BuildImageInfo(image, tag.Name, digest, manifest, imageConfig) + isSigned := olu.CheckManifestSignature(image, digest) + imageInfo := BuildImageInfo(image, tag.Name, digest, manifest, imageConfig, isSigned) unaffectedImages = append(unaffectedImages, imageInfo) } @@ -413,7 +414,7 @@ func (r *queryResolver) DerivedImageList(ctx context.Context, image string) ([]* imageDir, imageTag := common.GetImageDirAndTag(image) - imageManifest, err := layoutUtils.GetImageManifest(imageDir, imageTag) + imageManifest, _, err := layoutUtils.GetImageManifest(imageDir, imageTag) if err != nil { r.log.Info().Str("image", image).Msg("image not found") @@ -481,7 +482,7 @@ func (r *queryResolver) BaseImageList(ctx context.Context, image string) ([]*gql imageDir, imageTag := common.GetImageDirAndTag(image) - imageManifest, err := layoutUtils.GetImageManifest(imageDir, imageTag) + imageManifest, _, err := layoutUtils.GetImageManifest(imageDir, imageTag) if err != nil { r.log.Info().Str("image", image).Msg("image not found") @@ -539,6 +540,24 @@ func (r *queryResolver) BaseImageList(ctx context.Context, image string) ([]*gql return imageList, nil } +// Image is the resolver for the Image field. +func (r *queryResolver) Image(ctx context.Context, image string) (*gql_generated.ImageSummary, error) { + repo, tag := common.GetImageDirAndTag(image) + layoutUtils := common.NewBaseOciLayoutUtils(r.storeController, r.log) + + digest, manifest, imageConfig, err := extractImageDetails(ctx, layoutUtils, repo, tag, r.log) + if err != nil { + r.log.Error().Err(err).Msg("unable to get image details") + + return nil, err + } + + isSigned := layoutUtils.CheckManifestSignature(repo, digest) + result := BuildImageInfo(repo, tag, digest, *manifest, *imageConfig, isSigned) + + return result, nil +} + // Query returns gql_generated.QueryResolver implementation. func (r *Resolver) Query() gql_generated.QueryResolver { return &queryResolver{r} } diff --git a/pkg/test/mocks/oci_mock.go b/pkg/test/mocks/oci_mock.go index 9864bff9..cb9bd1a6 100644 --- a/pkg/test/mocks/oci_mock.go +++ b/pkg/test/mocks/oci_mock.go @@ -10,6 +10,7 @@ import ( ) type OciLayoutUtilsMock struct { + GetImageManifestFn func(repo string, reference string) (ispec.Manifest, string, error) GetImageManifestsFn func(image string) ([]ispec.Descriptor, error) GetImageBlobManifestFn func(imageDir string, digest godigest.Digest) (v1.Manifest, error) GetImageInfoFn func(imageDir string, hash v1.Hash) (ispec.Image, error) @@ -26,6 +27,14 @@ type OciLayoutUtilsMock struct { GetRepositoriesFn func() ([]string, error) } +func (olum OciLayoutUtilsMock) GetImageManifest(repo string, reference string) (ispec.Manifest, string, error) { + if olum.GetImageManifestFn != nil { + return olum.GetImageManifestFn(repo, reference) + } + + return ispec.Manifest{}, "", nil +} + func (olum OciLayoutUtilsMock) GetRepositories() ([]string, error) { if olum.GetRepositoriesFn != nil { return olum.GetRepositoriesFn()