diff --git a/pkg/api/handlers/compat/distribution.go b/pkg/api/handlers/compat/distribution.go new file mode 100644 index 0000000000..5218fdd655 --- /dev/null +++ b/pkg/api/handlers/compat/distribution.go @@ -0,0 +1,133 @@ +//go:build !remote + +package compat + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/image" + "github.com/containers/image/v5/manifest" + "github.com/containers/image/v5/types" + "github.com/containers/podman/v5/libpod" + "github.com/containers/podman/v5/pkg/api/handlers/utils" + api "github.com/containers/podman/v5/pkg/api/types" + registrytypes "github.com/docker/docker/api/types/registry" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func InspectDistribution(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + + _, imgRef, err := parseImageReference(utils.GetName(r)) + if err != nil { + utils.Error(w, http.StatusUnauthorized, err) + return + } + + imgSrc, err := imgRef.NewImageSource(r.Context(), nil) + if err != nil { + var unauthErr docker.ErrUnauthorizedForCredentials + if errors.As(err, &unauthErr) { + utils.Error(w, http.StatusUnauthorized, errors.New("401 Unauthorized")) + } else { + utils.Error(w, http.StatusUnauthorized, fmt.Errorf("image not found: %w", err)) + } + return + } + defer imgSrc.Close() + + unparsedImage := image.UnparsedInstance(imgSrc, nil) + manBlob, manType, err := unparsedImage.Manifest(r.Context()) + if err != nil { + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("error getting manifest: %w", err)) + return + } + img, err := image.FromUnparsedImage(r.Context(), runtime.SystemContext(), unparsedImage) + if err != nil { + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("error getting manifest: %w", err)) + return + } + + digest, err := manifest.Digest(manBlob) + if err != nil { + utils.Error(w, http.StatusInternalServerError, fmt.Errorf("error getting manifest digest: %w", err)) + return + } + + distributionInspect := registrytypes.DistributionInspect{ + Descriptor: ocispec.Descriptor{ + Digest: digest, + Size: int64(len(manBlob)), + MediaType: manType, + }, + } + + platforms, err := getPlatformsFromManifest(r.Context(), img, manBlob, manType) + if err != nil { + utils.Error(w, http.StatusInternalServerError, err) + return + } + distributionInspect.Platforms = platforms + + utils.WriteResponse(w, http.StatusOK, distributionInspect) +} + +func parseImageReference(name string) (reference.Named, types.ImageReference, error) { + namedRef, err := reference.ParseNormalizedNamed(name) + if err != nil { + return nil, nil, fmt.Errorf("not a valid image reference: %q", name) + } + + namedRef = reference.TagNameOnly(namedRef) + + imgRef, err := docker.NewReference(namedRef) + if err != nil { + return nil, nil, fmt.Errorf("error creating image reference: %w", err) + } + + return namedRef, imgRef, nil +} + +func getPlatformsFromManifest(ctx context.Context, img types.Image, manBlob []byte, manType string) ([]ocispec.Platform, error) { + if manType == "" { + manType = manifest.GuessMIMEType(manBlob) + } + + if manifest.MIMETypeIsMultiImage(manType) { + manifestList, err := manifest.ListFromBlob(manBlob, manType) + if err != nil { + return nil, fmt.Errorf("error parsing manifest list: %w", err) + } + + instanceDigests := manifestList.Instances() + platforms := make([]ocispec.Platform, 0, len(instanceDigests)) + for _, digest := range instanceDigests { + instance, err := manifestList.Instance(digest) + if err != nil { + return nil, fmt.Errorf("error getting manifest list instance: %w", err) + } + if instance.ReadOnly.Platform == nil { + continue + } + platforms = append(platforms, *instance.ReadOnly.Platform) + } + return platforms, nil + } + + switch manType { + case ocispec.MediaTypeImageManifest, manifest.DockerV2Schema2MediaType, manifest.DockerV2Schema1MediaType, manifest.DockerV2Schema1SignedMediaType: + config, err := img.OCIConfig(ctx) + if err != nil { + return nil, fmt.Errorf("error getting OCI config: %w", err) + } + return []ocispec.Platform{config.Platform}, nil + } + return []ocispec.Platform{}, nil +} diff --git a/pkg/api/server/register_distribution.go b/pkg/api/server/register_distribution.go index 90607bb99f..37cea5115f 100644 --- a/pkg/api/server/register_distribution.go +++ b/pkg/api/server/register_distribution.go @@ -3,13 +3,16 @@ package server import ( + "net/http" + "github.com/containers/podman/v5/pkg/api/handlers/compat" "github.com/gorilla/mux" ) func (s *APIServer) registerDistributionHandlers(r *mux.Router) error { - r.HandleFunc(VersionedPath("/distribution/{name}/json"), compat.UnsupportedHandler) + r.HandleFunc(VersionedPath("/distribution/{name:.*}/json"), s.APIHandler(compat.InspectDistribution)).Methods(http.MethodGet) + r.HandleFunc(VersionedPath("/libpod/distribution/{name:.*}/json"), s.APIHandler(compat.InspectDistribution)).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.InspectDistribution)).Methods(http.MethodGet) return nil } diff --git a/test/apiv2/python/rest_api/test_v2_0_0_distribution.py b/test/apiv2/python/rest_api/test_v2_0_0_distribution.py new file mode 100644 index 0000000000..0bd90ccf45 --- /dev/null +++ b/test/apiv2/python/rest_api/test_v2_0_0_distribution.py @@ -0,0 +1,34 @@ +import unittest + +import requests +from .fixtures import APITestCase + + +class DistributionTestCase(APITestCase): + def test_distribution_inspect(self): + # Make sure the image exists + r = requests.post(self.uri("/images/pull?reference=alpine:latest"), timeout=15) + self.assertEqual(r.status_code, 200, r.text) + + r = requests.get(self.podman_url + "/v1.40/distribution/alpine/json") + self.assertEqual(r.status_code, 200, r.text) + + result = r.json() + self.assertIn("Descriptor", result) + self.assertIn("Platforms", result) + + descriptor = result["Descriptor"] + self.assertIn("mediaType", descriptor) + self.assertIn("digest", descriptor) + self.assertIn("size", descriptor) + + for platform in result["Platforms"]: + self.assertIn("architecture", platform) + self.assertIn("os", platform) + + def test_distribution_inspect_invalid_image(self): + r = requests.get(self.podman_url + "/v1.40/distribution/nonexistentimage/json") + self.assertEqual(r.status_code, 401, r.text) + +if __name__ == "__main__": + unittest.main()