0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2024-12-16 21:56:37 -05:00
zot/pkg/cli/service.go
Andrei Aaron faa410a0c3
feat(cli): Fix multiple issues with zli output (#1612)
https://github.com/project-zot/zot/issues/1591
    - I will rename "IMAGE NAME" to "REPOSITORY" in order to make the header easier to parse
    - The order of the images cannot be predicted if zot is getting them 1 by 1 using the REST API for manifests, so they cannot be sorted when printed. We could wait on all calls to return but that may take minutes, and printing partial results as they become available is better.
    - The order of the images can be predicted when relying on the zot specific search API, but that is not available in all zot servers depending on build options. I added sorting ascending by default. We are planning to implement configurable sorting in a separate PR - see the work under https://github.com/project-zot/zot/pull/1577
    - With regards to the column widths/alignments that was discussed before, and the issue is we don't know the values beforehand for the REST API based responses. As mentioned above printing partial results as they become available is better.
    - The column widths/alignments are partially fixed in this PR for the search API, but we should properly fix this in - see https://github.com/project-zot/zot/pull/851

https://github.com/project-zot/zot/issues/1592
    - Fix missing space after help message

https://github.com/project-zot/zot/issues/1598
    - Fix table headers showing for json/yaml format
    - Fix spacing shown with json format, use 1 row per shown entry in order to be compatible with json lines format: https://jsonlines.org/
    - Add document header `---` to every image shown in yaml format to separate the entries

Signed-off-by: Andrei Aaron <aaaron@luxoft.com>
2023-07-12 10:21:12 -07:00

1693 lines
40 KiB
Go

//go:build search
// +build search
package cli
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"sync"
"github.com/dustin/go-humanize"
jsoniter "github.com/json-iterator/go"
"github.com/olekukonko/tablewriter"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
"gopkg.in/yaml.v2"
zotErrors "zotregistry.io/zot/errors"
"zotregistry.io/zot/pkg/api/constants"
"zotregistry.io/zot/pkg/common"
)
const (
jsonFormat = "json"
yamlFormat = "yaml"
ymlFormat = "yml"
)
type SearchService interface { //nolint:interfacebloat
getImagesGQL(ctx context.Context, config searchConfig, username, password string,
imageName string) (*common.ImageListResponse, error)
getImagesByDigestGQL(ctx context.Context, config searchConfig, username, password string,
digest string) (*common.ImagesForDigest, error)
getCveByImageGQL(ctx context.Context, config searchConfig, username, password,
imageName string, searchedCVE string) (*cveResult, error)
getImagesByCveIDGQL(ctx context.Context, config searchConfig, username, password string,
digest string) (*common.ImagesForCve, error)
getTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, imageName,
cveID string) (*common.ImagesForCve, error)
getFixedTagsForCVEGQL(ctx context.Context, config searchConfig, username, password, imageName,
cveID string) (*common.ImageListWithCVEFixedResponse, error)
getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string,
derivedImage string) (*common.DerivedImageListResponse, error)
getBaseImageListGQL(ctx context.Context, config searchConfig, username, password string,
baseImage string) (*common.BaseImageListResponse, error)
getReferrersGQL(ctx context.Context, config searchConfig, username, password string,
repo, digest string) (*common.ReferrersResp, error)
globalSearchGQL(ctx context.Context, config searchConfig, username, password string,
query string) (*common.GlobalSearch, error)
getAllImages(ctx context.Context, config searchConfig, username, password string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getCveByImage(ctx context.Context, config searchConfig, username, password, imageName, searchedCVE string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getImagesByCveID(ctx context.Context, config searchConfig, username, password, cvid string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getImagesByDigest(ctx context.Context, config searchConfig, username, password, digest string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getFixedTagsForCVE(ctx context.Context, config searchConfig, username, password, imageName, cvid string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getRepos(ctx context.Context, config searchConfig, username, password string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getImageByName(ctx context.Context, config searchConfig, username, password, imageName string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getImageByNameAndCVEID(ctx context.Context, config searchConfig, username, password, imageName, cvid string,
channel chan stringResult, wtgrp *sync.WaitGroup)
getReferrers(ctx context.Context, config searchConfig, username, password string, repo, digest string,
) (referrersResult, error)
}
type searchService struct{}
func NewSearchService() SearchService {
return searchService{}
}
func (service searchService) getDerivedImageListGQL(ctx context.Context, config searchConfig, username, password string,
derivedImage string,
) (*common.DerivedImageListResponse, error) {
query := fmt.Sprintf(`
{
DerivedImageList(image:"%s", requestedPage: {sortBy: ALPHABETIC_ASC}){
Results{
RepoName,
Tag,
Digest,
MediaType,
Manifests {
Digest,
ConfigDigest,
Layers {Size Digest},
LastUpdated,
IsSigned,
Size
},
LastUpdated,
IsSigned,
Size
}
}
}`, derivedImage)
result := &common.DerivedImageListResponse{}
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) getReferrersGQL(ctx context.Context, config searchConfig, username, password string,
repo, digest string,
) (*common.ReferrersResp, error) {
query := fmt.Sprintf(`
{
Referrers( repo: "%s", digest: "%s" ){
ArtifactType,
Digest,
MediaType,
Size,
Annotations{
Key
Value
}
}
}`, repo, digest)
result := &common.ReferrersResp{}
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) globalSearchGQL(ctx context.Context, config searchConfig, username, password string,
query string,
) (*common.GlobalSearch, error) {
GQLQuery := fmt.Sprintf(`
{
GlobalSearch(query:"%s"){
Images {
RepoName
Tag
MediaType
Digest
Size
Manifests {
Digest
ConfigDigest
Platform {Os Arch}
Size
IsSigned
Layers {Digest Size}
}
}
Repos {
Name
Platforms { Os Arch }
LastUpdated
Size
DownloadCount
StarCount
}
}
}`, query)
result := &common.GlobalSearchResultResp{}
err := service.makeGraphQLQuery(ctx, config, username, password, GQLQuery, result)
if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil {
return nil, errResult
}
return &result.GlobalSearch, nil
}
func (service searchService) getBaseImageListGQL(ctx context.Context, config searchConfig, username, password string,
baseImage string,
) (*common.BaseImageListResponse, error) {
query := fmt.Sprintf(`
{
BaseImageList(image:"%s", requestedPage: {sortBy: ALPHABETIC_ASC}){
Results{
RepoName,
Tag,
Digest,
MediaType,
Manifests {
Digest,
ConfigDigest,
Layers {Size Digest},
LastUpdated,
IsSigned,
Size
},
LastUpdated,
IsSigned,
Size
}
}
}`, baseImage)
result := &common.BaseImageListResponse{}
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,
) (*common.ImageListResponse, error) {
query := fmt.Sprintf(`
{
ImageList(repo: "%s", requestedPage: {sortBy: ALPHABETIC_ASC}) {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
Platform {Os Arch}
IsSigned
Layers {Size Digest}
}
Size
IsSigned
}
}
}`,
imageName)
result := &common.ImageListResponse{}
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) getImagesByDigestGQL(ctx context.Context, config searchConfig, username, password string,
digest string,
) (*common.ImagesForDigest, error) {
query := fmt.Sprintf(`
{
ImageListForDigest(id: "%s", requestedPage: {sortBy: ALPHABETIC_ASC}) {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
IsSigned
}
}
}`,
digest)
result := &common.ImagesForDigest{}
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) getImagesByCveIDGQL(ctx context.Context, config searchConfig, username,
password, cveID string,
) (*common.ImagesForCve, error) {
query := fmt.Sprintf(`
{
ImageListForCVE(id: "%s", requestedPage: {sortBy: ALPHABETIC_ASC}) {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
IsSigned
}
}
}`,
cveID)
result := &common.ImagesForCve{}
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) getCveByImageGQL(ctx context.Context, config searchConfig, username, password,
imageName, searchedCVE string,
) (*cveResult, error) {
query := fmt.Sprintf(`{ CVEListForImage (image:"%s", searchedCVE:"%s")`+
` { Tag CVEList { Id Title Severity Description `+
`PackageList {Name InstalledVersion FixedVersion}} } }`, imageName, searchedCVE)
result := &cveResult{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if errResult := checkResultGraphQLQuery(ctx, err, result.Errors); errResult != nil {
return nil, errResult
}
result.Data.CVEListForImage.CVEList = groupCVEsBySeverity(result.Data.CVEListForImage.CVEList)
return result, nil
}
func (service searchService) getTagsForCVEGQL(ctx context.Context, config searchConfig,
username, password, imageName, cveID string,
) (*common.ImagesForCve, error) {
query := fmt.Sprintf(`
{
ImageListForCVE(id: "%s") {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
}
}
}`,
cveID)
result := &common.ImagesForCve{}
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) getFixedTagsForCVEGQL(ctx context.Context, config searchConfig,
username, password, imageName, cveID string,
) (*common.ImageListWithCVEFixedResponse, error) {
query := fmt.Sprintf(`
{
ImageListWithCVEFixed(id: "%s", image: "%s") {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
}
}
}`,
cveID, imageName)
result := &common.ImageListWithCVEFixedResponse{}
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) getReferrers(ctx context.Context, config searchConfig, username, password string,
repo, digest string,
) (referrersResult, error) {
referrersEndpoint, err := combineServerAndEndpointURL(*config.servURL,
fmt.Sprintf("/v2/%s/referrers/%s", repo, digest))
if err != nil {
if isContextDone(ctx) {
return referrersResult{}, nil
}
return referrersResult{}, err
}
referrerResp := &ispec.Index{}
_, err = makeGETRequest(ctx, referrersEndpoint, username, password, *config.verifyTLS,
*config.debug, &referrerResp, config.resultWriter)
if err != nil {
if isContextDone(ctx) {
return referrersResult{}, nil
}
return referrersResult{}, err
}
referrersList := referrersResult{}
for _, referrer := range referrerResp.Manifests {
referrersList = append(referrersList, common.Referrer{
ArtifactType: referrer.ArtifactType,
Digest: referrer.Digest.String(),
Size: int(referrer.Size),
})
}
return referrersList, nil
}
func (service searchService) getImageByName(ctx context.Context, config searchConfig,
username, password, imageName string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
var localWg sync.WaitGroup
rlim := newSmoothRateLimiter(&localWg, rch)
localWg.Add(1)
go rlim.startRateLimiter(ctx)
localWg.Add(1)
go getImage(ctx, config, username, password, imageName, rch, &localWg, rlim)
localWg.Wait()
}
func (service searchService) getAllImages(ctx context.Context, config searchConfig, username, password string,
rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
catalog := &catalogResponse{}
catalogEndPoint, err := combineServerAndEndpointURL(*config.servURL, fmt.Sprintf("%s%s",
constants.RoutePrefix, constants.ExtCatalogPrefix))
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
_, err = makeGETRequest(ctx, catalogEndPoint, username, password, *config.verifyTLS,
*config.debug, catalog, config.resultWriter)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
var localWg sync.WaitGroup
rlim := newSmoothRateLimiter(&localWg, rch)
localWg.Add(1)
go rlim.startRateLimiter(ctx)
for _, repo := range catalog.Repositories {
localWg.Add(1)
go getImage(ctx, config, username, password, repo, rch, &localWg, rlim)
}
localWg.Wait()
}
func getImage(ctx context.Context, config searchConfig, username, password, imageName string,
rch chan stringResult, wtgrp *sync.WaitGroup, pool *requestsPool,
) {
defer wtgrp.Done()
tagListEndpoint, err := combineServerAndEndpointURL(*config.servURL, fmt.Sprintf("/v2/%s/tags/list", imageName))
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
tagList := &tagListResp{}
_, err = makeGETRequest(ctx, tagListEndpoint, username, password, *config.verifyTLS,
*config.debug, &tagList, config.resultWriter)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
for _, tag := range tagList.Tags {
hasTagPrefix := strings.HasPrefix(tag, "sha256-")
hasTagSuffix := strings.HasSuffix(tag, ".sig")
// check if it's an image or a signature
// we don't want to show signatures in cli responses
if hasTagPrefix && hasTagSuffix {
continue
}
wtgrp.Add(1)
go addManifestCallToPool(ctx, config, pool, username, password, imageName, tag, rch, wtgrp)
}
}
func (service searchService) getImagesByCveID(ctx context.Context, config searchConfig, username,
password, cvid string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
query := fmt.Sprintf(
`{
ImageListForCVE(id: "%s") {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
}
}
}`,
cvid)
result := &common.ImagesForCve{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
if result.Errors != nil || err != nil {
var errBuilder strings.Builder
for _, err := range result.Errors {
fmt.Fprintln(&errBuilder, err.Message)
}
if isContextDone(ctx) {
return
}
rch <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113
return
}
var localWg sync.WaitGroup
rlim := newSmoothRateLimiter(&localWg, rch)
localWg.Add(1)
go rlim.startRateLimiter(ctx)
for _, image := range result.Results {
localWg.Add(1)
go addManifestCallToPool(ctx, config, rlim, username, password, image.RepoName, image.Tag, rch, &localWg)
}
localWg.Wait()
}
func (service searchService) getImagesByDigest(ctx context.Context, config searchConfig, username,
password string, digest string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
query := fmt.Sprintf(
`{
ImageListForDigest(id: "%s") {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
}
}
}`,
digest)
result := &common.ImagesForDigest{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
if result.Errors != nil {
var errBuilder strings.Builder
for _, err := range result.Errors {
fmt.Fprintln(&errBuilder, err.Message)
}
if isContextDone(ctx) {
return
}
rch <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113
return
}
var localWg sync.WaitGroup
rlim := newSmoothRateLimiter(&localWg, rch)
localWg.Add(1)
go rlim.startRateLimiter(ctx)
for _, image := range result.Results {
localWg.Add(1)
go addManifestCallToPool(ctx, config, rlim, username, password, image.RepoName, image.Tag, rch, &localWg)
}
localWg.Wait()
}
func (service searchService) getImageByNameAndCVEID(ctx context.Context, config searchConfig, username,
password, imageName, cvid string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
query := fmt.Sprintf(
`{
ImageListForCVE(id: "%s") {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
}
}
}`,
cvid)
result := &common.ImagesForCve{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
if result.Errors != nil {
var errBuilder strings.Builder
for _, err := range result.Errors {
fmt.Fprintln(&errBuilder, err.Message)
}
if isContextDone(ctx) {
return
}
rch <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113
return
}
var localWg sync.WaitGroup
rlim := newSmoothRateLimiter(&localWg, rch)
localWg.Add(1)
go rlim.startRateLimiter(ctx)
for _, image := range result.Results {
if !strings.EqualFold(imageName, image.RepoName) {
continue
}
localWg.Add(1)
go addManifestCallToPool(ctx, config, rlim, username, password, image.RepoName, image.Tag, rch, &localWg)
}
localWg.Wait()
}
func (service searchService) getCveByImage(ctx context.Context, config searchConfig, username, password,
imageName, searchedCVE string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
query := fmt.Sprintf(`{ CVEListForImage (image:"%s", searchedCVE:"%s")`+
` { Tag CVEList { Id Title Severity Description `+
`PackageList {Name InstalledVersion FixedVersion}} } }`, imageName, searchedCVE)
result := &cveResult{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
if result.Errors != nil {
var errBuilder strings.Builder
for _, err := range result.Errors {
fmt.Fprintln(&errBuilder, err.Message)
}
if isContextDone(ctx) {
return
}
rch <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113
return
}
result.Data.CVEListForImage.CVEList = groupCVEsBySeverity(result.Data.CVEListForImage.CVEList)
str, err := result.string(*config.outputFormat)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
if isContextDone(ctx) {
return
}
rch <- stringResult{str, nil}
}
func (service searchService) getFixedTagsForCVE(ctx context.Context, config searchConfig,
username, password, imageName, cvid string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
query := fmt.Sprintf(`
{
ImageListWithCVEFixed (id: "%s", image: "%s") {
Results {
RepoName Tag
Digest
MediaType
Manifests {
Digest
ConfigDigest
Size
IsSigned
Layers {Size Digest}
}
Size
}
}
}`, cvid, imageName)
result := &common.ImageListWithCVEFixedResponse{}
err := service.makeGraphQLQuery(ctx, config, username, password, query, result)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
if result.Errors != nil {
var errBuilder strings.Builder
for _, err := range result.Errors {
fmt.Fprintln(&errBuilder, err.Message)
}
if isContextDone(ctx) {
return
}
rch <- stringResult{"", errors.New(errBuilder.String())} //nolint: goerr113
return
}
var localWg sync.WaitGroup
rlim := newSmoothRateLimiter(&localWg, rch)
localWg.Add(1)
go rlim.startRateLimiter(ctx)
for _, img := range result.Results {
localWg.Add(1)
go addManifestCallToPool(ctx, config, rlim, username, password, imageName, img.Tag, rch, &localWg)
}
localWg.Wait()
}
func groupCVEsBySeverity(cveList []cve) []cve {
var (
unknown = make([]cve, 0)
none = make([]cve, 0)
high = make([]cve, 0)
med = make([]cve, 0)
low = make([]cve, 0)
critical = make([]cve, 0)
)
for _, cve := range cveList {
switch cve.Severity {
case "NONE":
none = append(none, cve)
case "LOW":
low = append(low, cve)
case "MEDIUM":
med = append(med, cve)
case "HIGH":
high = append(high, cve)
case "CRITICAL":
critical = append(critical, cve)
default:
unknown = append(unknown, cve)
}
}
vulnsCount := len(unknown) + len(none) + len(high) + len(med) + len(low) + len(critical)
vulns := make([]cve, 0, vulnsCount)
vulns = append(vulns, critical...)
vulns = append(vulns, high...)
vulns = append(vulns, med...)
vulns = append(vulns, low...)
vulns = append(vulns, none...)
vulns = append(vulns, unknown...)
return vulns
}
func isContextDone(ctx context.Context) bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}
// Query using GQL, the query string is passed as a parameter
// errors are returned in the stringResult channel, the unmarshalled payload is in resultPtr.
func (service searchService) makeGraphQLQuery(ctx context.Context,
config searchConfig, username, password, query string,
resultPtr interface{},
) error {
endPoint, err := combineServerAndEndpointURL(*config.servURL, constants.FullSearchPrefix)
if err != nil {
return err
}
err = makeGraphQLRequest(ctx, endPoint, query, username, password, *config.verifyTLS,
*config.debug, resultPtr, config.resultWriter)
if err != nil {
return err
}
return nil
}
func checkResultGraphQLQuery(ctx context.Context, err error, resultErrors []common.ErrorGQL,
) error {
if err != nil {
if isContextDone(ctx) {
return nil //nolint:nilnil
}
return err
}
if resultErrors != nil {
var errBuilder strings.Builder
for _, error := range resultErrors {
fmt.Fprintln(&errBuilder, error.Message)
}
if isContextDone(ctx) {
return nil
}
//nolint: goerr113
return errors.New(errBuilder.String())
}
return nil
}
func addManifestCallToPool(ctx context.Context, config searchConfig, pool *requestsPool,
username, password, imageName, tagName string, rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
manifestEndpoint, err := combineServerAndEndpointURL(*config.servURL,
fmt.Sprintf("/v2/%s/manifests/%s", imageName, tagName))
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
}
job := httpJob{
url: manifestEndpoint,
username: username,
imageName: imageName,
password: password,
tagName: tagName,
config: config,
}
wtgrp.Add(1)
pool.submitJob(&job)
}
type cveResult struct {
Errors []common.ErrorGQL `json:"errors"`
Data cveData `json:"data"`
}
type tagListResp struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
//nolint:tagliatelle // graphQL schema
type packageList struct {
Name string `json:"Name"`
InstalledVersion string `json:"InstalledVersion"`
FixedVersion string `json:"FixedVersion"`
}
//nolint:tagliatelle // graphQL schema
type cve struct {
ID string `json:"Id"`
Severity string `json:"Severity"`
Title string `json:"Title"`
Description string `json:"Description"`
PackageList []packageList `json:"PackageList"`
}
//nolint:tagliatelle // graphQL schema
type cveListForImage struct {
Tag string `json:"Tag"`
CVEList []cve `json:"CVEList"`
}
//nolint:tagliatelle // graphQL schema
type cveData struct {
CVEListForImage cveListForImage `json:"CVEListForImage"`
}
func (cve cveResult) string(format string) (string, error) {
switch strings.ToLower(format) {
case "", defaultOutoutFormat:
return cve.stringPlainText()
case jsonFormat:
return cve.stringJSON()
case ymlFormat, yamlFormat:
return cve.stringYAML()
default:
return "", ErrInvalidOutputFormat
}
}
func (cve cveResult) stringPlainText() (string, error) {
var builder strings.Builder
table := getCVETableWriter(&builder)
for _, c := range cve.Data.CVEListForImage.CVEList {
id := ellipsize(c.ID, cveIDWidth, ellipsis)
title := ellipsize(c.Title, cveTitleWidth, ellipsis)
severity := ellipsize(c.Severity, cveSeverityWidth, ellipsis)
row := make([]string, 3) //nolint:gomnd
row[colCVEIDIndex] = id
row[colCVESeverityIndex] = severity
row[colCVETitleIndex] = title
table.Append(row)
}
table.Render()
return builder.String(), nil
}
func (cve cveResult) stringJSON() (string, error) {
// Output is in json lines format - do not indent, append new line after json
json := jsoniter.ConfigCompatibleWithStandardLibrary
body, err := json.Marshal(cve.Data.CVEListForImage)
if err != nil {
return "", err
}
return string(body) + "\n", nil
}
func (cve cveResult) stringYAML() (string, error) {
// Output will be a multidoc yaml - use triple-dash to indicate a new document
body, err := yaml.Marshal(&cve.Data.CVEListForImage)
if err != nil {
return "", err
}
return "---\n" + string(body), nil
}
type referrersResult []common.Referrer
func (ref referrersResult) string(format string, maxArtifactTypeLen int) (string, error) {
switch strings.ToLower(format) {
case "", defaultOutoutFormat:
return ref.stringPlainText(maxArtifactTypeLen)
case jsonFormat:
return ref.stringJSON()
case ymlFormat, yamlFormat:
return ref.stringYAML()
default:
return "", ErrInvalidOutputFormat
}
}
func (ref referrersResult) stringPlainText(maxArtifactTypeLen int) (string, error) {
var builder strings.Builder
table := getImageTableWriter(&builder)
table.SetColMinWidth(refArtifactTypeIndex, maxArtifactTypeLen)
table.SetColMinWidth(refDigestIndex, digestWidth)
table.SetColMinWidth(refSizeIndex, sizeWidth)
for _, referrer := range ref {
artifactType := ellipsize(referrer.ArtifactType, maxArtifactTypeLen, ellipsis)
// digest := ellipsize(godigest.Digest(referrer.Digest).Encoded(), digestWidth, "")
size := ellipsize(humanize.Bytes(uint64(referrer.Size)), sizeWidth, ellipsis)
row := make([]string, refRowWidth)
row[refArtifactTypeIndex] = artifactType
row[refDigestIndex] = referrer.Digest
row[refSizeIndex] = size
table.Append(row)
}
table.Render()
return builder.String(), nil
}
func (ref referrersResult) stringJSON() (string, error) {
// Output is in json lines format - do not indent, append new line after json
json := jsoniter.ConfigCompatibleWithStandardLibrary
body, err := json.Marshal(ref)
if err != nil {
return "", err
}
return string(body) + "\n", nil
}
func (ref referrersResult) stringYAML() (string, error) {
// Output will be a multidoc yaml - use triple-dash to indicate a new document
body, err := yaml.Marshal(ref)
if err != nil {
return "", err
}
return "---\n" + string(body), nil
}
type repoStruct common.RepoSummary
func (repo repoStruct) string(format string, maxImgNameLen, maxTimeLen int, verbose bool) (string, error) { //nolint: lll
switch strings.ToLower(format) {
case "", defaultOutoutFormat:
return repo.stringPlainText(maxImgNameLen, maxTimeLen, verbose)
case jsonFormat:
return repo.stringJSON()
case ymlFormat, yamlFormat:
return repo.stringYAML()
default:
return "", ErrInvalidOutputFormat
}
}
func (repo repoStruct) stringPlainText(repoMaxLen, maxTimeLen int, verbose bool) (string, error) {
var builder strings.Builder
table := getImageTableWriter(&builder)
table.SetColMinWidth(repoNameIndex, repoMaxLen)
table.SetColMinWidth(repoSizeIndex, sizeWidth)
table.SetColMinWidth(repoLastUpdatedIndex, maxTimeLen)
table.SetColMinWidth(repoDownloadsIndex, dounloadsWidth)
table.SetColMinWidth(repoStarsIndex, signedWidth)
if verbose {
table.SetColMinWidth(repoPlatformsIndex, platformWidth)
}
repoSize, err := strconv.Atoi(repo.Size)
if err != nil {
return "", err
}
repoName := repo.Name
repoLastUpdated := repo.LastUpdated
repoDownloads := repo.DownloadCount
repoStars := repo.StarCount
repoPlatforms := repo.Platforms
row := make([]string, repoRowWidth)
row[repoNameIndex] = repoName
row[repoSizeIndex] = ellipsize(strings.ReplaceAll(humanize.Bytes(uint64(repoSize)), " ", ""), sizeWidth, ellipsis)
row[repoLastUpdatedIndex] = repoLastUpdated.String()
row[repoDownloadsIndex] = strconv.Itoa(repoDownloads)
row[repoStarsIndex] = strconv.Itoa(repoStars)
if verbose && len(repoPlatforms) > 0 {
row[repoPlatformsIndex] = getPlatformStr(repoPlatforms[0])
repoPlatforms = repoPlatforms[1:]
}
table.Append(row)
if verbose {
for _, platform := range repoPlatforms {
row := make([]string, repoRowWidth)
row[repoPlatformsIndex] = getPlatformStr(platform)
table.Append(row)
}
}
table.Render()
return builder.String(), nil
}
func (repo repoStruct) stringJSON() (string, error) {
// Output is in json lines format - do not indent, append new line after json
json := jsoniter.ConfigCompatibleWithStandardLibrary
body, err := json.Marshal(repo)
if err != nil {
return "", err
}
return string(body) + "\n", nil
}
func (repo repoStruct) stringYAML() (string, error) {
// Output will be a multidoc yaml - use triple-dash to indicate a new document
body, err := yaml.Marshal(&repo)
if err != nil {
return "", err
}
return "---\n" + string(body), nil
}
type imageStruct common.ImageSummary
func (img imageStruct) string(format string, maxImgNameLen, maxTagLen, maxPlatformLen int, verbose bool) (string, error) { //nolint: lll
switch strings.ToLower(format) {
case "", defaultOutoutFormat:
return img.stringPlainText(maxImgNameLen, maxTagLen, maxPlatformLen, verbose)
case jsonFormat:
return img.stringJSON()
case ymlFormat, yamlFormat:
return img.stringYAML()
default:
return "", ErrInvalidOutputFormat
}
}
func (img imageStruct) stringPlainText(maxImgNameLen, maxTagLen, maxPlatformLen int, verbose bool) (string, error) {
var builder strings.Builder
table := getImageTableWriter(&builder)
table.SetColMinWidth(colImageNameIndex, maxImgNameLen)
table.SetColMinWidth(colTagIndex, maxTagLen)
table.SetColMinWidth(colPlatformIndex, platformWidth)
table.SetColMinWidth(colDigestIndex, digestWidth)
table.SetColMinWidth(colSizeIndex, sizeWidth)
table.SetColMinWidth(colIsSignedIndex, isSignedWidth)
if verbose {
table.SetColMinWidth(colConfigIndex, configWidth)
table.SetColMinWidth(colLayersIndex, layersWidth)
}
var imageName, tagName string
imageName = img.RepoName
tagName = img.Tag
if imageNameWidth > maxImgNameLen {
maxImgNameLen = imageNameWidth
}
if tagWidth > maxTagLen {
maxTagLen = tagWidth
}
// adding spaces so that image name and tag columns are aligned
// in case the name/tag are fully shown and too long
var offset string
if maxImgNameLen > len(imageName) {
offset = strings.Repeat(" ", maxImgNameLen-len(imageName))
imageName += offset
}
if maxTagLen > len(tagName) {
offset = strings.Repeat(" ", maxTagLen-len(tagName))
tagName += offset
}
err := addImageToTable(table, &img, maxPlatformLen, imageName, tagName, verbose)
if err != nil {
return "", err
}
table.Render()
return builder.String(), nil
}
func addImageToTable(table *tablewriter.Table, img *imageStruct, maxPlatformLen int,
imageName, tagName string, verbose bool,
) error {
switch img.MediaType {
case ispec.MediaTypeImageManifest:
return addManifestToTable(table, imageName, tagName, &img.Manifests[0], maxPlatformLen, verbose)
case ispec.MediaTypeImageIndex:
return addImageIndexToTable(table, img, maxPlatformLen, imageName, tagName, verbose)
}
return nil
}
func addImageIndexToTable(table *tablewriter.Table, img *imageStruct, maxPlatformLen int,
imageName, tagName string, verbose bool,
) error {
indexDigest, err := godigest.Parse(img.Digest)
if err != nil {
return fmt.Errorf("error parsing index digest %s: %w", indexDigest, err)
}
row := make([]string, rowWidth)
row[colImageNameIndex] = imageName
row[colTagIndex] = tagName
row[colDigestIndex] = ellipsize(indexDigest.Encoded(), digestWidth, "")
row[colPlatformIndex] = "*"
imgSize, _ := strconv.ParseUint(img.Size, 10, 64)
row[colSizeIndex] = ellipsize(strings.ReplaceAll(humanize.Bytes(imgSize), " ", ""), sizeWidth, ellipsis)
row[colIsSignedIndex] = strconv.FormatBool(img.IsSigned)
if verbose {
row[colConfigIndex] = ""
row[colLayersIndex] = ""
}
table.Append(row)
for i := range img.Manifests {
err := addManifestToTable(table, "", "", &img.Manifests[i], maxPlatformLen, verbose)
if err != nil {
return err
}
}
return nil
}
func addManifestToTable(table *tablewriter.Table, imageName, tagName string, manifest *common.ManifestSummary,
maxPlatformLen int, verbose bool,
) error {
manifestDigest, err := godigest.Parse(manifest.Digest)
if err != nil {
return fmt.Errorf("error parsing manifest digest %s: %w", manifest.Digest, err)
}
configDigest, err := godigest.Parse(manifest.ConfigDigest)
if err != nil {
return fmt.Errorf("error parsing config digest %s: %w", manifest.ConfigDigest, err)
}
platform := getPlatformStr(manifest.Platform)
if maxPlatformLen > len(platform) {
offset := strings.Repeat(" ", maxPlatformLen-len(platform))
platform += offset
}
minifestDigestStr := ellipsize(manifestDigest.Encoded(), digestWidth, "")
configDigestStr := ellipsize(configDigest.Encoded(), configWidth, "")
imgSize, _ := strconv.ParseUint(manifest.Size, 10, 64)
size := ellipsize(strings.ReplaceAll(humanize.Bytes(imgSize), " ", ""), sizeWidth, ellipsis)
isSigned := manifest.IsSigned
row := make([]string, 8) //nolint:gomnd
row[colImageNameIndex] = imageName
row[colTagIndex] = tagName
row[colDigestIndex] = minifestDigestStr
row[colPlatformIndex] = platform
row[colSizeIndex] = size
row[colIsSignedIndex] = strconv.FormatBool(isSigned)
if verbose {
row[colConfigIndex] = configDigestStr
row[colLayersIndex] = ""
}
table.Append(row)
if verbose {
for _, entry := range manifest.Layers {
layerSize, _ := strconv.ParseUint(entry.Size, 10, 64)
size := ellipsize(strings.ReplaceAll(humanize.Bytes(layerSize), " ", ""), sizeWidth, ellipsis)
layerDigest, err := godigest.Parse(entry.Digest)
if err != nil {
return fmt.Errorf("error parsing layer digest %s: %w", entry.Digest, err)
}
layerDigestStr := ellipsize(layerDigest.Encoded(), digestWidth, "")
layerRow := make([]string, 8) //nolint:gomnd
layerRow[colImageNameIndex] = ""
layerRow[colTagIndex] = ""
layerRow[colDigestIndex] = ""
layerRow[colPlatformIndex] = ""
layerRow[colSizeIndex] = size
layerRow[colConfigIndex] = ""
layerRow[colLayersIndex] = layerDigestStr
table.Append(layerRow)
}
}
return nil
}
func getPlatformStr(platf common.Platform) string {
if platf.Arch == "" && platf.Os == "" {
return ""
}
platform := platf.Os
if platf.Arch != "" {
platform = platform + "/" + platf.Arch
platform = strings.Trim(platform, "/")
if platf.Variant != "" {
platform = platform + "/" + platf.Variant
}
}
return platform
}
func (img imageStruct) stringJSON() (string, error) {
// Output is in json lines format - do not indent, append new line after json
json := jsoniter.ConfigCompatibleWithStandardLibrary
body, err := json.Marshal(img)
if err != nil {
return "", err
}
return string(body) + "\n", nil
}
func (img imageStruct) stringYAML() (string, error) {
// Output will be a multidoc yaml - use triple-dash to indicate a new document
body, err := yaml.Marshal(&img)
if err != nil {
return "", err
}
return "---\n" + string(body), nil
}
type catalogResponse struct {
Repositories []string `json:"repositories"`
}
func combineServerAndEndpointURL(serverURL, endPoint string) (string, error) {
if !isURL(serverURL) {
return "", zotErrors.ErrInvalidURL
}
newURL, err := url.Parse(serverURL)
if err != nil {
return "", zotErrors.ErrInvalidURL
}
newURL, _ = newURL.Parse(endPoint)
return newURL.String(), nil
}
func ellipsize(text string, max int, trailing string) string {
text = strings.TrimSpace(text)
if len(text) <= max {
return text
}
chopLength := len(trailing)
return text[:max-chopLength] + trailing
}
func getImageTableWriter(writer io.Writer) *tablewriter.Table {
table := tablewriter.NewWriter(writer)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
return table
}
func getCVETableWriter(writer io.Writer) *tablewriter.Table {
table := tablewriter.NewWriter(writer)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
table.SetColMinWidth(colCVEIDIndex, cveIDWidth)
table.SetColMinWidth(colCVESeverityIndex, cveSeverityWidth)
table.SetColMinWidth(colCVETitleIndex, cveTitleWidth)
return table
}
func getReferrersTableWriter(writer io.Writer) *tablewriter.Table {
table := tablewriter.NewWriter(writer)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
return table
}
func getRepoTableWriter(writer io.Writer) *tablewriter.Table {
table := tablewriter.NewWriter(writer)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
return table
}
func (service searchService) getRepos(ctx context.Context, config searchConfig, username, password string,
rch chan stringResult, wtgrp *sync.WaitGroup,
) {
defer wtgrp.Done()
defer close(rch)
catalog := &catalogResponse{}
catalogEndPoint, err := combineServerAndEndpointURL(*config.servURL, fmt.Sprintf("%s%s",
constants.RoutePrefix, constants.ExtCatalogPrefix))
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
_, err = makeGETRequest(ctx, catalogEndPoint, username, password, *config.verifyTLS,
*config.debug, catalog, config.resultWriter)
if err != nil {
if isContextDone(ctx) {
return
}
rch <- stringResult{"", err}
return
}
fmt.Fprintln(config.resultWriter, "\n\nREPOSITORY NAME")
for _, repo := range catalog.Repositories {
fmt.Fprintln(config.resultWriter, repo)
}
}
const (
imageNameWidth = 10
tagWidth = 8
digestWidth = 8
platformWidth = 14
sizeWidth = 10
isSignedWidth = 8
dounloadsWidth = 10
signedWidth = 10
lastUpdatedWidth = 14
configWidth = 8
layersWidth = 8
ellipsis = "..."
cveIDWidth = 16
cveSeverityWidth = 8
cveTitleWidth = 48
colCVEIDIndex = 0
colCVESeverityIndex = 1
colCVETitleIndex = 2
defaultOutoutFormat = "text"
)
const (
colImageNameIndex = iota
colTagIndex
colPlatformIndex
colDigestIndex
colConfigIndex
colIsSignedIndex
colLayersIndex
colSizeIndex
rowWidth
)
const (
repoNameIndex = iota
repoSizeIndex
repoLastUpdatedIndex
repoDownloadsIndex
repoStarsIndex
repoPlatformsIndex
repoRowWidth
)
const (
refArtifactTypeIndex = iota
refSizeIndex
refDigestIndex
refRowWidth
)