Repository: regclient/regclient
Pull request #850 (2024-11-11 20:02:00)

Feat: Switch regctl to slog
<!--

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

Partial fix for #749.
<!-- If this is a bug fix, include "fixes #xxxx", or "closes #xxxx" -->

### Describe the change

This switches regctl to use slog for logging. This also removed the global `log` variable.
<!-- 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 -v trace tag ls alpine
```
<!-- Include steps that can be taken to verify the change -->

### Changelog text

- Feat: Switch regctl to slog.
<!-- 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/artifact.go b/cmd/regctl/artifact.go
index bc12675..bf60cdc 100644
--- a/cmd/regctl/artifact.go
+++ b/cmd/regctl/artifact.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"log/slog"
 	"os"
 	"path"
 	"path/filepath"
@@ -252,7 +253,7 @@ func (artifactOpts *artifactCmd) runArtifactGet(cmd *cobra.Command, args []strin
 
 	// validate inputs
 	if artifactOpts.refers != "" {
-		log.Warnf("--refers is deprecated, use --subject instead")
+		artifactOpts.rootOpts.log.Warn("--refers is deprecated, use --subject instead")
 		if artifactOpts.subject == "" {
 			artifactOpts.subject = artifactOpts.refers
 		}
@@ -325,7 +326,9 @@ func (artifactOpts *artifactCmd) runArtifactGet(cmd *cobra.Command, args []strin
 		if len(rl.Descriptors) == 0 {
 			return fmt.Errorf("no matching referrers to %s", artifactOpts.subject)
 		} else if len(rl.Descriptors) > 1 && artifactOpts.sortAnnot == "" && !artifactOpts.latest {
-			log.Warnf("found %d matching referrers to %s, using first match, use --sort-annotation", len(rl.Descriptors), artifactOpts.subject)
+			artifactOpts.rootOpts.log.Warn("multiple referrers match, using first match",
+				slog.Int("match count", len(rl.Descriptors)),
+				slog.String("subject", artifactOpts.subject))
 		}
 		r = rSubject.SetDigest(rl.Descriptors[0].Digest.String())
 	} else if len(args) > 0 {
@@ -644,7 +647,7 @@ func (artifactOpts *artifactCmd) runArtifactPut(cmd *cobra.Command, args []strin
 
 	switch artifactOpts.artifactMT {
 	case mediatype.OCI1Artifact:
-		log.Warnf("changing media-type is experimental and non-portable")
+		artifactOpts.rootOpts.log.Warn("changing media-type is experimental and non-portable")
 		hasConfig = false
 	case "", mediatype.OCI1Manifest:
 		hasConfig = true
@@ -659,7 +662,7 @@ func (artifactOpts *artifactCmd) runArtifactPut(cmd *cobra.Command, args []strin
 
 	// validate inputs
 	if artifactOpts.refers != "" {
-		log.Warnf("--refers is deprecated, use --subject instead")
+		artifactOpts.rootOpts.log.Warn("--refers is deprecated, use --subject instead")
 		if artifactOpts.subject == "" {
 			artifactOpts.subject = artifactOpts.refers
 		}
@@ -705,7 +708,7 @@ func (artifactOpts *artifactCmd) runArtifactPut(cmd *cobra.Command, args []strin
 		} else {
 			if artifactOpts.artifactType != "" {
 				artifactOpts.artifactConfigMT = artifactOpts.artifactType
-				log.Warnf("setting config-type using artifact-type")
+				artifactOpts.rootOpts.log.Warn("setting config-type using artifact-type")
 			} else {
 				return fmt.Errorf("config-type is required for config-file")
 			}
@@ -716,7 +719,7 @@ func (artifactOpts *artifactCmd) runArtifactPut(cmd *cobra.Command, args []strin
 	}
 	if artifactOpts.artifactType == "" {
 		if !hasConfig || artifactOpts.artifactConfigMT == mediatype.OCI1Empty {
-			log.Warnf("using default value for artifact-type is not recommended")
+			artifactOpts.rootOpts.log.Warn("using default value for artifact-type is not recommended")
 			artifactOpts.artifactType = defaultMTArtifact
 		}
 	}
diff --git a/cmd/regctl/blob.go b/cmd/regctl/blob.go
index f317bdc..d0edf0e 100644
--- a/cmd/regctl/blob.go
+++ b/cmd/regctl/blob.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"io/fs"
+	"log/slog"
 	"math"
 	"os"
 	"strings"
@@ -16,7 +17,6 @@ import (
 	_ "crypto/sha512"
 
 	"github.com/opencontainers/go-digest"
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/regclient/regclient/internal/diff"
@@ -221,11 +221,10 @@ func (blobOpts *blobCmd) runBlobDelete(cmd *cobra.Command, args []string) error
 	rc := blobOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-		"digest":     args[1],
-	}).Debug("Deleting blob")
+	blobOpts.rootOpts.log.Debug("Deleting blob",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository),
+		slog.String("digest", args[1]))
 	return rc.BlobDelete(ctx, r, descriptor.Descriptor{Digest: d})
 }
 
@@ -382,16 +381,14 @@ func (blobOpts *blobCmd) runBlobGet(cmd *cobra.Command, args []string) error {
 	rc := blobOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 	if blobOpts.mt != "" {
-		log.WithFields(logrus.Fields{
-			"mt": blobOpts.mt,
-		}).Info("Specifying the blob media type is deprecated")
+		blobOpts.rootOpts.log.Info("Specifying the blob media type is deprecated",
+			slog.String("mt", blobOpts.mt))
 	}
 
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-		"digest":     args[1],
-	}).Debug("Pulling blob")
+	blobOpts.rootOpts.log.Debug("Pulling blob",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository),
+		slog.String("digest", args[1]))
 	blob, err := rc.BlobGet(ctx, r, descriptor.Descriptor{Digest: d})
 	if err != nil {
 		return err
@@ -428,12 +425,11 @@ func (blobOpts *blobCmd) runBlobGetFile(cmd *cobra.Command, args []string) error
 	rc := blobOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-		"digest":     args[1],
-		"filename":   filename,
-	}).Debug("Get file")
+	blobOpts.rootOpts.log.Debug("Get file",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository),
+		slog.String("digest", args[1]),
+		slog.String("filename", filename))
 	blob, err := rc.BlobGet(ctx, r, descriptor.Descriptor{Digest: d})
 	if err != nil {
 		return err
@@ -491,11 +487,10 @@ func (blobOpts *blobCmd) runBlobHead(cmd *cobra.Command, args []string) error {
 	rc := blobOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-		"digest":     args[1],
-	}).Debug("Blob head")
+	blobOpts.rootOpts.log.Debug("Blob head",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository),
+		slog.String("digest", args[1]))
 	blob, err := rc.BlobHead(ctx, r, descriptor.Descriptor{Digest: d})
 	if err != nil {
 		return err
@@ -518,16 +513,14 @@ func (blobOpts *blobCmd) runBlobPut(cmd *cobra.Command, args []string) error {
 	rc := blobOpts.rootOpts.newRegClient()
 
 	if blobOpts.mt != "" {
-		log.WithFields(logrus.Fields{
-			"mt": blobOpts.mt,
-		}).Info("Specifying the blob media type is deprecated")
+		blobOpts.rootOpts.log.Info("Specifying the blob media type is deprecated",
+			slog.String("mt", blobOpts.mt))
 	}
 
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-		"digest":     blobOpts.digest,
-	}).Debug("Pushing blob")
+	blobOpts.rootOpts.log.Debug("Pushing blob",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository),
+		slog.String("digest", blobOpts.digest))
 	dOut, err := rc.BlobPut(ctx, r, descriptor.Descriptor{Digest: digest.Digest(blobOpts.digest)}, cmd.InOrStdin())
 	if err != nil {
 		return err
@@ -561,11 +554,10 @@ func (blobOpts *blobCmd) runBlobCopy(cmd *cobra.Command, args []string) error {
 	rc := blobOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, rSrc)
 
-	log.WithFields(logrus.Fields{
-		"source": rSrc.CommonName(),
-		"target": rTgt.CommonName(),
-		"digest": args[2],
-	}).Debug("Blob copy")
+	blobOpts.rootOpts.log.Debug("Blob copy",
+		slog.String("source", rSrc.CommonName()),
+		slog.String("target", rTgt.CommonName()),
+		slog.String("digest", args[2]))
 	err = rc.BlobCopy(ctx, rSrc, rTgt, descriptor.Descriptor{Digest: d})
 	if err != nil {
 		return err
diff --git a/cmd/regctl/image.go b/cmd/regctl/image.go
index 10cde92..1ca72f0 100644
--- a/cmd/regctl/image.go
+++ b/cmd/regctl/image.go
@@ -8,6 +8,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"os"
 	"regexp"
 	"sort"
@@ -17,7 +18,6 @@ import (
 	"time"
 
 	"github.com/opencontainers/go-digest"
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/regclient/regclient"
@@ -1017,12 +1017,11 @@ func (imageOpts *imageCmd) runImageCheckBase(cmd *cobra.Command, args []string)
 
 	err = rc.ImageCheckBase(ctx, r, opts...)
 	if err == nil {
-		log.Info("base image matches")
+		imageOpts.rootOpts.log.Info("base image matches")
 		return nil
 	} else if errors.Is(err, errs.ErrMismatch) {
-		log.WithFields(logrus.Fields{
-			"err": err,
-		}).Info("base image mismatch")
+		imageOpts.rootOpts.log.Info("base image mismatch",
+			slog.String("err", err.Error()))
 		// return empty error message
 		return fmt.Errorf("%.0w", err)
 	} else {
@@ -1054,12 +1053,11 @@ func (imageOpts *imageCmd) runImageCopy(cmd *cobra.Command, args []string) error
 		}
 		rSrc = rSrc.SetDigest(m.GetDescriptor().Digest.String())
 	}
-	log.WithFields(logrus.Fields{
-		"source":      rSrc.CommonName(),
-		"target":      rTgt.CommonName(),
-		"recursive":   imageOpts.forceRecursive,
-		"digest-tags": imageOpts.digestTags,
-	}).Debug("Image copy")
+	imageOpts.rootOpts.log.Debug("Image copy",
+		slog.String("source", rSrc.CommonName()),
+		slog.String("target", rTgt.CommonName()),
+		slog.Bool("recursive", imageOpts.forceRecursive),
+		slog.Bool("digest-tags", imageOpts.digestTags))
 	opts := []regclient.ImageOpts{}
 	if imageOpts.fastCheck {
 		opts = append(opts, regclient.ImageWithFastCheck())
@@ -1427,9 +1425,8 @@ func (imageOpts *imageCmd) runImageExport(cmd *cobra.Command, args []string) err
 		}
 		opts = append(opts, regclient.ImageWithExportRef(eRef))
 	}
-	log.WithFields(logrus.Fields{
-		"ref": r.CommonName(),
-	}).Debug("Image export")
+	imageOpts.rootOpts.log.Debug("Image export",
+		slog.String("ref", r.CommonName()))
 	return rc.ImageExport(ctx, r, w, opts...)
 }
 
@@ -1448,20 +1445,18 @@ func (imageOpts *imageCmd) runImageGetFile(cmd *cobra.Command, args []string) er
 	rc := imageOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"ref":      r.CommonName(),
-		"filename": filename,
-	}).Debug("Get file")
+	imageOpts.rootOpts.log.Debug("Get file",
+		slog.String("ref", r.CommonName()),
+		slog.String("filename", filename))
 
 	if imageOpts.platform == "" {
 		imageOpts.platform = "local"
 	}
 	p, err := platform.Parse(imageOpts.platform)
 	if err != nil {
-		log.WithFields(logrus.Fields{
-			"platform": imageOpts.platform,
-			"err":      err,
-		}).Warn("Could not parse platform")
+		imageOpts.rootOpts.log.Warn("Could not parse platform",
+			slog.String("platform", imageOpts.platform),
+			slog.String("err", err.Error()))
 	}
 	m, err := rc.ManifestGet(ctx, r, regclient.WithManifestPlatform(p))
 	if err != nil {
@@ -1551,10 +1546,9 @@ func (imageOpts *imageCmd) runImageImport(cmd *cobra.Command, args []string) err
 	defer rs.Close()
 	rc := imageOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
-	log.WithFields(logrus.Fields{
-		"ref":  r.CommonName(),
-		"file": args[1],
-	}).Debug("Image import")
+	imageOpts.rootOpts.log.Debug("Image import",
+		slog.String("ref", r.CommonName()),
+		slog.String("file", args[1]))
 
 	return rc.ImageImport(ctx, r, rs, opts...)
 }
@@ -1568,12 +1562,11 @@ func (imageOpts *imageCmd) runImageInspect(cmd *cobra.Command, args []string) er
 	rc := imageOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"host":     r.Registry,
-		"repo":     r.Repository,
-		"tag":      r.Tag,
-		"platform": imageOpts.platform,
-	}).Debug("Image inspect")
+	imageOpts.rootOpts.log.Debug("Image inspect",
+		slog.String("host", r.Registry),
+		slog.String("repo", r.Repository),
+		slog.String("tag", r.Tag),
+		slog.String("platform", imageOpts.platform))
 
 	opts := []regclient.ImageOpts{}
 	if imageOpts.platform != "" {
@@ -1626,9 +1619,8 @@ func (imageOpts *imageCmd) runImageMod(cmd *cobra.Command, args []string) error
 	imageOpts.modOpts = append(imageOpts.modOpts, mod.WithRefTgt(rTgt))
 	rc := imageOpts.rootOpts.newRegClient()
 
-	log.WithFields(logrus.Fields{
-		"ref": rSrc.CommonName(),
-	}).Debug("Modifying image")
+	imageOpts.rootOpts.log.Debug("Modifying image",
+		slog.String("ref", rSrc.CommonName()))
 
 	defer rc.Close(ctx, rSrc)
 	rOut, err := mod.Apply(ctx, rc, rSrc, imageOpts.modOpts...)
@@ -1651,11 +1643,10 @@ func (imageOpts *imageCmd) runImageRateLimit(cmd *cobra.Command, args []string)
 	}
 	rc := imageOpts.rootOpts.newRegClient()
 
-	log.WithFields(logrus.Fields{
-		"host": r.Registry,
-		"repo": r.Repository,
-		"tag":  r.Tag,
-	}).Debug("Image rate limit")
+	imageOpts.rootOpts.log.Debug("Image rate limit",
+		slog.String("host", r.Registry),
+		slog.String("repo", r.Repository),
+		slog.String("tag", r.Tag))
 
 	// request only the headers, avoids adding to Docker Hub rate limits
 	m, err := rc.ManifestHead(ctx, r)
diff --git a/cmd/regctl/main.go b/cmd/regctl/main.go
index a61a126..480b6dc 100644
--- a/cmd/regctl/main.go
+++ b/cmd/regctl/main.go
@@ -8,24 +8,23 @@ import (
 	"strings"
 	"syscall"
 
-	"github.com/sirupsen/logrus"
-
 	"github.com/regclient/regclient/internal/godbg"
 )
 
 func main() {
 	ctx, cancel := context.WithCancel(context.Background())
+	rootTopCmd, rootOpts := NewRootCmd()
+
 	sig := make(chan os.Signal, 1)
 	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
 	go func() {
 		<-sig
-		log.WithFields(logrus.Fields{}).Debug("Interrupt received, stopping")
+		rootOpts.log.Debug("Interrupt received, stopping")
 		// clean shutdown
 		cancel()
 	}()
 	godbg.SignalTrace()
 
-	rootTopCmd := NewRootCmd()
 	if err := rootTopCmd.ExecuteContext(ctx); err != nil {
 		fmt.Fprintf(os.Stderr, "%v\n", err)
 		// provide tips for common error messages
diff --git a/cmd/regctl/manifest.go b/cmd/regctl/manifest.go
index 80cb6b6..68ea76b 100644
--- a/cmd/regctl/manifest.go
+++ b/cmd/regctl/manifest.go
@@ -4,9 +4,9 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"log/slog"
 	"strings"
 
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/regclient/regclient"
@@ -195,17 +195,15 @@ func (manifestOpts *manifestCmd) runManifestDelete(cmd *cobra.Command, args []st
 			return err
 		}
 		r.Digest = manifest.GetDigest(m).String()
-		log.WithFields(logrus.Fields{
-			"tag":    r.Tag,
-			"digest": r.Digest,
-		}).Debug("Forced dereference of tag")
+		manifestOpts.rootOpts.log.Debug("Forced dereference of tag",
+			slog.String("tag", r.Tag),
+			slog.String("digest", r.Digest))
 	}
 
-	log.WithFields(logrus.Fields{
-		"host":   r.Registry,
-		"repo":   r.Repository,
-		"digest": r.Digest,
-	}).Debug("Manifest delete")
+	manifestOpts.rootOpts.log.Debug("Manifest delete",
+		slog.String("host", r.Registry),
+		slog.String("repo", r.Repository),
+		slog.String("digest", r.Digest))
 	mOpts := []regclient.ManifestOpts{}
 	if manifestOpts.referrers {
 		mOpts = append(mOpts, regclient.WithManifestCheckReferrers())
@@ -242,10 +240,9 @@ func (manifestOpts *manifestCmd) runManifestDiff(cmd *cobra.Command, args []stri
 
 	rc := manifestOpts.rootOpts.newRegClient()
 
-	log.WithFields(logrus.Fields{
-		"ref1": r1.CommonName(),
-		"ref2": r2.CommonName(),
-	}).Debug("Manifest diff")
+	manifestOpts.rootOpts.log.Debug("Manifest diff",
+		slog.String("ref1", r1.CommonName()),
+		slog.String("ref2", r2.CommonName()))
 
 	m1, err := rc.ManifestGet(ctx, r1)
 	if err != nil {
@@ -276,7 +273,7 @@ func (manifestOpts *manifestCmd) runManifestDiff(cmd *cobra.Command, args []stri
 func (manifestOpts *manifestCmd) runManifestHead(cmd *cobra.Command, args []string) error {
 	ctx := cmd.Context()
 	if flagChanged(cmd, "list") {
-		log.Info("list option has been deprecated, manifest list is output by default until a platform is specified")
+		manifestOpts.rootOpts.log.Info("list option has been deprecated, manifest list is output by default until a platform is specified")
 	}
 	if manifestOpts.platform != "" && manifestOpts.requireList {
 		return fmt.Errorf("cannot request a platform and require-list simultaneously")
@@ -289,11 +286,10 @@ func (manifestOpts *manifestCmd) runManifestHead(cmd *cobra.Command, args []stri
 	rc := manifestOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"host": r.Registry,
-		"repo": r.Repository,
-		"tag":  r.Tag,
-	}).Debug("Manifest head")
+	manifestOpts.rootOpts.log.Debug("Manifest head",
+		slog.String("host", r.Registry),
+		slog.String("repo", r.Repository),
+		slog.String("tag", r.Tag))
 
 	mOpts := []regclient.ManifestOpts{}
 	if manifestOpts.requireDigest || (!flagChanged(cmd, "require-digest") && !flagChanged(cmd, "format")) {
@@ -324,7 +320,7 @@ func (manifestOpts *manifestCmd) runManifestHead(cmd *cobra.Command, args []stri
 func (manifestOpts *manifestCmd) runManifestGet(cmd *cobra.Command, args []string) error {
 	ctx := cmd.Context()
 	if flagChanged(cmd, "list") {
-		log.Info("list option has been deprecated, manifest list is output by default until a platform is specified")
+		manifestOpts.rootOpts.log.Info("list option has been deprecated, manifest list is output by default until a platform is specified")
 	}
 	if manifestOpts.platform != "" && manifestOpts.requireList {
 		return fmt.Errorf("cannot request a platform and require-list simultaneously")
@@ -337,11 +333,10 @@ func (manifestOpts *manifestCmd) runManifestGet(cmd *cobra.Command, args []strin
 	rc := manifestOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
 
-	log.WithFields(logrus.Fields{
-		"host": r.Registry,
-		"repo": r.Repository,
-		"tag":  r.Tag,
-	}).Debug("Manifest get")
+	manifestOpts.rootOpts.log.Debug("Manifest get",
+		slog.String("host", r.Registry),
+		slog.String("repo", r.Repository),
+		slog.String("tag", r.Tag))
 
 	mOpts := []regclient.ManifestOpts{}
 	if manifestOpts.platform != "" {
diff --git a/cmd/regctl/registry.go b/cmd/regctl/registry.go
index 79aaf1e..40c6e62 100644
--- a/cmd/regctl/registry.go
+++ b/cmd/regctl/registry.go
@@ -4,12 +4,12 @@ import (
 	"bufio"
 	"fmt"
 	"io"
+	"log/slog"
 	"os"
 	"os/signal"
 	"strings"
 	"syscall"
 
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"golang.org/x/term"
 
@@ -204,9 +204,8 @@ func (registryOpts *registryCmd) runRegistryConfig(cmd *cobra.Command, args []st
 	if len(args) > 0 {
 		h, ok := c.Hosts[args[0]]
 		if !ok {
-			log.WithFields(logrus.Fields{
-				"registry": args[0],
-			}).Warn("No configuration found for registry")
+			registryOpts.rootOpts.log.Warn("No configuration found for registry",
+				slog.String("registry", args[0]))
 			return nil
 		}
 		return template.Writer(cmd.OutOrStdout(), registryOpts.formatConf, h)
@@ -249,7 +248,8 @@ func (registryOpts *registryCmd) runRegistryLogin(cmd *cobra.Command, args []str
 		if user != "" {
 			h.User = user
 		} else if h.User == "" {
-			log.Error("Username is required")
+			registryOpts.rootOpts.log.Error("Username is required")
+
 			return ErrMissingInput
 		}
 	}
@@ -264,7 +264,8 @@ func (registryOpts *registryCmd) runRegistryLogin(cmd *cobra.Command, args []str
 		if passwd != "" {
 			h.Pass = passwd
 		} else {
-			log.Error("Password is required")
+			registryOpts.rootOpts.log.Error("Password is required")
+
 			return ErrMissingInput
 		}
 	} else {
@@ -285,7 +286,8 @@ func (registryOpts *registryCmd) runRegistryLogin(cmd *cobra.Command, args []str
 		if passwd != "" {
 			h.Pass = passwd
 		} else {
-			log.Error("Password is required")
+			registryOpts.rootOpts.log.Error("Password is required")
+
 			return ErrMissingInput
 		}
 	}
@@ -309,13 +311,13 @@ func (registryOpts *registryCmd) runRegistryLogin(cmd *cobra.Command, args []str
 		rc := registryOpts.rootOpts.newRegClient()
 		_, err = rc.Ping(ctx, r)
 		if err != nil {
-			log.Warn("Failed to ping registry, credentials were still stored")
+			registryOpts.rootOpts.log.Warn("Failed to ping registry, credentials were still stored")
+
 			return err
 		}
 	}
-	log.WithFields(logrus.Fields{
-		"registry": args[0],
-	}).Info("Credentials set")
+	registryOpts.rootOpts.log.Info("Credentials set",
+		slog.String("registry", args[0]))
 	return nil
 }
 
@@ -331,9 +333,8 @@ func (registryOpts *registryCmd) runRegistryLogout(cmd *cobra.Command, args []st
 	if curH, ok := c.Hosts[h.Name]; ok {
 		h = curH
 	} else {
-		log.WithFields(logrus.Fields{
-			"registry": h.Name,
-		}).Warn("No configuration/credentials found")
+		registryOpts.rootOpts.log.Warn("No configuration/credentials found",
+			slog.String("registry", h.Name))
 		return nil
 	}
 	h.User = ""
@@ -345,9 +346,8 @@ func (registryOpts *registryCmd) runRegistryLogout(cmd *cobra.Command, args []st
 		return err
 	}
 
-	log.WithFields(logrus.Fields{
-		"registry": args[0],
-	}).Debug("Credentials unset")
+	registryOpts.rootOpts.log.Debug("Credentials unset",
+		slog.String("registry", args[0]))
 	return nil
 }
 
@@ -367,16 +367,14 @@ func (registryOpts *registryCmd) runRegistrySet(cmd *cobra.Command, args []strin
 		c.Hosts[h.Name] = h
 	}
 	if flagChanged(cmd, "scheme") {
-		log.WithFields(logrus.Fields{
-			"name":   h.Name,
-			"scheme": registryOpts.scheme,
-		}).Warn("Scheme flag is deprecated, for http set tls to disabled")
+		registryOpts.rootOpts.log.Warn("Scheme flag is deprecated, for http set tls to disabled",
+			slog.String("name", h.Name),
+			slog.String("scheme", registryOpts.scheme))
 	}
 	if flagChanged(cmd, "dns") {
-		log.WithFields(logrus.Fields{
-			"name": h.Name,
-			"dns":  registryOpts.dns,
-		}).Warn("DNS flag is deprecated, use hostname and mirrors instead")
+		registryOpts.rootOpts.log.Warn("DNS flag is deprecated, use hostname and mirrors instead",
+			slog.String("name", h.Name),
+			slog.Any("dns", registryOpts.dns))
 	}
 	if flagChanged(cmd, "cred-helper") {
 		h.CredHelper = registryOpts.credHelper
@@ -451,13 +449,13 @@ func (registryOpts *registryCmd) runRegistrySet(cmd *cobra.Command, args []strin
 		rc := registryOpts.rootOpts.newRegClient()
 		_, err = rc.Ping(ctx, r)
 		if err != nil {
-			log.Warn("Failed to ping registry, configuration still updated")
+			registryOpts.rootOpts.log.Warn("Failed to ping registry, configuration still updated")
+
 			return err
 		}
 	}
 
-	log.WithFields(logrus.Fields{
-		"name": h.Name,
-	}).Info("Registry configuration updated/set")
+	registryOpts.rootOpts.log.Info("Registry configuration updated/set",
+		slog.String("name", h.Name))
 	return nil
 }
diff --git a/cmd/regctl/repo.go b/cmd/regctl/repo.go
index 92c37e8..bb821fb 100644
--- a/cmd/regctl/repo.go
+++ b/cmd/regctl/repo.go
@@ -1,9 +1,9 @@
 package main
 
 import (
+	"log/slog"
 	"strings"
 
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/regclient/regclient/pkg/template"
@@ -59,17 +59,15 @@ func (repoOpts *repoCmd) runRepoLs(cmd *cobra.Command, args []string) error {
 	// TODO: use regex to validate hostname + port
 	i := strings.IndexRune(host, '/')
 	if i >= 0 {
-		log.WithFields(logrus.Fields{
-			"host": host,
-		}).Error("Hostname invalid")
+		repoOpts.rootOpts.log.Error("Hostname invalid",
+			slog.String("host", host))
 		return ErrInvalidInput
 	}
 	rc := repoOpts.rootOpts.newRegClient()
-	log.WithFields(logrus.Fields{
-		"host":  host,
-		"last":  repoOpts.last,
-		"limit": repoOpts.limit,
-	}).Debug("Listing repositories")
+	repoOpts.rootOpts.log.Debug("Listing repositories",
+		slog.String("host", host),
+		slog.String("last", repoOpts.last),
+		slog.Int("limit", repoOpts.limit))
 	opts := []scheme.RepoOpts{}
 	if repoOpts.last != "" {
 		opts = append(opts, scheme.WithRepoLast(repoOpts.last))
diff --git a/cmd/regctl/root.go b/cmd/regctl/root.go
index f043f6f..6e1697a 100644
--- a/cmd/regctl/root.go
+++ b/cmd/regctl/root.go
@@ -2,10 +2,10 @@ package main
 
 import (
 	"fmt"
-	"os"
+	"log/slog"
+	"strings"
 	"time"
 
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/regclient/regclient"
@@ -14,6 +14,7 @@ import (
 	"github.com/regclient/regclient/internal/version"
 	"github.com/regclient/regclient/pkg/template"
 	"github.com/regclient/regclient/scheme/reg"
+	"github.com/regclient/regclient/types"
 )
 
 const (
@@ -22,21 +23,17 @@ const (
 	UserAgent = "regclient/regctl"
 )
 
-// TODO: remove global, configure tests with t.Parallel
-var (
-	log *logrus.Logger
-)
-
 type rootCmd struct {
 	name      string
 	verbosity string
 	logopts   []string
+	log       *slog.Logger
 	format    string // for Go template formatting of various commands
 	hosts     []string
 	userAgent string
 }
 
-func NewRootCmd() *cobra.Command {
+func NewRootCmd() (*cobra.Command, *rootCmd) {
 	rootOpts := rootCmd{}
 	var rootTopCmd = &cobra.Command{
 		Use:   "regctl <cmd>",
@@ -79,14 +76,9 @@ regctl version --format '{{.VCSTag}}'`,
 		RunE: rootOpts.runVersion,
 	}
 
