Repository: regclient/regclient
Pull request #917 (2025-03-05 22:29:09)

Feat: Improve regctl image check-base output
<!--

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

<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

The new output makes it more apparent when the base image has changed to casual users. Tests were added, which required some work to pass through `regclient.Opts`. The empty error message is also suppressed to avoid an extra linefeed to stderr.

<!-- 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
# report if base image has changed using annotations
regctl image check-base ghcr.io/regclient/regctl:alpine

# suppress the normal output with --quiet for scripts
if ! regctl image check-base ghcr.io/regclient/regctl:alpine --quiet; then
  echo build a new image here
fi
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Feat: Improve `regctl image check-base` output.
<!-- 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/image.go b/cmd/regctl/image.go
index 0fb31ae..99d42b6 100644
--- a/cmd/regctl/image.go
+++ b/cmd/regctl/image.go
@@ -63,6 +63,7 @@ type imageOpts struct {
 	modOpts         []mod.Opts
 	platform        string
 	platforms       []string
+	quiet           bool
 	referrers       bool
 	referrerSrc     string
 	referrerTgt     string
@@ -107,11 +108,15 @@ If the base name is not provided, annotations will be checked in the image.
 If the digest is available, this checks if that matches the base name.
 If the digest is not available, layers of each manifest are compared.
 If the layers match, the config (history and roots) are optionally compared.	
-If the base image does not match, the command exits with a non-zero status.
-Use "-v info" to see more details.`,
+If the base image does not match, the command exits with a non-zero status.`,
 		Example: `
 # report if base image has changed using annotations
-regctl image check-base ghcr.io/regclient/regctl:alpine -v info`,
+regctl image check-base ghcr.io/regclient/regctl:alpine
+
+# suppress the normal output with --quiet for scripts
+if ! regctl image check-base ghcr.io/regclient/regctl:alpine --quiet; then
+  echo build a new image here
+fi`,
 		Args:              cobra.ExactArgs(1),
 		ValidArgsFunction: rOpts.completeArgTag,
 		RunE:              opts.runImageCheckBase,
@@ -120,6 +125,7 @@ regctl image check-base ghcr.io/regclient/regctl:alpine -v info`,
 	cmd.Flags().StringVar(&opts.checkBaseDigest, "digest", "", "Base image digest (checks if digest matches base)")
 	cmd.Flags().BoolVar(&opts.checkSkipConfig, "no-config", false, "Skip check of config history")
 	cmd.Flags().StringVarP(&opts.platform, "platform", "p", "", "Specify platform (e.g. linux/amd64 or local)")
+	cmd.Flags().BoolVar(&opts.quiet, "quiet", false, "Do not output to stdout")
 	return cmd
 }
 
@@ -1059,15 +1065,19 @@ func (opts *imageOpts) runImageCheckBase(cmd *cobra.Command, args []string) erro
 	err = rc.ImageCheckBase(ctx, r, rcOpts...)
 	if err == nil {
 		opts.rootOpts.log.Info("base image matches")
-		return nil
+		if !opts.quiet {
+			fmt.Fprintf(cmd.OutOrStdout(), "base image matches\n")
+		}
 	} else if errors.Is(err, errs.ErrMismatch) {
 		opts.rootOpts.log.Info("base image mismatch",
 			slog.String("err", err.Error()))
 		// return empty error message
-		return fmt.Errorf("%.0w", err)
-	} else {
-		return err
+		err = fmt.Errorf("%.0w", err)
+		if !opts.quiet {
+			fmt.Fprintf(cmd.OutOrStdout(), "base image has changed\n")
+		}
 	}
