Repository: regclient/regclient (go)

Issue:
[Feature] Tag delete flag not to fail if tag already deleted
<!--
Note: Please search to see if an issue already exists for the bug you encountered.
-->

In scripts you may delete many tags  (e.g. per-arch tags like foo-bar-aarch64), and in case of network errors you may have retries.
So part of tags may be already deleted.

### Current Behavior

```
regctl tag delete registry/image:foo-bar-aarch64
failed to request manifest head registry/image:foo-bar-aarch64: request failed: not found [http 404]:
echo $? # != 0
```

### Expected Behavior

Add flag like `--missing-ok`, so:

```
regctl tag delete --missing-ok registry/image:foo-bar-aarch64
warning: failed to request manifest head registry/image:foo-bar-aarch64: 404
echo $? # == 0
```

### Example Solution


### Version

`go install @latest`

```text
VCSTag:     v0.8.2
VCSRef:     unknown
VCSCommit:  unknown
VCSState:   unknown
VCSDate:    unknown
Platform:   linux/amd64
GoVer:      go1.24.0
GoCompiler: gc
```

### Environment

<!--
Please include:
- How you are running the tool: downloaded binary or from within a container
- Your platform: Linux, Windows, or Mac, x86 or ARM.
- Your registry: Docker Hub, ECR, GCR, ACR, Harbor, registry:2, etc.
-->

- Running as binary or container: both options
- Host platform: linux
- Registry description: harbor & zot

### Anything else

Pull request:
Feat: regctl flag to ignore missing images on delete
<!--

Commits must be signed indicating your agreement to the [DCO](https://developercertificate.org/).
See [DCO missing](https://github.com/src-d/guide/blob/master/developer-community/fix-DCO.md) for steps to fix a missing signoff.

-->

### Fixes issue

Fixes #928.
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

Add a flag to `regctl tag rm` and `regctl manifest rm` to ignore a missing image if the delete API fails.
<!-- Include the type of change: bug fix, new feature, breaking change, documentation update -->
<!-- Describe what was changed, why the change was made, and how it was implemented -->

### How to verify it

```shell
regctl tag rm registry.example.org/repo:missing
```

<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Feat: regctl flag to ignore missing images on delete.
<!-- If the release changelog should have an entry for this, include it here -->

### Please verify and check that the pull request fulfills the following requirements

<!-- Mark the following with an [X] to verify they are included -->

- [X] Tests have been added or not applicable
- [X] Documentation has been added, updated, or not applicable
- [X] Changes have been rebased to main
- [X] Multiple commits to the same code have been squashed

<!-- markdownlint-disable-file MD041 -->


Patch:
diff --git a/cmd/regctl/manifest.go b/cmd/regctl/manifest.go
index 2972c4f..cca5024 100644
--- a/cmd/regctl/manifest.go
+++ b/cmd/regctl/manifest.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"log/slog"
@@ -13,6 +14,7 @@ import (
 	"github.com/regclient/regclient/internal/diff"
 	"github.com/regclient/regclient/pkg/template"
 	"github.com/regclient/regclient/types/descriptor"
+	"github.com/regclient/regclient/types/errs"
 	"github.com/regclient/regclient/types/manifest"
 	"github.com/regclient/regclient/types/platform"
 	"github.com/regclient/regclient/types/ref"
@@ -27,6 +29,7 @@ type manifestOpts struct {
 	diffFullCtx   bool
 	forceTagDeref bool
 	format        string
+	ignoreMissing bool
 	list          bool
 	platform      string
 	referrers     bool
@@ -75,8 +78,9 @@ regctl manifest delete --referrers \
 		ValidArgs: []string{}, // do not auto complete digests
 		RunE:      opts.runManifestDelete,
 	}
-	cmd.Flags().BoolVarP(&opts.forceTagDeref, "force-tag-dereference", "", false, "Dereference the a tag to a digest, this is unsafe")
-	cmd.Flags().BoolVarP(&opts.referrers, "referrers", "", false, "Check for referrers, recommended when deleting artifacts")
+	cmd.Flags().BoolVar(&opts.forceTagDeref, "force-tag-dereference", false, "Dereference the a tag to a digest, this is unsafe")
+	cmd.Flags().BoolVar(&opts.ignoreMissing, "ignore-missing", false, "Ignore errors if manifest is missing")
+	cmd.Flags().BoolVar(&opts.referrers, "referrers", false, "Check for referrers, recommended when deleting artifacts")
 	return cmd
 }
 
@@ -102,8 +106,8 @@ regctl manifest diff --context-full \
 		ValidArgsFunction: rOpts.completeArgTag,
 		RunE:              opts.runManifestDiff,
 	}
-	cmd.Flags().IntVarP(&opts.diffCtx, "context", "", 3, "Lines of context")
-	cmd.Flags().BoolVarP(&opts.diffFullCtx, "context-full", "", false, "Show all lines of context")
+	cmd.Flags().IntVar(&opts.diffCtx, "context", 3, "Lines of context")
+	cmd.Flags().BoolVar(&opts.diffFullCtx, "context-full", false, "Show all lines of context")
 	return cmd
 }
 
