From 16b6f0ade5729a9561f48e981d2d0483fbfc6ec9 Mon Sep 17 00:00:00 2001
From: Michael Vogt <mvogt@redhat.com>
Date: Mon, 26 Feb 2024 12:25:32 +0100
Subject: [PATCH] main: return exit code `2` when an input is not found

This commit makes skopeo return a different exit code when an
input is not found. The use case is `osbuild` which uses skopeo
to inspect images and it would be nice to differenciate between
an image that is not found and general skopeo errors (or errors
like network issues etc).

I picked exit code `2` for `not found` because it is also the value
of `ENOENT`.

Man page and a test are added.

Signed-off-by: Michael Vogt <mvogt@redhat.com>
---
 cmd/skopeo/main.go                      |  4 ++++
 cmd/skopeo/proxy.go                     | 22 ----------------------
 cmd/skopeo/utils.go                     | 24 ++++++++++++++++++++++++
 docs/skopeo.1.md                        |  9 +++++++++
 systemtest/010-inspect.bats             |  7 +++++++
 systemtest/040-local-registry-auth.bats |  2 +-
 systemtest/060-delete.bats              |  2 +-
 7 files changed, 46 insertions(+), 24 deletions(-)

diff --git a/cmd/skopeo/main.go b/cmd/skopeo/main.go
index 3617eeb2..8b8de3c7 100644
--- a/cmd/skopeo/main.go
+++ b/cmd/skopeo/main.go
@@ -129,6 +129,10 @@ func main() {
 	}
 	rootCmd, _ := createApp()
 	if err := rootCmd.Execute(); err != nil {
+		if isNotFoundImageError(err) {
+			logrus.StandardLogger().Log(logrus.FatalLevel, err)
+			logrus.Exit(2)
+		}
 		logrus.Fatal(err)
 	}
 }
