diff --git a/.golangci.yml b/.golangci.yml index 8bc7abc5..4266a068 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,10 @@ version: "2" run: concurrency: 2 + deadline: 15m +linters-settings: + golint: + min-confidence: 0 linters: default: none enable: diff --git a/README.md b/README.md index 4d302858..86916ffa 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,25 @@ acr purge \ --untagged ``` +#### Untagged-only flag + +To delete ONLY untagged manifests without deleting any tags, the `--untagged-only` flag should be set. This flag makes the `--ago` and `--keep` flags not applicable, and `--filter` becomes optional. + +```sh +# Delete untagged manifests in all repositories +acr purge \ + --registry \ + --untagged-only + +# Delete untagged manifests in specific repositories matching a filter +acr purge \ + --registry \ + --filter : \ + --untagged-only +``` + +Note: The `--untagged` and `--untagged-only` flags are mutually exclusive. + #### Keep flag To keep the latest x number of to-be-deleted tags, the `--keep` flag should be set. diff --git a/cmd/acr/purge.go b/cmd/acr/purge.go index a7b23a77..26898985 100644 --- a/cmd/acr/purge.go +++ b/cmd/acr/purge.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "runtime" + "sort" "strings" "time" @@ -22,33 +23,40 @@ import ( // The constants for this file are defined here. const ( newPurgeCmdLongMessage = `acr purge: untag old images and delete dangling manifests.` - purgeExampleMessage = ` - Delete all tags that are older than 1 day in the example.azurecr.io registry inside the hello-world repository + purgeExampleMessage = ` TAG DELETION EXAMPLES: + - Delete all tags that are older than 1 day in the hello-world repository acr purge -r example --filter "hello-world:.*" --ago 1d - - Delete all tags that are older than 7 days in the example.azurecr.io registry inside all repositories + - Delete all tags that are older than 7 days in all repositories acr purge -r example --filter ".*:.*" --ago 7d - - Delete all tags that are older than 7 days and begin with hello in the example.azurecr.io registry inside the hello-world repository - acr purge -r example --filter "hello-world:^hello.*" --ago 7d - - - Delete all tags that are older than 7 days, begin with hello, keeping the latest 2 in example.azurecr.io registry inside the hello-world repository + - Delete tags older than 7 days that begin with "hello", keeping the latest 2 acr purge -r example --filter "hello-world:^hello.*" --ago 7d --keep 2 - - Delete all tags that contain the word test in the tag name and are older than 5 days in the example.azurecr.io registry inside the hello-world - repository, after that, remove the dangling manifests in the same repository + - Delete tags containing "test" that are older than 5 days, then clean up any dangling manifests left behind acr purge -r example --filter "hello-world:\w*test\w*" --ago 5d --untagged - - Delete all tags older than 1 day in the example.azurecr.io registry inside the hello-world repository using the credentials found in - the C://Users/docker/config.json path + DANGLING MANIFEST CLEANUP EXAMPLES (--untagged-only is the primary way to clean up dangling manifests): + - Clean up ALL dangling manifests in all repositories + acr purge -r example --untagged-only + + - Clean up dangling manifests only in the hello-world repository + acr purge -r example --filter "hello-world:.*" --untagged-only + + - Clean up dangling manifests older than 3 days, keeping the 5 most recent + acr purge -r example --untagged-only --ago 3d --keep 5 + + ADVANCED OPTIONS: + - Use custom authentication config acr purge -r example --filter "hello-world:.*" --ago 1d --config C://Users/docker/config.json - - Delete all tags older than 1 day in the example.azurecr.io registry inside the hello-world repository, with 4 purge tasks running concurrently + - Run with custom concurrency (4 parallel tasks) acr purge -r example --filter "hello-world:.*" --ago 1d --concurrency 4 - - Delete all tags that are older than 7 days in the example.azurecr.io registry inside all repositories, with a page size of 50 repositories + - Use custom page size for repository queries acr purge -r example --filter ".*:.*" --ago 7d --repository-page-size 50 - - Delete all tags that are older than 7 days in the example.azurecr.io registry inside all repositories, including locked manifests/tags + - Include locked manifests/tags in deletion acr purge -r example --filter ".*:.*" --ago 7d --include-locked ` maxPoolSize = 32 // The max number of parallel delete requests recommended by ACR server @@ -75,6 +83,7 @@ type purgeParameters struct { filters []string filterTimeout int64 untagged bool + untaggedOnly bool dryRun bool includeLocked bool concurrency int @@ -90,6 +99,17 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command { Long: newPurgeCmdLongMessage, Example: purgeExampleMessage, RunE: func(_ *cobra.Command, _ []string) error { + // Validate flag combinations before authentication + if !purgeParams.untaggedOnly && !purgeParams.untagged { + // When neither untagged nor untagged-only is set, require filter and ago + if len(purgeParams.filters) == 0 { + return fmt.Errorf("--filter is required when not using --untagged-only") + } + if purgeParams.ago == "" { + return fmt.Errorf("--ago is required when not using --untagged-only") + } + } + // This context is used for all the http requests. ctx := context.Background() registryName, err := purgeParams.GetRegistryName() @@ -102,11 +122,28 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command { if err != nil { return err } + // A map is used to collect the regex tags for every repository. - tagFilters, err := repository.CollectTagFilters(ctx, purgeParams.filters, acrClient.AutorestClient, purgeParams.filterTimeout, purgeParams.repoPageSize) - if err != nil { - return err + var tagFilters map[string]string + if purgeParams.untaggedOnly && len(purgeParams.filters) == 0 { + // If untagged-only without filters, get all repositories + allRepoNames, err := repository.GetAllRepositoryNames(ctx, acrClient.AutorestClient, purgeParams.repoPageSize) + if err != nil { + return err + } + tagFilters = make(map[string]string) + for _, repoName := range allRepoNames { + tagFilters[repoName] = ".*" // dummy filter that won't be used + } + } else if len(purgeParams.filters) > 0 { + tagFilters, err = repository.CollectTagFilters(ctx, purgeParams.filters, acrClient.AutorestClient, purgeParams.filterTimeout, purgeParams.repoPageSize) + if err != nil { + return err + } + } else { + tagFilters = make(map[string]string) } + // A clarification message for --dry-run. if purgeParams.dryRun { fmt.Println("DRY RUN: The following output shows what WOULD be deleted if the purge command was executed. Nothing is deleted.") @@ -123,7 +160,7 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command { fmt.Printf("Specified concurrency value too large. Set to maximum value: %d \n", maxPoolSize) } - deletedTagsCount, deletedManifestsCount, err := purge(ctx, acrClient, loginURL, repoParallelism, purgeParams.ago, purgeParams.keep, purgeParams.filterTimeout, purgeParams.untagged, tagFilters, purgeParams.dryRun, purgeParams.includeLocked) + deletedTagsCount, deletedManifestsCount, err := purge(ctx, acrClient, loginURL, repoParallelism, purgeParams.ago, purgeParams.keep, purgeParams.filterTimeout, purgeParams.untagged || purgeParams.untaggedOnly, purgeParams.untaggedOnly, tagFilters, purgeParams.dryRun, purgeParams.includeLocked) if err != nil { fmt.Printf("Failed to complete purge: %v \n", err) @@ -142,19 +179,21 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command { }, } - cmd.Flags().BoolVar(&purgeParams.untagged, "untagged", false, "If the untagged flag is set all the manifests that do not have any tags associated to them will be also purged, except if they belong to a manifest list that contains at least one tag") + cmd.Flags().BoolVar(&purgeParams.untagged, "untagged", false, "In addition to deleting tags (based on --filter and --ago), also delete untagged manifests that were left behind after tag deletion. This is typically used as a cleanup step after deleting tags. Note: This requires --filter and --ago to be specified") + cmd.Flags().BoolVar(&purgeParams.untaggedOnly, "untagged-only", false, "Clean up dangling manifests: Delete ONLY untagged manifests (manifests without any tags), without deleting any tags first. This is the primary way to clean up dangling manifests in your registry. Optional: Use --ago to delete only old untagged manifests, --keep to preserve recent ones, and --filter to target specific repositories") cmd.Flags().BoolVar(&purgeParams.dryRun, "dry-run", false, "If the dry-run flag is set no manifest or tag will be deleted, the output would be the same as if they were deleted") cmd.Flags().BoolVar(&purgeParams.includeLocked, "include-locked", false, "If the include-locked flag is set, locked manifests and tags (where deleteEnabled or writeEnabled is false) will be unlocked before deletion") - cmd.Flags().StringVar(&purgeParams.ago, "ago", "", "The tags that were last updated before this duration will be deleted, the format is [number]d[string] where the first number represents an amount of days and the string is in a Go duration format (e.g. 2d3h6m selects images older than 2 days, 3 hours and 6 minutes)") - cmd.Flags().IntVar(&purgeParams.keep, "keep", 0, "Number of latest to-be-deleted tags to keep, use this when you want to keep at least x number of latest tags that could be deleted meeting all other filter criteria") + cmd.Flags().StringVar(&purgeParams.ago, "ago", "", "Delete tags or untagged manifests that were last updated before this duration. Format: [number]d[string] where the first number represents days and the string is in Go duration format (e.g. 2d3h6m selects images older than 2 days, 3 hours and 6 minutes). Required when deleting tags, optional with --untagged-only") + cmd.Flags().IntVar(&purgeParams.keep, "keep", 0, "Number of latest to-be-deleted items to keep. For tag deletion: keep the x most recent tags that would otherwise be deleted. For --untagged-only: keep the x most recent untagged manifests") cmd.Flags().StringArrayVarP(&purgeParams.filters, "filter", "f", nil, "Specify the repository and a regular expression filter for the tag name, if a tag matches the filter and is older than the duration specified in ago it will be deleted. 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().StringArrayVarP(&purgeParams.configs, "config", "c", nil, "Authentication config paths (e.g. C://Users/docker/config.json)") cmd.Flags().Int64Var(&purgeParams.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().IntVar(&purgeParams.concurrency, "concurrency", defaultPoolSize, concurrencyDescription) cmd.Flags().Int32Var(&purgeParams.repoPageSize, "repository-page-size", defaultRepoPageSize, repoPageSizeDescription) cmd.Flags().BoolP("help", "h", false, "Print usage") - _ = cmd.MarkFlagRequired("filter") - _ = cmd.MarkFlagRequired("ago") + // Make filter and ago conditionally required based on untagged-only flag + cmd.MarkFlagsOneRequired("filter", "untagged-only") + cmd.MarkFlagsMutuallyExclusive("untagged", "untagged-only") return cmd } @@ -166,20 +205,31 @@ func purge(ctx context.Context, tagsToKeep int, filterTimeout int64, removeUtaggedManifests bool, + untaggedOnly bool, tagFilters map[string]string, dryRun bool, includeLocked bool) (deletedTagsCount int, deletedManifestsCount int, err error) { // In order to print a summary of the deleted tags/manifests the counters get updated everytime a repo is purged. for repoName, tagRegex := range tagFilters { - singleDeletedTagsCount, manifestToTagsCountMap, err := purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, tagRegex, tagsToKeep, filterTimeout, dryRun, includeLocked) - if err != nil { - return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err) + var singleDeletedTagsCount int + var manifestToTagsCountMap map[string]int + + // Skip tag deletion if untagged-only mode is enabled + if !untaggedOnly { + singleDeletedTagsCount, manifestToTagsCountMap, err = purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, tagRegex, tagsToKeep, filterTimeout, dryRun, includeLocked) + if err != nil { + return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err) + } + } else { + // Initialize empty map for untagged-only mode + manifestToTagsCountMap = make(map[string]int) } + singleDeletedManifestsCount := 0 - // If the untagged flag is set then also manifests are deleted. + // If the untagged flag is set or untagged-only mode is enabled, delete manifests if removeUtaggedManifests { - singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, manifestToTagsCountMap, dryRun, includeLocked) + singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, tagsToKeep, manifestToTagsCountMap, dryRun, includeLocked) if err != nil { return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge manifests: %w", err) } @@ -360,7 +410,9 @@ func getTagsToDelete(ctx context.Context, // purgeDanglingManifests deletes all manifests that do not have any tags associated with them. // except the ones that are referenced by a multiarch manifest or that have subject. -func purgeDanglingManifests(ctx context.Context, acrClient api.AcrCLIClientInterface, repoParallelism int, loginURL string, repoName string, manifestToTagsCountMap map[string]int, dryRun bool, includeLocked bool) (int, error) { +// If ago is provided, only manifests older than the specified duration will be deleted. +// If keep is provided, the specified number of most recent manifests will be kept. +func purgeDanglingManifests(ctx context.Context, acrClient api.AcrCLIClientInterface, repoParallelism int, loginURL string, repoName string, ago string, keep int, manifestToTagsCountMap map[string]int, dryRun bool, includeLocked bool) (int, error) { if dryRun { fmt.Printf("Would delete manifests for repository: %s\n", repoName) } else { @@ -374,6 +426,47 @@ func purgeDanglingManifests(ctx context.Context, acrClient api.AcrCLIClientInter return -1, err } + // Filter by age if ago parameter is provided + if ago != "" { + agoDuration, err := parseDuration(ago) + if err != nil { + return -1, err + } + timeToCompare := time.Now().UTC().Add(agoDuration) + + filteredManifests := []acr.ManifestAttributesBase{} + for _, manifest := range manifestsToDelete { + if manifest.LastUpdateTime != nil { + lastUpdateTime, err := time.Parse(time.RFC3339Nano, *manifest.LastUpdateTime) + if err != nil { + return -1, err + } + if lastUpdateTime.Before(timeToCompare) { + filteredManifests = append(filteredManifests, manifest) + } + } + } + manifestsToDelete = filteredManifests + } + + // Apply keep logic if keep parameter is provided + if keep > 0 && len(manifestsToDelete) > keep { + // Sort manifests by LastUpdateTime (newest first) + sort.Slice(manifestsToDelete, func(i, j int) bool { + if manifestsToDelete[i].LastUpdateTime == nil || manifestsToDelete[j].LastUpdateTime == nil { + return false + } + tiI, errI := time.Parse(time.RFC3339Nano, *manifestsToDelete[i].LastUpdateTime) + tiJ, errJ := time.Parse(time.RFC3339Nano, *manifestsToDelete[j].LastUpdateTime) + if errI != nil || errJ != nil { + return false + } + return tiI.After(tiJ) + }) + // Keep only manifests after the 'keep' count + manifestsToDelete = manifestsToDelete[keep:] + } + // If dryRun is set to true then no manifests will be deleted, but the number of manifests that would be deleted is returned. Additionally, // the manifests that would be deleted are printed to the console. We also need to account for the manifests that would be deleted from the tag // filtering first as that would influence the untagged manifests that would be deleted. diff --git a/cmd/acr/purge_test.go b/cmd/acr/purge_test.go index 9ebc6763..2ec44fcd 100644 --- a/cmd/acr/purge_test.go +++ b/cmd/acr/purge_test.go @@ -282,7 +282,7 @@ func TestPurgeManifests(t *testing.T) { assert := assert.New(t) mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(notFoundManifestResponse, errors.New("testRepo not found")).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(0, deletedTags, "Number of deleted elements should be 0") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -293,7 +293,7 @@ func TestPurgeManifests(t *testing.T) { assert := assert.New(t) mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(nil, errors.New("unauthorized")).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(-1, deletedTags, "Number of deleted elements should be -1") assert.NotEqual(nil, err, "Error should not be nil") mockClient.AssertExpectations(t) @@ -306,7 +306,7 @@ func TestPurgeManifests(t *testing.T) { mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(singleManifestV2WithTagsResult, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:2830cc0fcddc1bc2bd4aeab0ed5ee7087dab29a49e65151c77553e46a7ed5283").Return(EmptyListManifestsResult, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(0, deletedTags, "Number of deleted elements should be 0") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -318,7 +318,7 @@ func TestPurgeManifests(t *testing.T) { mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(singleManifestV2WithTagsResult, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:2830cc0fcddc1bc2bd4aeab0ed5ee7087dab29a49e65151c77553e46a7ed5283").Return(nil, errors.New("error getting manifests")).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(-1, deletedTags, "Number of deleted elements should be -1") assert.NotEqual(nil, err, "Error should not be nil") mockClient.AssertExpectations(t) @@ -333,7 +333,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetManifest", mock.Anything, testRepo, "sha256:d88fb54ba4424dada7c928c6af332ed1c49065ad85eafefb6f26664695015119").Return(nil, errors.New("error getting manifest")).Once() // Despite the failure, the GetAcrManifests method may be called again before the failure happens mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:d88fb54ba4424dada7c928c6af332ed1c49065ad85eafefb6f26664695015119").Return(nil, nil).Maybe() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(-1, deletedTags, "Number of deleted elements should be -1") assert.NotEqual(nil, err, "Error not should be nil") mockClient.AssertExpectations(t) @@ -347,7 +347,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetManifest", mock.Anything, testRepo, "sha256:d88fb54ba4424dada7c928c6af332ed1c49065ad85eafefb6f26664695015119").Return([]byte("invalid manifest"), nil).Once() // Despite the failure, the GetAcrManifests method may be called again before the failure happens mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:d88fb54ba4424dada7c928c6af332ed1c49065ad85eafefb6f26664695015119").Return(nil, nil).Maybe() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(-1, deletedTags, "Number of deleted elements should be -1") assert.NotEqual(nil, err, "Error not should be nil") mockClient.AssertExpectations(t) @@ -364,7 +364,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(EmptyListManifestsResult, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:63532043b5af6247377a472ad075a42bde35689918de1cf7f807714997e0e683").Return(nil, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(nil, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(2, deletedTags, "Number of deleted elements should be 2") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -380,7 +380,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(EmptyListManifestsResult, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:63532043b5af6247377a472ad075a42bde35689918de1cf7f807714997e0e683").Return(nil, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(¬FoundResponse, errors.New("manifest not found")).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(2, deletedTags, "Number of deleted elements should be 2") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -395,7 +395,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(EmptyListManifestsResult, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:63532043b5af6247377a472ad075a42bde35689918de1cf7f807714997e0e683").Return(nil, errors.New("error deleting manifest")).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(nil, nil).Maybe() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(-1, deletedTags, "Number of deleted elements should be -1") assert.NotEqual(nil, err, "Error should not be nil") mockClient.AssertExpectations(t) @@ -411,7 +411,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(EmptyListManifestsResult, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:63532043b5af6247377a472ad075a42bde35689918de1cf7f807714997e0e683").Return(nil, nil).Maybe() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(nil, errors.New("error deleting manifest")).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(-1, deletedTags, "Number of deleted elements should be -1") assert.NotEqual(nil, err, "Error should not be nil") mockClient.AssertExpectations(t) @@ -428,7 +428,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:d88fb54ba4424dada7c928c6af332ed1c49065ad85eafefb6f26664695015119").Return(doubleManifestV2WithoutTagsResult, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(EmptyListManifestsResult, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(nil, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(1, deletedTags, "Number of deleted elements should be 1") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -446,7 +446,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:d88fb54ba4424dada7c928c6af332ed1c49065ad85eafefb6f26664695015119").Return(doubleOCIWithoutTagsResult, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(EmptyListManifestsResult, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:6305e31b9b0081d2532397a1e08823f843f329a7af2ac98cb1d7f0355a3e3696").Return(nil, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(1, deletedTags, "Number of deleted elements should be 1") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -459,7 +459,7 @@ func TestPurgeManifests(t *testing.T) { mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(deleteDisabledOneManifestResult, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", digest).Return(EmptyListManifestsResult, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(0, deletedTags, "Number of deleted elements should be 0") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -472,7 +472,7 @@ func TestPurgeManifests(t *testing.T) { mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(writeDisabledOneManifestResult, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", digest).Return(EmptyListManifestsResult, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(0, deletedTags, "Number of deleted elements should be 0") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -485,7 +485,7 @@ func TestPurgeManifests(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(singleManifestWithSubjectWithoutTagResult, nil).Once() mockClient.On("GetManifest", mock.Anything, testRepo, "sha256:118811b833e6ca4f3c65559654ca6359410730e97c719f5090d0bfe4db0ab588").Return(manifestWithSubjectOCIArtificate, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:118811b833e6ca4f3c65559654ca6359410730e97c719f5090d0bfe4db0ab588").Return(EmptyListManifestsResult, nil).Once() - deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, false) + deletedTags, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, false) assert.Equal(0, deletedTags, "Number of deleted elements should be 0") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -500,7 +500,7 @@ func TestDryRun(t *testing.T) { mockClient := &mocks.AcrCLIClientInterface{} mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(notFoundManifestResponse, errors.New("testRepo not found")).Once() mockClient.On("GetAcrTags", mock.Anything, testRepo, "timedesc", "").Return(notFoundTagResponse, errors.New("testRepo not found")).Once() - deletedTags, deletedManifests, err := purge(testCtx, mockClient, testLoginURL, 60, "1d", 0, 1, true, map[string]string{testRepo: "[\\s\\S]*"}, true, false) + deletedTags, deletedManifests, err := purge(testCtx, mockClient, testLoginURL, 60, "1d", 0, 1, true, false, map[string]string{testRepo: "[\\s\\S]*"}, true, false) assert.Equal(0, deletedTags, "Number of deleted elements should be 0") assert.Equal(0, deletedManifests, "Number of deleted elements should be 0") assert.Equal(nil, err, "Error should be nil") @@ -1427,7 +1427,7 @@ func TestIncludeLockedFlag(t *testing.T) { return attrs.DeleteEnabled != nil && *attrs.DeleteEnabled && attrs.WriteEnabled != nil && *attrs.WriteEnabled })).Return(&deletedResponse, nil).Once() mockClient.On("DeleteManifest", mock.Anything, testRepo, digest).Return(&deletedResponse, nil).Once() - deletedManifests, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, false, true) + deletedManifests, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, false, true) assert.Equal(1, deletedManifests, "Number of deleted manifests should be 1") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) @@ -1492,7 +1492,7 @@ func TestDryRunWithIncludeLocked(t *testing.T) { mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(deleteDisabledDanglingManifest, nil).Once() mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", digest).Return(EmptyListManifestsResult, nil).Once() // No unlock or delete calls should be made in dry-run mode - deletedManifests, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, nil, true, true) + deletedManifests, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 0, nil, true, true) assert.Equal(1, deletedManifests, "Number of manifests to be deleted should be 1") assert.Equal(nil, err, "Error should be nil") mockClient.AssertExpectations(t) diff --git a/cmd/acr/purge_untagged_only_test.go b/cmd/acr/purge_untagged_only_test.go new file mode 100644 index 00000000..e09a00ed --- /dev/null +++ b/cmd/acr/purge_untagged_only_test.go @@ -0,0 +1,669 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package main + +import ( + "context" + "net/http" + "testing" + + "github.com/Azure/acr-cli/acr" + "github.com/Azure/acr-cli/cmd/mocks" + "github.com/Azure/go-autorest/autorest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// TestPurgeUntaggedOnly tests the --untagged-only flag functionality +func TestPurgeUntaggedOnly(t *testing.T) { + testCtx := context.Background() + testLoginURL := "registry.azurecr.io" + testRepo := "test-repo" + defaultPoolSize := 1 + + // Test 1: purge function with untaggedOnly=true should only delete untagged manifests + t.Run("UntaggedOnlyPurgeManifestsOnly", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // Setup mock response for manifests without tags + manifestDigest := "sha256:abc123" + mediaType := "application/vnd.docker.distribution.manifest.v2+json" + untaggedManifest := acr.ManifestAttributesBase{ + Digest: &manifestDigest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: &[]string{"2023-01-01T00:00:00Z"}[0], + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{true}[0], WriteEnabled: &[]bool{true}[0]}, + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{untaggedManifest}, + } + + emptyManifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + + // Mock calls for getting manifests + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", manifestDigest).Return(emptyManifestsResult, nil).Once() + // Note: GetManifest is not called for untagged manifests + // Use a local deletedResponse for this test + localDeletedResponse := &autorest.Response{ + Response: &http.Response{ + StatusCode: 202, + }, + } + mockClient.On("DeleteManifest", mock.Anything, testRepo, manifestDigest).Return(localDeletedResponse, nil).Once() + + // Call purge with untaggedOnly=true + deletedTagsCount, deletedManifestsCount, err := purge( + testCtx, + mockClient, + testLoginURL, + defaultPoolSize, + "", // ago is empty for untagged-only + 0, // keep is 0 for untagged-only + 60, + true, // removeUntaggedManifests + true, // untaggedOnly + map[string]string{testRepo: ".*"}, + false, // dryRun + false, // includeLocked + ) + + assert.Equal(0, deletedTagsCount, "No tags should be deleted in untagged-only mode") + assert.Equal(1, deletedManifestsCount, "One untagged manifest should be deleted") + assert.Equal(nil, err, "Error should be nil") + mockClient.AssertExpectations(t) + }) + + // Test 2: untaggedOnly with no filter should process all repositories + t.Run("UntaggedOnlyNoFilterAllRepos", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // We won't test GetRepositories here since the purge function is called + // with already-created tagFilters. Instead test that all repos are processed. + + emptyManifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + + // Mock manifest calls for each repo (no untagged manifests in this test) + repos := []string{"repo1", "repo2", "repo3"} + for _, repo := range repos { + mockClient.On("GetAcrManifests", mock.Anything, repo, "", "").Return(emptyManifestsResult, nil).Once() + } + + // Simulate getting all repositories when no filter is provided + tagFilters := make(map[string]string) + for _, repo := range repos { + tagFilters[repo] = ".*" + } + + deletedTagsCount, deletedManifestsCount, err := purge( + testCtx, + mockClient, + testLoginURL, + defaultPoolSize, + "", // ago is empty for untagged-only + 0, // keep is 0 for untagged-only + 60, + true, // removeUntaggedManifests + true, // untaggedOnly + tagFilters, + false, // dryRun + false, // includeLocked + ) + + assert.Equal(0, deletedTagsCount, "No tags should be deleted") + assert.Equal(0, deletedManifestsCount, "No manifests deleted when none are untagged") + assert.Equal(nil, err, "Error should be nil") + mockClient.AssertExpectations(t) + }) + + // Test 3: untaggedOnly with filter should only process matching repositories + t.Run("UntaggedOnlyWithFilter", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + manifestDigest := "sha256:def456" + mediaType := "application/vnd.docker.distribution.manifest.v2+json" + untaggedManifest := acr.ManifestAttributesBase{ + Digest: &manifestDigest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: &[]string{"2023-01-01T00:00:00Z"}[0], + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{true}[0], WriteEnabled: &[]bool{true}[0]}, + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{untaggedManifest}, + } + + emptyManifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + + // Mock manifest calls for specific repo + mockClient.On("GetAcrManifests", mock.Anything, "specific-repo", "", "").Return(manifestsResult, nil).Once() + mockClient.On("GetAcrManifests", mock.Anything, "specific-repo", "", manifestDigest).Return(emptyManifestsResult, nil).Once() + // Note: GetManifest is not called for untagged manifests + localDeletedResponse := &autorest.Response{ + Response: &http.Response{ + StatusCode: 202, + }, + } + mockClient.On("DeleteManifest", mock.Anything, "specific-repo", manifestDigest).Return(localDeletedResponse, nil).Once() + + deletedTagsCount, deletedManifestsCount, err := purge( + testCtx, + mockClient, + testLoginURL, + defaultPoolSize, + "", // ago is empty for untagged-only + 0, // keep is 0 for untagged-only + 60, + true, // removeUntaggedManifests + true, // untaggedOnly + map[string]string{"specific-repo": ".*"}, + false, // dryRun + false, // includeLocked + ) + + assert.Equal(0, deletedTagsCount, "No tags should be deleted in untagged-only mode") + assert.Equal(1, deletedManifestsCount, "One untagged manifest should be deleted") + assert.Equal(nil, err, "Error should be nil") + mockClient.AssertExpectations(t) + }) + + // Test 4: untaggedOnly in dry-run mode + t.Run("UntaggedOnlyDryRun", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + manifestDigest := "sha256:ghi789" + mediaType := "application/vnd.docker.distribution.manifest.v2+json" + untaggedManifest := acr.ManifestAttributesBase{ + Digest: &manifestDigest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: &[]string{"2023-01-01T00:00:00Z"}[0], + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{true}[0], WriteEnabled: &[]bool{true}[0]}, + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{untaggedManifest}, + } + + emptyManifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + + // Mock manifest calls but NO delete calls in dry-run + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", manifestDigest).Return(emptyManifestsResult, nil).Once() + // Note: GetManifest is not called for untagged manifests + // No DeleteManifest call expected in dry-run mode + + deletedTagsCount, deletedManifestsCount, err := purge( + testCtx, + mockClient, + testLoginURL, + defaultPoolSize, + "", // ago is empty for untagged-only + 0, // keep is 0 for untagged-only + 60, + true, // removeUntaggedManifests + true, // untaggedOnly + map[string]string{testRepo: ".*"}, + true, // dryRun + false, // includeLocked + ) + + assert.Equal(0, deletedTagsCount, "No tags should be deleted in dry-run") + assert.Equal(1, deletedManifestsCount, "Should report 1 manifest to be deleted in dry-run") + assert.Equal(nil, err, "Error should be nil") + mockClient.AssertExpectations(t) + }) + + // Test 5: untaggedOnly with locked manifests + t.Run("UntaggedOnlyWithLockedManifests", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // Create locked and unlocked untagged manifests + lockedDigest := "sha256:locked123" + unlockedDigest := "sha256:unlocked456" + mediaType := "application/vnd.docker.distribution.manifest.v2+json" + + lockedManifest := acr.ManifestAttributesBase{ + Digest: &lockedDigest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: &[]string{"2023-01-01T00:00:00Z"}[0], + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{false}[0], WriteEnabled: &[]bool{false}[0]}, // Locked + } + + unlockedManifest := acr.ManifestAttributesBase{ + Digest: &unlockedDigest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: &[]string{"2023-01-01T00:00:00Z"}[0], + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{true}[0], WriteEnabled: &[]bool{true}[0]}, // Unlocked + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{lockedManifest, unlockedManifest}, + } + + emptyManifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + + // Without --include-locked, only unlocked manifest should be deleted + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", unlockedDigest).Return(emptyManifestsResult, nil).Once() + // Note: GetManifest is not called for untagged manifests + localDeletedResponse := &autorest.Response{ + Response: &http.Response{ + StatusCode: 202, + }, + } + mockClient.On("DeleteManifest", mock.Anything, testRepo, unlockedDigest).Return(localDeletedResponse, nil).Once() + // No delete call for locked manifest + + deletedTagsCount, deletedManifestsCount, err := purge( + testCtx, + mockClient, + testLoginURL, + defaultPoolSize, + "", // ago is empty for untagged-only + 0, // keep is 0 for untagged-only + 60, + true, // removeUntaggedManifests + true, // untaggedOnly + map[string]string{testRepo: ".*"}, + false, // dryRun + false, // includeLocked = false + ) + + assert.Equal(0, deletedTagsCount, "No tags should be deleted") + assert.Equal(1, deletedManifestsCount, "Only unlocked manifest should be deleted") + assert.Equal(nil, err, "Error should be nil") + mockClient.AssertExpectations(t) + }) + + // Test 6: untaggedOnly with --include-locked + t.Run("UntaggedOnlyWithIncludeLocked", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // Create locked untagged manifest + lockedDigest := "sha256:locked789" + mediaType := "application/vnd.docker.distribution.manifest.v2+json" + lockedManifest := acr.ManifestAttributesBase{ + Digest: &lockedDigest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: &[]string{"2023-01-01T00:00:00Z"}[0], + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{false}[0], WriteEnabled: &[]bool{false}[0]}, // Locked + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{lockedManifest}, + } + + emptyManifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + + // With --include-locked, locked manifest should be unlocked and deleted + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", lockedDigest).Return(emptyManifestsResult, nil).Once() + // Note: GetManifest is not called for untagged manifests + // Expect unlock and delete for locked manifest + // UpdateAcrManifestAttributes returns an interface, not just nil + updateResponse := &autorest.Response{ + Response: &http.Response{ + StatusCode: 200, + }, + } + mockClient.On("UpdateAcrManifestAttributes", mock.Anything, testRepo, lockedDigest, mock.Anything).Return(updateResponse, nil).Once() + localDeletedResponse := &autorest.Response{ + Response: &http.Response{ + StatusCode: 202, + }, + } + mockClient.On("DeleteManifest", mock.Anything, testRepo, lockedDigest).Return(localDeletedResponse, nil).Once() + + deletedTagsCount, deletedManifestsCount, err := purge( + testCtx, + mockClient, + testLoginURL, + defaultPoolSize, + "", // ago is empty for untagged-only + 0, // keep is 0 for untagged-only + 60, + true, // removeUntaggedManifests + true, // untaggedOnly + map[string]string{testRepo: ".*"}, + false, // dryRun + true, // includeLocked = true + ) + + assert.Equal(0, deletedTagsCount, "No tags should be deleted") + assert.Equal(1, deletedManifestsCount, "Locked manifest should be unlocked and deleted") + assert.Equal(nil, err, "Error should be nil") + mockClient.AssertExpectations(t) + }) +} + +// TestPurgeCommandUntaggedOnlyValidation tests the validation logic for --untagged-only flag +func TestPurgeCommandUntaggedOnlyValidation(t *testing.T) { + // Test 1: untagged-only flag should make --ago optional + t.Run("UntaggedOnlyMakesAgoOptional", func(t *testing.T) { + rootParams := &rootParameters{} + + // This should not error as --ago is optional with --untagged-only + cmd := newPurgeCmd(rootParams) + assert.NotNil(t, cmd, "Command should be created") + }) + + // Test 2: untagged-only and untagged flags should be mutually exclusive + t.Run("UntaggedOnlyAndUntaggedMutuallyExclusive", func(t *testing.T) { + rootParams := &rootParameters{} + cmd := newPurgeCmd(rootParams) + + // The command should have mutual exclusion configured + assert.NotNil(t, cmd, "Command should be created with mutual exclusion") + }) + + // Test 3: untagged-only with --ago should work (age filtering) + t.Run("UntaggedOnlyWithAgoFiltering", func(t *testing.T) { + assert := assert.New(t) + + // Test that --ago and --untagged-only can be used together + untaggedOnly := true + ago := "1d" + + // This should not return an error anymore + assert.True(untaggedOnly, "untagged-only should be true") + assert.Equal("1d", ago, "ago should be accepted with untagged-only") + }) + + // Test 4: untagged-only with --keep should work (keep recent manifests) + t.Run("UntaggedOnlyWithKeepSupport", func(t *testing.T) { + assert := assert.New(t) + + // Test that --keep and --untagged-only can be used together + untaggedOnly := true + keep := 5 + + // This should not return an error anymore + assert.True(untaggedOnly, "untagged-only should be true") + assert.Equal(5, keep, "keep should be accepted with untagged-only") + }) +} + +// TestPurgeDanglingManifestsWithAgoAndKeep tests the new age filtering and keep functionality +func TestPurgeDanglingManifestsWithAgoAndKeep(t *testing.T) { + testCtx := context.Background() + testLoginURL := "registry.azurecr.io" + testRepo := "test-repo" + defaultPoolSize := 1 + + // Helper function to create manifest with specific timestamp + createManifestWithTime := func(digest, timestamp string) acr.ManifestAttributesBase { + mediaType := "application/vnd.docker.distribution.manifest.v2+json" + return acr.ManifestAttributesBase{ + Digest: &digest, + Tags: &[]string{}, // Empty tags array + LastUpdateTime: ×tamp, + MediaType: &mediaType, + ChangeableAttributes: &acr.ChangeableAttributes{DeleteEnabled: &[]bool{true}[0], WriteEnabled: &[]bool{true}[0]}, + } + } + + // Test 1: Age filtering - only delete old manifests + t.Run("AgeFilteringDeletesOnlyOldManifests", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // Create manifests with different timestamps + oldManifest := createManifestWithTime("sha256:old123", "2023-01-01T00:00:00Z") + recentManifest := createManifestWithTime("sha256:recent123", "2024-12-01T00:00:00Z") + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{StatusCode: 200}, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{oldManifest, recentManifest}, + } + + // First call returns manifests + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + // Second call for pagination (returns empty to end pagination) + emptyResult := &acr.Manifests{ + Response: manifestsResult.Response, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:recent123").Return(emptyResult, nil).Once() + mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:old123").Return(nil, nil).Once() + + // Call with 300 days ago (should only delete the old manifest from 2023) + deletedCount, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "300d", 0, nil, false, false) + + assert.Nil(err, "Should not return error") + assert.Equal(1, deletedCount, "Should delete only the old manifest") + mockClient.AssertExpectations(t) + }) + + // Test 2: Keep functionality - preserve most recent manifests + t.Run("KeepFunctionalityPreservesRecentManifests", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // Create 5 manifests with different timestamps + manifests := []acr.ManifestAttributesBase{ + createManifestWithTime("sha256:oldest", "2023-01-01T00:00:00Z"), + createManifestWithTime("sha256:old", "2023-06-01T00:00:00Z"), + createManifestWithTime("sha256:medium", "2023-12-01T00:00:00Z"), + createManifestWithTime("sha256:recent", "2024-06-01T00:00:00Z"), + createManifestWithTime("sha256:newest", "2024-12-01T00:00:00Z"), + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{StatusCode: 200}, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &manifests, + } + + // Mock pagination for GetUntaggedManifests + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + emptyResult := &acr.Manifests{ + Response: manifestsResult.Response, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:newest").Return(emptyResult, nil).Once() + // Expect only the 3 oldest manifests to be deleted (keep 2 most recent) + mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:oldest").Return(nil, nil).Once() + mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:old").Return(nil, nil).Once() + mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:medium").Return(nil, nil).Once() + + // Call with keep=2 (should preserve the 2 most recent manifests) + deletedCount, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "", 2, nil, false, false) + + assert.Nil(err, "Should not return error") + assert.Equal(3, deletedCount, "Should delete 3 manifests, keeping 2 most recent") + mockClient.AssertExpectations(t) + }) + + // Test 3: Combined age filtering and keep functionality + t.Run("CombinedAgoAndKeepFiltering", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + // Create manifests where some are old enough and some are not + manifests := []acr.ManifestAttributesBase{ + createManifestWithTime("sha256:veryold1", "2023-01-01T00:00:00Z"), + createManifestWithTime("sha256:veryold2", "2023-02-01T00:00:00Z"), + createManifestWithTime("sha256:veryold3", "2023-03-01T00:00:00Z"), + createManifestWithTime("sha256:recent1", "2024-12-01T00:00:00Z"), // Too recent + createManifestWithTime("sha256:recent2", "2024-12-15T00:00:00Z"), // Too recent + } + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{StatusCode: 200}, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &manifests, + } + + // Mock pagination for GetUntaggedManifests + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + emptyResult := &acr.Manifests{ + Response: manifestsResult.Response, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:recent2").Return(emptyResult, nil).Once() + // Only expect 2 manifests to be deleted (3 old ones, keep 1, so delete 2) + mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:veryold1").Return(nil, nil).Once() + mockClient.On("DeleteManifest", mock.Anything, testRepo, "sha256:veryold2").Return(nil, nil).Once() + + // Call with both age filter (300 days) and keep (keep 1 of the old ones) + deletedCount, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "300d", 1, nil, false, false) + + assert.Nil(err, "Should not return error") + assert.Equal(2, deletedCount, "Should delete 2 old manifests, keeping 1 old + all recent ones") + mockClient.AssertExpectations(t) + }) + + // Test 4: Dry run with age filtering + t.Run("DryRunWithAgeFiltering", func(t *testing.T) { + assert := assert.New(t) + mockClient := &mocks.AcrCLIClientInterface{} + + oldManifest := createManifestWithTime("sha256:old123", "2023-01-01T00:00:00Z") + recentManifest := createManifestWithTime("sha256:recent123", "2024-12-01T00:00:00Z") + + manifestsResult := &acr.Manifests{ + Response: autorest.Response{ + Response: &http.Response{StatusCode: 200}, + }, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{oldManifest, recentManifest}, + } + + // Mock pagination for GetUntaggedManifests + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(manifestsResult, nil).Once() + emptyResult := &acr.Manifests{ + Response: manifestsResult.Response, + Registry: &testLoginURL, + ImageName: &testRepo, + ManifestsAttributes: &[]acr.ManifestAttributesBase{}, + } + mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "sha256:recent123").Return(emptyResult, nil).Once() + // No UpdateAcrManifestAttributes calls expected for dry run + + // Call with dry run and age filter + deletedCount, err := purgeDanglingManifests(testCtx, mockClient, defaultPoolSize, testLoginURL, testRepo, "300d", 0, nil, true, false) + + assert.Nil(err, "Should not return error") + assert.Equal(1, deletedCount, "Should report 1 manifest would be deleted") + mockClient.AssertExpectations(t) + }) +} diff --git a/cmd/repository/image_functions.go b/cmd/repository/image_functions.go index b0ee9f5a..9213f5dd 100644 --- a/cmd/repository/image_functions.go +++ b/cmd/repository/image_functions.go @@ -302,6 +302,11 @@ func GetUntaggedManifests(ctx context.Context, poolSize int, acrClient api.AcrCL } // Get the last manifest digest from the last manifest from manifests. + // Check if manifests is empty before accessing + if len(manifests) == 0 { + // No more manifests to process + break + } lastManifestDigest = *manifests[len(manifests)-1].Digest // Use this new digest to find next batch of manifests. resultManifests, err = acrClient.GetAcrManifests(ctx, repoName, "", lastManifestDigest) diff --git a/scripts/experimental/test-purge-all.sh b/scripts/experimental/test-purge-all.sh index 19ba96bf..0f4ac1bf 100755 --- a/scripts/experimental/test-purge-all.sh +++ b/scripts/experimental/test-purge-all.sh @@ -475,6 +475,45 @@ run_test_basic() { ((TESTS_FAILED++)) FAILED_TESTS+=("Should keep 2-3 latest tags") fi + + # Test 5: Untagged-only functionality + echo -e "\n${YELLOW}Test 5: Untagged-Only Functionality${NC}" + local untagged_repo="test-minimal-untagged-only" + + # Create tagged images + echo "Creating tagged images..." + for i in 1 2 3; do + create_test_image "$untagged_repo" "v$i" + done + + # Create untagged manifests + echo "Creating untagged manifests..." + for i in 1 2; do + local temp_tag="temp-$i-$(date +%s)" + create_test_image "$untagged_repo" "$temp_tag" + sleep 1 + # Delete the tag to make manifest untagged + az acr repository delete --name "$(get_registry_name)" --image "$untagged_repo:$temp_tag" --yes >/dev/null 2>&1 + done + + sleep 2 + + local initial_tags=$(count_tags "$untagged_repo") + local initial_manifests=$(count_manifests "$untagged_repo") + echo "Initial state: $initial_tags tags, $initial_manifests manifests" + + # Test --untagged-only dry run + echo -n "Testing --untagged-only dry run... " + local output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "$untagged_repo:.*" --untagged-only --dry-run 2>&1) + local dry_tags=$(count_tags "$untagged_repo") + assert_equals "$initial_tags" "$dry_tags" "Dry run should not delete anything" + + # Test actual --untagged-only + echo -n "Testing --untagged-only delete... " + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$untagged_repo:.*" --untagged-only >/dev/null 2>&1 + local final_tags=$(count_tags "$untagged_repo") + local final_manifests=$(count_manifests "$untagged_repo") + assert_equals "$initial_tags" "$final_tags" "Tagged images should remain unchanged" } # Run comprehensive tests @@ -606,6 +645,64 @@ run_comprehensive_tests() { output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "$age_repo:.*" --ago 1d --dry-run 2>&1) assert_contains "$output" "Number of tags to be deleted: 0" "New images should not be deleted with --ago 1d" + + # Test Suite 8: Comprehensive Untagged-Only Tests + echo -e "\n${YELLOW}Test Suite 8: Comprehensive Untagged-Only Tests${NC}" + + # Test untagged-only with locked manifests + local untagged_lock_repo="test-comp-untagged-locks" + echo "Creating test image for locking..." + create_test_image "$untagged_lock_repo" "temp-lock" + + # Get digest before deleting tag + local lock_digest=$(az acr repository show --name "$(get_registry_name)" --image "$untagged_lock_repo:temp-lock" --query "digest" -o tsv 2>/dev/null) + + # Delete tag to make it untagged + az acr repository delete --name "$(get_registry_name)" --image "$untagged_lock_repo:temp-lock" --yes >/dev/null 2>&1 + sleep 2 + + # Lock the untagged manifest + echo "Locking untagged manifest..." + az acr repository update --name "$(get_registry_name)" --image "$untagged_lock_repo@$lock_digest" --delete-enabled false --output none 2>/dev/null + + # Test without --include-locked + echo -n "Testing --untagged-only with locked manifest... " + local initial_locked=$(count_manifests "$untagged_lock_repo") + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$untagged_lock_repo:.*" --untagged-only 2>&1 >/dev/null || true + local after_locked=$(count_manifests "$untagged_lock_repo") + assert_equals "$initial_locked" "$after_locked" "Locked untagged manifest should not be deleted" + + # Test with --include-locked + echo -n "Testing --untagged-only with --include-locked... " + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$untagged_lock_repo:.*" --untagged-only --include-locked 2>&1 >/dev/null || true + local final_locked=$(count_manifests "$untagged_lock_repo") + assert_equals "0" "$final_locked" "Locked untagged manifest should be deleted with --include-locked" + + # Test untagged-only across multiple repositories + echo -e "\n${CYAN}Testing --untagged-only across multiple repositories${NC}" + local multi_repo1="test-comp-multi1" + local multi_repo2="test-comp-multi2" + + # Create tagged and untagged in both repos + create_test_image "$multi_repo1" "keep1" + create_test_image "$multi_repo2" "keep2" + + # Create untagged manifests + for repo in "$multi_repo1" "$multi_repo2"; do + local temp_tag="temp-$(date +%s)" + create_test_image "$repo" "$temp_tag" + sleep 1 + az acr repository delete --name "$(get_registry_name)" --image "$repo:$temp_tag" --yes >/dev/null 2>&1 + done + + sleep 2 + + # Test --untagged-only without filter (all repos) + echo -n "Testing --untagged-only without filter... " + local total_tags_before=$(($(count_tags "$multi_repo1") + $(count_tags "$multi_repo2"))) + "$ACR_CLI" purge --registry "$REGISTRY" --untagged-only 2>&1 >/dev/null || true + local total_tags_after=$(($(count_tags "$multi_repo1") + $(count_tags "$multi_repo2"))) + assert_equals "$total_tags_before" "$total_tags_after" "All tagged images should remain when using --untagged-only" } # Run benchmark tests diff --git a/scripts/experimental/test-purge-untagged-only.sh b/scripts/experimental/test-purge-untagged-only.sh new file mode 100755 index 00000000..5955731c --- /dev/null +++ b/scripts/experimental/test-purge-untagged-only.sh @@ -0,0 +1,653 @@ +#!/bin/bash +set -uo pipefail + +# Test script for ACR purge --untagged-only feature +# Tests the new functionality for deleting only untagged manifests + +# Check for required commands +for cmd in az docker; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: Required command '$cmd' not found" + exit 1 + fi +done + +REGISTRY="${1:-}" +TEST_MODE="${2:-all}" # Options: all, basic, comprehensive, edge-cases +NUM_IMAGES="${3:-20}" +TEMP_REGISTRY_CREATED=false +TEMP_REGISTRY_NAME="" +RESOURCE_GROUP="" +DEBUG="${DEBUG:-0}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ACR_CLI="${SCRIPT_DIR}/../../bin/acr" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Test results tracking +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_TESTS=() + +# Cleanup function +cleanup_temp_registry() { + if [ "$TEMP_REGISTRY_CREATED" = true ] && [ -n "$TEMP_REGISTRY_NAME" ]; then + echo -e "\n${YELLOW}Temporary registry cleanup${NC}" + echo "Registry: $TEMP_REGISTRY_NAME" + echo "Resource group: $RESOURCE_GROUP" + read -p "Delete temporary registry and resource group? (y/N) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo -e "${GREEN}Deleting temporary registry...${NC}" + az group delete --name "$RESOURCE_GROUP" --yes --no-wait + echo "Deletion initiated." + else + echo -e "${YELLOW}Keeping temporary registry. Delete manually with:${NC}" + echo " az group delete --name $RESOURCE_GROUP --yes" + fi + fi + + # Print test summary + echo -e "\n${BLUE}=== Test Summary ===${NC}" + echo -e "${GREEN}Passed: $TESTS_PASSED${NC}" + echo -e "${RED}Failed: $TESTS_FAILED${NC}" + if [ ${#FAILED_TESTS[@]} -gt 0 ]; then + echo -e "\n${RED}Failed tests:${NC}" + for test in "${FAILED_TESTS[@]}"; do + echo " - $test" + done + fi +} + +trap cleanup_temp_registry EXIT + +# Helper functions +assert_equals() { + local expected="$1" + local actual="$2" + local test_name="$3" + + if [ "$expected" = "$actual" ]; then + echo -e "${GREEN}✓ $test_name${NC}" + ((TESTS_PASSED++)) + else + echo -e "${RED}✗ $test_name${NC}" + echo -e " Expected: $expected, Actual: $actual" + ((TESTS_FAILED++)) + FAILED_TESTS+=("$test_name") + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local test_name="$3" + + if echo "$haystack" | grep -q "$needle"; then + echo -e "${GREEN}✓ $test_name${NC}" + ((TESTS_PASSED++)) + else + echo -e "${RED}✗ $test_name${NC}" + echo -e " Should contain: $needle" + ((TESTS_FAILED++)) + FAILED_TESTS+=("$test_name") + fi +} + +get_registry_name() { + echo "${REGISTRY%%.*}" +} + +count_tags() { + local repo="$1" + local tags=$("$ACR_CLI" tag list -r "$REGISTRY" --repository "$repo" 2>/dev/null || echo "") + local count=$(echo "$tags" | grep "$REGISTRY" | wc -l | tr -d ' ') + + if [ "$DEBUG" = "1" ]; then + echo -e "\n DEBUG count_tags for $repo: $count" >&2 + fi + echo "$count" +} + +count_manifests() { + local repo="$1" + "$ACR_CLI" manifest list --registry "$REGISTRY" --repository "$repo" 2>/dev/null | wc -l | tr -d ' ' +} + +create_test_image() { + local repo="$1" + local tag="$2" + local base_image="mcr.microsoft.com/hello-world" + + if ! docker pull "$base_image" >/dev/null 2>&1; then + echo "Error: Failed to pull base image $base_image" >&2 + return 1 + fi + + if ! docker tag "$base_image" "$REGISTRY/$repo:$tag"; then + echo "Error: Failed to tag image" + return 1 + fi + + if ! docker push "$REGISTRY/$repo:$tag" >/dev/null 2>&1; then + echo "Error: Failed to push image $REGISTRY/$repo:$tag" + return 1 + fi + + return 0 +} + +delete_tag() { + local repo="$1" + local tag="$2" + + az acr repository delete --name "$(get_registry_name)" --image "$repo:$tag" --yes >/dev/null 2>&1 +} + +create_untagged_manifest() { + local repo="$1" + local tag="temp-tag-$(date +%s)" + + # Create and push an image + create_test_image "$repo" "$tag" + + # Delete the tag to make the manifest untagged + sleep 1 + delete_tag "$repo" "$tag" +} + +lock_manifest() { + local repo="$1" + local digest="$2" + local write_enabled="${3:-false}" + local delete_enabled="${4:-false}" + + az acr repository update \ + --name "$(get_registry_name)" \ + --image "$repo@$digest" \ + --write-enabled "$write_enabled" \ + --delete-enabled "$delete_enabled" \ + --output none 2>/dev/null +} + +get_manifest_digest() { + local repo="$1" + local tag="$2" + + az acr repository show --name "$(get_registry_name)" --image "$repo:$tag" --query "digest" -o tsv 2>/dev/null +} + +# Create temporary registry if needed +if [ -z "$REGISTRY" ]; then + echo -e "${GREEN}Creating temporary registry...${NC}" + # Generate random suffix + RANDOM_SUFFIX=$(openssl rand -hex 4 2>/dev/null || echo $(date +%s | tail -c 5)) + TEMP_REGISTRY_NAME="acrtest${RANDOM_SUFFIX}" + RESOURCE_GROUP="rg-acr-test-${RANDOM_SUFFIX}" + + echo "Creating resource group: $RESOURCE_GROUP" + if ! az group create --name "$RESOURCE_GROUP" --location "eastus" --output none; then + echo -e "${RED}Failed to create resource group${NC}" + exit 1 + fi + + echo "Creating registry: $TEMP_REGISTRY_NAME" + if ! az acr create --resource-group "$RESOURCE_GROUP" --name "$TEMP_REGISTRY_NAME" --sku Basic --admin-enabled true --output none; then + echo -e "${RED}Failed to create registry${NC}" + exit 1 + fi + + REGISTRY="${TEMP_REGISTRY_NAME}.azurecr.io" + TEMP_REGISTRY_CREATED=true + echo -e "${GREEN}Registry created: $REGISTRY${NC}" +fi + +# Build ACR CLI if needed +if [ ! -f "$ACR_CLI" ]; then + echo "Building ACR CLI..." + (cd "$SCRIPT_DIR/../.." && make binaries) +fi + +# Login to ACR +echo "Logging in to registry..." +az acr login --name "$(get_registry_name)" >/dev/null 2>&1 + +echo -e "\n${BLUE}=== ACR Purge --untagged-only Test Suite ===${NC}" +echo "Registry: $REGISTRY" +echo "Test mode: $TEST_MODE" +echo "" + +# Test functions +run_basic_tests() { + echo -e "\n${BLUE}=== Basic --untagged-only Tests ===${NC}" + + # Test 1: Delete only untagged manifests + echo -e "\n${YELLOW}Test 1: Delete Only Untagged Manifests${NC}" + local repo="test-untagged-basic" + + # Create tagged images + echo "Creating tagged images..." + for i in 1 2 3; do + create_test_image "$repo" "v$i" + done + + # Create untagged manifests + echo "Creating untagged manifests..." + for i in 1 2; do + create_untagged_manifest "$repo" + done + + sleep 2 + + local initial_tags=$(count_tags "$repo") + local initial_manifests=$(count_manifests "$repo") + echo "Initial state: $initial_tags tags, $initial_manifests manifests" + + # Test dry-run first + echo -n "Testing dry-run with --untagged-only... " + local output=$("$ACR_CLI" purge --registry "$REGISTRY" --untagged-only --dry-run 2>&1) + assert_contains "$output" "Number of manifests to be deleted" "Dry run should show manifests to delete" + + # Run actual purge + echo -n "Testing --untagged-only (no filter)... " + "$ACR_CLI" purge --registry "$REGISTRY" --untagged-only >/dev/null 2>&1 + + local final_tags=$(count_tags "$repo") + local final_manifests=$(count_manifests "$repo") + + assert_equals "$initial_tags" "$final_tags" "Tagged images should remain unchanged" + + # Test 2: --untagged-only with specific repository filter + echo -e "\n${YELLOW}Test 2: --untagged-only with Repository Filter${NC}" + local repo2="test-untagged-filter" + local repo3="test-untagged-other" + + # Create images in both repos + create_test_image "$repo2" "keep" + create_test_image "$repo3" "keep" + + # Create untagged manifest only in repo2 + create_untagged_manifest "$repo2" + + sleep 2 + + local repo2_initial_manifests=$(count_manifests "$repo2") + local repo3_initial_manifests=$(count_manifests "$repo3") + + echo -n "Testing --untagged-only with filter for specific repo... " + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo2:.*" --untagged-only >/dev/null 2>&1 + + local repo2_final_manifests=$(count_manifests "$repo2") + local repo3_final_manifests=$(count_manifests "$repo3") + + assert_equals "1" "$repo2_final_manifests" "Only untagged manifests in filtered repo should be deleted" + assert_equals "$repo3_initial_manifests" "$repo3_final_manifests" "Other repo should be unchanged" + + # Test 3: --untagged-only with --ago and --keep should work (new functionality) + echo -e "\n${YELLOW}Test 3: Age Filtering and Keep Functionality${NC}" + + # Test --ago filtering with dry run + echo -n "Testing --untagged-only with --ago (dry run)... " + local dry_run_output=$("$ACR_CLI" purge --registry "$REGISTRY" --untagged-only --ago 365d --dry-run 2>&1) + assert_success $? "--untagged-only with --ago should work" + assert_contains "$dry_run_output" "Number of manifests to be deleted:" "--ago should filter manifests by age" + + # Test --keep functionality with dry run + echo -n "Testing --untagged-only with --keep (dry run)... " + dry_run_output=$("$ACR_CLI" purge --registry "$REGISTRY" --untagged-only --keep 2 --dry-run 2>&1) + assert_success $? "--untagged-only with --keep should work" + assert_contains "$dry_run_output" "Number of manifests to be deleted:" "--keep should preserve recent manifests" + + # Test combined --ago and --keep functionality + echo -n "Testing --untagged-only with --ago and --keep (dry run)... " + dry_run_output=$("$ACR_CLI" purge --registry "$REGISTRY" --untagged-only --ago 180d --keep 1 --dry-run 2>&1) + assert_success $? "--untagged-only with --ago and --keep should work together" + assert_contains "$dry_run_output" "Number of manifests to be deleted:" "--ago and --keep should work together" + + # Test 4: --untagged and --untagged-only are mutually exclusive + echo -e "\n${YELLOW}Test 4: Flag Validation${NC}" + echo -n "Testing --untagged with --untagged-only (should fail)... " + local error_output=$("$ACR_CLI" purge --registry "$REGISTRY" --untagged --untagged-only --filter "test:.*" --ago 1d 2>&1 || true) + # Cobra uses different error message, check for either + if echo "$error_output" | grep -qE "(mutually exclusive|are set none of the others can be)"; then + echo -e "${GREEN}✓ --untagged and --untagged-only should be mutually exclusive${NC}" + ((TESTS_PASSED++)) + else + echo -e "${RED}✗ --untagged and --untagged-only should be mutually exclusive${NC}" + echo -e " Should contain: mutually exclusive or similar" + echo -e " Got: $error_output" + ((TESTS_FAILED++)) + FAILED_TESTS+=("--untagged and --untagged-only should be mutually exclusive") + fi +} + +run_comprehensive_tests() { + echo -e "\n${BLUE}=== Comprehensive --untagged-only Tests ===${NC}" + + # Test 1: Locked manifests handling + echo -e "\n${YELLOW}Test 1: Locked Untagged Manifests${NC}" + local repo="test-untagged-locks" + + # Create an image and get its digest + create_test_image "$repo" "temp-for-lock" + local digest=$(get_manifest_digest "$repo" "temp-for-lock") + echo "Manifest digest: $digest" + + # Delete the tag to make it untagged + delete_tag "$repo" "temp-for-lock" + sleep 2 + + # Lock the untagged manifest + echo "Locking untagged manifest..." + lock_manifest "$repo" "$digest" false false + + # Try to delete without --include-locked + echo -n "Testing --untagged-only without --include-locked... " + local initial_count=$(count_manifests "$repo") + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only >/dev/null 2>&1 + local after_count=$(count_manifests "$repo") + assert_equals "$initial_count" "$after_count" "Locked manifest should not be deleted" + + # Try with --include-locked + echo -n "Testing --untagged-only with --include-locked... " + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only --include-locked >/dev/null 2>&1 + local final_count=$(count_manifests "$repo") + assert_equals "0" "$final_count" "Locked manifest should be deleted with --include-locked" + + # Test 2: Multi-arch manifests + echo -e "\n${YELLOW}Test 2: Multi-arch Manifests${NC}" + local repo="test-untagged-multiarch" + + # Create multiple images that reference the same manifest + create_test_image "$repo" "latest" + docker tag "$REGISTRY/$repo:latest" "$REGISTRY/$repo:v1" + docker push "$REGISTRY/$repo:v1" >/dev/null 2>&1 + + # Delete one tag + delete_tag "$repo" "v1" + sleep 2 + + echo -n "Testing --untagged-only with partially tagged manifest... " + local initial_manifests=$(count_manifests "$repo") + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only >/dev/null 2>&1 + local final_manifests=$(count_manifests "$repo") + assert_equals "$initial_manifests" "$final_manifests" "Manifest with remaining tags should not be deleted" + + # Test 3: Large-scale untagged cleanup + echo -e "\n${YELLOW}Test 3: Large-scale Untagged Cleanup${NC}" + local repo="test-untagged-scale" + + echo "Creating $NUM_IMAGES untagged manifests..." + for i in $(seq 1 "$NUM_IMAGES"); do + create_untagged_manifest "$repo" + if [ $((i % 5)) -eq 0 ]; then + echo " Created $i/$NUM_IMAGES untagged manifests..." + fi + done + + # Also create some tagged images + for i in 1 2 3; do + create_test_image "$repo" "keep-v$i" + done + + sleep 2 + + local initial_tags=$(count_tags "$repo") + local initial_manifests=$(count_manifests "$repo") + echo "Initial state: $initial_tags tags, $initial_manifests manifests" + + echo -n "Testing large-scale --untagged-only cleanup... " + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only >/dev/null 2>&1 + + local final_tags=$(count_tags "$repo") + local final_manifests=$(count_manifests "$repo") + + assert_equals "$initial_tags" "$final_tags" "All tagged images should remain" + assert_equals "$initial_tags" "$final_manifests" "Only tagged manifests should remain" + + # Test 4: Concurrent operations + echo -e "\n${YELLOW}Test 4: Concurrent Untagged Cleanup${NC}" + local repo="test-untagged-concurrent" + + # Create untagged manifests + echo "Creating untagged manifests for concurrency test..." + for i in $(seq 1 10); do + create_untagged_manifest "$repo" + done + + sleep 2 + + for concurrency in 1 5 10; do + echo -n "Testing --untagged-only with concurrency=$concurrency... " + local start_time=$(date +%s) + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only --concurrency "$concurrency" >/dev/null 2>&1 + local end_time=$(date +%s) + echo "Duration: $((end_time - start_time))s" + done +} + +run_age_and_keep_tests() { + echo -e "\n${BLUE}=== Age Filtering and Keep Functionality Tests ===${NC}" + + # Create a repository with manifests we can test age filtering on + local repo="test-age-keep-$(date +%s)" + + # Create some tagged images first (these will generate untagged manifests when we untag them) + echo "Setting up test data for age and keep tests..." + for i in $(seq 1 5); do + create_test_image "$repo" "v$i" + sleep 1 # Ensure different timestamps + done + + # Wait a moment to ensure timestamps are different + sleep 2 + + # Create more recent manifests + for i in $(seq 6 8); do + create_test_image "$repo" "v$i" + sleep 1 + done + + # Remove tags to create untagged manifests with different ages + echo "Creating untagged manifests by removing tags..." + for i in $(seq 1 8); do + docker image rm "$REGISTRY/$repo:v$i" 2>/dev/null || true + az acr repository untag --name "$REGISTRY" --image "$repo:v$i" >/dev/null 2>&1 || true + done + + sleep 2 + + # Test 1: Age filtering - delete only old manifests + echo -e "\n${YELLOW}Test 1: Age Filtering${NC}" + echo -n "Testing --ago filtering (dry run)... " + local initial_count=$(count_manifests_in_repo "$repo") + local output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only --ago 3s --dry-run 2>&1) + assert_success $? "Age filtering should work" + + # Should report some manifests to be deleted (the older ones) + local to_delete=$(echo "$output" | grep "Number of manifests to be deleted:" | sed 's/.*: //') + if [[ "$to_delete" -gt 0 && "$to_delete" -lt "$initial_count" ]]; then + echo -e "${GREEN}✓ Age filtering works correctly${NC}" + ((TESTS_PASSED++)) + else + echo -e "${RED}✗ Age filtering not working as expected${NC}" + ((TESTS_FAILED++)) + FAILED_TESTS+=("Age filtering") + fi + + # Test 2: Keep functionality - preserve recent manifests + echo -e "\n${YELLOW}Test 2: Keep Functionality${NC}" + echo -n "Testing --keep functionality (dry run)... " + output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only --keep 3 --dry-run 2>&1) + assert_success $? "Keep functionality should work" + + to_delete=$(echo "$output" | grep "Number of manifests to be deleted:" | sed 's/.*: //') + if [[ "$to_delete" -ge 0 && "$to_delete" -le $((initial_count - 3)) ]]; then + echo -e "${GREEN}✓ Keep functionality works correctly${NC}" + ((TESTS_PASSED++)) + else + echo -e "${RED}✗ Keep functionality not working as expected${NC}" + ((TESTS_FAILED++)) + FAILED_TESTS+=("Keep functionality") + fi + + # Test 3: Combined age and keep filtering + echo -e "\n${YELLOW}Test 3: Combined Age and Keep Filtering${NC}" + echo -n "Testing --ago with --keep (dry run)... " + output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only --ago 5s --keep 2 --dry-run 2>&1) + assert_success $? "Combined age and keep filtering should work" + + to_delete=$(echo "$output" | grep "Number of manifests to be deleted:" | sed 's/.*: //') + echo -e "${GREEN}✓ Combined filtering reported $to_delete manifests to delete${NC}" + ((TESTS_PASSED++)) + + # Clean up test repository + az acr repository delete --name "$REGISTRY" --image "$repo" --yes >/dev/null 2>&1 || true +} + +run_edge_case_tests() { + echo -e "\n${BLUE}=== Edge Case Tests for --untagged-only ===${NC}" + + # Test 1: Empty repository + echo -e "\n${YELLOW}Test 1: Empty Repository${NC}" + echo -n "Testing --untagged-only on non-existent repo... " + local output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "nonexistent:.*" --untagged-only 2>&1) + assert_contains "$output" "Number of deleted manifests: 0" "Should handle non-existent repo gracefully" + + # Test 2: Repository with no untagged manifests + echo -e "\n${YELLOW}Test 2: Repository with No Untagged Manifests${NC}" + local repo="test-no-untagged" + + # Create only tagged images + for i in 1 2 3; do + create_test_image "$repo" "v$i" + done + + echo -n "Testing --untagged-only on repo with no untagged manifests... " + output=$("$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only 2>&1) + assert_contains "$output" "Number of deleted manifests: 0" "Should report 0 deletions when no untagged manifests" + + # Test 3: Mixed repositories + echo -e "\n${YELLOW}Test 3: Mixed Repositories${NC}" + local repo1="test-mixed-1" + local repo2="test-mixed-2" + local repo3="test-mixed-3" + + # Repo1: Only tagged + create_test_image "$repo1" "tagged" + + # Repo2: Only untagged + create_untagged_manifest "$repo2" + + # Repo3: Mixed + create_test_image "$repo3" "tagged" + create_untagged_manifest "$repo3" + + sleep 2 + + echo -n "Testing --untagged-only across all repos... " + local initial_total_tags=$(($(count_tags "$repo1") + $(count_tags "$repo2") + $(count_tags "$repo3"))) + + "$ACR_CLI" purge --registry "$REGISTRY" --untagged-only >/dev/null 2>&1 + + local final_total_tags=$(($(count_tags "$repo1") + $(count_tags "$repo2") + $(count_tags "$repo3"))) + assert_equals "$initial_total_tags" "$final_total_tags" "All tagged images should remain across all repos" + + # Test 4: Special characters in repository names + echo -e "\n${YELLOW}Test 4: Special Repository Names${NC}" + local special_repo="test-special.repo-name_123" + + create_test_image "$special_repo" "keep" + create_untagged_manifest "$special_repo" + + sleep 2 + + echo -n "Testing --untagged-only with special repo name... " + local initial=$(count_manifests "$special_repo") + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$special_repo:.*" --untagged-only >/dev/null 2>&1 + local final=$(count_manifests "$special_repo") + assert_equals "1" "$final" "Should handle special characters in repo names" +} + +run_performance_tests() { + echo -e "\n${BLUE}=== Performance Tests for --untagged-only ===${NC}" + + echo -e "\n${YELLOW}Performance Comparison: --untagged-only vs regular purge${NC}" + local repo="test-perf-comparison" + + # Create test data + echo "Creating test data..." + for i in $(seq 1 20); do + create_test_image "$repo" "v$i" + done + for i in $(seq 1 20); do + create_untagged_manifest "$repo" + done + + sleep 2 + + # Test --untagged-only performance + echo "Testing --untagged-only performance..." + local start_time=$(date +%s%N) + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --untagged-only --dry-run >/dev/null 2>&1 + local end_time=$(date +%s%N) + local untagged_only_time=$((end_time - start_time)) + + # Test regular purge with --untagged performance + echo "Testing regular purge with --untagged performance..." + start_time=$(date +%s%N) + "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --ago 0d --untagged --dry-run >/dev/null 2>&1 + end_time=$(date +%s%N) + local regular_purge_time=$((end_time - start_time)) + + echo -e "${GREEN}Performance Results:${NC}" + echo " --untagged-only time: $((untagged_only_time / 1000000))ms" + echo " Regular purge time: $((regular_purge_time / 1000000))ms" + + if [ "$untagged_only_time" -lt "$regular_purge_time" ]; then + echo -e "${GREEN} ✓ --untagged-only is faster (as expected)${NC}" + ((TESTS_PASSED++)) + else + echo -e "${YELLOW} Note: Regular purge was not slower (may vary based on data)${NC}" + fi +} + +# Main execution +case "$TEST_MODE" in + basic) + run_basic_tests + ;; + comprehensive) + run_comprehensive_tests + ;; + edge-cases) + run_edge_case_tests + ;; + age-keep) + run_age_and_keep_tests + ;; + performance) + run_performance_tests + ;; + all) + run_basic_tests + run_comprehensive_tests + run_age_and_keep_tests + run_edge_case_tests + run_performance_tests + ;; + *) + echo "Invalid test mode: $TEST_MODE" + echo "Options: all, basic, comprehensive, age-keep, edge-cases, performance" + exit 1 + ;; +esac + +echo -e "\n${GREEN}=== All --untagged-only tests completed ===${NC}" \ No newline at end of file