@@ -129,13 +133,13 @@ regctl manifest get golang --platform windows/amd64,osver=10.0.17763.4974`,
 		ValidArgsFunction: rOpts.completeArgTag,
 		RunE:              opts.runManifestGet,
 	}
-	cmd.Flags().StringVarP(&opts.format, "format", "", "{{printPretty .}}", "Format output with go template syntax (use \"raw-body\" for the original manifest)")
+	cmd.Flags().StringVar(&opts.format, "format", "{{printPretty .}}", "Format output with go template syntax (use \"raw-body\" for the original manifest)")
 	_ = cmd.RegisterFlagCompletionFunc("format", completeArgNone)
-	cmd.Flags().BoolVarP(&opts.list, "list", "", true, "Deprecated: Output manifest list if available")
+	cmd.Flags().BoolVar(&opts.list, "list", true, "Deprecated: Output manifest list if available")
 	_ = cmd.Flags().MarkHidden("list")
 	cmd.Flags().StringVarP(&opts.platform, "platform", "p", "", "Specify platform (e.g. linux/amd64 or local)")
 	_ = cmd.RegisterFlagCompletionFunc("platform", completeArgPlatform)
-	cmd.Flags().BoolVarP(&opts.requireList, "require-list", "", false, "Deprecated: Fail if manifest list is not received")
+	cmd.Flags().BoolVar(&opts.requireList, "require-list", false, "Deprecated: Fail if manifest list is not received")
 	return cmd
 }
 
@@ -164,14 +168,14 @@ regctl manifest head alpine --format raw-headers`,
 		ValidArgsFunction: rOpts.completeArgTag,
 		RunE:              opts.runManifestHead,
 	}
-	cmd.Flags().StringVarP(&opts.format, "format", "", "", "Format output with go template syntax (use \"raw-body\" for the original manifest)")
+	cmd.Flags().StringVar(&opts.format, "format", "", "Format output with go template syntax (use \"raw-body\" for the original manifest)")
 	_ = cmd.RegisterFlagCompletionFunc("format", completeArgNone)
-	cmd.Flags().BoolVarP(&opts.list, "list", "", true, "Do not resolve platform from manifest list (enabled by default)")
+	cmd.Flags().BoolVar(&opts.list, "list", true, "Do not resolve platform from manifest list (enabled by default)")
 	_ = cmd.Flags().MarkHidden("list")
 	cmd.Flags().StringVarP(&opts.platform, "platform", "p", "", "Specify platform (e.g. linux/amd64 or local, requires a get request)")
 	_ = cmd.RegisterFlagCompletionFunc("platform", completeArgPlatform)
-	cmd.Flags().BoolVarP(&opts.requireDigest, "require-digest", "", false, "Fallback to a GET request if digest is not received")
-	cmd.Flags().BoolVarP(&opts.requireList, "require-list", "", false, "Fail if manifest list is not received")
+	cmd.Flags().BoolVar(&opts.requireDigest, "require-digest", false, "Fallback to a GET request if digest is not received")
+	cmd.Flags().BoolVar(&opts.requireList, "require-list", false, "Fail if manifest list is not received")
 	return cmd
 }
 
@@ -193,10 +197,10 @@ regctl manifest put \
 		ValidArgsFunction: rOpts.completeArgTag,
 		RunE:              opts.runManifestPut,
 	}