+	return err
 }
 
 func (opts *imageOpts) runImageCopy(cmd *cobra.Command, args []string) error {
diff --git a/cmd/regctl/main.go b/cmd/regctl/main.go
index b1acb5d..c7ca8c5 100644
--- a/cmd/regctl/main.go
+++ b/cmd/regctl/main.go
@@ -26,7 +26,9 @@ func main() {
 	godbg.SignalTrace()
 
 	if err := cmd.ExecuteContext(ctx); err != nil {
-		fmt.Fprintf(os.Stderr, "%v\n", err)
+		if err.Error() != "" {
+			fmt.Fprintf(os.Stderr, "%s\n", err.Error())
+		}
 		// provide tips for common error messages
 		switch {
 		case strings.Contains(err.Error(), "http: server gave HTTP response to HTTPS client"):
diff --git a/cmd/regctl/root.go b/cmd/regctl/root.go
index 433f8d1..491bea5 100644
--- a/cmd/regctl/root.go
+++ b/cmd/regctl/root.go
@@ -25,12 +25,13 @@ const (
 )
 
 type rootOpts struct {
+	hosts     []string
 	name      string
-	verbosity string
 	logopts   []string
 	log       *slog.Logger
-	hosts     []string
+	rcOpts    []regclient.Opt
 	userAgent string
+	verbosity string
 }
 
 type versionOpts struct {
@@ -160,6 +161,9 @@ func (opts *rootOpts) newRegClient() *regclient.RegClient {
 		regclient.WithSlog(opts.log),
 		regclient.WithRegOpts(reg.WithCache(time.Minute*5, 500)),
 	}
+	if len(opts.rcOpts) > 0 {
+		rcOpts = append(rcOpts, opts.rcOpts...)
+	}
 	if opts.userAgent != "" {
 		rcOpts = append(rcOpts, regclient.WithUserAgent(opts.userAgent))
 	} else {


Test patch:
diff --git a/cmd/regctl/image_test.go b/cmd/regctl/image_test.go
index bb5e78f..3cf7aec 100644
--- a/cmd/regctl/image_test.go
+++ b/cmd/regctl/image_test.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http/httptest"
@@ -12,9 +13,125 @@ import (
 	"github.com/olareg/olareg"
 	oConfig "github.com/olareg/olareg/config"
 
+	"github.com/regclient/regclient"
+	"github.com/regclient/regclient/config"
 	"github.com/regclient/regclient/types/errs"
+	"github.com/regclient/regclient/types/ref"
 )
 
+func TestImageCheckBase(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+	regHandler := olareg.New(oConfig.Config{
+		Storage: oConfig.ConfigStorage{
+			StoreType: oConfig.StoreMem,
+			RootDir:   "../../testdata",
+		},
+	})
+	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:     "registry.example.org",
+				Hostname: tsHost,
+				TLS:      config.TLSDisabled,
+			},
+		),
+	}
+
+	rb, err := ref.New("ocidir://../../testdata/testrepo:b3")
+	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
+
+	tt := []struct {
+		name      string
+		args      []string
+		expectErr error
+		expectOut string
+	}{
+		{
+			name:      "missing annotation",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v1"},
+			expectErr: errs.ErrMissingAnnotation,
+		},
+		{
+			name:      "annotation v2",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v2"},
+			expectErr: errs.ErrMismatch,
+			expectOut: "base image has changed",
+		},
+		{
+			name:      "annotation v3",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v3"},
+			expectErr: errs.ErrMismatch,
+			expectOut: "base image has changed",
+		},
+		{
+			name:      "manual v2 b1",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v2", "--base", tsHost + "/testrepo:b1"},
+			expectOut: "base image matches",
+		},
+		{
+			name:      "manual v2 b2",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v2", "--base", tsHost + "/testrepo:b2"},
+			expectErr: errs.ErrMismatch,
+			expectOut: "base image has changed",
+		},
+		{
+			name:      "manual v3 b1",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v3", "--base", tsHost + "/testrepo:b1"},
+			expectOut: "base image matches",
+		},
+		{
+			name:      "manual v3 b3",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v3", "--base", tsHost + "/testrepo:b3"},
+			expectErr: errs.ErrMismatch,
+			expectOut: "base image has changed",
+		},
+		{
+			name:      "manual v3 b3 with digest",
+			args:      []string{"image", "check-base", tsHost + "/testrepo:v3", "--base", tsHost + "/testrepo:b3", "--digest", dig.String()},
+			expectOut: "base image matches",
+		},
+	}
+	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 out != tc.expectOut {
+				t.Errorf("unexpected output, expected %s, received %s", tc.expectOut, out)
+			}
+		})
+	}
+}
+
 func TestImageCopy(t *testing.T) {
 	tempDir := t.TempDir()
 	srcRef := "ocidir://../../testdata/testrepo:v2"
diff --git a/cmd/regctl/main_test.go b/cmd/regctl/main_test.go
index a82548b..cdc867c 100644
--- a/cmd/regctl/main_test.go
+++ b/cmd/regctl/main_test.go
@@ -5,17 +5,23 @@ import (
 	"io"
 	"strings"
 	"testing"
+
+	"github.com/regclient/regclient"
 )
 
 type cobraTestOpts struct {
-	stdin io.Reader
+	stdin  io.Reader
+	rcOpts []regclient.Opt
 }
 
 func cobraTest(t *testing.T, opts *cobraTestOpts, args ...string) (string, error) {
 	t.Helper()
 
 	buf := new(bytes.Buffer)
-	rootTopCmd, _ := NewRootCmd()
+	rootTopCmd, rootOpts := NewRootCmd()
+	if opts != nil && opts.rcOpts != nil {
+		rootOpts.rcOpts = opts.rcOpts
+	}
 	if opts != nil && opts.stdin != nil {
 		rootTopCmd.SetIn(opts.stdin)
 	}