cmd/acr/annotate.go (250 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package main
import (
"context"
"fmt"
"net/http"
"github.com/Azure/acr-cli/cmd/common"
"github.com/Azure/acr-cli/internal/api"
"github.com/Azure/acr-cli/internal/worker"
"github.com/dlclark/regexp2"
"github.com/spf13/cobra"
)
// The constants for the file are defined here
const (
newAnnotateCmdLongMessage = `acr annotate: annotate individual tags for an image and untagged referrers.`
annotateExampleMessage = `- Annotate all images with tags that begin with hello in the example.azurecr.io registry inside the hello-world repository
acr annotate -r example --filter "hello-world:^hello.*" --annotations "vnd.microsoft.artifact.lifecycle.end-of-life.date=2024-04-09"
--artifact-type "application/vnd.microsoft.artifact.lifecycle"
- Annotate all tags that contain the word test in the tag name in the example.azurecr.io registry inside the hello-world
repository, after that, remove the dangling manifests in the same repository
acr annotate -r example --filter "hello-world:\w*test\w*" --annotations "vnd.microsoft.artifact.lifecycle.end-of-life.date=2024-04-09"
--artifact-type "application/vnd.microsoft.artifact.lifecycle" --untagged
- Annotate all tags that contain the word test in the tag name in the example.azurecr.io registry inside the hello-world
repository. Two annotations need to be applied.
acr annotate -r example --filter "hello-world:\w*test\w* --annotations "vnd.microsoft.artifact.lifecycle.end-of-life.date=2024-04-09"
--annotations "key=value" --artifact-type "application/vnd.microsoft.artifact.lifecycle"
- Annotate all tags in the example.azurecr.io registry inside the hello-world repository, with 4 annotate tasks running concurrently
acr annotate -r example --filter "hello-world:.*" --annotations "vnd.microsoft.artifact.lifecycle.end-of-life.date=2024-04-09"
--artifact-type "application/vnd.microsoft.artifact.lifecycle" --concurrency 4
`
)
var (
annotatedConcurrencyDescription = fmt.Sprintf("Number of concurrent annotate tasks. Range: [1 - %d]", maxPoolSize)
)
// annotateParameters defines the parameters that the annotate command uses (including the registry name, username, and password)
type annotateParameters struct {
*rootParameters
filters []string
filterTimeout int64
artifactType string
annotations []string
untagged bool
dryRun bool
concurrency int
}
// newAnnotateCmd defines the annotate command
func newAnnotateCmd(rootParams *rootParameters) *cobra.Command {
annotateParams := annotateParameters{rootParameters: rootParams}
cmd := &cobra.Command{
Use: "annotate",
Short: "[Preview] Annotate images in a registry",
Long: newAnnotateCmdLongMessage,
Example: annotateExampleMessage,
RunE: func(_ *cobra.Command, _ []string) error {
// This context is used for all the http requests
ctx := context.Background()
registryName, err := annotateParams.GetRegistryName()
if err != nil {
return err
}
loginURL := api.LoginURL(registryName)
// An acrClient with authentication is generated, if the authentication cannot be resolved an error is returned.
acrClient, err := api.GetAcrCLIClientWithAuth(loginURL, annotateParams.username, annotateParams.password, annotateParams.configs)
if err != nil {
return err
}
orasClient, err := api.GetORASClientWithAuth(annotateParams.username, annotateParams.password, annotateParams.configs)
if err != nil {
return err
}
// A map is used to collect the regex tags for every repository.
tagFilters, err := common.CollectTagFilters(ctx, annotateParams.filters, acrClient.AutorestClient, annotateParams.filterTimeout, defaultRepoPageSize)
if err != nil {
return err
}
// A clarification message for --dry-run.
if annotateParams.dryRun {
fmt.Println("DRY RUN: The following output shows what WOULD be annotated if the annotate command was executed. Nothing is annotated.")
}
// In order to print a summary of the annotated tags/manifests, the counters get updated every time a repo is annotated.
annotatedTagsCount := 0
annotatedManifestsCount := 0
poolSize := annotateParams.concurrency
if poolSize <= 0 {
poolSize = defaultPoolSize
fmt.Printf("Specified concurrency value invalid. Set to default value: %d \n", defaultPoolSize)
} else if poolSize > maxPoolSize {
poolSize = maxPoolSize
fmt.Printf("Specified concurrency value too large. Set to maximum value: %d \n", maxPoolSize)
}
for repoName, tagRegex := range tagFilters {
singleAnnotatedTagsCount, err := annotateTags(ctx, acrClient, orasClient, poolSize, loginURL, repoName, annotateParams.artifactType, annotateParams.annotations, tagRegex, annotateParams.filterTimeout, annotateParams.dryRun)
if err != nil {
return fmt.Errorf("failed to annotate tags: %w", err)
}
singleAnnotatedManifestsCount := 0
// If the untagged flag is set, then manifests with no tags are also annotated..
if annotateParams.untagged {
singleAnnotatedManifestsCount, err = annotateUntaggedManifests(ctx, acrClient, orasClient, poolSize, loginURL, repoName, annotateParams.artifactType, annotateParams.annotations, annotateParams.dryRun)
if err != nil {
return fmt.Errorf("failed to annotate manifests: %w", err)
}
}
// After every repository is annotated, the counters are updated
annotatedTagsCount += singleAnnotatedTagsCount
annotatedManifestsCount += singleAnnotatedManifestsCount
}
// After all repos have been annotated, the summary is printed
if annotateParams.dryRun {
fmt.Printf("\nNumber of tags to be annotated: %d", annotatedTagsCount)
fmt.Printf("\nNumber of manifests to be annotated: %d\n", annotatedManifestsCount)
} else {
fmt.Printf("\nNumber of annotated tags: %d", annotatedTagsCount)
fmt.Printf("\nNumber of annotated manifests: %d\n", annotatedManifestsCount)
}
return nil
},
}
cmd.Flags().StringArrayVarP(&annotateParams.filters, "filter", "f", nil, `Specify the repository and a regular expression filter for the tag name. If a tag matches the filter, it will be annotated. If multiple tags refer to the same manifest and a tag matches the filter, the manifest will be annotated.
Note: If backtracking is used in the regexp it's possible for the expression to run into an infinite loop. The default timeout is set to 1 minute for evaluation of any filter expression. Use the '--filter-timeout-seconds' option to set a different value`)
cmd.Flags().Int64Var(&annotateParams.filterTimeout, "filter-timeout-seconds", defaultRegexpMatchTimeoutSeconds, "This limits the evaluation of the regex filter, and will return a timeout error if this duration is exceeded during a single evaluation. If written incorrectly a regexp filter with backtracking can result in an infinite loop")
cmd.Flags().StringVar(&annotateParams.artifactType, "artifact-type", "", "The configurable artifact type for an organization")
cmd.Flags().StringSliceVarP(&annotateParams.annotations, "annotations", "a", []string{}, "The configurable annotation key value that can be specified one or more times")
cmd.Flags().BoolVar(&annotateParams.untagged, "untagged", false, "If the untagged flag is set, all the manifests that do not have any tags associated to them will also be annotated, except if they belong to a manifest list that contains at least one tag")
cmd.Flags().BoolVar(&annotateParams.dryRun, "dry-run", false, "If the dry-run flag is set, no manifest or tag will be annotated. The output would be the same as if they were annotated")
cmd.Flags().IntVar(&annotateParams.concurrency, "concurrency", defaultPoolSize, annotatedConcurrencyDescription)
cmd.Flags().BoolP("help", "h", false, "Print usage")
cmd.MarkFlagRequired("filter")
cmd.MarkFlagRequired("artifact-type")
cmd.MarkFlagRequired("annotations")
return cmd
}
// annotateTags annotates all tags that match the tagFilter string.
func annotateTags(ctx context.Context,
acrClient api.AcrCLIClientInterface,
orasClient api.ORASClientInterface,
poolSize int,
loginURL string,
repoName string,
artifactType string,
annotations []string,
tagFilter string,
regexpMatchTimeoutSeconds int64,
dryRun bool) (int, error) {
if !dryRun {
fmt.Printf("\nAnnotating tags for repository: %s\n", repoName)
} else {
fmt.Printf("\nTags for this repository would be annotated: %s\n", repoName)
}
tagRegex, err := common.BuildRegexFilter(tagFilter, regexpMatchTimeoutSeconds)
if err != nil {
return -1, err
}
lastTag := ""
annotatedTagsCount := 0
var annotator *worker.Annotator
if !dryRun {
// In order to only have a limited amount of http requests, an annotator is used that will start goroutines to annotate tags.
annotator, err = worker.NewAnnotator(poolSize, orasClient, loginURL, repoName, artifactType, annotations)
if err != nil {
return -1, err
}
}
for {
// GetTagsToAnnotate will return an empty lastTag when there are no more tags.
manifestsToAnnotate, newLastTag, err := getManifestsToAnnotate(ctx, acrClient, orasClient, loginURL, repoName, tagRegex, lastTag, artifactType, dryRun)
if err != nil {
return -1, err
}
lastTag = newLastTag
if manifestsToAnnotate != nil {
count := len(*manifestsToAnnotate)
if !dryRun {
_, annotateErr := annotator.Annotate(ctx, manifestsToAnnotate)
if annotateErr != nil {
return -1, annotateErr
}
}
annotatedTagsCount += count
}
if len(lastTag) == 0 {
break
}
}
return annotatedTagsCount, nil
}
// getManifestsToAnnotate gets all manifests that should be annotated according to the filter flag.
// Returns a pointer to a slice that contains the manifests that will be annotated and an error in case it occurred.
// Only manifests that would be annotated during a dry-run are printed here. If it's not a dry-run, there will
// be a print after a digest has been successfully annotated.
func getManifestsToAnnotate(ctx context.Context,
acrClient api.AcrCLIClientInterface,
orasClient api.ORASClientInterface,
loginURL string,
repoName string,
filter *regexp2.Regexp,
lastTag string, artifactType string, dryRun bool) (*[]string, string, error) {
resultTags, err := acrClient.GetAcrTags(ctx, repoName, "timedesc", lastTag)
if err != nil {
if resultTags != nil && resultTags.Response.Response != nil && resultTags.StatusCode == http.StatusNotFound {
fmt.Printf("%s repository not found\n", repoName)
return nil, "", nil
}
return nil, "", err
}
newLastTag := ""
if resultTags != nil && resultTags.TagsAttributes != nil && len(*resultTags.TagsAttributes) > 0 {
tags := *resultTags.TagsAttributes
manifestsToAnnotate := []string{}
for _, tag := range tags {
matches, err := filter.MatchString(*tag.Name)
if err != nil {
// The only error that regexp2 will return is a timeout error
return nil, "", err
}
if !matches {
// If a tag does not match the regex then it's not added to the list
continue
}
// If a tag is changable, then it is returned as a tag to annotate
if *tag.ChangeableAttributes.WriteEnabled {
ref := fmt.Sprintf("%s/%s:%s", loginURL, repoName, *tag.Name)
skip, err := orasClient.DiscoverLifecycleAnnotation(ctx, ref, artifactType)
if err != nil {
return nil, "", err
}
if !skip {
// Only print what would be annotated during a dry-run. Successfully annotated manifests
// will be logged after the annotation.
if dryRun {
fmt.Printf("%s/%s:%s\n", loginURL, repoName, *tag.Name)
}
manifestsToAnnotate = append(manifestsToAnnotate, *tag.Digest)
}
}
}
newLastTag = common.GetLastTagFromResponse(resultTags)
return &manifestsToAnnotate, newLastTag, nil
}
return nil, "", nil
}
// annotateUntaggedManifests annotates all manifests that do not have any tags associated with them except the ones
// that are referenced by a multiarch manifest
func annotateUntaggedManifests(ctx context.Context,
acrClient api.AcrCLIClientInterface,
orasClient api.ORASClientInterface,
poolSize int, loginURL string,
repoName string, artifactType string,
annotations []string,
dryRun bool) (int, error) {
if !dryRun {
fmt.Printf("Annotating manifests for repository: %s\n", repoName)
} else {
fmt.Printf("Manifests for this repository would be annotated: %s\n", repoName)
}
// Contrary to getTagsToAnnotate, getManifests gets all the manifests at once.
// This was done because if there is a manifest that has no tag but is referenced by a multiarch manifest that has tags then it
// should not be annotated.
manifestsToAnnotate, err := common.GetUntaggedManifests(ctx, acrClient, loginURL, repoName, dryRun, false)
if err != nil {
return -1, err
}
var annotator *worker.Annotator
annotatedManifestsCount := 0
if !dryRun {
// In order to only have a limited amount of http requests, an annotator is used that will start goroutines to annotate manifests.
annotator, err = worker.NewAnnotator(poolSize, orasClient, loginURL, repoName, artifactType, annotations)
if err != nil {
return -1, err
}
manifestsCount, annotateErr := annotator.Annotate(ctx, manifestsToAnnotate)
if annotateErr != nil {
return manifestsCount, annotateErr
}
annotatedManifestsCount += manifestsCount
} else {
annotatedManifestsCount = len(*manifestsToAnnotate)
}
return annotatedManifestsCount, nil
}