-	cmd.Flags().BoolVarP(&opts.byDigest, "by-digest", "", false, "Push manifest by digest instead of tag")
+	cmd.Flags().BoolVar(&opts.byDigest, "by-digest", false, "Push manifest by digest instead of tag")
 	cmd.Flags().StringVarP(&opts.contentType, "content-type", "t", "", "Specify content-type (e.g. application/vnd.docker.distribution.manifest.v2+json)")
 	_ = cmd.RegisterFlagCompletionFunc("content-type", completeArgMediaTypeManifest)
-	cmd.Flags().StringVarP(&opts.format, "format", "", "", "Format output with go template syntax")
+	cmd.Flags().StringVar(&opts.format, "format", "", "Format output with go template syntax")
 	_ = cmd.RegisterFlagCompletionFunc("format", completeArgNone)
 	return cmd
 }
@@ -216,6 +220,12 @@ func (opts *manifestOpts) runManifestDelete(cmd *cobra.Command, args []string) e
 
 	if r.Digest == "" && opts.forceTagDeref {
 		m, err := rc.ManifestHead(ctx, r, regclient.WithManifestRequireDigest())
+		if err != nil && opts.ignoreMissing {
+			_, mErr := rc.ManifestHead(ctx, r)
+			if errors.Is(mErr, errs.ErrNotFound) {
+				return nil
+			}
+		}
 		if err != nil {
 			return err
 		}
@@ -235,6 +245,12 @@ func (opts *manifestOpts) runManifestDelete(cmd *cobra.Command, args []string) e
 	}
 
 	err = rc.ManifestDelete(ctx, r, mOpts...)
+	if err != nil && opts.ignoreMissing {
+		_, mErr := rc.ManifestHead(ctx, r)
+		if errors.Is(mErr, errs.ErrNotFound) {
+			return nil
+		}
+	}
 	if err != nil {
 		return err
 	}
diff --git a/cmd/regctl/tag.go b/cmd/regctl/tag.go
index 1ede671..d3870a8 100644
--- a/cmd/regctl/tag.go
+++ b/cmd/regctl/tag.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"log/slog"
 	"regexp"