-	log = &logrus.Logger{
-		Out:       os.Stderr,
-		Formatter: new(logrus.TextFormatter),
-		Hooks:     make(logrus.LevelHooks),
-		Level:     logrus.WarnLevel,
-	}
+	rootOpts.log = slog.New(slog.NewTextHandler(rootTopCmd.ErrOrStderr(), &slog.HandlerOptions{Level: slog.LevelWarn}))
 
-	rootTopCmd.PersistentFlags().StringVarP(&rootOpts.verbosity, "verbosity", "v", logrus.WarnLevel.String(), "Log level (debug, info, warn, error, fatal, panic)")
+	rootTopCmd.PersistentFlags().StringVarP(&rootOpts.verbosity, "verbosity", "v", slog.LevelWarn.String(), "Log level (debug, info, warn, error, fatal, panic)")
 	rootTopCmd.PersistentFlags().StringArrayVar(&rootOpts.logopts, "logopt", []string{}, "Log options")
 	rootTopCmd.PersistentFlags().StringArrayVar(&rootOpts.hosts, "host", []string{}, "Registry hosts to add (reg=registry,user=username,pass=password,tls=enabled)")
 	rootTopCmd.PersistentFlags().StringVarP(&rootOpts.userAgent, "user-agent", "", "", "Override user agent")
