1
0
mirror of https://github.com/regclient/regclient.git synced 2025-04-18 22:44:00 +03:00

Merge pull request #930 from sudo-bmitch/pr-regctl-tag-rm-missing

Feat: regctl flag to ignore missing images on delete
This commit is contained in:
Brandon Mitchell 2025-03-31 16:37:03 -04:00 committed by GitHub
commit e5a71ed0be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 278 additions and 19 deletions

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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
}

View File

@ -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)
}
})
}
}