@@ -9,16 +10,18 @@ import (
 
 	"github.com/regclient/regclient/pkg/template"
 	"github.com/regclient/regclient/scheme"
+	"github.com/regclient/regclient/types/errs"
 	"github.com/regclient/regclient/types/ref"
 )
 
 type tagOpts struct {
-	rootOpts *rootOpts
-	limit    int
-	last     string
-	include  []string
-	exclude  []string
-	format   string
+	rootOpts      *rootOpts
+	limit         int
+	last          string
+	include       []string
+	exclude       []string
+	format        string
+	ignoreMissing bool
 }
 
 func NewTagCmd(rOpts *rootOpts) *cobra.Command {
@@ -51,6 +54,7 @@ regctl tag delete registry.example.org/repo:v42`,
 		ValidArgsFunction: rOpts.completeArgTag,
 		RunE:              opts.runTagDelete,
 	}
+	cmd.Flags().BoolVar(&opts.ignoreMissing, "ignore-missing", false, "Ignore errors if tag is missing")
 	return cmd
 }
 
@@ -102,6 +106,12 @@ func (opts *tagOpts) runTagDelete(cmd *cobra.Command, args []string) error {
 		slog.String("repository", r.Repository),
 		slog.String("tag", r.Tag))
 	err = rc.TagDelete(ctx, r)
+	if err != nil && opts.ignoreMissing {
+		_, mErr := rc.ManifestHead(ctx, r)
+		if errors.Is(mErr, errs.ErrNotFound) {
+			return nil
+		}
+	}
 	if err != nil {
 		return err
 	}


Test patch:
diff --git a/cmd/regctl/manifest_test.go b/cmd/regctl/manifest_test.go
index 51f59e1..121c115 100644
--- a/cmd/regctl/manifest_test.go
+++ b/cmd/regctl/manifest_test.go
@@ -1,12 +1,25 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
 	"strings"
 	"testing"
+	"time"
 
+	"github.com/olareg/olareg"
+	oConfig "github.com/olareg/olareg/config"
+	"github.com/opencontainers/go-digest"
+
+	"github.com/regclient/regclient"
+	"github.com/regclient/regclient/config"
+	"github.com/regclient/regclient/scheme/reg"
 	"github.com/regclient/regclient/types/errs"
+	"github.com/regclient/regclient/types/ref"
 )
 
 func TestManifestHead(t *testing.T) {
@@ -70,3 +83,119 @@ func TestManifestHead(t *testing.T) {
 		})
 	}
 }
+
+func TestManifestRm(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+	boolT := true
+	regHandler := olareg.New(oConfig.Config{
+		Storage: oConfig.ConfigStorage{
+			StoreType: oConfig.StoreMem,
+			RootDir:   "../../testdata",
+		},
+		API: oConfig.ConfigAPI{
+			DeleteEnabled: &boolT,
+		},
+	})
+	ts := httptest.NewServer(regHandler)
+	tsURL, _ := url.Parse(ts.URL)
+	tsHost := tsURL.Host
+	t.Cleanup(func() {
+		ts.Close()
+		_ = regHandler.Close()
+	})
+	rcOpts := []regclient.Opt{
+		regclient.WithConfigHost(
+			config.Host{
+				Name: tsHost,
+				TLS:  config.TLSDisabled,
+			},
+			config.Host{
+				Name:     "invalid-tls." + tsHost,
+				Hostname: tsHost,
+				TLS:      config.TLSEnabled,
+			},
+		),
+		regclient.WithRegOpts(reg.WithDelay(time.Millisecond*10, time.Millisecond*100), reg.WithRetryLimit(2)),
+	}
+	rb, err := ref.New("ocidir://../../testdata/testrepo:v3")
+	if err != nil {
+		t.Fatalf("failed to parse ref: %v", err)
+	}
+	rc := regclient.New(rcOpts...)
+	mb, err := rc.ManifestHead(ctx, rb, regclient.WithManifestRequireDigest())
+	if err != nil {
+		t.Fatalf("failed to head ref %s: %v", rb.CommonName(), err)
+	}
+	dig := mb.GetDescriptor().Digest.String()
+	missing := digest.Canonical.FromString("missing").String()
+
+	tt := []struct {
+		name        string
+		args        []string
+		expectErr   error
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "Missing arg",
+			args:      []string{"manifest", "rm"},
+			expectErr: fmt.Errorf("accepts 1 arg(s), received 0"),
+		},
+		{
+			name: "Delete v3 by digest",
+			args: []string{"manifest", "rm", tsHost + "/testrepo@" + dig},
+		},
+		{
+			name:      "Delete v1 without deref",
+			args:      []string{"manifest", "rm", tsHost + "/testrepo:v1"},
+			expectErr: errs.ErrMissingDigest,
+		},
+		{
+			name: "Delete v1 with deref",
+			args: []string{"manifest", "rm", tsHost + "/testrepo:v1", "--force-tag-dereference"},
+		},
+		{
+			name:      "Delete missing digest",
+			args:      []string{"manifest", "rm", tsHost + "/testrepo@" + missing, "--force-tag-dereference"},
+			expectErr: errs.ErrNotFound,
+		},
+		{
+			name: "Delete missing digest with ignore missing",
+			args: []string{"manifest", "rm", tsHost + "/testrepo@" + missing, "--ignore-missing"},
+		},
+		{
+			name:      "Delete missing tag with deref",
+			args:      []string{"manifest", "rm", tsHost + "/testrepo:missing", "--force-tag-dereference"},
+			expectErr: errs.ErrNotFound,
+		},
+		{
+			name: "Delete missing tag with deref and ignore missing",
+			args: []string{"manifest", "rm", tsHost + "/testrepo:missing", "--force-tag-dereference", "--ignore-missing"},
+		},
+		{
+			name:      "Delete tls error with ignore missing",
+			args:      []string{"manifest", "rm", "invalid-tls." + tsHost + "/testrepo@" + missing, "--ignore-missing"},
+			expectErr: http.ErrSchemeMismatch,
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			out, err := cobraTest(t, &cobraTestOpts{rcOpts: rcOpts}, tc.args...)
+			if tc.expectErr != nil {
+				if err == nil {
+					t.Errorf("did not receive expected error: %v", tc.expectErr)
+				} else if !errors.Is(err, tc.expectErr) && err.Error() != tc.expectErr.Error() {
+					t.Errorf("unexpected error, received %v, expected %v", err, tc.expectErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("returned unexpected error: %v", err)
+			}
+			if (!tc.outContains && out != tc.expectOut) || (tc.outContains && !strings.Contains(out, tc.expectOut)) {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+}
diff --git a/cmd/regctl/tag_test.go b/cmd/regctl/tag_test.go
index 77846ed..9531fbe 100644
--- a/cmd/regctl/tag_test.go
+++ b/cmd/regctl/tag_test.go
@@ -4,13 +4,25 @@ import (
 	"errors"
 	"fmt"
 	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
 	"strings"
 	"testing"
+	"time"
 
+	"github.com/olareg/olareg"
+	oConfig "github.com/olareg/olareg/config"
+	"github.com/opencontainers/go-digest"
+
+	"github.com/regclient/regclient"
+	"github.com/regclient/regclient/config"
+	"github.com/regclient/regclient/scheme/reg"
 	"github.com/regclient/regclient/types/errs"
 )
 
 func TestTagList(t *testing.T) {
+	t.Parallel()
 	tt := []struct {
 		name        string
 		args        []string
@@ -78,3 +90,95 @@ func TestTagList(t *testing.T) {
 		})
 	}
 }
+
+func TestTagRm(t *testing.T) {
+	t.Parallel()
+	boolT := true
+	regHandler := olareg.New(oConfig.Config{
+		Storage: oConfig.ConfigStorage{
+			StoreType: oConfig.StoreMem,
+			RootDir:   "../../testdata",
+		},
+		API: oConfig.ConfigAPI{
+			DeleteEnabled: &boolT,
+		},
+	})
+	ts := httptest.NewServer(regHandler)
+	tsURL, _ := url.Parse(ts.URL)
+	tsHost := tsURL.Host
+	t.Cleanup(func() {
+		ts.Close()
+		_ = regHandler.Close()
+	})
+	rcOpts := []regclient.Opt{
+		regclient.WithConfigHost(
+			config.Host{
+				Name: tsHost,
+				TLS:  config.TLSDisabled,
+			},
+			config.Host{
+				Name:     "invalid-tls." + tsHost,
+				Hostname: tsHost,
+				TLS:      config.TLSEnabled,
+			},
+		),
+		regclient.WithRegOpts(reg.WithDelay(time.Millisecond*10, time.Millisecond*100), reg.WithRetryLimit(2)),
+	}
+	dig := digest.Canonical.FromString("test digest").String()
+
+	tt := []struct {
+		name        string
+		args        []string
+		expectErr   error
+		expectOut   string
+		outContains bool
+	}{
+		{
+			name:      "Missing arg",
+			args:      []string{"tag", "rm"},
+			expectErr: fmt.Errorf("accepts 1 arg(s), received 0"),
+		},
+		{
+			name:      "Delete digest",
+			args:      []string{"tag", "rm", tsHost + "/testrepo@" + dig},
+			expectErr: errs.ErrMissingTag,
+		},
+		{
+			name: "Delete v1",
+			args: []string{"tag", "rm", tsHost + "/testrepo:v1"},
+		},
+		{
+			name:      "Delete missing",
+			args:      []string{"tag", "rm", tsHost + "/testrepo:missing"},
+			expectErr: errs.ErrNotFound,
+		},
+		{
+			name: "Delete missing with ignore missing",
+			args: []string{"tag", "rm", tsHost + "/testrepo:missing", "--ignore-missing"},
+		},
+		{
+			name:      "Delete tls error with ignore missing",
+			args:      []string{"tag", "rm", "invalid-tls." + tsHost + "/testrepo:missing", "--ignore-missing"},
+			expectErr: http.ErrSchemeMismatch,
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			out, err := cobraTest(t, &cobraTestOpts{rcOpts: rcOpts}, tc.args...)
+			if tc.expectErr != nil {
+				if err == nil {
+					t.Errorf("did not receive expected error: %v", tc.expectErr)
+				} else if !errors.Is(err, tc.expectErr) && err.Error() != tc.expectErr.Error() {
+					t.Errorf("unexpected error, received %v, expected %v", err, tc.expectErr)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("returned unexpected error: %v", err)
+			}
+			if (!tc.outContains && out != tc.expectOut) || (tc.outContains && !strings.Contains(out, tc.expectOut)) {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+}