cmd/common/image_functions.go (224 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package common import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/Azure/acr-cli/acr" "github.com/Azure/acr-cli/acr/acrapi" "github.com/Azure/acr-cli/internal/api" "github.com/Azure/acr-cli/internal/container/set" "github.com/dlclark/regexp2" v1 "github.com/opencontainers/image-spec/specs-go/v1" ) const ( headerLink = "Link" mediaTypeDockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" defaultRegexpOptions regexp2.RegexOptions = regexp2.RE2 // This option will turn on compatibility mode so that it uses the group rules in regexp defaultRegexpMatchTimeoutSeconds int64 = 60 mediaTypeArtifactManifest = "application/vnd.oci.artifact.manifest.v1+json" ) func GetAllRepositoryNames(ctx context.Context, client acrapi.BaseClientAPI, pageSize int32) ([]string, error) { allRepoNames := make([]string, 0) lastName := "" for { repos, err := client.GetRepositories(ctx, lastName, &pageSize) if err != nil { return nil, err } if repos.Names == nil || len(*repos.Names) == 0 { break } allRepoNames = append(allRepoNames, *repos.Names...) lastName = allRepoNames[len(allRepoNames)-1] } return allRepoNames, nil } // GetMatchingRepos get all repositories in current registry, that match the provided regular expression func GetMatchingRepos(repoNames []string, repoRegex string, regexMatchTimeout int64) ([]string, error) { filter, err := BuildRegexFilter(repoRegex, regexMatchTimeout) if err != nil { return nil, err } var matchedRepos []string for _, repo := range repoNames { matched, err := filter.MatchString(repo) if err != nil { // The only error regexp2 can throw is a timeout error return nil, err } if matched { matchedRepos = append(matchedRepos, repo) } } return matchedRepos, nil } // GetRepositoryAndTagRegex splits the strings that are in the form <repository>:<regex filter> func GetRepositoryAndTagRegex(filter string) (string, string, error) { // This only selects colons that are not apart of a non-capture group // Note: regexp2 doesn't have .Split support yet, so we just replace the colon with another delimitter \r\n // We choose \r\n since it is an escape sequence that cannot be a part of repo name or a tag // For information on how this expression was written, see https://regexr.com/6jqp3 noncaptureGroupSupport := regexp2.MustCompile(`(?<!\(\?[imsU-]{0,5}|\[*\^*\[\^*):(?!\]\]*)`, defaultRegexpOptions) // Note: We could just find the first 1, however we want to know if there are more than 1 colon that is not part of a non-capture group newlineDelimitted, err := noncaptureGroupSupport.Replace(filter, "\r\n", -1, -1) if err != nil { return "", "", errors.New("could not replace split filter by repo and tag") } repoAndRegex := strings.Split(newlineDelimitted, "\r\n") if len(repoAndRegex) != 2 { return "", "", errors.New("unable to correctly parse filter flag") } if repoAndRegex[0] == "" { return "", "", errors.New("missing repository name/expression") } if repoAndRegex[1] == "" { return "", "", errors.New("missing tag name/expression") } return repoAndRegex[0], repoAndRegex[1], nil } // CollectTagFilters collects all matching repos and collects the associated tag filters func CollectTagFilters(ctx context.Context, rawFilters []string, client acrapi.BaseClientAPI, regexMatchTimeout int64, repoPageSize int32) (map[string]string, error) { allRepoNames, err := GetAllRepositoryNames(ctx, client, repoPageSize) if err != nil { return nil, err } tagFilters := map[string]string{} for _, filter := range rawFilters { repoRegex, tagRegex, err := GetRepositoryAndTagRegex(filter) if err != nil { return nil, err } repoNames, err := GetMatchingRepos(allRepoNames, "^"+repoRegex+"$", regexMatchTimeout) if err != nil { return nil, err } for _, repoName := range repoNames { if _, ok := tagFilters[repoName]; ok { // To only iterate through a repo once a big regex filter is made of all the filters of a particular repo. tagFilters[repoName] = tagFilters[repoName] + "|" + tagRegex } else { tagFilters[repoName] = tagRegex } } } return tagFilters, nil } func GetLastTagFromResponse(resultTags *acr.RepositoryTagsType) string { // The lastTag is updated to keep the for loop going. if resultTags.Header == nil { return "" } link := resultTags.Header.Get(headerLink) if len(link) == 0 { return "" } queryString := strings.Split(link, "?") if len(queryString) <= 1 { return "" } queryStringToParse := strings.Split(queryString[1], ">") vals, err := url.ParseQuery(queryStringToParse[0]) if err != nil { return "" } return vals.Get("last") } // GetUntaggedManifests gets all the manifests for the command to be executed on. The command will be executed on this manifest if it does not // have any tag and does not form part of a manifest list that has tags referencing it. If the purge command is to be executed, // the manifest should also not have a tag and not have a subject manifest. func GetUntaggedManifests(ctx context.Context, acrClient api.AcrCLIClientInterface, loginURL string, repoName string, dryRun bool, ignoreReferrerManifests bool) (*[]string, error) { lastManifestDigest := "" var manifestsForCommand []string resultManifests, err := acrClient.GetAcrManifests(ctx, repoName, "", lastManifestDigest) if err != nil { if resultManifests != nil && resultManifests.Response.Response != nil && resultManifests.StatusCode == http.StatusNotFound { fmt.Printf("%s repository not found\n", repoName) return &manifestsForCommand, nil } return nil, err } // This will act as a set. If a key is present, then the command shouldn't be executed because it is referenced by a multiarch manifest // or the manifest has subjects attached ignoreList := set.New[string]() var candidates []acr.ManifestAttributesBase for resultManifests != nil && resultManifests.ManifestsAttributes != nil { manifests := *resultManifests.ManifestsAttributes for _, manifest := range manifests { if manifest.Tags != nil { // If a manifest has Tags and its media type supports multiarch manifest, we will // iterate all its dependent manifests and mark them to not have the command execute on them. if err = AddDependentManifestsToIgnoreList(ctx, manifest, ignoreList, acrClient, repoName); err != nil { return nil, err } } else { if ignoreReferrerManifests { // If a manifest does not have Tags and its media type supports subject, we will // check if the subject exists. If so, the manifest is marked not to be affected by the command. if candidates, err = UpdateForManifestWithoutSubjectToDelete(ctx, manifest, ignoreList, candidates, acrClient, repoName); err != nil { return nil, err } } else { if *manifest.MediaType != v1.MediaTypeImageManifest { candidates = append(candidates, manifest) } } } } // Get the last manifest digest from the last manifest from manifests. lastManifestDigest = *manifests[len(manifests)-1].Digest // Use this new digest to find next batch of manifests. resultManifests, err = acrClient.GetAcrManifests(ctx, repoName, "", lastManifestDigest) if err != nil { return nil, err } } // Remove all manifests that should not be deleted for i := 0; i < len(candidates); i++ { if !ignoreList.Contains(*candidates[i].Digest) { // if a manifest has no tags, is not part of a manifest list and can be deleted then it is added to the // manifestsForCommand array. if *(candidates[i].ChangeableAttributes).DeleteEnabled && *(candidates[i].ChangeableAttributes).WriteEnabled { manifestsForCommand = append(manifestsForCommand, *candidates[i].Digest) if dryRun && !ignoreReferrerManifests { fmt.Printf("%s/%s@%s\n", loginURL, repoName, *candidates[i].Digest) } } } } return &manifestsForCommand, nil } // AddDependentManifestsToIgnoreList adds the dependant manifest to doNotAffect if the referred manifest has tags. func AddDependentManifestsToIgnoreList(ctx context.Context, manifest acr.ManifestAttributesBase, doNotAffect set.Set[string], acrClient api.AcrCLIClientInterface, repoName string) error { switch *manifest.MediaType { case mediaTypeDockerManifestList, v1.MediaTypeImageIndex: var manifestBytes []byte manifestBytes, err := acrClient.GetManifest(ctx, repoName, *manifest.Digest) if err != nil { return err } // this struct defines a customized struct for manifests // which is used to parse the content of a multiarch manifest mam := struct { Manifests []v1.Descriptor `json:"manifests"` }{} if err = json.Unmarshal(manifestBytes, &mam); err != nil { return err } for _, dependentManifest := range mam.Manifests { doNotAffect.Add(dependentManifest.Digest.String()) } } return nil } // UpdateForManifestWithoutSubjectToDelete adds the manifest to candidatesToDelete // if the manifest does not have subject, otherwise add it to doNotDelete. func UpdateForManifestWithoutSubjectToDelete(ctx context.Context, manifest acr.ManifestAttributesBase, doNotDelete set.Set[string], candidatesToDelete []acr.ManifestAttributesBase, acrClient api.AcrCLIClientInterface, repoName string) ([]acr.ManifestAttributesBase, error) { switch *manifest.MediaType { case mediaTypeArtifactManifest, v1.MediaTypeImageManifest, v1.MediaTypeImageIndex: var manifestBytes []byte manifestBytes, err := acrClient.GetManifest(ctx, repoName, *manifest.Digest) if err != nil { return nil, err } // this struct defines a customized struct for manifests which // is used to parse the content of a manifest references a subject mws := struct { Subject *v1.Descriptor `json:"subject,omitempty"` }{} if err = json.Unmarshal(manifestBytes, &mws); err != nil { return nil, err } if mws.Subject != nil { doNotDelete.Add(*manifest.Digest) } else { candidatesToDelete = append(candidatesToDelete, manifest) } default: candidatesToDelete = append(candidatesToDelete, manifest) } return candidatesToDelete, nil } // BuildRegexFilter compiles a regex state machine from a regex expression func BuildRegexFilter(expression string, regexpMatchTimeoutSeconds int64) (*regexp2.Regexp, error) { regexp, err := regexp2.Compile(expression, defaultRegexpOptions) if err != nil { return nil, err } // A timeout value must always be set if regexpMatchTimeoutSeconds <= 0 { regexpMatchTimeoutSeconds = defaultRegexpMatchTimeoutSeconds } regexp.MatchTimeout = time.Duration(regexpMatchTimeoutSeconds) * time.Second return regexp, nil }