From 91325241e2ee228f57ac54573c651e6a2e446479 Mon Sep 17 00:00:00 2001 From: Antonio Murdaca Date: Thu, 14 Jan 2016 00:40:23 +0100 Subject: [PATCH] Allow for remote repository inspection **Inspect remote images** Extended server API to allow for inspection of remote images. If a short name is given, list of additional registries will be queried until a repository is found. This allows for remote inspection without downloading image layers. The URL is following: //images//json?remote=1 The output is the same json as for local images. However, it excludes few attributes specific only to local images: - GraphDriver - VirtualSize And it adds some additional attributes: - Digest - Tag - Registry Signed-off-by: Michal Minar Signed-off-by: Antonio Murdaca --- api/server/router/local/image.go | 37 ++- daemon/daemon.go | 56 ++-- distribution/inspect.go | 216 ++++++++++++++ distribution/inspect_v1.go | 158 ++++++++++ distribution/inspect_v2.go | 328 +++++++++++++++++++++ distribution/util.go | 20 ++ image/v1/imagev1.go | 22 +- integration-cli/check_test.go | 37 ++- integration-cli/docker_api_inspect_test.go | 290 ++++++++++++++++++ integration-cli/docker_utils.go | 73 ++++- integration-cli/registry.go | 4 - .../github.com/docker/engine-api/types/types.go | 24 +- 12 files changed, 1227 insertions(+), 38 deletions(-) create mode 100644 distribution/inspect.go create mode 100644 distribution/inspect_v1.go create mode 100644 distribution/inspect_v2.go create mode 100644 distribution/util.go diff --git a/api/server/router/local/image.go b/api/server/router/local/image.go index f26cd48..bac6aa9 100644 --- a/api/server/router/local/image.go +++ b/api/server/router/local/image.go @@ -292,7 +292,42 @@ func (s *router) deleteImages(ctx context.Context, w http.ResponseWriter, r *htt } func (s *router) getImagesByName(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error { - imageInspect, err := s.daemon.LookupImage(vars["name"]) + if vars == nil { + return fmt.Errorf("Missing parameter") + } + + name := vars["name"] + + if httputils.BoolValue(r, "remote") { + authEncoded := r.Header.Get("X-Registry-Auth") + authConfig := &types.AuthConfig{} + if authEncoded != "" { + authJSON := base64.NewDecoder(base64.URLEncoding, strings.NewReader(authEncoded)) + if err := json.NewDecoder(authJSON).Decode(authConfig); err != nil { + // for a pull it is not an error if no auth was given + // to increase compatibility with the existing api it is defaulting to be empty + authConfig = &types.AuthConfig{} + } + } + + ref, err := reference.ParseNamed(name) + if err != nil { + return err + } + metaHeaders := map[string][]string{} + for k, v := range r.Header { + if strings.HasPrefix(k, "X-Meta-") { + metaHeaders[k] = v + } + } + imageInspect, err := s.daemon.LookupRemote(ref, metaHeaders, authConfig) + if err != nil { + return err + } + return httputils.WriteJSON(w, http.StatusOK, imageInspect) + } + + imageInspect, err := s.daemon.LookupImage(name) if err != nil { return err } diff --git a/daemon/daemon.go b/daemon/daemon.go index 5d10e06..7094779 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -1122,30 +1122,46 @@ func (daemon *Daemon) LookupImage(name string) (*types.ImageInspect, error) { } imageInspect := &types.ImageInspect{ - ID: img.ID().String(), - RepoTags: repoTags, - RepoDigests: repoDigests, - Parent: img.Parent.String(), - Comment: comment, - Created: img.Created.Format(time.RFC3339Nano), - Container: img.Container, - ContainerConfig: &img.ContainerConfig, - DockerVersion: img.DockerVersion, - Author: img.Author, - Config: img.Config, - Architecture: img.Architecture, - Os: img.OS, - Size: size, - VirtualSize: size, // TODO: field unused, deprecate - } - - imageInspect.GraphDriver.Name = daemon.GraphDriverName() - - imageInspect.GraphDriver.Data = layerMetadata + ID: img.ID().String(), + ImageInspectBase: types.ImageInspectBase{ + RepoTags: repoTags, + RepoDigests: repoDigests, + Parent: img.Parent.String(), + Comment: comment, + Created: img.Created.Format(time.RFC3339Nano), + Container: img.Container, + ContainerConfig: &img.ContainerConfig, + DockerVersion: img.DockerVersion, + Author: img.Author, + Config: img.Config, + Architecture: img.Architecture, + Os: img.OS, + Size: size, + }, + VirtualSize: size, // TODO: field unused, deprecate + GraphDriver: types.GraphDriverData{ + Name: daemon.GraphDriverName(), + Data: layerMetadata, + }, + } return imageInspect, nil } +// LookupRemote looks up an image in remote repository. +func (daemon *Daemon) LookupRemote(ref reference.Named, metaHeaders map[string][]string, authConfig *types.AuthConfig) (*types.RemoteImageInspect, error) { + inspectConfig := &distribution.InspectConfig{ + MetaHeaders: metaHeaders, + AuthConfig: authConfig, + RegistryService: daemon.RegistryService, + MetadataStore: daemon.distributionMetadataStore, + } + + ctx := context.Background() + + return distribution.Inspect(ctx, ref, inspectConfig) +} + // LoadImage uploads a set of images into the repository. This is the // complement of ImageExport. The input stream is an uncompressed tar // ball containing images and metadata. diff --git a/distribution/inspect.go b/distribution/inspect.go new file mode 100644 index 0000000..5240d98 --- /dev/null +++ b/distribution/inspect.go @@ -0,0 +1,216 @@ +package distribution + +import ( + "fmt" + "io" + "time" + + "github.com/Sirupsen/logrus" + "github.com/docker/distribution/digest" + "github.com/docker/docker/distribution/metadata" + "github.com/docker/docker/image" + "github.com/docker/docker/reference" + "github.com/docker/docker/registry" + "github.com/docker/engine-api/types" + "golang.org/x/net/context" +) + +// InspectConfig allows you to pass transport-related data to Inspect +// function. +type InspectConfig struct { + // MetaHeaders stores HTTP headers with metadata about the image + // (DockerHeaders with prefix X-Meta- in the request). + MetaHeaders map[string][]string + // AuthConfig holds authentication credentials for authenticating with + // the registry. + AuthConfig *types.AuthConfig + // OutStream is the output writer for showing the status of the pull + // operation. + OutStream io.Writer + // RegistryService is the registry service to use for TLS configuration + // and endpoint lookup. + RegistryService *registry.Service + // MetadataStore is the storage backend for distribution-specific + // metadata. + MetadataStore metadata.Store +} + +// ManifestFetcher allows to pull image's json without any binary blobs. +type ManifestFetcher interface { + Fetch(ctx context.Context, ref reference.Named) (imgInspect *types.RemoteImageInspect, err error) +} + +// NewManifestFetcher creates appropriate fetcher instance for given endpoint. +func newManifestFetcher(endpoint registry.APIEndpoint, repoInfo *registry.RepositoryInfo, config *InspectConfig) (ManifestFetcher, error) { + switch endpoint.Version { + case registry.APIVersion2: + return &v2ManifestFetcher{ + endpoint: endpoint, + config: config, + repoInfo: repoInfo, + }, nil + case registry.APIVersion1: + return &v1ManifestFetcher{ + endpoint: endpoint, + config: config, + repoInfo: repoInfo, + }, nil + } + return nil, fmt.Errorf("unknown version %d for registry %s", endpoint.Version, endpoint.URL) +} + +func makeRemoteImageInspect(repoInfo *registry.RepositoryInfo, img *image.Image, tag string, dgst digest.Digest) *types.RemoteImageInspect { + var repoTags = make([]string, 0, 1) + if tagged, isTagged := repoInfo.Named.(reference.NamedTagged); isTagged || tag != "" { + if !isTagged { + newTagged, err := reference.WithTag(repoInfo, tag) + if err == nil { + tagged = newTagged + } + } + if tagged != nil { + repoTags = append(repoTags, tagged.String()) + } + } + + var repoDigests = make([]string, 0, 1) + if err := dgst.Validate(); err == nil { + repoDigests = append(repoDigests, dgst.String()) + } + + return &types.RemoteImageInspect{ + V1ID: img.V1Image.ID, + ImageInspectBase: types.ImageInspectBase{ + RepoTags: repoTags, + RepoDigests: repoDigests, + Parent: img.Parent.String(), + Comment: img.Comment, + Created: img.Created.Format(time.RFC3339Nano), + Container: img.Container, + ContainerConfig: &img.ContainerConfig, + DockerVersion: img.DockerVersion, + Author: img.Author, + Config: img.Config, + Architecture: img.Architecture, + Os: img.OS, + Size: img.Size, + }, + Registry: repoInfo.Index.Name, + } +} + +// Inspect returns metadata for remote image. +func Inspect(ctx context.Context, ref reference.Named, config *InspectConfig) (*types.RemoteImageInspect, error) { + var imageInspect *types.RemoteImageInspect + // Unless the index name is specified, iterate over all registries until + // the matching image is found. + if reference.IsReferenceFullyQualified(ref) { + return fetchManifest(ctx, ref, config) + } + if len(registry.DefaultRegistries) == 0 { + return nil, fmt.Errorf("No configured registry to pull from.") + } + err := validateRepoName(ref.Name()) + if err != nil { + return nil, err + } + for _, r := range registry.DefaultRegistries { + // Prepend the index name to the image name. + fqr, _err := reference.QualifyUnqualifiedReference(ref, r) + if _err != nil { + logrus.Warnf("Failed to fully qualify %q name with %q registry: %v", ref.Name(), r, _err) + err = _err + continue + } + // Prepend the index name to the image name. + if imageInspect, err = fetchManifest(ctx, fqr, config); err == nil { + return imageInspect, nil + } + } + return imageInspect, err +} + +func fetchManifest(ctx context.Context, ref reference.Named, config *InspectConfig) (*types.RemoteImageInspect, error) { + // Resolve the Repository name from fqn to RepositoryInfo + repoInfo, err := config.RegistryService.ResolveRepository(ref) + if err != nil { + return nil, err + } + + if err := validateRepoName(repoInfo.Name()); err != nil { + return nil, err + } + + endpoints, err := config.RegistryService.LookupPullEndpoints(repoInfo) + if err != nil { + return nil, err + } + + var ( + errors []error + // discardNoSupportErrors is used to track whether an endpoint encountered an error of type registry.ErrNoSupport + // By default it is false, which means that if a ErrNoSupport error is encountered, it will be saved in lastErr. + // As soon as another kind of error is encountered, discardNoSupportErrors is set to true, avoiding the saving of + // any subsequent ErrNoSupport errors in lastErr. + // It's needed for pull-by-digest on v1 endpoints: if there are only v1 endpoints configured, the error should be + // returned and displayed, but if there was a v2 endpoint which supports pull-by-digest, then the last relevant + // error is the ones from v2 endpoints not v1. + discardNoSupportErrors bool + imgInspect *types.RemoteImageInspect + + // confirmedV2 is set to true if a pull attempt managed to + // confirm that it was talking to a v2 registry. This will + // prevent fallback to the v1 protocol. + confirmedV2 bool + ) + for _, endpoint := range endpoints { + if confirmedV2 && endpoint.Version == registry.APIVersion1 { + logrus.Debugf("Skipping v1 endpoint %s because v2 registry was detected", endpoint.URL) + continue + } + logrus.Debugf("Trying to fetch image manifest of %s repository from %s %s", repoInfo.Name(), endpoint.URL, endpoint.Version) + + fetcher, err := newManifestFetcher(endpoint, repoInfo, config) + if err != nil { + errors = append(errors, err) + continue + } + if imgInspect, err = fetcher.Fetch(ctx, ref); err != nil { + // Was this fetch cancelled? If so, don't try to fall back. + fallback := false + select { + case <-ctx.Done(): + default: + if fallbackErr, ok := err.(fallbackError); ok { + fallback = true + confirmedV2 = confirmedV2 || fallbackErr.confirmedV2 + err = fallbackErr.err + } + } + if fallback { + if _, ok := err.(registry.ErrNoSupport); !ok { + // Because we found an error that's not ErrNoSupport, discard all subsequent ErrNoSupport errors. + discardNoSupportErrors = true + // save the current error + errors = append(errors, err) + } else if !discardNoSupportErrors { + // Save the ErrNoSupport error, because it's either the first error or all encountered errors + // were also ErrNoSupport errors. + errors = append(errors, err) + } + continue + } + errors = append(errors, err) + logrus.Debugf("Not continuing with error: %v", combineErrors(errors...).Error()) + return nil, combineErrors(errors...) + } + + return imgInspect, nil + } + + if len(errors) > 0 { + return nil, combineErrors(errors...) + } + + return nil, fmt.Errorf("no endpoints found for %s", ref.String()) +} diff --git a/distribution/inspect_v1.go b/distribution/inspect_v1.go new file mode 100644 index 0000000..37dab4b --- /dev/null +++ b/distribution/inspect_v1.go @@ -0,0 +1,158 @@ +package distribution + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/docker/distribution/registry/client/transport" + "github.com/docker/docker/image" + "github.com/docker/docker/image/v1" + "github.com/docker/docker/reference" + "github.com/docker/docker/registry" + "github.com/docker/engine-api/types" + "golang.org/x/net/context" +) + +type v1ManifestFetcher struct { + endpoint registry.APIEndpoint + config *InspectConfig + repoInfo *registry.RepositoryInfo + session *registry.Session +} + +func (mf *v1ManifestFetcher) Fetch(ctx context.Context, ref reference.Named) (imgInspect *types.RemoteImageInspect, err error) { + if _, isCanonical := ref.(reference.Canonical); isCanonical { + // Allowing fallback, because HTTPS v1 is before HTTP v2 + return nil, fallbackError{err: registry.ErrNoSupport{errors.New("Cannot pull by digest with v1 registry")}} + } + tag := "" + if tagged, isTagged := ref.(reference.NamedTagged); isTagged { + tag = tagged.Tag() + } + tlsConfig, err := mf.config.RegistryService.TLSConfig(mf.repoInfo.Index.Name) + if err != nil { + return nil, err + } + // Adds Docker-specific headers as well as user-specified headers (metaHeaders) + tr := transport.NewTransport( + registry.NewTransport(tlsConfig), + registry.DockerHeaders(mf.config.MetaHeaders)..., + ) + client := registry.HTTPClient(tr) + v1Endpoint, err := mf.endpoint.ToV1Endpoint(mf.config.MetaHeaders) + if err != nil { + logrus.Debugf("Could not get v1 endpoint: %v", err) + return nil, fallbackError{err: err} + } + mf.session, err = registry.NewSession(client, mf.config.AuthConfig, v1Endpoint) + if err != nil { + logrus.Debugf("Fallback from error: %s", err) + return nil, fallbackError{err: err} + } + imgInspect, err = mf.fetchWithSession(ctx, tag) + return +} + +func (mf *v1ManifestFetcher) fetchWithSession(ctx context.Context, askedTag string) (*types.RemoteImageInspect, error) { + repoData, err := mf.session.GetRepositoryData(mf.repoInfo) + if err != nil { + if strings.Contains(err.Error(), "HTTP code: 404") { + return nil, fmt.Errorf("Error: image %s not found", mf.repoInfo.RemoteName()) + } + // Unexpected HTTP error + return nil, err + } + + logrus.Debugf("Retrieving the tag list from V1 endpoints") + tagsList, err := mf.session.GetRemoteTags(repoData.Endpoints, mf.repoInfo) + if err != nil { + logrus.Errorf("Unable to get remote tags: %s", err) + return nil, err + } + if len(tagsList) < 1 { + return nil, fmt.Errorf("No tags available for remote repository %s", mf.repoInfo.FullName()) + } + + for tag, id := range tagsList { + repoData.ImgList[id] = ®istry.ImgData{ + ID: id, + Tag: tag, + Checksum: "", + } + } + + // If no tag has been specified, choose `latest` if it exists + if askedTag == "" { + if _, exists := tagsList[reference.DefaultTag]; exists { + askedTag = reference.DefaultTag + } + } + if askedTag == "" { + // fallback to any tag in the repository + for tag := range tagsList { + askedTag = tag + break + } + } + + id, exists := tagsList[askedTag] + if !exists { + return nil, fmt.Errorf("Tag %s not found in repository %s", askedTag, mf.repoInfo.FullName()) + } + img := repoData.ImgList[id] + + var pulledImg *image.Image + for _, ep := range mf.repoInfo.Index.Mirrors { + if pulledImg, err = mf.pullImageJSON(img.ID, ep, repoData.Tokens); err != nil { + // Don't report errors when pulling from mirrors. + logrus.Debugf("Error pulling image json of %s:%s, mirror: %s, %s", mf.repoInfo.FullName(), img.Tag, ep, err) + continue + } + break + } + if pulledImg == nil { + for _, ep := range repoData.Endpoints { + if pulledImg, err = mf.pullImageJSON(img.ID, ep, repoData.Tokens); err != nil { + // It's not ideal that only the last error is returned, it would be better to concatenate the errors. + logrus.Infof("Error pulling image json of %s:%s, endpoint: %s, %v", mf.repoInfo.FullName(), img.Tag, ep, err) + continue + } + break + } + } + if err != nil { + return nil, fmt.Errorf("Error pulling image (%s) from %s, %v", img.Tag, mf.repoInfo.FullName(), err) + } + if pulledImg == nil { + return nil, fmt.Errorf("No such image %s:%s", mf.repoInfo.FullName(), askedTag) + } + + return makeRemoteImageInspect(mf.repoInfo, pulledImg, askedTag, ""), nil +} + +func (mf *v1ManifestFetcher) pullImageJSON(imgID, endpoint string, token []string) (*image.Image, error) { + imgJSON, _, err := mf.session.GetRemoteImageJSON(imgID, endpoint) + if err != nil { + return nil, err + } + h, err := v1.HistoryFromConfig(imgJSON, false) + if err != nil { + return nil, err + } + configRaw, err := v1.MakeRawConfigFromV1Config(imgJSON, image.NewRootFS(), []image.History{h}) + if err != nil { + return nil, err + } + config, err := json.Marshal(configRaw) + if err != nil { + return nil, err + } + img, err := image.NewFromJSON(config) + if err != nil { + return nil, err + } + return img, nil +} diff --git a/distribution/inspect_v2.go b/distribution/inspect_v2.go new file mode 100644 index 0000000..ecb4688 --- /dev/null +++ b/distribution/inspect_v2.go @@ -0,0 +1,328 @@ +package distribution + +import ( + "encoding/json" + "errors" + "fmt" + "runtime" + + "github.com/Sirupsen/logrus" + "github.com/docker/distribution" + "github.com/docker/distribution/digest" + "github.com/docker/distribution/manifest/manifestlist" + "github.com/docker/distribution/manifest/schema1" + "github.com/docker/distribution/manifest/schema2" + "github.com/docker/distribution/registry/api/errcode" + "github.com/docker/distribution/registry/client" + "github.com/docker/docker/image" + "github.com/docker/docker/image/v1" + "github.com/docker/docker/reference" + "github.com/docker/docker/registry" + "github.com/docker/engine-api/types" + "golang.org/x/net/context" +) + +type v2ManifestFetcher struct { + endpoint registry.APIEndpoint + config *InspectConfig + repoInfo *registry.RepositoryInfo + repo distribution.Repository + // confirmedV2 is set to true if we confirm we're talking to a v2 + // registry. This is used to limit fallbacks to the v1 protocol. + confirmedV2 bool +} + +func (mf *v2ManifestFetcher) Fetch(ctx context.Context, ref reference.Named) (imgInspect *types.RemoteImageInspect, err error) { + mf.repo, mf.confirmedV2, err = NewV2Repository(ctx, mf.repoInfo, mf.endpoint, mf.config.MetaHeaders, mf.config.AuthConfig, "pull") + if err != nil { + logrus.Debugf("Error getting v2 registry: %v", err) + return nil, fallbackError{err: err, confirmedV2: mf.confirmedV2} + } + + imgInspect, err = mf.fetchWithRepository(ctx, ref) + if err != nil { + switch t := err.(type) { + case errcode.Errors: + if len(t) == 1 { + err = t[0] + } + } + if registry.ContinueOnError(err) { + logrus.Debugf("Error trying v2 registry: %v", err) + err = fallbackError{err: err, confirmedV2: mf.confirmedV2} + } + } + return +} + +func (mf *v2ManifestFetcher) fetchWithRepository(ctx context.Context, ref reference.Named) (*types.RemoteImageInspect, error) { + var ( + manifest distribution.Manifest + tagOrDigest string // Used for logging/progress only + + tag string + ) + + manSvc, err := mf.repo.Manifests(ctx) + if err != nil { + return nil, err + } + + if digested, isDigested := ref.(reference.Canonical); isDigested { + manifest, err = manSvc.Get(ctx, digested.Digest()) + if err != nil { + return nil, err + } + tagOrDigest = digested.Digest().String() + } else { + if tagged, isTagged := ref.(reference.NamedTagged); isTagged { + tagOrDigest = tagged.Tag() + tag = tagOrDigest + } else { + tagList, err := mf.repo.Tags(ctx).All(ctx) + if err != nil { + return nil, err + } + for _, t := range tagList { + if t == reference.DefaultTag { + tag = t + } + } + if tag == "" && len(tagList) > 0 { + tag = tagList[0] + } + if tag == "" { + return nil, fmt.Errorf("No tags available for remote repository %s", mf.repoInfo.FullName()) + } + } + // NOTE: not using TagService.Get, since it uses HEAD requests + // against the manifests endpoint, which are not supported by + // all registry versions. + manifest, err = manSvc.Get(ctx, "", client.WithTag(tag)) + if err != nil { + return nil, allowV1Fallback(err) + } + } + + if manifest == nil { + return nil, fmt.Errorf("image manifest does not exist for tag or digest %q", tagOrDigest) + } + + // If manSvc.Get succeeded, we can be confident that the registry on + // the other side speaks the v2 protocol. + mf.confirmedV2 = true + + var ( + image *image.Image + manifestDigest digest.Digest + ) + + switch v := manifest.(type) { + case *schema1.SignedManifest: + image, manifestDigest, err = mf.pullSchema1(ctx, ref, v) + if err != nil { + return nil, err + } + case *schema2.DeserializedManifest: + image, manifestDigest, err = mf.pullSchema2(ctx, ref, v) + if err != nil { + return nil, err + } + case *manifestlist.DeserializedManifestList: + image, manifestDigest, err = mf.pullManifestList(ctx, ref, v) + if err != nil { + return nil, err + } + default: + return nil, errors.New("unsupported manifest format") + } + + return makeRemoteImageInspect(mf.repoInfo, image, tag, manifestDigest), nil +} + +func (mf *v2ManifestFetcher) pullSchema1(ctx context.Context, ref reference.Named, unverifiedManifest *schema1.SignedManifest) (img *image.Image, manifestDigest digest.Digest, err error) { + var verifiedManifest *schema1.Manifest + verifiedManifest, err = verifySchema1Manifest(unverifiedManifest, ref) + if err != nil { + return nil, "", err + } + + // remove duplicate layers and check parent chain validity + err = fixManifestLayers(verifiedManifest) + if err != nil { + return nil, "", err + } + + // Image history converted to the new format + var history []image.History + + // Note that the order of this loop is in the direction of bottom-most + // to top-most, so that the downloads slice gets ordered correctly. + for i := len(verifiedManifest.FSLayers) - 1; i >= 0; i-- { + var throwAway struct { + ThrowAway bool `json:"throwaway,omitempty"` + } + if err := json.Unmarshal([]byte(verifiedManifest.History[i].V1Compatibility), &throwAway); err != nil { + return nil, "", err + } + + h, err := v1.HistoryFromConfig([]byte(verifiedManifest.History[i].V1Compatibility), throwAway.ThrowAway) + if err != nil { + return nil, "", err + } + history = append(history, h) + } + + rootFS := image.NewRootFS() + configRaw, err := v1.MakeRawConfigFromV1Config([]byte(verifiedManifest.History[0].V1Compatibility), rootFS, history) + if err != nil { + return nil, "", err + } + + config, err := json.Marshal(configRaw) + if err != nil { + return nil, "", err + } + + img, err = image.NewFromJSON(config) + if err != nil { + return nil, "", err + } + + manifestDigest = digest.FromBytes(unverifiedManifest.Canonical) + + return img, manifestDigest, nil +} + +func (mf *v2ManifestFetcher) pullSchema2(ctx context.Context, ref reference.Named, mfst *schema2.DeserializedManifest) (img *image.Image, manifestDigest digest.Digest, err error) { + manifestDigest, err = schema2ManifestDigest(ref, mfst) + if err != nil { + return nil, "", err + } + + target := mfst.Target() + + configChan := make(chan []byte, 1) + errChan := make(chan error, 1) + var cancel func() + ctx, cancel = context.WithCancel(ctx) + + // Pull the image config + go func() { + configJSON, err := mf.pullSchema2ImageConfig(ctx, target.Digest) + if err != nil { + errChan <- err + cancel() + return + } + configChan <- configJSON + }() + + var ( + configJSON []byte // raw serialized image config + unmarshalledConfig image.Image // deserialized image config + ) + if runtime.GOOS == "windows" { + configJSON, unmarshalledConfig, err = receiveConfig(configChan, errChan) + if err != nil { + return nil, "", err + } + if unmarshalledConfig.RootFS == nil { + return nil, "", errors.New("image config has no rootfs section") + } + } + + if configJSON == nil { + configJSON, unmarshalledConfig, err = receiveConfig(configChan, errChan) + if err != nil { + return nil, "", err + } + } + + img, err = image.NewFromJSON(configJSON) + if err != nil { + return nil, "", err + } + + return img, manifestDigest, nil +} + +func (mf *v2ManifestFetcher) pullSchema2ImageConfig(ctx context.Context, dgst digest.Digest) (configJSON []byte, err error) { + blobs := mf.repo.Blobs(ctx) + configJSON, err = blobs.Get(ctx, dgst) + if err != nil { + return nil, err + } + + // Verify image config digest + verifier, err := digest.NewDigestVerifier(dgst) + if err != nil { + return nil, err + } + if _, err := verifier.Write(configJSON); err != nil { + return nil, err + } + if !verifier.Verified() { + err := fmt.Errorf("image config verification failed for digest %s", dgst) + logrus.Error(err) + return nil, err + } + + return configJSON, nil +} + +// pullManifestList handles "manifest lists" which point to various +// platform-specifc manifests. +func (mf *v2ManifestFetcher) pullManifestList(ctx context.Context, ref reference.Named, mfstList *manifestlist.DeserializedManifestList) (img *image.Image, manifestListDigest digest.Digest, err error) { + manifestListDigest, err = schema2ManifestDigest(ref, mfstList) + if err != nil { + return nil, "", err + } + + var manifestDigest digest.Digest + for _, manifestDescriptor := range mfstList.Manifests { + // TODO(aaronl): The manifest list spec supports optional + // "features" and "variant" fields. These are not yet used. + // Once they are, their values should be interpreted here. + if manifestDescriptor.Platform.Architecture == runtime.GOARCH && manifestDescriptor.Platform.OS == runtime.GOOS { + manifestDigest = manifestDescriptor.Digest + break + } + } + + if manifestDigest == "" { + return nil, "", errors.New("no supported platform found in manifest list") + } + + manSvc, err := mf.repo.Manifests(ctx) + if err != nil { + return nil, "", err + } + + manifest, err := manSvc.Get(ctx, manifestDigest) + if err != nil { + return nil, "", err + } + + manifestRef, err := reference.WithDigest(ref, manifestDigest) + if err != nil { + return nil, "", err + } + + switch v := manifest.(type) { + case *schema1.SignedManifest: + img, _, err = mf.pullSchema1(ctx, manifestRef, v) + if err != nil { + return nil, "", err + } + case *schema2.DeserializedManifest: + img, _, err = mf.pullSchema2(ctx, manifestRef, v) + if err != nil { + return nil, "", err + } + default: + return nil, "", errors.New("unsupported manifest format") + } + + return img, manifestListDigest, err +} diff --git a/distribution/util.go b/distribution/util.go new file mode 100644 index 0000000..3ce0288 --- /dev/null +++ b/distribution/util.go @@ -0,0 +1,20 @@ +package distribution + +import ( + "fmt" + "strings" +) + +func combineErrors(errors ...error) error { + if len(errors) == 0 { + return nil + } + if len(errors) == 1 { + return errors[0] + } + msgs := []string{} + for _, err := range errors { + msgs = append(msgs, err.Error()) + } + return fmt.Errorf(strings.Join(msgs, "\n")) +} diff --git a/image/v1/imagev1.go b/image/v1/imagev1.go index cdea0e7..6e9b9c0 100644 --- a/image/v1/imagev1.go +++ b/image/v1/imagev1.go @@ -66,8 +66,10 @@ func CreateID(v1Image image.V1Image, layerID layer.ChainID, parent digest.Digest return digest.FromBytes(configJSON), nil } -// MakeConfigFromV1Config creates an image config from the legacy V1 config format. -func MakeConfigFromV1Config(imageJSON []byte, rootfs *image.RootFS, history []image.History) ([]byte, error) { +// MakeRawConfigFromV1Config creates an image config from the legacy V1 config +// format and returns it as a map of json raw messages. No attributes will be +// removed from the config. +func MakeRawConfigFromV1Config(imageJSON []byte, rootfs *image.RootFS, history []image.History) (map[string]*json.RawMessage, error) { var dver struct { DockerVersion string `json:"docker_version"` } @@ -95,6 +97,19 @@ func MakeConfigFromV1Config(imageJSON []byte, rootfs *image.RootFS, history []im return nil, err } + c["rootfs"] = rawJSON(rootfs) + c["history"] = rawJSON(history) + + return c, nil +} + +// MakeConfigFromV1Config creates an image config from the legacy V1 config format. +func MakeConfigFromV1Config(imageJSON []byte, rootfs *image.RootFS, history []image.History) ([]byte, error) { + c, err := MakeRawConfigFromV1Config(imageJSON, rootfs, history) + if err != nil { + return nil, err + } + delete(c, "id") delete(c, "parent") delete(c, "Size") // Size is calculated from data on disk and is inconsitent @@ -102,9 +117,6 @@ func MakeConfigFromV1Config(imageJSON []byte, rootfs *image.RootFS, history []im delete(c, "layer_id") delete(c, "throwaway") - c["rootfs"] = rawJSON(rootfs) - c["history"] = rawJSON(history) - return json.Marshal(c) } diff --git a/integration-cli/check_test.go b/integration-cli/check_test.go index a1d51ac..c68aa1c 100644 --- a/integration-cli/check_test.go +++ b/integration-cli/check_test.go @@ -64,6 +64,39 @@ func (s *DockerRegistrySuite) TearDownTest(c *check.C) { } func init() { + check.Suite(&DockerSchema1RegistriesSuite{ + ds: &DockerSuite{}, + }) +} + +type DockerSchema1RegistriesSuite struct { + ds *DockerSuite + reg1 *testRegistryV2 + reg2 *testRegistryV2 + d *Daemon +} + +func (s *DockerSchema1RegistriesSuite) SetUpTest(c *check.C) { + testRequires(c, DaemonIsLinux) + s.reg1 = setupRegistryAt(c, privateRegistryURL, true) + s.reg2 = setupRegistryAt(c, privateRegistryURL2, true) + s.d = NewDaemon(c) +} + +func (s *DockerSchema1RegistriesSuite) TearDownTest(c *check.C) { + if s.reg2 != nil { + s.reg2.Close() + } + if s.reg1 != nil { + s.reg1.Close() + } + if s.d != nil { + s.d.Stop() + } + s.ds.TearDownTest(c) +} + +func init() { check.Suite(&DockerSchema1RegistrySuite{ ds: &DockerSuite{}, }) @@ -150,8 +183,8 @@ type DockerRegistriesSuite struct { } func (s *DockerRegistriesSuite) SetUpTest(c *check.C) { - s.reg1 = setupRegistryAt(c, privateRegistryURL) - s.reg2 = setupRegistryAt(c, privateRegistryURL2) + s.reg1 = setupRegistryAt(c, privateRegistryURL, false) + s.reg2 = setupRegistryAt(c, privateRegistryURL2, false) s.d = NewDaemon(c) } diff --git a/integration-cli/docker_api_inspect_test.go b/integration-cli/docker_api_inspect_test.go index 9d2ff51..89764e3 100644 --- a/integration-cli/docker_api_inspect_test.go +++ b/integration-cli/docker_api_inspect_test.go @@ -1,8 +1,11 @@ package main import ( + "bytes" "encoding/json" + "fmt" "net/http" + "reflect" "strings" "github.com/docker/docker/pkg/integration/checker" @@ -167,3 +170,290 @@ func (s *DockerSuite) TestInspectApiBridgeNetworkSettings121(c *check.C) { c.Assert(settings.Networks["bridge"], checker.Not(checker.IsNil)) c.Assert(settings.IPAddress, checker.Equals, settings.Networks["bridge"].IPAddress) } + +func compareInspectValues(c *check.C, name string, fst, snd interface{}, localVsRemote bool) { + additionalLocalAttributes := map[string]struct{}{} + additionalRemoteAttributes := map[string]struct{}{} + if localVsRemote { + additionalLocalAttributes = map[string]struct{}{ + "GraphDriver": {}, + "VirtualSize": {}, + } + additionalRemoteAttributes = map[string]struct{}{"Registry": {}} + } + + isRootObject := len(name) <= 1 + + compareArrays := func(lVal, rVal []interface{}) { + if len(lVal) != len(rVal) { + c.Errorf("array length differs between fst and snd for %q: %d != %d", name, len(lVal), len(rVal)) + } + for i := 0; i < len(lVal) && i < len(rVal); i++ { + compareInspectValues(c, fmt.Sprintf("%s[%d]", name, i), lVal[i], rVal[i], localVsRemote) + } + } + + if reflect.TypeOf(fst) != reflect.TypeOf(snd) { + c.Errorf("types don't match for %q: %T != %T", name, fst, snd) + return + } + switch fst.(type) { + case bool: + lVal := fst.(bool) + rVal := snd.(bool) + if lVal != rVal { + c.Errorf("fst value differs from snd for %q: %t != %t", name, lVal, rVal) + } + + case float64: + lVal := fst.(float64) + rVal := snd.(float64) + if !strings.HasSuffix(name, ".Size") { + if lVal != rVal { + c.Errorf("fst value differs from snd for %q: %f != %f", name, lVal, rVal) + } + } + + case string: + lVal := fst.(string) + rVal := snd.(string) + if !strings.HasSuffix(name, ".Id") && !strings.HasSuffix(name, ".Parent") { + if lVal != rVal { + c.Errorf("fst value differs from snd for %q: %q != %q", name, lVal, rVal) + } + } + + // JSON array + case []interface{}: + lVal := fst.([]interface{}) + rVal := snd.([]interface{}) + if strings.HasSuffix(name, ".RepoTags") { + if len(rVal) != 1 { + c.Errorf("expected one item in remote Tags, not: %d", len(rVal)) + } else { + found := false + for _, v := range lVal { + if v.(string) == rVal[0].(string) { + found = true + break + } + } + if !found { + c.Errorf("expected remote tag %q to be in among local ones: %v", rVal[0].(string), lVal) + } + } + } else if strings.HasSuffix(name, ".RepoDigests") { + if len(lVal) >= 1 { + compareArrays(lVal, rVal) + } + if len(rVal) != 1 { + c.Errorf("expected just one item in %q array, not %d (%v)", name, len(rVal), rVal) + } + } else { + compareArrays(lVal, rVal) + } + + // JSON object + case map[string]interface{}: + lMap := fst.(map[string]interface{}) + rMap := snd.(map[string]interface{}) + if isRootObject && len(lMap)-len(additionalLocalAttributes) != len(rMap)-len(additionalRemoteAttributes) { + c.Errorf("got unexpected number of root object's attributes from snd inpect %q: %d != %d", name, len(lMap)-len(additionalLocalAttributes), len(rMap)-len(additionalRemoteAttributes)) + } else if !isRootObject && len(lMap) != len(rMap) { + c.Errorf("map length differs between fst and snd for %q: %d != %d", name, len(lMap), len(rMap)) + } + for key, lVal := range lMap { + itemName := fmt.Sprintf("%s.%s", name, key) + rVal, ok := rMap[key] + if ok { + compareInspectValues(c, itemName, lVal, rVal, localVsRemote) + } else if _, exists := additionalLocalAttributes[key]; !isRootObject || !localVsRemote || !exists { + c.Errorf("attribute %q present in fst but not in snd object", itemName) + } + } + for key := range rMap { + if _, ok := lMap[key]; !ok { + if _, exists := additionalRemoteAttributes[key]; !isRootObject || !localVsRemote || !exists { + c.Errorf("attribute \"%s.%s\" present in snd but not in fst object", name, key) + } + } + } + + case nil: + if fst != snd { + c.Errorf("fst value differs from snd for %q: %v (%T) != %v (%T)", name, fst, fst, snd, snd) + } + + default: + c.Fatalf("got unexpected type (%T) for %q", fst, name) + } +} + +func apiCallInspectImage(c *check.C, d *Daemon, repoName string, remote, shouldFail bool) (value interface{}, status int, err error) { + suffix := "" + if remote { + suffix = "?remote=1" + } + endpoint := fmt.Sprintf("/v1.20/images/%s/json%s", repoName, suffix) + status, body, err := func() (int, []byte, error) { + if d == nil { + return sockRequest("GET", endpoint, nil) + } + return d.sockRequest("GET", endpoint, nil) + }() + if shouldFail { + c.Assert(status, check.Not(check.Equals), http.StatusOK) + if err == nil { + err = fmt.Errorf("%s", bytes.TrimSpace(body)) + } + } else { + c.Assert(err, check.IsNil) + c.Assert(status, check.Equals, http.StatusOK, check.Commentf(string(body))) + if err = json.Unmarshal(body, &value); err != nil { + what := "local" + if remote { + what = "remote" + } + c.Fatalf("failed to parse result for %s image %q: %v", what, repoName, err) + } + } + return +} + +func (s *DockerRegistrySuite) TestInspectApiRemoteImage(c *check.C) { + repoName := fmt.Sprintf("%v/dockercli/busybox", s.reg.url) + // tag the image and upload it to the private registry + dockerCmd(c, "tag", "busybox", repoName) + defer deleteImages(repoName) + + dockerCmd(c, "push", repoName) + localValue, _, _ := apiCallInspectImage(c, nil, repoName, false, false) + remoteValue, _, _ := apiCallInspectImage(c, nil, repoName, true, false) + compareInspectValues(c, "a", localValue, remoteValue, true) + + deleteImages(repoName) + + // local inspect shall fail now + _, status, _ := apiCallInspectImage(c, nil, repoName, false, true) + c.Assert(status, check.Equals, http.StatusNotFound) + + // remote inspect shall still succeed + remoteValue2, _, _ := apiCallInspectImage(c, nil, repoName, true, false) + compareInspectValues(c, "a", localValue, remoteValue2, true) +} + +func (s *DockerRegistrySuite) TestInspectApiImageFromAdditionalRegistry(c *check.C) { + daemonArgs := []string{"--add-registry=" + s.reg.url} + if err := s.d.StartWithBusybox(daemonArgs...); err != nil { + c.Fatalf("we should have been able to start the daemon with passing { %s } flags: %v", strings.Join(daemonArgs, ", "), err) + } + + repoName := fmt.Sprintf("dockercli/busybox") + fqn := s.reg.url + "/" + repoName + // tag the image and upload it to the private registry + if out, err := s.d.Cmd("tag", "busybox", fqn); err != nil { + c.Fatalf("image tagging failed: %s, %v", out, err) + } + + localValue, _, _ := apiCallInspectImage(c, s.d, repoName, false, false) + + _, status, _ := apiCallInspectImage(c, s.d, repoName, true, true) + // inspection falls back to docker.io which may return unexpected status + c.Assert(status, check.Not(check.Equals), http.StatusOK) + + if out, err := s.d.Cmd("push", fqn); err != nil { + c.Fatalf("failed to push image %s: error %v, output %q", fqn, err, out) + } + + remoteValue, _, _ := apiCallInspectImage(c, s.d, repoName, true, false) + compareInspectValues(c, "a", localValue, remoteValue, true) + + if out, err := s.d.Cmd("rmi", fqn); err != nil { + c.Fatalf("failed to remove image %s: %s, %v", fqn, out, err) + } + + remoteValue2, _, _ := apiCallInspectImage(c, s.d, fqn, true, false) + compareInspectValues(c, "a", localValue, remoteValue2, true) +} + +func (s *DockerRegistrySuite) TestInspectApiNonExistentRepository(c *check.C) { + repoName := fmt.Sprintf("%s/foo/non-existent", s.reg.url) + + _, status, err := apiCallInspectImage(c, nil, repoName, false, true) + c.Assert(status, check.Equals, http.StatusNotFound) + c.Assert(err, check.Not(check.IsNil)) + c.Assert(err.Error(), check.Matches, `(?i)no such image.*`) + + _, status, err = apiCallInspectImage(c, nil, repoName, true, true) + c.Assert(err, check.Not(check.IsNil)) + c.Assert(err.Error(), check.Matches, `(?is).*(not found|no such image|no tags available|not known).*`) +} + +func (s *DockerSchema1RegistrySuite) TestInspectApiRemoteImageSchema1(c *check.C) { + repoName := fmt.Sprintf("%v/dockercli/busybox", s.reg.url) + // tag the image and upload it to the private registry + dockerCmd(c, "tag", "busybox", repoName) + defer deleteImages(repoName) + + dockerCmd(c, "push", repoName) + localValue, _, _ := apiCallInspectImage(c, nil, repoName, false, false) + remoteValue, _, _ := apiCallInspectImage(c, nil, repoName, true, false) + compareInspectValues(c, "a", localValue, remoteValue, true) + + deleteImages(repoName) + + // local inspect shall fail now + _, status, _ := apiCallInspectImage(c, nil, repoName, false, true) + c.Assert(status, check.Equals, http.StatusNotFound) + + // remote inspect shall still succeed + remoteValue2, _, _ := apiCallInspectImage(c, nil, repoName, true, false) + compareInspectValues(c, "a", localValue, remoteValue2, true) +} + +func (s *DockerSchema1RegistrySuite) TestInspectApiImageFromAdditionalRegistrySchema1(c *check.C) { + daemonArgs := []string{"--add-registry=" + s.reg.url} + if err := s.d.StartWithBusybox(daemonArgs...); err != nil { + c.Fatalf("we should have been able to start the daemon with passing { %s } flags: %v", strings.Join(daemonArgs, ", "), err) + } + + repoName := fmt.Sprintf("dockercli/busybox") + fqn := s.reg.url + "/" + repoName + // tag the image and upload it to the private registry + if out, err := s.d.Cmd("tag", "busybox", fqn); err != nil { + c.Fatalf("image tagging failed: %s, %v", out, err) + } + + localValue, _, _ := apiCallInspectImage(c, s.d, repoName, false, false) + + _, status, _ := apiCallInspectImage(c, s.d, repoName, true, true) + // inspection falls back to docker.io which may return unexpected status + c.Assert(status, check.Not(check.Equals), http.StatusOK) + + if out, err := s.d.Cmd("push", fqn); err != nil { + c.Fatalf("failed to push image %s: error %v, output %q", fqn, err, out) + } + + remoteValue, _, _ := apiCallInspectImage(c, s.d, repoName, true, false) + compareInspectValues(c, "a", localValue, remoteValue, true) + + if out, err := s.d.Cmd("rmi", fqn); err != nil { + c.Fatalf("failed to remove image %s: %s, %v", fqn, out, err) + } + + remoteValue2, _, _ := apiCallInspectImage(c, s.d, fqn, true, false) + compareInspectValues(c, "a", localValue, remoteValue2, true) +} + +func (s *DockerSchema1RegistrySuite) TestInspectApiNonExistentRepositorySchema1(c *check.C) { + repoName := fmt.Sprintf("%s/foo/non-existent", s.reg.url) + + _, status, err := apiCallInspectImage(c, nil, repoName, false, true) + c.Assert(status, check.Equals, http.StatusNotFound) + c.Assert(err, check.Not(check.IsNil)) + c.Assert(err.Error(), check.Matches, `(?i)no such image.*`) + + _, status, err = apiCallInspectImage(c, nil, repoName, true, true) + c.Assert(err, check.Not(check.IsNil)) + c.Assert(err.Error(), check.Matches, `(?is).*(not found|no such image|no tags available|not known).*`) +} diff --git a/integration-cli/docker_utils.go b/integration-cli/docker_utils.go index 655c7c5..10ccc45 100644 --- a/integration-cli/docker_utils.go +++ b/integration-cli/docker_utils.go @@ -504,7 +504,7 @@ func (d *Daemon) getImages(c *check.C, args ...string) map[string]*localImageEnt reImageEntry := regexp.MustCompile(`(?m)^([[:alnum:]/.:_<>-]+)\s+([[:alnum:]._<>-]+)\s+((?:sha\d+:)?[a-fA-F0-9]+)\s+\S+\s+(.+)`) result := make(map[string]*localImageEntry) - out, err := d.Cmd("images", args...) + out, err := d.Cmd("images", append([]string{"--no-trunc"}, args...)...) if err != nil { c.Fatalf("failed to list images: %v", err) } @@ -583,6 +583,77 @@ func (d *Daemon) inspectField(name, field string) (string, error) { return strings.TrimSpace(out), nil } +func (d *Daemon) sockConn(timeout time.Duration) (net.Conn, error) { + daemon := d.sock() + daemonURL, err := url.Parse(daemon) + if err != nil { + return nil, fmt.Errorf("could not parse url %q: %v", daemon, err) + } + + var c net.Conn + switch daemonURL.Scheme { + case "unix": + return net.DialTimeout(daemonURL.Scheme, daemonURL.Path, timeout) + case "tcp": + return net.DialTimeout(daemonURL.Scheme, daemonURL.Host, timeout) + default: + return c, fmt.Errorf("unknown scheme %v (%s)", daemonURL.Scheme, daemon) + } +} + +func (d *Daemon) sockRequest(method, endpoint string, data interface{}) (int, []byte, error) { + jsonData := bytes.NewBuffer(nil) + if err := json.NewEncoder(jsonData).Encode(data); err != nil { + return -1, nil, err + } + + res, body, err := d.sockRequestRaw(method, endpoint, jsonData, "application/json") + if err != nil { + return -1, nil, err + } + b, err := readBody(body) + return res.StatusCode, b, err +} + +func (d *Daemon) sockRequestRaw(method, endpoint string, data io.Reader, ct string) (*http.Response, io.ReadCloser, error) { + req, client, err := d.newRequestClient(method, endpoint, data, ct) + if err != nil { + return nil, nil, err + } + + resp, err := client.Do(req) + if err != nil { + client.Close() + return nil, nil, err + } + body := ioutils.NewReadCloserWrapper(resp.Body, func() error { + defer resp.Body.Close() + return client.Close() + }) + + return resp, body, nil +} + +func (d *Daemon) newRequestClient(method, endpoint string, data io.Reader, ct string) (*http.Request, *httputil.ClientConn, error) { + c, err := d.sockConn(time.Duration(10 * time.Second)) + if err != nil { + return nil, nil, fmt.Errorf("could not dial docker daemon: %v", err) + } + + client := httputil.NewClientConn(c, nil) + + req, err := http.NewRequest(method, endpoint, data) + if err != nil { + client.Close() + return nil, nil, fmt.Errorf("could not create new request: %v", err) + } + + if ct != "" { + req.Header.Set("Content-Type", ct) + } + return req, client, nil +} + func daemonHost() string { daemonURLStr := "unix://" + opts.DefaultUnixSocket if daemonHostVar := os.Getenv("DOCKER_HOST"); daemonHostVar != "" { diff --git a/integration-cli/registry.go b/integration-cli/registry.go index 56b35a8..152cf20 100644 --- a/integration-cli/registry.go +++ b/integration-cli/registry.go @@ -64,10 +64,6 @@ http: }, nil } -func newTestRegistryV2(c *check.C) (*testRegistryV2, error) { - return newTestRegistryV2At(c, privateRegistryURL) -} - func (t *testRegistryV2) Ping() error { // We always ping through HTTP for our test registry. resp, err := http.Get(fmt.Sprintf("http://%s/v2/", t.url)) diff --git a/vendor/src/github.com/docker/engine-api/types/types.go b/vendor/src/github.com/docker/engine-api/types/types.go index 9209006..9949042 100644 --- a/vendor/src/github.com/docker/engine-api/types/types.go +++ b/vendor/src/github.com/docker/engine-api/types/types.go @@ -99,10 +99,9 @@ type GraphDriverData struct { Data map[string]string } -// ImageInspect contains response of Remote API: +// ImageInspectBase contains response of Remote API: // GET "/images/{name:.*}/json" -type ImageInspect struct { - ID string `json:"Id"` +type ImageInspectBase struct { RepoTags []string RepoDigests []string Parent string @@ -116,8 +115,23 @@ type ImageInspect struct { Architecture string Os string Size int64 - VirtualSize int64 - GraphDriver GraphDriverData +} + +// ImageInspect contains response of Remote API: +// GET "/images/{name:.*}/json" +type ImageInspect struct { + ID string `json:"Id"` + ImageInspectBase + VirtualSize int64 + GraphDriver GraphDriverData +} + +// RemoteImageInspect contains response of RemoteAPI: +// GET "/images/{name:.*}/json?remote=1" +type RemoteImageInspect struct { + V1ID string `json:"V1Id"` + ImageInspectBase + Registry string } // Port stores open ports info of container