@@ -116,20 +108,31 @@ regctl version --format '{{.VCSTag}}'`,
 		NewRepoCmd(&rootOpts),
 		NewTagCmd(&rootOpts),
 	)
-	return rootTopCmd
+	return rootTopCmd, &rootOpts
 }
 
 func (rootOpts *rootCmd) rootPreRun(cmd *cobra.Command, args []string) error {
-	lvl, err := logrus.ParseLevel(rootOpts.verbosity)
+	var lvl slog.Level
+	err := lvl.UnmarshalText([]byte(rootOpts.verbosity))
 	if err != nil {
-		return err
+		// handle custom levels
+		if rootOpts.verbosity == strings.ToLower("trace") {
+			lvl = types.LevelTrace
+		} else {
+			return fmt.Errorf("unable to parse verbosity %s: %v", rootOpts.verbosity, err)
+		}
 	}
-	log.SetLevel(lvl)
+	formatJSON := false
 	for _, opt := range rootOpts.logopts {
 		if opt == "json" {
-			log.Formatter = new(logrus.JSONFormatter)
+			formatJSON = true
 		}
 	}
+	if formatJSON {
+		rootOpts.log = slog.New(slog.NewJSONHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{Level: lvl}))
+	} else {
+		rootOpts.log = slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{Level: lvl}))
+	}
 	return nil
 }
 
@@ -141,16 +144,15 @@ func (rootOpts *rootCmd) runVersion(cmd *cobra.Command, args []string) error {
 func (rootOpts *rootCmd) newRegClient() *regclient.RegClient {
 	conf, err := ConfigLoadDefault()
 	if err != nil {
-		log.WithFields(logrus.Fields{
-			"err": err,
-		}).Warn("Failed to load default config")
+		rootOpts.log.Warn("Failed to load default config",
+			slog.String("err", err.Error()))
 		if conf == nil {
 			conf = ConfigNew()
 		}
 	}
 
 	rcOpts := []regclient.Opt{
-		regclient.WithLog(log),
+		regclient.WithSlog(rootOpts.log),
 		regclient.WithRegOpts(reg.WithCache(time.Minute*5, 500)),
 	}
 	if rootOpts.userAgent != "" {
@@ -184,10 +186,9 @@ func (rootOpts *rootCmd) newRegClient() *regclient.RegClient {
 	for _, h := range rootOpts.hosts {
 		hKV, err := strparse.SplitCSKV(h)
 		if err != nil {
-			log.WithFields(logrus.Fields{
-				"host": h,
-				"err":  err,
-			}).Warn("unable to parse host string")
+			rootOpts.log.Warn("unable to parse host string",
+				slog.String("host", h),
+				slog.String("err", err.Error()))
 		}
 		host := config.Host{
 			Name: hKV["reg"],
@@ -198,11 +199,10 @@ func (rootOpts *rootCmd) newRegClient() *regclient.RegClient {
 			var hostTLS config.TLSConf
 			err := hostTLS.UnmarshalText([]byte(hKV["tls"]))
 			if err != nil {
-				log.WithFields(logrus.Fields{
-					"host": h,
-					"tls":  hKV["tls"],
-					"err":  err,
-				}).Warn("unable to parse tls setting")
+				rootOpts.log.Warn("unable to parse tls setting",
+					slog.String("host", h),
+					slog.String("tls", hKV["tls"]),
+					slog.String("err", err.Error()))
 			} else {
 				host.TLS = hostTLS
 			}
diff --git a/cmd/regctl/tag.go b/cmd/regctl/tag.go
index eebb671..699ba03 100644
--- a/cmd/regctl/tag.go
+++ b/cmd/regctl/tag.go
@@ -2,9 +2,9 @@ package main
 
 import (
 	"fmt"
+	"log/slog"
 	"regexp"
 
-	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 
 	"github.com/regclient/regclient/pkg/template"
@@ -86,11 +86,10 @@ func (tagOpts *tagCmd) runTagDelete(cmd *cobra.Command, args []string) error {
 	}
 	rc := tagOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-		"tag":        r.Tag,
-	}).Debug("Delete tag")
+	tagOpts.rootOpts.log.Debug("Delete tag",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository),
+		slog.String("tag", r.Tag))
 	err = rc.TagDelete(ctx, r)
 	if err != nil {
 		return err
@@ -122,10 +121,9 @@ func (tagOpts *tagCmd) runTagLs(cmd *cobra.Command, args []string) error {
 	}
 	rc := tagOpts.rootOpts.newRegClient()
 	defer rc.Close(ctx, r)
-	log.WithFields(logrus.Fields{
-		"host":       r.Registry,
-		"repository": r.Repository,
-	}).Debug("Listing tags")
+	tagOpts.rootOpts.log.Debug("Listing tags",
+		slog.String("host", r.Registry),
+		slog.String("repository", r.Repository))
 	opts := []scheme.TagOpts{}
 	if tagOpts.limit != 0 {
 		opts = append(opts, scheme.WithTagLimit(tagOpts.limit))


Test patch:
diff --git a/cmd/regctl/artifact_test.go b/cmd/regctl/artifact_test.go
index 2052207..50db3fc 100644
--- a/cmd/regctl/artifact_test.go
+++ b/cmd/regctl/artifact_test.go
@@ -184,9 +184,11 @@ func TestArtifactPut(t *testing.T) {
 			expectErr: errs.ErrInvalidReference,
 		},
 		{
-			name: "Put artifact",
-			args: []string{"artifact", "put", "ocidir://" + testDir + ":put"},
-			in:   testData,
+			name:        "Put artifact",
+			args:        []string{"artifact", "put", "ocidir://" + testDir + ":put"},
+			in:          testData,
+			expectOut:   "using default value for artifact-type is not recommended",
+			outContains: true,
 		},
 		{
 			name: "Put artifact example AT",
diff --git a/cmd/regctl/main_test.go b/cmd/regctl/main_test.go
index 2a8ed8a..a82548b 100644
--- a/cmd/regctl/main_test.go
+++ b/cmd/regctl/main_test.go
@@ -15,7 +15,7 @@ func cobraTest(t *testing.T, opts *cobraTestOpts, args ...string) (string, error
 	t.Helper()
 
 	buf := new(bytes.Buffer)
-	rootTopCmd := NewRootCmd()
+	rootTopCmd, _ := NewRootCmd()
 	if opts != nil && opts.stdin != nil {
 		rootTopCmd.SetIn(opts.stdin)
 	}