diff --git a/pkg/api/handlers/compat/distribution.go b/pkg/api/handlers/compat/distribution.go new file mode 100644 index 00000000000..b1b6ff5bc74 --- /dev/null +++ b/pkg/api/handlers/compat/distribution.go @@ -0,0 +1,156 @@ +//go:build !remote && (linux || freebsd) + +package compat + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/docker/distribution/registry/api/errcode" + "github.com/hashicorp/go-multierror" + dockerRegistry "github.com/moby/moby/api/types/registry" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "go.podman.io/image/v5/docker" + "go.podman.io/image/v5/docker/reference" + "go.podman.io/image/v5/image" + "go.podman.io/image/v5/manifest" + "go.podman.io/image/v5/pkg/shortnames" + "go.podman.io/image/v5/types" + "go.podman.io/podman/v6/libpod" + "go.podman.io/podman/v6/pkg/api/handlers/utils" + api "go.podman.io/podman/v6/pkg/api/types" + "go.podman.io/podman/v6/pkg/auth" +) + +func DistributionInspect(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + imageName := utils.GetName(r) + + normalizedImageName, err := utils.NormalizeToDockerHub(r, imageName) + if err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + if _, err := reference.ParseNormalizedNamed(normalizedImageName); err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + + authConf, authfile, err := auth.GetCredentials(r) + if err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + defer auth.RemoveAuthfile(authfile) + + sys := runtime.SystemContext() + sys.AuthFilePath = authfile + sys.DockerAuthConfig = authConf + + resolved, err := shortnames.Resolve(sys, normalizedImageName) + if err != nil { + utils.InternalServerError(w, err) + return + } + + var merr *multierror.Error + + for _, candidate := range resolved.PullCandidates { + inspect, err := inspectCandidate(r.Context(), sys, candidate.Value) + if err != nil { + merr = multierror.Append(merr, fmt.Errorf("%s: %w", candidate.Value.String(), err)) + continue + } + + utils.WriteResponse(w, http.StatusOK, inspect) + return + } + + combinedErr := merr.ErrorOrNil() + if combinedErr == nil { + utils.InternalServerError(w, errors.New("no candidate succeeded but got no error")) + return + } + + var registryErrCode errcode.ErrorCoder + if errors.As(combinedErr, ®istryErrCode) { + switch registryErrCode.ErrorCode().Descriptor().HTTPStatusCode { + case http.StatusUnauthorized: + utils.Error(w, http.StatusUnauthorized, combinedErr) + case http.StatusNotFound: + utils.Error(w, http.StatusNotFound, combinedErr) + default: + utils.InternalServerError(w, combinedErr) + } + return + } + utils.InternalServerError(w, combinedErr) +} + +func inspectCandidate(ctx context.Context, sys *types.SystemContext, named reference.Named) (dockerRegistry.DistributionInspect, error) { + ref, err := docker.NewReference(named) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + + src, err := ref.NewImageSource(ctx, sys) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + defer src.Close() + + unparsed := image.UnparsedInstance(src, nil) + manifestBytes, manifestType, err := unparsed.Manifest(ctx) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + + inspect := dockerRegistry.DistributionInspect{ + Descriptor: ocispec.Descriptor{ + MediaType: manifestType, + Size: int64(len(manifestBytes)), + }, + } + + if canonical, ok := named.(reference.Canonical); ok { + inspect.Descriptor.Digest = canonical.Digest() + } else { + digest, err := manifest.Digest(manifestBytes) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + inspect.Descriptor.Digest = digest + } + + if manifest.MIMETypeIsMultiImage(manifestType) { + list, err := manifest.ListFromBlob(manifestBytes, manifestType) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + for _, d := range list.Instances() { + instance, err := list.Instance(d) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + if instance.ReadOnly.Platform != nil { + inspect.Platforms = append(inspect.Platforms, *instance.ReadOnly.Platform) + } + } + } else { + img, err := image.FromUnparsedImage(ctx, sys, unparsed) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + ociConfig, err := img.OCIConfig(ctx) + if err != nil { + return dockerRegistry.DistributionInspect{}, err + } + if ociConfig.OS != "" || ociConfig.Architecture != "" { + inspect.Platforms = append(inspect.Platforms, ociConfig.Platform) + } + } + + return inspect, nil +} diff --git a/pkg/api/handlers/swagger/errors.go b/pkg/api/handlers/swagger/errors.go index f16d0fcdbb0..d1b7194f168 100644 --- a/pkg/api/handlers/swagger/errors.go +++ b/pkg/api/handlers/swagger/errors.go @@ -16,6 +16,13 @@ type imageNotFound struct { Body errorhandling.ErrorModel } +// Failed authentication or no image found +// swagger:response +type distributionUnauthorized struct { + // in:body + Body errorhandling.ErrorModel +} + // No such file // swagger:response type fileNotFound struct { diff --git a/pkg/api/handlers/swagger/responses.go b/pkg/api/handlers/swagger/responses.go index 65c11dbb48d..39d5df979ca 100644 --- a/pkg/api/handlers/swagger/responses.go +++ b/pkg/api/handlers/swagger/responses.go @@ -7,6 +7,7 @@ import ( "github.com/moby/moby/api/types/container" dockerImage "github.com/moby/moby/api/types/image" "github.com/moby/moby/api/types/network" + dockerRegistry "github.com/moby/moby/api/types/registry" "github.com/moby/moby/api/types/volume" "go.podman.io/common/libnetwork/types" "go.podman.io/image/v5/manifest" @@ -38,6 +39,13 @@ type imageInspect struct { Body handlers.ImageInspect } +// Distribution Inspect +// swagger:response +type distributionInspectResponse struct { + // in:body + Body dockerRegistry.DistributionInspect +} + // Image Load // swagger:response type imagesLoadResponseLibpod struct { diff --git a/pkg/api/server/register_distribution.go b/pkg/api/server/register_distribution.go index 915f74725e6..a8a093615ce 100644 --- a/pkg/api/server/register_distribution.go +++ b/pkg/api/server/register_distribution.go @@ -3,13 +3,38 @@ package server import ( + "net/http" + "github.com/gorilla/mux" "go.podman.io/podman/v6/pkg/api/handlers/compat" ) func (s *APIServer) registerDistributionHandlers(r *mux.Router) error { - r.HandleFunc(VersionedPath("/distribution/{name}/json"), compat.UnsupportedHandler) + // swagger:operation GET /distribution/{name}/json compat DistributionInspect + // --- + // tags: + // - distribution (compat) + // summary: Get image information from the registry + // description: Return image digest and platform information by contacting the registry. + // parameters: + // - in: path + // name: name + // type: string + // required: true + // description: the name of the image + // produces: + // - application/json + // responses: + // 200: + // $ref: "#/responses/distributionInspectResponse" + // 401: + // $ref: "#/responses/distributionUnauthorized" + // 404: + // $ref: "#/responses/imageNotFound" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/distribution/{name:.*}/json"), s.APIHandler(compat.DistributionInspect)).Methods(http.MethodGet) // Added non version path to URI to support docker non versioned paths - r.HandleFunc("/distribution/{name}/json", compat.UnsupportedHandler) + r.HandleFunc("/distribution/{name:.*}/json", s.APIHandler(compat.DistributionInspect)).Methods(http.MethodGet) return nil } diff --git a/test/apiv2/10-images.at b/test/apiv2/10-images.at index 7112852a213..414c14df5aa 100644 --- a/test/apiv2/10-images.at +++ b/test/apiv2/10-images.at @@ -58,6 +58,32 @@ t GET images/$iid/json 200 \ .Id=sha256:$iid \ .RepoTags[0]=$IMAGE +# This depends on whether we're using local cache registry or real quay +expect_code=401 +if [[ -n "$CI_USE_REGISTRY_CACHE" ]]; then + # local registry has no auth, so it can return 404 + expect_code=404 +fi + +# Test versioned path (relative URL gets /vN.N/ prefix) +t GET "distribution/$IMAGE/json" 200 \ + .Descriptor.digest~"sha256:[0-9a-f]\\{64\\}" \ + .Descriptor.mediaType~".*" +# Test non-versioned path (absolute URL bypasses version prefix) +t GET "/distribution/$IMAGE/json" 200 \ + .Descriptor.digest~"sha256:[0-9a-f]\\{64\\}" \ + .Descriptor.mediaType~".*" +t GET "/distribution/NoCAPITALcharAllowed/json" 400 +t GET "/distribution/quay.io/idonotexist/idonotexist:dummy/json" $expect_code + +# Exercise the single-manifest path using a digest from the multi-arch test image. +single_manifest_digest=$(skopeo inspect --raw docker://$IMAGE | jq -r '.manifests[0].digest') +t GET "distribution/$PODMAN_TEST_IMAGE_REGISTRY/$PODMAN_TEST_IMAGE_USER/$PODMAN_TEST_IMAGE_NAME@$single_manifest_digest/json" 200 \ + .Descriptor.digest="$single_manifest_digest" \ + .Descriptor.mediaType="application/vnd.oci.image.manifest.v1+json" \ + .Platforms[0].architecture~".*" \ + .Platforms[0].os~".*" + # Test VirtualSize field is present in API v1.43 for single image inspect (backward compatibility) t GET /v1.43/images/$iid/json 200 \ .VirtualSize~[0-9]\\+ @@ -151,12 +177,8 @@ for i in $iid ${iid:0:12} $PODMAN_TEST_IMAGE_NAME:$PODMAN_TEST_IMAGE_TAG; do done # compat api pull image unauthorized message error -# This depends on whether we're using local cache registry or real quay -expect_code=401 expect_msg="unauthorized: access to the requested resource is not authorized" if [[ -n "$CI_USE_REGISTRY_CACHE" ]]; then - # local registry has no auth, so it can return 404 - expect_code=404 expect_msg="manifest unknown: manifest unknown" fi t POST "/images/create?fromImage=quay.io/idonotexist/idonotexist:dummy" $expect_code \