github.com/redhat-appstudio/e2e-tests@v0.0.0-20240520140907-9709f6f59323/pkg/utils/build/source_image.go (about)

     1  package build
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"os"
    12  	"path"
    13  	"path/filepath"
    14  	"regexp"
    15  	"strings"
    16  
    17  	"github.com/go-git/go-git/v5"
    18  	"github.com/go-git/go-git/v5/plumbing"
    19  	"github.com/moby/buildkit/frontend/dockerfile/parser"
    20  	"github.com/openshift/library-go/pkg/image/reference"
    21  	"sigs.k8s.io/controller-runtime/pkg/client"
    22  
    23  	"github.com/redhat-appstudio/e2e-tests/pkg/clients/github"
    24  	"github.com/redhat-appstudio/e2e-tests/pkg/clients/tekton"
    25  	"github.com/redhat-appstudio/e2e-tests/pkg/constants"
    26  	"github.com/redhat-appstudio/e2e-tests/pkg/utils"
    27  	pipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
    28  )
    29  
    30  const (
    31  	extraSourceSubDir     = "extra_src_dir"
    32  	rpmSubDir             = "rpm_dir"
    33  	srcTarFileRegex       = "extra-src-[0-9a-f]+.tar"
    34  	shaValueRegex         = "[a-f0-9]{40}"
    35  	tarGzFileRegex        = ".tar.gz$"
    36  	gomodDependencySubDir = "deps/gomod/pkg/mod/cache/download/"
    37  	pipDependencySubDir   = "deps/pip/"
    38  )
    39  
    40  func GetBinaryImage(pr *pipeline.PipelineRun) string {
    41  	for _, p := range pr.Spec.Params {
    42  		if p.Name == "output-image" {
    43  			return p.Value.StringVal
    44  		}
    45  	}
    46  	return ""
    47  }
    48  
    49  func IsSourceBuildEnabled(pr *pipeline.PipelineRun) bool {
    50  	for _, p := range pr.Status.PipelineRunStatusFields.PipelineSpec.Params {
    51  		if p.Name == "build-source-image" {
    52  			if p.Default.StringVal == "true" {
    53  				return true
    54  			}
    55  		}
    56  	}
    57  	return false
    58  }
    59  
    60  func IsHermeticBuildEnabled(pr *pipeline.PipelineRun) bool {
    61  	for _, p := range pr.Spec.Params {
    62  		if p.Name == "hermetic" {
    63  			if p.Value.StringVal == "true" {
    64  				return true
    65  			}
    66  		}
    67  	}
    68  	return false
    69  }
    70  
    71  func GetPrefetchValue(pr *pipeline.PipelineRun) string {
    72  	for _, p := range pr.Spec.Params {
    73  		if p.Name == "prefetch-input" {
    74  			return p.Value.StringVal
    75  		}
    76  	}
    77  	return ""
    78  }
    79  
    80  func IsSourceFilesExistsInSourceImage(srcImage string, gitUrl string, isHermetic bool, prefetchValue string) (bool, error) {
    81  	//Extract the src image locally
    82  	tmpDir, err := ExtractImage(srcImage)
    83  	defer os.RemoveAll(tmpDir)
    84  	if err != nil {
    85  		return false, err
    86  	}
    87  
    88  	// Check atleast one file present under extra_src_dir
    89  	absExtraSourceDirPath := filepath.Join(tmpDir, extraSourceSubDir)
    90  	fileNames, err := utils.GetFileNamesFromDir(absExtraSourceDirPath)
    91  	if err != nil {
    92  		return false, fmt.Errorf("error while getting files: %v", err)
    93  	}
    94  	if len(fileNames) == 0 {
    95  		return false, fmt.Errorf("no tar file found in extra_src_dir, found files %v", fileNames)
    96  	}
    97  
    98  	// Get all the extra-src-*.tar files
    99  	extraSrcTarFiles := utils.FilterSliceUsingPattern(srcTarFileRegex, fileNames)
   100  	if len(extraSrcTarFiles) == 0 {
   101  		return false, fmt.Errorf("no tar file found with pattern %s", srcTarFileRegex)
   102  	}
   103  	fmt.Printf("Files found with pattern %s: %v\n", srcTarFileRegex, extraSrcTarFiles)
   104  
   105  	//Untar all the extra-src-[0-9]+.tar files
   106  	for _, tarFile := range extraSrcTarFiles {
   107  		absExtraSourceTarPath := filepath.Join(absExtraSourceDirPath, tarFile)
   108  		err = utils.Untar(absExtraSourceDirPath, absExtraSourceTarPath)
   109  		if err != nil {
   110  			return false, fmt.Errorf("error while untaring %s: %v", tarFile, err)
   111  		}
   112  	}
   113  
   114  	//Check if application source files exists
   115  	_, err = doAppSourceFilesExist(absExtraSourceDirPath)
   116  	if err != nil {
   117  		return false, err
   118  	}
   119  	// Check the pre-fetch dependency related files
   120  	if isHermetic {
   121  		_, err := IsPreFetchDependenciesFilesExists(gitUrl, absExtraSourceDirPath, isHermetic, prefetchValue)
   122  		if err != nil {
   123  			return false, err
   124  		}
   125  	}
   126  
   127  	return true, nil
   128  }
   129  
   130  // doAppSourceFilesExist checks if there is app source archive included.
   131  // For the builds based on Konflux image, multiple app sources could be included.
   132  func doAppSourceFilesExist(absExtraSourceDirPath string) (bool, error) {
   133  	//Get the file list from extra_src_dir
   134  	fileNames, err := utils.GetFileNamesFromDir(absExtraSourceDirPath)
   135  	if err != nil {
   136  		return false, fmt.Errorf("error while getting files: %v", err)
   137  	}
   138  
   139  	//Get the component source with pattern <repo-name>-<git-sha>.tar.gz
   140  	filePatternToFind := "^.+-" + shaValueRegex + tarGzFileRegex
   141  	resultFiles := utils.FilterSliceUsingPattern(filePatternToFind, fileNames)
   142  	if len(resultFiles) == 0 {
   143  		return false, fmt.Errorf("did not found the component source inside extra_src_dir, files found are: %v", fileNames)
   144  	}
   145  
   146  	fmt.Println("file names:", fileNames)
   147  	fmt.Println("app sources:", resultFiles)
   148  
   149  	for _, sourceGzTarFileName := range resultFiles {
   150  		//Untar the <repo-name>-<git-sha>.tar.gz file
   151  		err = utils.Untar(absExtraSourceDirPath, filepath.Join(absExtraSourceDirPath, sourceGzTarFileName))
   152  		if err != nil {
   153  			return false, fmt.Errorf("error while untaring %s: %v", sourceGzTarFileName, err)
   154  		}
   155  
   156  		//Get the file list from extra_src_dir/<repo-name>-<sha>
   157  		sourceGzTarDirName := strings.TrimSuffix(sourceGzTarFileName, ".tar.gz")
   158  		absSourceGzTarPath := filepath.Join(absExtraSourceDirPath, sourceGzTarDirName)
   159  		fileNames, err = utils.GetFileNamesFromDir(absSourceGzTarPath)
   160  		if err != nil {
   161  			return false, fmt.Errorf("error while getting files from %s: %v", sourceGzTarDirName, err)
   162  		}
   163  		if len(fileNames) == 0 {
   164  			return false, fmt.Errorf("no file found under extra_src_dir/<repo-name>-<git-sha>")
   165  		}
   166  	}
   167  
   168  	return true, nil
   169  }
   170  
   171  // NewGithubClient creates a GitHub client with custom organization.
   172  // The token is retrieved in the same way as what SuiteController does.
   173  func NewGithubClient(organization string) (*github.Github, error) {
   174  	token := utils.GetEnv(constants.GITHUB_TOKEN_ENV, "")
   175  	if gh, err := github.NewGithubClient(token, organization); err != nil {
   176  		return nil, err
   177  	} else {
   178  		return gh, nil
   179  	}
   180  }
   181  
   182  // ReadFileFromGitRepo reads a file from a remote Git repository hosted in GitHub.
   183  // The filePath should be a relative path to the root of the repository.
   184  // File content is returned. If error occurs, the error will be returned and
   185  // empty string is returned as nothing is read.
   186  // If branch is omitted, file is read from the "main" branch.
   187  func ReadFileFromGitRepo(repoUrl, filePath, branch string) (string, error) {
   188  	fromBranch := branch
   189  	if fromBranch == "" {
   190  		fromBranch = "main"
   191  	}
   192  	wrapErr := func(err error) error {
   193  		return fmt.Errorf("error while reading file %s from repository %s: %v", filePath, repoUrl, err)
   194  	}
   195  	parsedUrl, err := url.Parse(repoUrl)
   196  	if err != nil {
   197  		return "", wrapErr(err)
   198  	}
   199  	org, repo := path.Split(parsedUrl.Path)
   200  	gh, err := NewGithubClient(strings.Trim(org, "/"))
   201  	if err != nil {
   202  		return "", wrapErr(err)
   203  	}
   204  	repoContent, err := gh.GetFile(repo, filePath, fromBranch)
   205  	if err != nil {
   206  		return "", wrapErr(err)
   207  	}
   208  	if content, err := repoContent.GetContent(); err != nil {
   209  		return "", wrapErr(err)
   210  	} else {
   211  		return content, nil
   212  	}
   213  }
   214  
   215  // ReadRequirements reads dependencies from compiled requirements.txt by pip-compile,
   216  // and it assumes the requirements.txt is simple in the root of the repository.
   217  // The requirements are returned a list of strings, each of them is in form name==version.
   218  func ReadRequirements(repoUrl string) ([]string, error) {
   219  	const requirementsFile = "requirements.txt"
   220  
   221  	wrapErr := func(err error) error {
   222  		return fmt.Errorf("error while reading requirements.txt from repo %s: %v", repoUrl, err)
   223  	}
   224  
   225  	content, err := ReadFileFromGitRepo(repoUrl, requirementsFile, "")
   226  	if err != nil {
   227  		return nil, wrapErr(err)
   228  	}
   229  
   230  	reqs := make([]string, 0, 5)
   231  	// Match line: "requests==2.31.0 \"
   232  	reqRegex := regexp.MustCompile(`^\S.+ \\$`)
   233  
   234  	scanner := bufio.NewScanner(strings.NewReader(content))
   235  	for scanner.Scan() {
   236  		line := scanner.Text()
   237  		if reqRegex.MatchString(line) {
   238  			reqs = append(reqs, strings.TrimSuffix(line, " \\"))
   239  		}
   240  	}
   241  
   242  	return reqs, nil
   243  }
   244  
   245  func IsPreFetchDependenciesFilesExists(gitUrl, absExtraSourceDirPath string, isHermetic bool, prefetchValue string) (bool, error) {
   246  	var absDependencyPath string
   247  	if prefetchValue == "gomod" {
   248  		fmt.Println("Checking go dependency files")
   249  		absDependencyPath = filepath.Join(absExtraSourceDirPath, gomodDependencySubDir)
   250  	} else if prefetchValue == "pip" {
   251  		fmt.Println("Checking python dependency files")
   252  		absDependencyPath = filepath.Join(absExtraSourceDirPath, pipDependencySubDir)
   253  	} else {
   254  		return false, fmt.Errorf("pre-fetch value type is not implemented")
   255  	}
   256  
   257  	fileNames, err := utils.GetFileNamesFromDir(absDependencyPath)
   258  	if err != nil {
   259  		return false, fmt.Errorf("error while getting files from %s: %v", absDependencyPath, err)
   260  	}
   261  	if len(fileNames) == 0 {
   262  		return false, fmt.Errorf("no file found under extra_src_dir/deps/")
   263  	}
   264  
   265  	// Easy to check for pip. Check if all requirements are included in the built source image.
   266  	if prefetchValue == "pip" {
   267  		fileSet := make(map[string]int)
   268  		for _, name := range fileNames {
   269  			fileSet[name] = 1
   270  		}
   271  		fmt.Println("file set:", fileSet)
   272  
   273  		requirements, err := ReadRequirements(gitUrl)
   274  		fmt.Println("requirements:", requirements)
   275  		if err != nil {
   276  			return false, fmt.Errorf("error while reading requirements.txt from repo %s: %v", gitUrl, err)
   277  		}
   278  		var sdistFilename string
   279  		for _, requirement := range requirements {
   280  			if strings.Contains(requirement, "==") {
   281  				sdistFilename = strings.Replace(requirement, "==", "-", 1) + ".tar.gz"
   282  			} else if strings.Contains(requirement, " @ https://") {
   283  				sdistFilename = fmt.Sprintf("external-%s", strings.Split(requirement, " ")[0])
   284  			} else {
   285  				fmt.Println("unknown requirement form:", requirement)
   286  				continue
   287  			}
   288  			if _, exists := fileSet[sdistFilename]; !exists {
   289  				return false, fmt.Errorf("requirement '%s' is not included", requirement)
   290  			}
   291  		}
   292  	}
   293  
   294  	return true, nil
   295  }
   296  
   297  // readDockerfile reads Dockerfile dockerfile from repository repoURL.
   298  // The Dockerfile is resolved by following the logic applied to the buildah task definition.
   299  func readDockerfile(pathContext, dockerfile, repoURL, repoRevision string) ([]byte, error) {
   300  	tempRepoDir, err := os.MkdirTemp("", "-test-repo")
   301  	if err != nil {
   302  		return nil, err
   303  	}
   304  	defer os.RemoveAll(tempRepoDir)
   305  	testRepo, err := git.PlainClone(tempRepoDir, false, &git.CloneOptions{URL: repoURL})
   306  	if err != nil {
   307  		return nil, err
   308  	}
   309  
   310  	// checkout to the revision. use go-git ResolveRevision since revision could be a branch, tag or commit hash
   311  	commitHash, err := testRepo.ResolveRevision(plumbing.Revision(repoRevision))
   312  	if err != nil {
   313  		return nil, err
   314  	}
   315  	workTree, err := testRepo.Worktree()
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  	if err := workTree.Checkout(&git.CheckoutOptions{Hash: *commitHash}); err != nil {
   320  		return nil, err
   321  	}
   322  
   323  	// check dockerfile in different paths
   324  	var dockerfilePath string
   325  	dockerfilePath = filepath.Join(tempRepoDir, dockerfile)
   326  	if content, err := os.ReadFile(dockerfilePath); err == nil {
   327  		return content, nil
   328  	}
   329  	dockerfilePath = filepath.Join(tempRepoDir, pathContext, dockerfile)
   330  	if content, err := os.ReadFile(dockerfilePath); err == nil {
   331  		return content, nil
   332  	}
   333  	if strings.HasPrefix(dockerfile, "https://") {
   334  		if resp, err := http.Get(dockerfile); err == nil {
   335  			defer resp.Body.Close()
   336  			if body, err := io.ReadAll(resp.Body); err == nil {
   337  				return body, err
   338  			} else {
   339  				return nil, err
   340  			}
   341  		} else {
   342  			return nil, err
   343  		}
   344  	}
   345  	return nil, fmt.Errorf(
   346  		fmt.Sprintf("resolveDockerfile: can't resolve Dockerfile from path context %s and dockerfile %s",
   347  			pathContext, dockerfile),
   348  	)
   349  }
   350  
   351  // ReadDockerfileUsedForBuild reads the Dockerfile and return its content.
   352  func ReadDockerfileUsedForBuild(c client.Client, tektonController *tekton.TektonController, pr *pipeline.PipelineRun) ([]byte, error) {
   353  	var paramDockerfileValue, paramPathContextValue string
   354  	var paramUrlValue, paramRevisionValue string
   355  	var err error
   356  	getParam := tektonController.GetTaskRunParam
   357  
   358  	if paramDockerfileValue, err = getParam(c, pr, "build-container", "DOCKERFILE"); err != nil {
   359  		return nil, err
   360  	}
   361  
   362  	if paramPathContextValue, err = getParam(c, pr, "build-container", "CONTEXT"); err != nil {
   363  		return nil, err
   364  	}
   365  
   366  	// get git-clone param url and revision
   367  	if paramUrlValue, err = getParam(c, pr, "clone-repository", "url"); err != nil {
   368  		return nil, err
   369  	}
   370  
   371  	if paramRevisionValue, err = getParam(c, pr, "clone-repository", "revision"); err != nil {
   372  		return nil, err
   373  	}
   374  
   375  	dockerfileContent, err := readDockerfile(paramPathContextValue, paramDockerfileValue, paramUrlValue, paramRevisionValue)
   376  	if err != nil {
   377  		return nil, err
   378  	}
   379  	return dockerfileContent, nil
   380  }
   381  
   382  type SourceBuildResult struct {
   383  	Status                  string `json:"status"`
   384  	Message                 string `json:"message,omitempty"`
   385  	DependenciesIncluded    bool   `json:"dependencies_included"`
   386  	BaseImageSourceIncluded bool   `json:"base_image_source_included"`
   387  	ImageUrl                string `json:"image_url"`
   388  	ImageDigest             string `json:"image_digest"`
   389  }
   390  
   391  // ReadSourceBuildResult reads source-build task result BUILD_RESULT and returns the decoded data.
   392  func ReadSourceBuildResult(c client.Client, tektonController *tekton.TektonController, pr *pipeline.PipelineRun) (*SourceBuildResult, error) {
   393  	sourceBuildResult, err := tektonController.GetTaskRunResult(c, pr, "build-source-image", "BUILD_RESULT")
   394  	if err != nil {
   395  		return nil, err
   396  	}
   397  	var buildResult SourceBuildResult
   398  	if err = json.Unmarshal([]byte(sourceBuildResult), &buildResult); err != nil {
   399  		return nil, err
   400  	}
   401  	return &buildResult, nil
   402  }
   403  
   404  type Dockerfile struct {
   405  	parsedContent *parser.Result
   406  }
   407  
   408  func ParseDockerfile(content []byte) (*Dockerfile, error) {
   409  	parsedContent, err := parser.Parse(bytes.NewReader(content))
   410  	if err != nil {
   411  		return nil, err
   412  	}
   413  	df := Dockerfile{
   414  		parsedContent: parsedContent,
   415  	}
   416  	return &df, nil
   417  }
   418  
   419  func (d *Dockerfile) ParentImages() []string {
   420  	parentImages := make([]string, 0, 5)
   421  	for _, child := range d.parsedContent.AST.Children {
   422  		if child.Value == "FROM" {
   423  			parentImages = append(parentImages, child.Next.Value)
   424  		}
   425  	}
   426  	return parentImages
   427  }
   428  
   429  func (d *Dockerfile) IsBuildFromScratch() bool {
   430  	parentImages := d.ParentImages()
   431  	return parentImages[len(parentImages)-1] == "scratch"
   432  }
   433  
   434  // convertImageToBuildahOutputForm converts an image pullspec to the corresponding form within
   435  // BASE_IMAGES_DIGESTS output by buildah task.
   436  func convertImageToBuildahOutputForm(imagePullspec string) (string, error) {
   437  	ref, err := reference.Parse(imagePullspec)
   438  	if err != nil {
   439  		return "", fmt.Errorf("fail to parse image %s: %s", imagePullspec, err)
   440  	}
   441  	var tag string
   442  	digest := ref.ID
   443  	if digest == "" {
   444  		val, err := FetchImageDigest(imagePullspec)
   445  		if err != nil {
   446  			return "", fmt.Errorf("fail to fetch image digest of %s: %s", imagePullspec, err)
   447  		}
   448  		digest = val
   449  
   450  		tag = ref.Tag
   451  		if tag == "" {
   452  			tag = "latest"
   453  		}
   454  	} else {
   455  		tag = "<none>"
   456  	}
   457  	digest = strings.TrimPrefix(digest, "sha256:")
   458  	// image could have no namespace.
   459  	converted := strings.TrimSuffix(filepath.Join(ref.Registry, ref.Namespace), "/")
   460  	return fmt.Sprintf("%s/%s:%s@sha256:%s", converted, ref.Name, tag, digest), nil
   461  }
   462  
   463  // ConvertParentImagesToBaseImagesDigestsForm is a helper function for testing the order is matched
   464  // between BASE_IMAGES_DIGESTS and parent images within Dockerfile.
   465  // ConvertParentImagesToBaseImagesDigestsForm de-duplicates the images what buildah task does for BASE_IMAGES_DIGESTS.
   466  func (d *Dockerfile) ConvertParentImagesToBaseImagesDigestsForm() ([]string, error) {
   467  	convertedImagePullspecs := make([]string, 0, 5)
   468  	seen := make(map[string]int)
   469  	parentImages := d.ParentImages()
   470  	for _, imagePullspec := range parentImages {
   471  		if imagePullspec == "scratch" {
   472  			continue
   473  		}
   474  		if _, exists := seen[imagePullspec]; exists {
   475  			continue
   476  		}
   477  		seen[imagePullspec] = 1
   478  		if converted, err := convertImageToBuildahOutputForm(imagePullspec); err == nil {
   479  			convertedImagePullspecs = append(convertedImagePullspecs, converted)
   480  		} else {
   481  			return nil, err
   482  		}
   483  	}
   484  	return convertedImagePullspecs, nil
   485  }
   486  
   487  func isRegistryAllowed(registry string) bool {
   488  	// For the list of allowed registries, refer to source-build task definition.
   489  	allowedRegistries := map[string]int{
   490  		"registry.access.redhat.com": 1,
   491  		"registry.redhat.io":         1,
   492  	}
   493  	_, exists := allowedRegistries[registry]
   494  	return exists
   495  }
   496  
   497  func IsImagePulledFromAllowedRegistry(imagePullspec string) (bool, error) {
   498  	if ref, err := reference.Parse(imagePullspec); err == nil {
   499  		return isRegistryAllowed(ref.Registry), nil
   500  	} else {
   501  		return false, err
   502  	}
   503  }
   504  
   505  func SourceBuildTaskRunLogsContain(
   506  	tektonController *tekton.TektonController, pr *pipeline.PipelineRun, message string) (bool, error) {
   507  	logs, err := tektonController.GetTaskRunLogs(pr.GetName(), "build-source-image", pr.GetNamespace())
   508  	if err != nil {
   509  		return false, err
   510  	}
   511  	for _, logMessage := range logs {
   512  		if strings.Contains(logMessage, message) {
   513  			return true, nil
   514  		}
   515  	}
   516  	return false, nil
   517  }
   518  
   519  func ResolveSourceImageByVersionRelease(image string) (string, error) {
   520  	config, err := FetchImageConfig(image)
   521  	if err != nil {
   522  		return "", err
   523  	}
   524  	labels := config.Config.Labels
   525  	var version, release string
   526  	var exists bool
   527  	if version, exists = labels["version"]; !exists {
   528  		return "", fmt.Errorf("cannot find out version label from image config")
   529  	}
   530  	if release, exists = labels["release"]; !exists {
   531  		return "", fmt.Errorf("cannot find out release label from image config")
   532  	}
   533  	ref, err := reference.Parse(image)
   534  	if err != nil {
   535  		return "", err
   536  	}
   537  	ref.ID = ""
   538  	ref.Tag = fmt.Sprintf("%s-%s-source", version, release)
   539  	return ref.Exact(), nil
   540  }
   541  
   542  func AllParentSourcesIncluded(parentSourceImage, builtSourceImage string) (bool, error) {
   543  	parentConfig, err := FetchImageConfig(parentSourceImage)
   544  	if err != nil {
   545  		return false, fmt.Errorf("error while getting parent source image manifest %s: %w", parentSourceImage, err)
   546  	}
   547  	builtConfig, err := FetchImageConfig(builtSourceImage)
   548  	if err != nil {
   549  		return false, fmt.Errorf("error while getting built source image manifest %s: %w", builtSourceImage, err)
   550  	}
   551  	srpmSha256Sums := make(map[string]int)
   552  	var parts []string
   553  	for _, history := range builtConfig.History {
   554  		// Example history: #(nop) bsi version 0.2.0-dev adding artifact: 5f526f4
   555  		parts = strings.Split(history.CreatedBy, " ")
   556  		// The last part 5f526f4 is the checksum calculated from the file included in the generated blob.
   557  		srpmSha256Sums[parts[len(parts)-1]] = 1
   558  	}
   559  	for _, history := range parentConfig.History {
   560  		parts = strings.Split(history.CreatedBy, " ")
   561  		if _, exists := srpmSha256Sums[parts[len(parts)-1]]; !exists {
   562  			return false, nil
   563  		}
   564  	}
   565  	return true, nil
   566  }
   567  
   568  func ResolveKonfluxSourceImage(image string) (string, error) {
   569  	digest, err := FetchImageDigest(image)
   570  	if err != nil {
   571  		return "", fmt.Errorf("error while fetching image digest of %s: %w", image, err)
   572  	}
   573  	ref, err := reference.Parse(image)
   574  	if err != nil {
   575  		return "", fmt.Errorf("error while parsing image %s: %w", image, err)
   576  	}
   577  	ref.ID = ""
   578  	ref.Tag = fmt.Sprintf("sha256-%s.src", digest)
   579  	return ref.Exact(), nil
   580  }