diff --git a/pkg/clients/oras/client.go b/pkg/clients/oras/client.go index f4b9f2679a..6ff76330a4 100644 --- a/pkg/clients/oras/client.go +++ b/pkg/clients/oras/client.go @@ -2,10 +2,14 @@ package oras import ( "context" + "encoding/json" + "fmt" "os" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/openshift/library-go/pkg/image/reference" oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/file" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" @@ -27,12 +31,12 @@ func PullArtifacts(imagePullSpec string) (string, error) { imageRef, err := reference.Parse(imagePullSpec) if err != nil { - return "", err + return "", fmt.Errorf("cannot parse %s: %w", imagePullSpec, err) } repo, err := remote.NewRepository(imagePullSpec) if err != nil { - return "", err + return "", fmt.Errorf("cannot get repository from %s: %w", imagePullSpec, err) } repo.Client = &auth.Client{ Client: retry.DefaultClient, @@ -43,10 +47,43 @@ func PullArtifacts(imagePullSpec string) (string, error) { } ctx := context.Background() - tag := imageRef.Tag - if _, err := oras.Copy(ctx, repo, tag, fs, tag, oras.DefaultCopyOptions); err != nil { - return "", err + srcRef := imageRef.ID + if srcRef == "" { + srcRef = imageRef.Tag + } + dstRef := srcRef + + opts := oras.DefaultCopyOptions + // Fetch only the artifacts directly referenced in the Image Manifest. + opts.FindSuccessors = noSuccessors + + if _, err := oras.Copy(ctx, repo, srcRef, fs, dstRef, opts); err != nil { + return "", fmt.Errorf("copying %s: %w", imagePullSpec, err) } return storePath, nil } + +// noSuccessors returns the nodes directly pointed by the current node. By default oras will follow +// the "subject" of an Image Manifest. For artifacts that are attached to an image, this causes the +// image itself to also be pulled. Since oras doesn't provide a public function for fetching only +// the direct nodes we must roll our own. Ironically, the oras CLI also uses a custom behavior: +// https://github.com/oras-project/oras/blob/00a19d20644fe57d051d3b871579167dc2ff98e5/internal/graph/graph.go#L61 +// This function only supports a very small set of subsets which is sufficient for e2e-tests. +func noSuccessors(ctx context.Context, fetcher content.Fetcher, node ocispec.Descriptor) ([]ocispec.Descriptor, error) { + + switch node.MediaType { + case ocispec.MediaTypeImageManifest: + content, err := content.FetchAll(ctx, fetcher, node) + if err != nil { + return nil, err + } + var manifest ocispec.Manifest + if err := json.Unmarshal(content, &manifest); err != nil { + return nil, err + } + return manifest.Layers, nil + default: + return nil, nil + } +} diff --git a/pkg/utils/build/task_results.go b/pkg/utils/build/task_results.go index ca7600c709..d39521a1b3 100644 --- a/pkg/utils/build/task_results.go +++ b/pkg/utils/build/task_results.go @@ -4,7 +4,11 @@ import ( "context" "encoding/json" "fmt" + "io/fs" + "path/filepath" + "strings" + "github.com/konflux-ci/e2e-tests/pkg/clients/oras" "github.com/konflux-ci/e2e-tests/pkg/constants" pipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "k8s.io/apimachinery/pkg/types" @@ -28,6 +32,8 @@ type ClairScanResult struct { Vulnerabilities Vulnerabilities `json:"vulnerabilities"` } +type ClairScanReports map[string]string + type Vulnerabilities struct { Critical int `json:"critical"` High int `json:"high"` @@ -36,6 +42,17 @@ type Vulnerabilities struct { } func ValidateBuildPipelineTestResults(pipelineRun *pipeline.PipelineRun, c crclient.Client, isFBCBuild bool) error { + var imageURL string + for _, result := range pipelineRun.Status.Results { + if result.Name == "IMAGE_URL" { + imageURL = strings.TrimSpace(result.Value.StringVal) + break + } + } + if imageURL == "" { + return fmt.Errorf("unable to find IMAGE_URL result from PipelineRun %s", pipelineRun.Name) + } + for _, taskName := range taskNames { // The inspect-image task is only required for FBC pipelines which we can infer by the component name @@ -55,13 +72,14 @@ func ValidateBuildPipelineTestResults(pipelineRun *pipeline.PipelineRun, c crcli switch taskName { case "clair-scan": resultsToValidate = append(resultsToValidate, "SCAN_OUTPUT") + resultsToValidate = append(resultsToValidate, "REPORTS") case "deprecated-image-check": resultsToValidate = append(resultsToValidate, "PYXIS_HTTP_CODE") case "inspect-image": resultsToValidate = append(resultsToValidate, "BASE_IMAGE", "BASE_IMAGE_REPOSITORY") } - if err := validateTaskRunResult(results, resultsToValidate, taskName); err != nil { + if err := validateTaskRunResult(imageURL, results, resultsToValidate, taskName); err != nil { return err } @@ -85,7 +103,7 @@ func fetchTaskRunResults(c crclient.Client, pr *pipeline.PipelineRun, pipelineTa "pipelineTaskName %q not found in PipelineRun %s/%s", pipelineTaskName, pr.GetName(), pr.GetNamespace()) } -func validateTaskRunResult(trResults []pipeline.TaskRunResult, expectedResultNames []string, taskName string) error { +func validateTaskRunResult(imageURL string, trResults []pipeline.TaskRunResult, expectedResultNames []string, taskName string) error { for _, rn := range expectedResultNames { found := false for _, r := range trResults { @@ -104,6 +122,41 @@ func validateTaskRunResult(trResults []pipeline.TaskRunResult, expectedResultNam if err != nil { return fmt.Errorf("cannot parse SCAN_OUTPUT result: %+v", err) } + case "REPORTS": + var reports = ClairScanReports{} + err := json.Unmarshal([]byte(r.Value.StringVal), &reports) + if err != nil { + return fmt.Errorf("cannot parse REPORTS result: %w", err) + } + for _, reportDigest := range reports { + reportRef := fmt.Sprintf("%s@%s", imageURL, reportDigest) + + imageDir, err := oras.PullArtifacts(reportRef) + if err != nil { + return fmt.Errorf("cannot fetch report from ref %s: %w", reportRef, err) + } + + var hasNonEmptyReport bool + if err := filepath.Walk(imageDir, func(p string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if info.Size() == 0 { + return fmt.Errorf("report %s from %s is empty", p, reportRef) + } + hasNonEmptyReport = true + return nil + }); err != nil { + return err + } + + if !hasNonEmptyReport { + return fmt.Errorf("no report files were found in %s", reportRef) + } + } case "PYXIS_HTTP_CODE", "BASE_IMAGE", "BASE_IMAGE_REPOSITORY": if len(r.Value.StringVal) < 1 { return fmt.Errorf("value of %q result is empty", r.Name)