diff --git a/pkg/cli/image_cmd.go b/pkg/cli/image_cmd.go index 0fbd9372..6f7e0bf4 100644 --- a/pkg/cli/image_cmd.go +++ b/pkg/cli/image_cmd.go @@ -121,6 +121,8 @@ func setupImageFlags(imageCmd *cobra.Command, searchImageParams map[string]*stri searchImageParams["imageName"] = imageCmd.Flags().StringP("name", "n", "", "List image details by name") searchImageParams["digest"] = imageCmd.Flags().StringP("digest", "d", "", "List images containing a specific manifest, config, or layer digest") + searchImageParams["derivedImage"] = imageCmd.Flags().StringP("derived-images", "D", "", + "List images that are derived from given image") imageCmd.Flags().StringVar(servURL, "url", "", "Specify zot server URL if config-name is not mentioned") imageCmd.Flags().StringVarP(user, "user", "u", "", `User Credentials of zot server in "username:password" format`) diff --git a/pkg/cli/image_cmd_test.go b/pkg/cli/image_cmd_test.go index ba67c215..deb3e1a1 100644 --- a/pkg/cli/image_cmd_test.go +++ b/pkg/cli/image_cmd_test.go @@ -250,6 +250,100 @@ func TestSearchImageCmd(t *testing.T) { }) } +func TestDerivedImageList(t *testing.T) { + Convey("Test from real server", t, func() { + port := test.GetFreePort() + url := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + defaultVal := true + conf.Extensions = &extconf.ExtensionConfig{ + Search: &extconf.SearchConfig{Enable: &defaultVal}, + } + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + + go func(controller *api.Controller) { + // this blocks + if err := controller.Run(context.Background()); err != nil { + return + } + }(ctlr) + // wait till ready + for { + _, err := resty.R().Get(url) + if err == nil { + break + } + + time.Sleep(100 * time.Millisecond) + } + defer func(controller *api.Controller) { + ctx := context.Background() + _ = controller.Server.Shutdown(ctx) + }(ctlr) + + err := uploadManifest(url) + So(err, ShouldBeNil) + t.Logf("rootDir: %s", ctlr.Config.Storage.RootDirectory) + + Convey("Test derived images list working", func() { + t.Logf("%s", ctlr.Config.Storage.RootDirectory) + args := []string{"imagetest", "--derived-images", "repo7:test:1.0"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cmd := NewImageCommand(new(searchService)) + buff := &bytes.Buffer{} + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldBeNil) + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + actual := strings.TrimSpace(str) + So(actual, ShouldContainSubstring, "IMAGE NAME TAG DIGEST SIZE") + So(actual, ShouldContainSubstring, "repo7 test:2.0 883fc0c5 15B") + So(actual, ShouldContainSubstring, "repo7 test:1.0 883fc0c5 15B") + }) + + Convey("Test derived images fail", func() { + t.Logf("%s", ctlr.Config.Storage.RootDirectory) + err = os.Chmod(ctlr.Config.Storage.RootDirectory, 0o000) + So(err, ShouldBeNil) + + defer func() { + err := os.Chmod(ctlr.Config.Storage.RootDirectory, 0o755) + So(err, ShouldBeNil) + }() + args := []string{"imagetest", "--derived-images", "repo7:test:1.0"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cmd := NewImageCommand(new(searchService)) + buff := &bytes.Buffer{} + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err = cmd.Execute() + So(err, ShouldNotBeNil) + }) + + Convey("Test derived images list cannot print", func() { + t.Logf("%s", ctlr.Config.Storage.RootDirectory) + args := []string{"imagetest", "--derived-images", "repo7:test:1.0", "-o", "random"} + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"imagetest","url":"%s","showspinner":false}]}`, url)) + defer os.Remove(configPath) + cmd := NewImageCommand(new(searchService)) + buff := &bytes.Buffer{} + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldNotBeNil) + }) + }) +} + func TestListRepos(t *testing.T) { Convey("Test listing repositories", t, func() { args := []string{"config-test"} @@ -1172,6 +1266,22 @@ func (service mockService) getRepos(ctx context.Context, config searchConfig, us channel <- stringResult{"", nil} } +func (service mockService) getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string, + derivedImage string, +) (*imageListStructForDerivedImagesGQL, error) { + imageListGQLResponse := &imageListStructForDerivedImagesGQL{} + imageListGQLResponse.Data.ImageList = []imageStruct{ + { + RepoName: "dummyImageName", + Tag: "tag", + Digest: "DigestsAreReallyLong", + Size: "123445", + }, + } + + return imageListGQLResponse, nil +} + func (service mockService) getImagesGQL(ctx context.Context, config searchConfig, username, password string, imageName string, ) (*imageListStructGQL, error) { diff --git a/pkg/cli/searcher.go b/pkg/cli/searcher.go index cf08d086..faaee7ca 100644 --- a/pkg/cli/searcher.go +++ b/pkg/cli/searcher.go @@ -42,6 +42,7 @@ func getImageSearchersGQL() []searcher { new(allImagesSearcherGQL), new(imageByNameSearcherGQL), new(imagesByDigestSearcherGQL), + new(derivedImageListSearcherGQL), } return searchers @@ -219,6 +220,31 @@ func (search imagesByDigestSearcher) search(config searchConfig) (bool, error) { } } +type derivedImageListSearcherGQL struct{} + +func (search derivedImageListSearcherGQL) search(config searchConfig) (bool, error) { + if !canSearch(config.params, newSet("derivedImage")) { + return false, nil + } + + username, password := getUsernameAndPassword(*config.user) + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + imageList, err := config.searchService.getDerivedImageListGQL(ctx, config, username, + password, *config.params["derivedImage"]) + if err != nil { + return true, err + } + + if err := printResult(config, imageList.Data.ImageList); err != nil { + return true, err + } + + return true, nil +} + type imagesByDigestSearcherGQL struct{} func (search imagesByDigestSearcherGQL) search(config searchConfig) (bool, error) { diff --git a/pkg/cli/service.go b/pkg/cli/service.go index 0f97cca2..b4b344d5 100644 --- a/pkg/cli/service.go +++ b/pkg/cli/service.go @@ -34,6 +34,8 @@ type SearchService interface { cveID string) (*imagesForCve, error) getFixedTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, imageName, cveID string) (*fixedTags, error) + getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string, + derivedImage string) (*imageListStructForDerivedImagesGQL, error) getAllImages(ctx context.Context, config searchConfig, username, password string, channel chan stringResult, wtgrp *sync.WaitGroup) @@ -59,6 +61,32 @@ func NewSearchService() SearchService { return searchService{} } +func (service searchService) getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string, + derivedImage string, +) (*imageListStructForDerivedImagesGQL, error) { + query := fmt.Sprintf(` + { + DerivedImageList(image:"%s"){ + RepoName, + Tag, + Digest, + ConfigDigest, + LastUpdated, + IsSigned, + Size + } + }`, derivedImage) + + result := &imageListStructForDerivedImagesGQL{} + err := service.makeGraphQLQuery(ctx, config, username, password, query, result) + + if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil { + return nil, errResult + } + + return result, nil +} + func (service searchService) getImagesGQL(ctx context.Context, config searchConfig, username, password string, imageName string, ) (*imageListStructGQL, error) { @@ -803,6 +831,13 @@ type imageListStructForDigestGQL struct { } `json:"data"` } +type imageListStructForDerivedImagesGQL struct { + Errors []errorGraphQL `json:"errors"` + Data struct { + ImageList []imageStruct `json:"DerivedImageList"` // nolint:tagliatelle + } `json:"data"` +} + type imagesForDigest struct { Errors []errorGraphQL `json:"errors"` Data struct { diff --git a/pkg/extensions/search/common/common_test.go b/pkg/extensions/search/common/common_test.go index 6792bbe5..8056eb0a 100644 --- a/pkg/extensions/search/common/common_test.go +++ b/pkg/extensions/search/common/common_test.go @@ -14,10 +14,12 @@ import ( "os/exec" "path" "strconv" + "strings" "testing" "time" "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sigstore/cosign/cmd/cosign/cli/generate" "github.com/sigstore/cosign/cmd/cosign/cli/options" @@ -982,6 +984,450 @@ func TestUtilsMethod(t *testing.T) { }) } +func TestGetImageManifest(t *testing.T) { + Convey("Test inexistent image", t, func() { + mockImageStore := mocks.MockedImageStore{} + + storeController := storage.StoreController{ + DefaultStore: mockImageStore, + } + olu := common.NewBaseOciLayoutUtils(storeController, log.NewLogger("debug", "")) + + _, err := olu.GetImageManifest("inexistent-repo", "latest") + So(err, ShouldNotBeNil) + }) + + Convey("Test inexistent image", t, func() { + mockImageStore := mocks.MockedImageStore{ + GetImageManifestFn: func(repo string, reference string) ([]byte, string, string, error) { + return []byte{}, "", "", ErrTestError + }, + } + + storeController := storage.StoreController{ + DefaultStore: mockImageStore, + } + olu := common.NewBaseOciLayoutUtils(storeController, log.NewLogger("debug", "")) + + _, err := olu.GetImageManifest("test-repo", "latest") + So(err, ShouldNotBeNil) + }) +} + +func TestDerivedImageList(t *testing.T) { + subpath := "/a" + + err := testSetup(t, subpath) + if err != nil { + panic(err) + } + + port := GetFreePort() + baseURL := GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + conf.Storage.RootDirectory = rootDir + conf.Storage.SubPaths = make(map[string]config.StorageConfig) + conf.Storage.SubPaths[subpath] = config.StorageConfig{RootDirectory: subRootDir} + defaultVal := true + conf.Extensions = &extconf.ExtensionConfig{ + Search: &extconf.SearchConfig{Enable: &defaultVal}, + } + + conf.Extensions.Search.CVE = nil + + ctlr := api.NewController(conf) + + go func() { + // this blocks + if err := ctlr.Run(context.Background()); err != nil { + return + } + }() + + // wait till ready + for { + _, err := resty.R().Get(baseURL) + if err == nil { + break + } + + time.Sleep(100 * time.Millisecond) + } + + // shut down server + + defer func() { + ctx := context.Background() + _ = ctlr.Server.Shutdown(ctx) + }() + + Convey("Test dependency list for image working", t, func() { + // create test images + config := ispec.Image{ + Architecture: "amd64", + OS: "linux", + RootFS: ispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{}, + }, + Author: "ZotUser", + } + + configBlob, err := json.Marshal(config) + So(err, ShouldBeNil) + + configDigest := digest.FromBytes(configBlob) + + layers := [][]byte{ + {10, 11, 10, 11}, + {11, 11, 11, 11}, + {10, 10, 10, 11}, + } + + manifest := ispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: configDigest, + Size: int64(len(configBlob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[0]), + Size: int64(len(layers[0])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[1]), + Size: int64(len(layers[1])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[2]), + Size: int64(len(layers[2])), + }, + }, + } + + repoName := "test-repo" + + err = UploadImage( + Image{ + Manifest: manifest, + Config: config, + Layers: layers, + Tag: "latest", + }, + baseURL, + repoName, + ) + So(err, ShouldBeNil) + + // create image with the same layers + manifest = ispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: configDigest, + Size: int64(len(configBlob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[0]), + Size: int64(len(layers[0])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[1]), + Size: int64(len(layers[1])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[2]), + Size: int64(len(layers[2])), + }, + }, + } + + repoName = "same-layers" + + err = UploadImage( + Image{ + Manifest: manifest, + Config: config, + Layers: layers, + Tag: "latest", + }, + baseURL, + repoName, + ) + So(err, ShouldBeNil) + + // create image with missing layer + layers = [][]byte{ + {10, 11, 10, 11}, + {10, 10, 10, 11}, + } + + manifest = ispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: configDigest, + Size: int64(len(configBlob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[0]), + Size: int64(len(layers[0])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[1]), + Size: int64(len(layers[1])), + }, + }, + } + + repoName = "missing-layer" + + err = UploadImage( + Image{ + Manifest: manifest, + Config: config, + Layers: layers, + Tag: "latest", + }, + baseURL, + repoName, + ) + So(err, ShouldBeNil) + + // create image with more layers than the original + layers = [][]byte{ + {10, 11, 10, 11}, + {11, 11, 11, 11}, + {10, 10, 10, 10}, + {10, 10, 10, 11}, + {11, 11, 10, 10}, + } + + manifest = ispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + Config: ispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: configDigest, + Size: int64(len(configBlob)), + }, + Layers: []ispec.Descriptor{ + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[0]), + Size: int64(len(layers[0])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[1]), + Size: int64(len(layers[1])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[2]), + Size: int64(len(layers[2])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[3]), + Size: int64(len(layers[3])), + }, + { + MediaType: "application/vnd.oci.image.layer.v1.tar", + Digest: digest.FromBytes(layers[4]), + Size: int64(len(layers[4])), + }, + }, + } + + repoName = "more-layers" + + err = UploadImage( + Image{ + Manifest: manifest, + Config: config, + Layers: layers, + Tag: "latest", + }, + baseURL, + repoName, + ) + So(err, ShouldBeNil) + + query := ` + { + DerivedImageList(image:"test-repo"){ + RepoName, + Tag, + Digest, + ConfigDigest, + LastUpdated, + IsSigned, + Size + } + }` + + 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()), "missing-layers"), ShouldBeFalse) + So(strings.Contains(string(resp.Body()), "more-layers"), ShouldBeTrue) + So(err, ShouldBeNil) + So(resp.StatusCode(), ShouldEqual, 200) + }) + + Convey("Inexistent repository", t, func() { + query := ` + { + DerivedImageList(image:"inexistent-image"){ + RepoName, + Tag, + Digest, + ConfigDigest, + LastUpdated, + IsSigned, + Size + } + }` + + resp, err := resty.R().Get(baseURL + graphqlQueryPrefix + "?query=" + url.QueryEscape(query)) + So(strings.Contains(string(resp.Body()), "repository: not found"), ShouldBeTrue) + So(err, ShouldBeNil) + }) + + Convey("Failed to get manifest", t, func() { + err := os.Mkdir(path.Join(rootDir, "fail-image"), 0o000) + So(err, ShouldBeNil) + + query := ` + { + DerivedImageList(image:"fail-image"){ + RepoName, + Tag, + Digest, + ConfigDigest, + LastUpdated, + IsSigned, + Size + } + }` + + resp, err := resty.R().Get(baseURL + graphqlQueryPrefix + "?query=" + url.QueryEscape(query)) + So(strings.Contains(string(resp.Body()), "permission denied"), ShouldBeTrue) + So(err, ShouldBeNil) + }) +} + +func TestDerivedImageListNoRepos(t *testing.T) { + Convey("No repositories found", 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) + + go func() { + // this blocks + if err := ctlr.Run(context.Background()); err != nil { + return + } + }() + + // wait till ready + for { + _, err := resty.R().Get(baseURL) + if err == nil { + break + } + + time.Sleep(100 * time.Millisecond) + } + + // shut down server + + defer func() { + ctx := context.Background() + _ = ctlr.Server.Shutdown(ctx) + }() + + query := ` + { + DerivedImageList(image:"test-image"){ + RepoName, + Tag, + Digest, + ConfigDigest, + LastUpdated, + IsSigned, + Size + } + }` + + resp, err := resty.R().Get(baseURL + graphqlQueryPrefix + "?query=" + url.QueryEscape(query)) + So(strings.Contains(string(resp.Body()), "{\"data\":{\"DerivedImageList\":[]}}"), ShouldBeTrue) + So(err, ShouldBeNil) + }) +} + +func TestGetRepositories(t *testing.T) { + Convey("Test getting the repositories list", t, func() { + mockImageStore := mocks.MockedImageStore{ + GetRepositoriesFn: func() ([]string, error) { + return []string{}, ErrTestError + }, + } + + storeController := storage.StoreController{ + DefaultStore: mockImageStore, + SubStore: map[string]storage.ImageStore{"test": mockImageStore}, + } + olu := common.NewBaseOciLayoutUtils(storeController, log.NewLogger("debug", "")) + + repoList, err := olu.GetRepositories() + So(repoList, ShouldBeEmpty) + So(err, ShouldNotBeNil) + + storeController = storage.StoreController{ + DefaultStore: mocks.MockedImageStore{}, + SubStore: map[string]storage.ImageStore{"test": mockImageStore}, + } + olu = common.NewBaseOciLayoutUtils(storeController, log.NewLogger("debug", "")) + + repoList, err = olu.GetRepositories() + So(repoList, ShouldBeEmpty) + So(err, ShouldNotBeNil) + }) +} + func TestGlobalSearch(t *testing.T) { Convey("Test utils", t, func() { subpath := "/a" diff --git a/pkg/extensions/search/common/oci_layout.go b/pkg/extensions/search/common/oci_layout.go index 4f77d62a..11f216d7 100644 --- a/pkg/extensions/search/common/oci_layout.go +++ b/pkg/extensions/search/common/oci_layout.go @@ -34,6 +34,7 @@ type OciLayoutUtils interface { GetExpandedRepoInfo(name string) (RepoInfo, error) GetImageConfigInfo(repo string, manifestDigest godigest.Digest) (ispec.Image, error) CheckManifestSignature(name string, digest godigest.Digest) bool + GetRepositories() ([]string, error) } // OciLayoutInfo ... @@ -78,6 +79,49 @@ func NewBaseOciLayoutUtils(storeController storage.StoreController, log log.Logg return &BaseOciLayoutUtils{Log: log, StoreController: storeController} } +func (olu BaseOciLayoutUtils) GetImageManifest(repo string, reference string) (ispec.Manifest, error) { + imageStore := olu.StoreController.GetImageStore(repo) + + if reference == "" { + reference = "latest" + } + + buf, _, _, err := imageStore.GetImageManifest(repo, reference) + if err != nil { + return ispec.Manifest{}, err + } + + var manifest ispec.Manifest + + err = json.Unmarshal(buf, &manifest) + if err != nil { + return ispec.Manifest{}, err + } + + return manifest, nil +} + +func (olu BaseOciLayoutUtils) GetRepositories() ([]string, error) { + defaultStore := olu.StoreController.DefaultStore + substores := olu.StoreController.SubStore + + repoList, err := defaultStore.GetRepositories() + if err != nil { + return []string{}, err + } + + for _, sub := range substores { + repoListForSubstore, err := sub.GetRepositories() + if err != nil { + return []string{}, err + } + + repoList = append(repoList, repoListForSubstore...) + } + + return repoList, nil +} + // Below method will return image path including root dir, root dir is determined by splitting. func (olu BaseOciLayoutUtils) GetImageManifests(image string) ([]ispec.Descriptor, error) { imageStore := olu.StoreController.GetImageStore(image) @@ -476,9 +520,7 @@ func GetImageDirAndTag(imageName string) (string, string) { var imageTag string if strings.Contains(imageName, ":") { - splitImageName := strings.Split(imageName, ":") - imageDir = splitImageName[0] - imageTag = splitImageName[1] + imageDir, imageTag, _ = strings.Cut(imageName, ":") } else { imageDir = imageName } diff --git a/pkg/extensions/search/gql_generated/generated.go b/pkg/extensions/search/gql_generated/generated.go index 59fa49df..35c487b1 100644 --- a/pkg/extensions/search/gql_generated/generated.go +++ b/pkg/extensions/search/gql_generated/generated.go @@ -99,6 +99,7 @@ type ComplexityRoot struct { Query struct { CVEListForImage func(childComplexity int, image string) int + DerivedImageList func(childComplexity int, image string) int ExpandedRepoInfo func(childComplexity int, repo string) int GlobalSearch func(childComplexity int, query string) int ImageList func(childComplexity int, repo string) int @@ -136,6 +137,7 @@ type QueryResolver interface { ImageList(ctx context.Context, repo string) ([]*ImageSummary, error) ExpandedRepoInfo(ctx context.Context, repo string) (*RepoInfo, error) GlobalSearch(ctx context.Context, query string) (*GlobalSearchResult, error) + DerivedImageList(ctx context.Context, image string) ([]*ImageSummary, error) } type executableSchema struct { @@ -396,6 +398,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.CVEListForImage(childComplexity, args["image"].(string)), true + case "Query.DerivedImageList": + if e.complexity.Query.DerivedImageList == nil { + break + } + + args, err := ec.field_Query_DerivedImageList_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.DerivedImageList(childComplexity, args["image"].(string)), true + case "Query.ExpandedRepoInfo": if e.complexity.Query.ExpandedRepoInfo == nil { break @@ -701,6 +715,7 @@ type Query { ImageList(repo: String!): [ImageSummary!] ExpandedRepoInfo(repo: String!): RepoInfo! GlobalSearch(query: String!): GlobalSearchResult! + DerivedImageList(image: String!): [ImageSummary!] } `, BuiltIn: false}, } @@ -725,6 +740,21 @@ func (ec *executionContext) field_Query_CVEListForImage_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Query_DerivedImageList_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_ExpandedRepoInfo_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2913,6 +2943,90 @@ func (ec *executionContext) fieldContext_Query_GlobalSearch(ctx context.Context, return fc, nil } +func (ec *executionContext) _Query_DerivedImageList(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_DerivedImageList(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().DerivedImageList(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_DerivedImageList(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) + } + 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_DerivedImageList_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 { @@ -5874,6 +5988,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 "DerivedImageList": + 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_DerivedImageList(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/schema.graphql b/pkg/extensions/search/schema.graphql index 562a4771..4c421efb 100644 --- a/pkg/extensions/search/schema.graphql +++ b/pkg/extensions/search/schema.graphql @@ -88,4 +88,5 @@ type Query { ImageList(repo: String!): [ImageSummary!] ExpandedRepoInfo(repo: String!): RepoInfo! GlobalSearch(query: String!): GlobalSearchResult! + DerivedImageList(image: String!): [ImageSummary!] } diff --git a/pkg/extensions/search/schema.resolvers.go b/pkg/extensions/search/schema.resolvers.go index 216e08be..d37b08c0 100644 --- a/pkg/extensions/search/schema.resolvers.go +++ b/pkg/extensions/search/schema.resolvers.go @@ -6,6 +6,7 @@ package search import ( "context" "fmt" + "strconv" "strings" godigest "github.com/opencontainers/go-digest" @@ -470,6 +471,77 @@ func (r *queryResolver) GlobalSearch(ctx context.Context, query string) (*gql_ge }, nil } +// DependencyListForImage is the resolver for the DependencyListForImage field. +func (r *queryResolver) DerivedImageList(ctx context.Context, image string) ([]*gql_generated.ImageSummary, error) { + layoutUtils := common.NewBaseOciLayoutUtils(r.storeController, r.log) + imageList := make([]*gql_generated.ImageSummary, 0) + + repoList, err := layoutUtils.GetRepositories() + if err != nil { + r.log.Error().Err(err).Msg("unable to get repositories list") + + return nil, err + } + + if len(repoList) == 0 { + r.log.Info().Msg("no repositories found") + + return imageList, nil + } + + imageDir, imageTag := common.GetImageDirAndTag(image) + + imageManifest, err := layoutUtils.GetImageManifest(imageDir, imageTag) + if err != nil { + r.log.Info().Str("image", image).Msg("image not found") + + return imageList, err + } + + imageLayers := imageManifest.Layers + + for _, repo := range repoList { + repoInfo, err := r.ExpandedRepoInfo(ctx, repo) + if err != nil { + r.log.Error().Err(err).Msg("unable to get image list") + + return nil, err + } + + manifests := repoInfo.Images + + // verify every image + for _, manifest := range manifests { + layers := manifest.Layers + + sameLayer := 0 + imageSize := 0 + + for _, l := range imageLayers { + for _, k := range layers { + if *k.Digest == l.Digest.Encoded() { + sameLayer++ + layerSize, _ := strconv.Atoi(*k.Size) + imageSize += layerSize + } + } + } + + // if all layers are the same + if sameLayer == len(imageLayers) { + // add to returned list + name := repo + manifest.RepoName = &name + size := strconv.Itoa(imageSize) + manifest.Size = &size + imageList = append(imageList, manifest) + } + } + } + + return imageList, 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 44d6bcf4..8b09b135 100644 --- a/pkg/test/mocks/oci_mock.go +++ b/pkg/test/mocks/oci_mock.go @@ -24,6 +24,15 @@ type OciLayoutUtilsMock struct { GetExpandedRepoInfoFn func(name string) (common.RepoInfo, error) GetImageConfigInfoFn func(repo string, manifestDigest godigest.Digest) (ispec.Image, error) CheckManifestSignatureFn func(name string, digest godigest.Digest) bool + GetRepositoriesFn func() ([]string, error) +} + +func (olum OciLayoutUtilsMock) GetRepositories() ([]string, error) { + if olum.GetImageManifestsFn != nil { + return olum.GetRepositoriesFn() + } + + return []string{}, nil } func (olum OciLayoutUtilsMock) GetImageManifests(image string) ([]ispec.Descriptor, error) {