diff --git a/cmd/skopeo/proxy.go b/cmd/skopeo/proxy.go
index aab85365..9396f422 100644
--- a/cmd/skopeo/proxy.go
+++ b/cmd/skopeo/proxy.go
@@ -73,13 +73,10 @@ import (
 
 	"github.com/containers/image/v5/image"
 	"github.com/containers/image/v5/manifest"
-	ocilayout "github.com/containers/image/v5/oci/layout"
 	"github.com/containers/image/v5/pkg/blobinfocache"
 	"github.com/containers/image/v5/transports"
 	"github.com/containers/image/v5/transports/alltransports"
 	"github.com/containers/image/v5/types"
-	dockerdistributionerrcode "github.com/docker/distribution/registry/api/errcode"
-	dockerdistributionapi "github.com/docker/distribution/registry/api/v2"
 	"github.com/opencontainers/go-digest"
 	imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
 	"github.com/sirupsen/logrus"
@@ -219,25 +216,6 @@ func (h *proxyHandler) OpenImage(args []any) (replyBuf, error) {
 	return h.openImageImpl(args, false)
 }
 
-// isDockerManifestUnknownError is a copy of code from containers/image,
-// please update there first.
-func isDockerManifestUnknownError(err error) bool {
-	var ec dockerdistributionerrcode.ErrorCoder
-	if !errors.As(err, &ec) {
-		return false
-	}
-	return ec.ErrorCode() == dockerdistributionapi.ErrorCodeManifestUnknown
-}
-
-// isNotFoundImageError heuristically attempts to determine whether an error
-// is saying the remote source couldn't find the image (as opposed to an
-// authentication error, an I/O error etc.)
-// TODO drive this into containers/image properly
-func isNotFoundImageError(err error) bool {
-	return isDockerManifestUnknownError(err) ||
-		errors.Is(err, ocilayout.ImageNotFoundError{})
-}
-
 func (h *proxyHandler) openImageImpl(args []any, allowNotFound bool) (retReplyBuf replyBuf, retErr error) {
 	h.lock.Lock()
 	defer h.lock.Unlock()
diff --git a/cmd/skopeo/utils.go b/cmd/skopeo/utils.go
index 3fb0fab9..75a2fa82 100644
--- a/cmd/skopeo/utils.go
+++ b/cmd/skopeo/utils.go
@@ -12,9 +12,13 @@ import (
 	"github.com/containers/common/pkg/retry"
 	"github.com/containers/image/v5/directory"
 	"github.com/containers/image/v5/manifest"
+	ocilayout "github.com/containers/image/v5/oci/layout"
 	"github.com/containers/image/v5/pkg/compression"
+	"github.com/containers/image/v5/storage"
 	"github.com/containers/image/v5/transports/alltransports"
 	"github.com/containers/image/v5/types"
+	dockerdistributionerrcode "github.com/docker/distribution/registry/api/errcode"
+	dockerdistributionapi "github.com/docker/distribution/registry/api/v2"
 	imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
@@ -406,3 +410,23 @@ func promptForPassphrase(privateKeyFile string, stdin, stdout *os.File) (string,
 	fmt.Fprintf(stdout, "\n")
 	return string(passphrase), nil
 }
+
+// isNotFoundImageError heuristically attempts to determine whether an error
+// is saying the remote source couldn't find the image (as opposed to an
+// authentication error, an I/O error etc.)
+// TODO drive this into containers/image properly
+func isNotFoundImageError(err error) bool {
+	return isDockerManifestUnknownError(err) ||
+		errors.Is(err, storage.ErrNoSuchImage) ||
+		errors.Is(err, ocilayout.ImageNotFoundError{})
+}
+
+// isDockerManifestUnknownError is a copy of code from containers/image,
+// please update there first.
+func isDockerManifestUnknownError(err error) bool {
+	var ec dockerdistributionerrcode.ErrorCoder
+	if !errors.As(err, &ec) {
+		return false
+	}
+	return ec.ErrorCode() == dockerdistributionapi.ErrorCodeManifestUnknown
+}
diff --git a/docs/skopeo.1.md b/docs/skopeo.1.md
index 26583f11..53feab57 100644
--- a/docs/skopeo.1.md
+++ b/docs/skopeo.1.md
@@ -114,6 +114,15 @@ Print the version number
 | [skopeo-standalone-verify(1)](skopeo-standalone-verify.1.md)| Verify an image signature.                                   |
 | [skopeo-sync(1)](skopeo-sync.1.md)| Synchronize images between registry repositories and local directories.                |
 
+## EXIT STATUS
+`skopeo` exits with status 0 on success, non-zero on error.
+
+Details about the exit statuses:
+
+**1** Generic error, details can be found in the error message.
+
+**2** The input image cannot be found. Note that this is best effort and for remote registries the status often cannot be reliably reported.
+
 ## FILES
   **/etc/containers/policy.json**
   Default trust policy file, if **--policy** is not specified.
diff --git a/systemtest/010-inspect.bats b/systemtest/010-inspect.bats
index 548174da..6a8ad809 100644
--- a/systemtest/010-inspect.bats
+++ b/systemtest/010-inspect.bats
@@ -129,4 +129,11 @@ END_EXPECT
     expect_output --from="$repo_tags" "" "inspect --no-tags was expected to return empty RepoTags[]"
 }
 
+@test "inspect: image unknown" {
+    # non existing image
+    run_skopeo 2 inspect containers-storage:non-existing-tag
+    expect_output --substring "identifier is not an image" \
+		  "skopeo inspect containers-storage:010101010101"
+}
+
 # vim: filetype=sh
diff --git a/systemtest/040-local-registry-auth.bats b/systemtest/040-local-registry-auth.bats
index 34e5c685..e2880bf7 100644
--- a/systemtest/040-local-registry-auth.bats
+++ b/systemtest/040-local-registry-auth.bats
@@ -40,7 +40,7 @@ function setup() {
     expect_output --substring "authentication required"
 
     # Correct creds, but no such image
-    run_skopeo 1 inspect --tls-verify=false --creds=$testuser:$testpassword \
+    run_skopeo 2 inspect --tls-verify=false --creds=$testuser:$testpassword \
                docker://localhost:5000/nonesuch
     expect_output --substring "manifest unknown"
 
diff --git a/systemtest/060-delete.bats b/systemtest/060-delete.bats
index 09e6688a..70b3a191 100644
--- a/systemtest/060-delete.bats
+++ b/systemtest/060-delete.bats
@@ -24,7 +24,7 @@ function setup() {
     run_skopeo delete --tls-verify=false $localimg
 
     # make sure image is removed from registry
-    expected_rc=1
+    expected_rc=2
     run_skopeo $expected_rc inspect --tls-verify=false $localimg
 }