internal/cssc/cssc.go (303 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package cssc
import (
"context"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"github.com/Azure/acr-cli/acr"
"github.com/Azure/acr-cli/internal/api"
"github.com/Azure/acr-cli/internal/tag"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
oras "oras.land/oras-go/v2"
"oras.land/oras-go/v2/content"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/retry"
)
type TagConvention string
const (
Incremental TagConvention = "incremental"
Floating TagConvention = "floating"
)
func (tc TagConvention) IsValid() error {
switch tc {
case Incremental, Floating:
return nil
}
return errors.New("tag-convention should be either incremental or floating")
}
var (
floatingTagRegex = regexp.MustCompile(`^(.+)-patched$`) // Matches strings ending with -patched
incrementalTagRegex = regexp.MustCompile(`^(.+)-([1-9]\d{0,2})$`) // Matches strings ending with -1 to -999
reservedSuffixRegex = regexp.MustCompile(`(-[1-9]\d{0,2}|-patched)$`) // Matches strings ending with -1 to -999 or -patched
)
// Filter struct to hold the filter policy
type Filter struct {
Version string `json:"version"`
TagConvention TagConvention `json:"tag-convention"`
Repositories []Repository `json:"repositories"`
}
// Repository struct to hold the repository, tags and enabled flag
type Repository struct {
Repository string `json:"repository"`
Tags []string `json:"tags"`
Enabled *bool `json:"enabled"`
}
// FilteredRepository struct to hold the filtered repository, tag and patch tag if any
type FilteredRepository struct {
Repository string
Tag string
PatchTag string
}
// Reads the filter policy from the specified repository and tag and returns the Filter struct
func GetFilterFromFilterPolicy(ctx context.Context, filterPolicy string, loginURL string, username string, password string) (Filter, error) {
filterPolicyPattern := `^[^:]+:[^:]+$`
re := regexp.MustCompile(filterPolicyPattern)
if !re.MatchString(filterPolicy) {
return Filter{}, errors.New("filter-policy should be in the format repository:tag e.g. continuouspatchpolicy:latest")
}
repoTag := strings.Split(filterPolicy, ":")
filterRepoName := repoTag[0]
filterRepoTagName := repoTag[1]
// Connect to the remote repository
repo, err := remote.NewRepository(fmt.Sprintf("%s/%s", loginURL, filterRepoName))
if err != nil {
return Filter{}, errors.Wrap(err, "error connecting to the repository when reading the filter policy")
}
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(loginURL, auth.Credential{
Username: username,
Password: password,
}),
}
// Get manifest and read content
_, pulledManifestContent, err := oras.FetchBytes(ctx, repo, filterRepoTagName, oras.DefaultFetchBytesOptions)
if err != nil {
return Filter{}, errors.Wrap(err, "error fetching filter manifest content when reading the filter policy")
}
var pulledManifest v1.Manifest
if err := json.Unmarshal(pulledManifestContent, &pulledManifest); err != nil {
return Filter{}, errors.Wrap(err, "error unmarshalling filter manifest content when reading the filter policy")
}
var fileContent []byte
for _, layer := range pulledManifest.Layers {
fileContent, err = content.FetchAll(ctx, repo, layer)
if err != nil {
return Filter{}, errors.Wrap(err, "error fetching filter content when reading the filter policy")
}
}
// Unmarshal the JSON file data to Filter struct
var filter = Filter{}
if err := json.Unmarshal(fileContent, &filter); err != nil {
return Filter{}, errors.Wrap(err, "error unmarshalling json content when reading the filter policy")
}
return filter, nil
}
// Reads the filter json from the specified file path and returns the Filter struct
func GetFilterFromFilePath(filePath string) (Filter, error) {
file, err := os.ReadFile(filePath)
if err != nil {
return Filter{}, errors.Wrap(err, "error reading the filter json file from file path")
}
var filter = Filter{}
if err := json.Unmarshal(file, &filter); err != nil {
return Filter{}, errors.Wrap(err, "error unmarshalling json content when reading the filter file from file path")
}
return filter, nil
}
// Validates the filter and returns an error if the filter is invalid
func (filter *Filter) ValidateFilter() error {
fmt.Println("Validating filter...")
const versionV1 = "v1"
if filter.Version == "" || filter.Version != versionV1 {
return errors.New("Version is required in the filter and should be " + versionV1)
}
if len(filter.Repositories) == 0 {
return errors.New("Repositories is required in the filter")
}
if filter.TagConvention != "" {
err := filter.TagConvention.IsValid()
if err != nil {
return err
}
}
allErrors := []string{}
for _, repo := range filter.Repositories {
if repo.Repository == "" {
return errors.New("Repository is required in the filter")
}
if repo.Tags == nil || len(repo.Tags) == 0 {
return errors.New("Tags is required in the filter")
}
incorrectTags := []string{}
for _, tag := range repo.Tags {
if tag == "" {
return errors.New("Tag is required in the filter")
}
if endsWithIncrementalOrFloatingPattern(tag) {
incorrectTags = append(incorrectTags, tag)
}
}
if len(incorrectTags) > 0 {
allErrors = append(allErrors, fmt.Sprintf("Repo:%s Invalid Tag(s): %s", repo.Repository, strings.Join(incorrectTags, ", ")))
}
}
if len(allErrors) > 0 {
allErrors = append(allErrors, "Tags in filter json should not end with -1 to -999 or -patched")
return errors.New(strings.Join(allErrors, "\n"))
}
return nil
}
// Applies filter to filter out the repositories and tags from the ACR according to the specified criteria and returns the FilteredRepository struct
func ApplyFilterAndGetFilteredList(ctx context.Context, acrClient api.AcrCLIClientInterface, filter Filter) ([]FilteredRepository, []FilteredRepository, error) {
var filteredRepos []FilteredRepository
var artifactsNotFound []FilteredRepository
// Default is incremental tag regex, only if tag convention is specified as floating, use floating tag regex
patchTagRegex := incrementalTagRegex
if filter.TagConvention == Floating {
patchTagRegex = floatingTagRegex
}
uniqueFilteredRepos := make(map[string]bool)
for _, filterRepo := range filter.Repositories {
// Create a tag map for each repository, where the key will be the base tag and the value will be the list of tags matching the tag convention from the filter
tagMap := make(map[string][]string)
if filterRepo.Enabled != nil && !*filterRepo.Enabled {
continue
}
if filterRepo.Repository == "" || filterRepo.Tags == nil || len(filterRepo.Tags) == 0 {
continue
}
tagList, err := tag.ListTags(ctx, acrClient, filterRepo.Repository)
if err != nil {
var listTagsErr *tag.ListTagsError
if errors.As(err, &listTagsErr) {
for _, tag := range filterRepo.Tags {
artifactsNotFound = append(artifactsNotFound, FilteredRepository{
Repository: filterRepo.Repository,
Tag: tag,
})
}
continue
}
return nil, nil, errors.Wrap(err, "Some unexpected error occurred while listing tags for repository-"+filterRepo.Repository)
}
if len(filterRepo.Tags) == 1 && filterRepo.Tags[0] == "*" { // If the repo has * as tags defined in the filter, then all tags are considered for that repo
for _, tag := range tagList {
matches := patchTagRegex.FindStringSubmatch(*tag.Name)
if matches != nil {
baseTag := matches[1]
tagMap[baseTag] = append(tagMap[baseTag], *tag.Name)
} else if !endsWithIncrementalOrFloatingPattern(*tag.Name) {
tagMap[*tag.Name] = append(tagMap[*tag.Name], *tag.Name)
}
}
} else {
for _, ftag := range filterRepo.Tags { // If the repo has specific tags defined in the filter, then only those tags are considered
// If tag from filter is not found in the tag list obtained for the repository, then add it to artifactsNotFound and continue with the next tag
if !containsTag(tagList, ftag) {
artifactsNotFound = append(artifactsNotFound, FilteredRepository{
Repository: filterRepo.Repository,
Tag: ftag,
})
continue
}
// This is needed to evaluate all versions of patch tags when original tag is specified in the filter
var re *regexp.Regexp
if filter.TagConvention == Floating {
re = regexp.MustCompile(`^` + ftag + `(-patched\d*)?$`)
} else {
re = regexp.MustCompile(`^` + ftag + `(-[1-9]\d{0,2})?$`)
}
for _, tag := range tagList {
if re.MatchString(*tag.Name) {
matches := patchTagRegex.FindStringSubmatch(*tag.Name)
if matches != nil {
baseTag := matches[1]
tagMap[baseTag] = append(tagMap[baseTag], *tag.Name)
} else if !endsWithIncrementalOrFloatingPattern(*tag.Name) {
tagMap[*tag.Name] = append(tagMap[*tag.Name], *tag.Name)
}
}
}
}
}
// Iterate over the tagMap to generate the FilteredRepository list
for baseTag, tags := range tagMap {
sort.Slice(tags, func(i, j int) bool {
return compareTags(tags[i], tags[j])
})
latestTag := tags[len(tags)-1]
if latestTag == baseTag {
latestTag = "N/A"
}
key := fmt.Sprintf("%s-%s-%s", filterRepo.Repository, baseTag, latestTag)
if !uniqueFilteredRepos[key] {
uniqueFilteredRepos[key] = true
filteredRepos = append(filteredRepos, FilteredRepository{
Repository: filterRepo.Repository,
Tag: baseTag,
PatchTag: latestTag,
})
}
}
}
return filteredRepos, artifactsNotFound, nil
}
// Prints the filtered result to the console
func PrintFilteredResult(filteredResult []FilteredRepository, showPatchTags bool) {
if len(filteredResult) == 0 {
fmt.Println("No matching repository and tag found!")
} else if showPatchTags {
fmt.Println("Listing repositories and tags matching the filter with corresponding latest patch tag (if present):")
fmt.Printf("%s,%s,%s\n", "Repo", "Tag", "LatestPatchTag")
for _, result := range filteredResult {
fmt.Printf("%s,%s,%s\n", result.Repository, result.Tag, result.PatchTag)
}
} else {
fmt.Println("Listing repositories and tags matching the filter:")
fmt.Printf("%s,%s\n", "Repo", "Tag")
for _, result := range filteredResult {
fmt.Printf("%s,%s\n", result.Repository, result.Tag)
}
}
fmt.Println("Matches found:", len(filteredResult))
}
// Prints the artifacts not found to the console
func PrintNotFoundArtifacts(artifactsNotFound []FilteredRepository) {
if len(artifactsNotFound) > 0 {
fmt.Printf("%s\n", "Artifacts specified in the filter that do not exist:")
fmt.Printf("%s,%s\n", "Repo", "Tag")
for _, result := range artifactsNotFound {
fmt.Printf("%s,%s\n", result.Repository, result.Tag)
}
fmt.Println("Not found:", len(artifactsNotFound))
}
}
// Helper function that compares two tags and returns true if tag1 is less than tag2
func compareTags(tag1, tag2 string) bool {
// If tag1 and tag2 ends with incremental or floating pattern, then extract suffix based on last occurrence of "-" and compare the suffixes
if endsWithIncrementalOrFloatingPattern(tag1) && endsWithIncrementalOrFloatingPattern(tag2) {
tag1Index := strings.LastIndex(tag1, "-")
tag2Index := strings.LastIndex(tag2, "-")
var tag1Suffix, tag2Suffix string
if tag1Index != -1 {
tag1Suffix = tag1[tag1Index+1:]
} else {
tag1Suffix = tag1
}
if tag2Index != -1 {
tag2Suffix = tag2[tag2Index+1:]
} else {
tag2Suffix = tag2
}
// Compare the suffixes
if isNumeric(tag1Suffix) && isNumeric(tag2Suffix) {
aNum, _ := strconv.Atoi(tag1Suffix)
bNum, _ := strconv.Atoi(tag2Suffix)
return aNum < bNum
}
}
// Fallback to lexicographic comparison
return tag1 < tag2
}
// Helper function to check if a string is numeric
func isNumeric(s string) bool {
_, err := strconv.Atoi(s)
return err == nil
}
// Helper function to check if tagList contains the specified tag
func containsTag(tagList []acr.TagAttributesBase, tag string) bool {
for _, item := range tagList {
if item.Name != nil && *item.Name == tag {
return true
}
}
return false
}
// Helper function to check if the string ends with incremental or floating pattern
func endsWithIncrementalOrFloatingPattern(str string) bool {
return reservedSuffixRegex.MatchString(str)
}