Add support for platform (os and architecture) on image import
fixes https://github.com/moby/moby/issues/42813
fixes https://github.com/docker/cli/issues/3408

Commit 0380fbff37922cadf294851b1546f4c212c7f364 (part of https://github.com/moby/moby/pull/34642) added the ability to pass a `--platform flag` on `docker import` when importing an archive. The intent of that commit was to allow importing a Linux rootfs on a Windows daemon (as part of the experimental LCOW feature).

A later commit (337ba71fc1124603302e28d94e2f08674e31a756, part of https://github.com/moby/moby/pull/37350) changed some of this code to take both OS and Architecture into account (for `docker build` and `docker pull`), but did not yet update the `docker image import`.

This patch updates the import endpoitn to allow passing both OS and Architecture. Note that currently only matching OSes are accepted, and an error will be produced when (e.g.) specifying `linux` on Windows and vice-versa.

**- How to verify it**

A test has been added, which can be run with:

```bash
make DOCKER_GRAPHDRIVER=vfs TEST_SKIP_INTEGRATION_CLI=1 TESTFLAGS='-test.run TestImportWithCustomPlatform' test-integration

...

INFO: Testing against a local daemon
=== RUN   TestImportWithCustomPlatform
=== PAUSE TestImportWithCustomPlatform
=== CONT  TestImportWithCustomPlatform
=== RUN   TestImportWithCustomPlatform/#00
=== RUN   TestImportWithCustomPlatform/_______
=== RUN   TestImportWithCustomPlatform//
=== RUN   TestImportWithCustomPlatform/linux
=== RUN   TestImportWithCustomPlatform/LINUX
=== RUN   TestImportWithCustomPlatform/linux/sparc64
=== RUN   TestImportWithCustomPlatform/macos
=== RUN   TestImportWithCustomPlatform/macos/arm64
=== RUN   TestImportWithCustomPlatform/nintendo64
--- PASS: TestImportWithCustomPlatform (1.44s)
    --- PASS: TestImportWithCustomPlatform/#00 (0.14s)
    --- PASS: TestImportWithCustomPlatform/_______ (0.00s)
    --- PASS: TestImportWithCustomPlatform// (0.00s)
    --- PASS: TestImportWithCustomPlatform/linux (0.13s)
    --- PASS: TestImportWithCustomPlatform/LINUX (0.13s)
    --- PASS: TestImportWithCustomPlatform/linux/sparc64 (0.13s)
    --- PASS: TestImportWithCustomPlatform/macos (0.03s)
    --- PASS: TestImportWithCustomPlatform/macos/arm64 (0.00s)
    --- PASS: TestImportWithCustomPlatform/nintendo64 (0.00s)
PASS
```

**- Description for the changelog**
<!--
Write a short (one line) summary that describes the changes in this
pull request for inclusion in the changelog:
-->


**- A picture of a cute animal (not mandatory but encouraged)**


daemon/images/image_import.go
package images // import "github.com/docker/docker/daemon/images"

import (
	"encoding/json"
	"io"
	"net/http"
	"net/url"
	"runtime"
	"strings"
	"time"

	"github.com/docker/distribution/reference"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/builder/dockerfile"
	"github.com/docker/docker/builder/remotecontext"
	"github.com/docker/docker/dockerversion"
	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/image"
	"github.com/docker/docker/layer"
	"github.com/docker/docker/pkg/archive"
	"github.com/docker/docker/pkg/progress"
	"github.com/docker/docker/pkg/streamformatter"
	"github.com/pkg/errors"
)

// ImportImage imports an image, getting the archived layer data either from
// inConfig (if src is "-"), or from a URI specified in src. Progress output is
// written to outStream. Repository and tag names can optionally be given in
// the repo and tag arguments, respectively.
func (i *ImageService) ImportImage(src string, repository, os string, tag string, msg string, inConfig io.ReadCloser, outStream io.Writer, changes []string) error {
	var (
		rc     io.ReadCloser
		resp   *http.Response
		newRef reference.Named
	)

	// Default the operating system if not supplied.
	if os == "" {
		os = runtime.GOOS
	}

	if repository != "" {
		var err error
		newRef, err = reference.ParseNormalizedNamed(repository)
		if err != nil {
			return errdefs.InvalidParameter(err)
		}
		if _, isCanonical := newRef.(reference.Canonical); isCanonical {
			return errdefs.InvalidParameter(errors.New("cannot import digest reference"))
		}

		if tag != "" {
			newRef, err = reference.WithTag(newRef, tag)
			if err != nil {
				return errdefs.InvalidParameter(err)
			}
		}
	}

	config, err := dockerfile.BuildFromConfig(&container.Config{}, changes, os)
	if err != nil {
		return err
	}
	if src == "-" {
		rc = inConfig
	} else {
		inConfig.Close()
		if len(strings.Split(src, "://")) == 1 {
			src = "http://" + src
		}
		u, err := url.Parse(src)
		if err != nil {
			return errdefs.InvalidParameter(err)
		}

		resp, err = remotecontext.GetWithStatusError(u.String())
		if err != nil {
			return err
		}
		outStream.Write(streamformatter.FormatStatus("", "Downloading from %s", u))
		progressOutput := streamformatter.NewJSONProgressOutput(outStream, true)
		rc = progress.NewProgressReader(resp.Body, progressOutput, resp.ContentLength, "", "Importing")
	}

	defer rc.Close()
	if len(msg) == 0 {
		msg = "Imported from " + src
	}

	inflatedLayerData, err := archive.DecompressStream(rc)
	if err != nil {
		return err
	}
	l, err := i.layerStore.Register(inflatedLayerData, "")
	if err != nil {
		return err
	}
	defer layer.ReleaseAndLog(i.layerStore, l)

	created := time.Now().UTC()
	imgConfig, err := json.Marshal(&image.Image{
		V1Image: image.V1Image{
			DockerVersion: dockerversion.Version,
			Config:        config,
			Architecture:  runtime.GOARCH,
			OS:            os,
			Created:       created,
			Comment:       msg,
		},
		RootFS: &image.RootFS{
			Type:    "layers",
			DiffIDs: []layer.DiffID{l.DiffID()},
		},
		History: []image.History{{
			Created: created,
			Comment: msg,
		}},
	})
	if err != nil {
		return err
	}

	id, err := i.imageStore.Create(imgConfig)
	if err != nil {
		return err
	}

	// FIXME: connect with commit code and call refstore directly
	if newRef != nil {
		if err := i.TagImageWithReference(id, newRef); err != nil {
			return err
		}
	}

	i.LogImageEvent(id.String(), id.String(), "import")
	outStream.Write(streamformatter.FormatStatus("", id.String()))
	return nil
}

package images // import "github.com/docker/docker/daemon/images"

import (
	"encoding/json"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/containerd/containerd/platforms"
	"github.com/docker/distribution/reference"
	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/builder/dockerfile"
	"github.com/docker/docker/builder/remotecontext"
	"github.com/docker/docker/dockerversion"
	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/image"
	"github.com/docker/docker/layer"
	"github.com/docker/docker/pkg/archive"
	"github.com/docker/docker/pkg/progress"
	"github.com/docker/docker/pkg/streamformatter"
	specs "github.com/opencontainers/image-spec/specs-go/v1"
	"github.com/pkg/errors"
)

// ImportImage imports an image, getting the archived layer data either from
// inConfig (if src is "-"), or from a URI specified in src. Progress output is
// written to outStream. Repository and tag names can optionally be given in
// the repo and tag arguments, respectively.
func (i *ImageService) ImportImage(src string, repository string, platform *specs.Platform, tag string, msg string, inConfig io.ReadCloser, outStream io.Writer, changes []string) error {
	var (
		rc     io.ReadCloser
		resp   *http.Response
		newRef reference.Named
	)

	if repository != "" {
		var err error
		newRef, err = reference.ParseNormalizedNamed(repository)
		if err != nil {
			return errdefs.InvalidParameter(err)
		}
		if _, isCanonical := newRef.(reference.Canonical); isCanonical {
			return errdefs.InvalidParameter(errors.New("cannot import digest reference"))
		}

		if tag != "" {
			newRef, err = reference.WithTag(newRef, tag)
			if err != nil {
				return errdefs.InvalidParameter(err)
			}
		}
	}

	// Normalize platform - default to the operating system and architecture if not supplied.
	if platform == nil {
		p := platforms.DefaultSpec()
		platform = &p
	}

	config, err := dockerfile.BuildFromConfig(&container.Config{}, changes, platform.OS)
	if err != nil {
		return err
	}
	if src == "-" {
		rc = inConfig
	} else {
		inConfig.Close()
		if len(strings.Split(src, "://")) == 1 {
			src = "http://" + src
		}
		u, err := url.Parse(src)
		if err != nil {
			return errdefs.InvalidParameter(err)
		}

		resp, err = remotecontext.GetWithStatusError(u.String())
		if err != nil {
			return err
		}
		outStream.Write(streamformatter.FormatStatus("", "Downloading from %s", u))
		progressOutput := streamformatter.NewJSONProgressOutput(outStream, true)
		rc = progress.NewProgressReader(resp.Body, progressOutput, resp.ContentLength, "", "Importing")
	}

	defer rc.Close()
	if len(msg) == 0 {
		msg = "Imported from " + src
	}

	inflatedLayerData, err := archive.DecompressStream(rc)
	if err != nil {
		return err
	}
	l, err := i.layerStore.Register(inflatedLayerData, "")
	if err != nil {
		return err
	}
	defer layer.ReleaseAndLog(i.layerStore, l)

	created := time.Now().UTC()
	imgConfig, err := json.Marshal(&image.Image{
		V1Image: image.V1Image{
			DockerVersion: dockerversion.Version,
			Config:        config,
			Architecture:  platform.Architecture,
			Variant:       platform.Variant,
			OS:            platform.OS,
			Created:       created,
			Comment:       msg,
		},
		RootFS: &image.RootFS{
			Type:    "layers",
			DiffIDs: []layer.DiffID{l.DiffID()},
		},
		History: []image.History{{
			Created: created,
			Comment: msg,
		}},
	})
	if err != nil {
		return err
	}

	id, err := i.imageStore.Create(imgConfig)
	if err != nil {
		return err
	}

	// FIXME: connect with commit code and call refstore directly
	if newRef != nil {
		if err := i.TagImageWithReference(id, newRef); err != nil {
			return err
		}
	}

	i.LogImageEvent(id.String(), id.String(), "import")
	outStream.Write(streamformatter.FormatStatus("", id.String()))
	return nil
}

This was mostly an observation; the `Normalize()` code is here; https://github.com/moby/moby/blob/ea96e160e437bb241ed638f5f279c2fd58f139c4/vendor/github.com/containerd/containerd/platforms/platforms.go#L269-L271

That code calls both `normalizeOS()` and `normalizeArch()`; I would expect those to act the same, but where `normalizeOS()` both _normalizes_ the OS, _and_ sets a default; https://github.com/moby/moby/blob/ea96e160e437bb241ed638f5f279c2fd58f139c4/vendor/github.com/containerd/containerd/platforms/database.go#L69-L72

But `normalizeArch()` only "normalizes" (but does _not_ set defaults); https://github.com/moby/moby/blob/ea96e160e437bb241ed638f5f279c2fd58f139c4/vendor/github.com/containerd/containerd/platforms/database.go#L83

I would've expected them both to do the same (fill in missing fields if not set, and normalize the fields where needed), so if no architecture is set, it would set the local architecture as default.
