diff --git a/cmd/build.go b/cmd/build.go index 2662b2fc4..3fc433bd0 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "os" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" @@ -46,7 +47,7 @@ and the image name is stored in the configuration file. {{.Name}} build --builder=pack --builder-image cnbs/sample-builder:bionic `, SuggestFor: []string{"biuld", "buidl", "built"}, - PreRunE: bindEnv("image", "path", "builder", "registry", "confirm", "push", "builder-image"), + PreRunE: bindEnv("image", "path", "builder", "registry", "confirm", "push", "builder-image", "platform"), } cmd.Flags().StringP("builder", "b", "pack", "build strategy to use when creating the underlying image. Currently supported build strategies are 'pack' and 's2i'.") @@ -55,6 +56,7 @@ and the image name is stored in the configuration file. cmd.Flags().StringP("image", "i", "", "Full image name in the form [registry]/[namespace]/[name]:[tag] (optional). This option takes precedence over --registry (Env: $FUNC_IMAGE)") cmd.Flags().StringP("registry", "r", GetDefaultRegistry(), "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)") cmd.Flags().BoolP("push", "u", false, "Attempt to push the function image after being successfully built") + cmd.Flags().StringP("platform", "", "", "Target platform to build (e.g. linux/amd64).") setPathFlag(cmd) if err := cmd.RegisterFlagCompletionFunc("builder", CompleteBuildStrategyList); err != nil { @@ -158,9 +160,12 @@ func runBuild(cmd *cobra.Command, _ []string, newClient ClientFactory) (err erro // Choose a builder based on the value of the --builder flag var builder fn.Builder if config.Builder == "pack" { + if config.Platform != "" { + fmt.Fprintln(os.Stderr, "the --platform flag works only with s2i build") + } builder = buildpacks.NewBuilder(buildpacks.WithVerbose(config.Verbose)) } else if config.Builder == "s2i" { - builder = s2i.NewBuilder(s2i.WithVerbose(config.Verbose)) + builder = s2i.NewBuilder(s2i.WithVerbose(config.Verbose), s2i.WithPlatform(config.Platform)) } else { err = errors.New("unrecognized builder: valid values are: s2i, pack") return @@ -216,6 +221,8 @@ type buildConfig struct { // BuilderImage is the image (name or mapping) to use for building. Usually // set automatically. BuilderImage string + + Platform string } func newBuildConfig() buildConfig { @@ -228,6 +235,7 @@ func newBuildConfig() buildConfig { Builder: viper.GetString("builder"), BuilderImage: viper.GetString("builder-image"), Push: viper.GetBool("push"), + Platform: viper.GetString("platform"), } } diff --git a/cmd/deploy.go b/cmd/deploy.go index 28122bb1f..e4ce3139c 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -49,7 +49,7 @@ that is pushed to an image registry, and finally the function's Knative service {{.Name}} deploy --image quay.io/myuser/myfunc -n myns `, SuggestFor: []string{"delpoy", "deplyo"}, - PreRunE: bindEnv("image", "path", "registry", "confirm", "build", "push", "git-url", "git-branch", "git-dir", "builder", "builder-image"), + PreRunE: bindEnv("image", "path", "registry", "confirm", "build", "push", "git-url", "git-branch", "git-dir", "builder", "builder-image", "platform"), } cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)") @@ -66,6 +66,7 @@ that is pushed to an image registry, and finally the function's Knative service cmd.Flags().StringP("image", "i", "", "Full image name in the form [registry]/[namespace]/[name]:[tag] (optional). This option takes precedence over --registry (Env: $FUNC_IMAGE)") cmd.Flags().StringP("registry", "r", GetDefaultRegistry(), "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)") cmd.Flags().BoolP("push", "u", true, "Attempt to push the function image to registry before deploying (Env: $FUNC_PUSH)") + cmd.Flags().StringP("platform", "", "", "Target platform to build (e.g. linux/amd64).") setPathFlag(cmd) if err := cmd.RegisterFlagCompletionFunc("build", CompleteDeployBuildType); err != nil { @@ -174,9 +175,12 @@ func runDeploy(cmd *cobra.Command, _ []string, newClient ClientFactory) (err err // Choose a builder based on the value of the --builder flag var builder fn.Builder if config.Builder == "pack" { + if config.Platform != "" { + fmt.Fprintln(os.Stderr, "the --platform flag works only with s2i build") + } builder = buildpacks.NewBuilder(buildpacks.WithVerbose(config.Verbose)) } else if config.Builder == "s2i" { - builder = s2i.NewBuilder(s2i.WithVerbose(config.Verbose)) + builder = s2i.NewBuilder(s2i.WithVerbose(config.Verbose), s2i.WithPlatform(config.Platform)) } else { err = errors.New("unrecognized builder: valid values are: s2i, pack") return diff --git a/docker/platform.go b/docker/platform.go new file mode 100644 index 000000000..0c0d6723e --- /dev/null +++ b/docker/platform.go @@ -0,0 +1,71 @@ +package docker + +import ( + "fmt" + + "github.com/containerd/containerd/platforms" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + gcrTypes "github.com/google/go-containerregistry/pkg/v1/types" +) + +// GetPlatformImage returns image reference for specific platform. +// If the image is not multi-arch it returns ref argument directly (provided platform matches). +// If the image is multi-arch it returns digest based reference (provided the platform is part of the multi-arch image). +func GetPlatformImage(ref, platform string) (string, error) { + + plat, err := platforms.Parse(platform) + if err != nil { + return "", fmt.Errorf("cannot parse platform: %w", err) + } + + r, err := name.ParseReference(ref) + if err != nil { + return "", fmt.Errorf("cannot parse reference: %w", err) + } + + desc, err := remote.Get(r) + if err != nil { + return "", fmt.Errorf("cannot get remote image: %w", err) + } + + if desc.MediaType != gcrTypes.OCIImageIndex && desc.MediaType != gcrTypes.DockerManifestList { + // it's non-multi-arch image + var img v1.Image + var cfg *v1.ConfigFile + img, err = desc.Image() + if err != nil { + return "", fmt.Errorf("cannot get image from the descriptor: %w", err) + } + cfg, err = img.ConfigFile() + if err != nil { + return "", fmt.Errorf("cannot get config file for the image: %w", err) + } + + if plat.OS == cfg.OS && + plat.Architecture == cfg.Architecture { + return ref, nil + } + return "", fmt.Errorf("the %q platform is not supported by the %q image", platform, ref) + } + + idx, err := desc.ImageIndex() + if err != nil { + return "", fmt.Errorf("cannot get image index: %w", err) + } + + idxMft, err := idx.IndexManifest() + if err != nil { + return "", fmt.Errorf("cannot get index manifest: %w", err) + } + + for _, manifest := range idxMft.Manifests { + if plat.OS == manifest.Platform.OS && + plat.Architecture == manifest.Platform.Architecture { + return r.Context().Name() + "@" + manifest.Digest.String(), nil + } + } + + return "", fmt.Errorf("the %q platform is not supported by the %q image", platform, ref) +} diff --git a/docker/platform_test.go b/docker/platform_test.go new file mode 100644 index 000000000..69696d644 --- /dev/null +++ b/docker/platform_test.go @@ -0,0 +1,127 @@ +package docker_test + +import ( + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + gcrTypes "github.com/google/go-containerregistry/pkg/v1/types" + + "knative.dev/kn-plugin-func/docker" +) + +func TestPlatform(t *testing.T) { + testRegistry := startRegistry(t) + + nonMultiArchBuilder := testRegistry + "/default/builder:nonmultiarch" + multiArchBuilder := testRegistry + "/default/builder:multiarch" + + // begin push testing builders to registry + tag, err := name.NewTag(nonMultiArchBuilder) + if err != nil { + t.Fatal(err) + } + + var img v1.Image + img, err = mutate.ConfigFile(empty.Image, &v1.ConfigFile{ + Architecture: "ppc64le", + OS: "linux", + }) + if err != nil { + t.Fatal(err) + } + + err = remote.Write(&tag, img) + if err != nil { + t.Fatal(err) + } + + tag, err = name.NewTag(multiArchBuilder) + if err != nil { + t.Fatal(err) + } + + zeroHash := v1.Hash{ + Algorithm: "sha256", + Hex: "0000000000000000000000000000000000000000000000000000000000000000", + } + + var imgIdx = mutate.AppendManifests(empty.Index, mutate.IndexAddendum{ + Add: empty.Index, + Descriptor: v1.Descriptor{ + MediaType: gcrTypes.DockerManifestList, + Digest: zeroHash, + Platform: &v1.Platform{ + Architecture: "ppc64le", + OS: "linux", + }, + }, + }) + + err = remote.WriteIndex(tag, imgIdx) + if err != nil { + t.Fatal(err) + } + // end push testing builders to registry + + _, err = docker.GetPlatformImage(nonMultiArchBuilder, "windows/amd64") + if err == nil { + t.Error("expected error but got nil") + } + + _, err = docker.GetPlatformImage(multiArchBuilder, "windows/amd64") + if err == nil { + t.Error("expected error but got nil") + } + + var ref string + + ref, err = docker.GetPlatformImage(nonMultiArchBuilder, "linux/ppc64le") + if err != nil { + t.Errorf("unexpeced error: %v", err) + } + if ref != nonMultiArchBuilder { + t.Error("incorrect reference") + } + + ref, err = docker.GetPlatformImage(multiArchBuilder, "linux/ppc64le") + if err != nil { + t.Errorf("unexpeced error: %v", err) + } + if ref != testRegistry+"/default/builder@sha256:0000000000000000000000000000000000000000000000000000000000000000" { + t.Error("incorrect reference") + } +} + +func startRegistry(t *testing.T) (addr string) { + s := http.Server{ + Handler: registry.New(registry.Logger(log.New(io.Discard, "", 0))), + } + t.Cleanup(func() { s.Close() }) + + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + addr = l.Addr().String() + + go func() { + err = s.Serve(l) + if err != nil && !errors.Is(err, net.ErrClosed) { + fmt.Fprintln(os.Stderr, "ERROR: ", err) + } + }() + + return addr +} diff --git a/go.mod b/go.mod index 05858c892..0fbd0b1a9 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/alecthomas/jsonschema v0.0.0-20210526225647-edb03dcab7bc github.com/buildpacks/pack v0.24.0 github.com/cloudevents/sdk-go/v2 v2.8.0 + github.com/containerd/containerd v1.6.0 github.com/containers/image/v5 v5.19.1 github.com/coreos/go-semver v0.3.0 github.com/docker/cli v20.10.12+incompatible diff --git a/s2i/builder.go b/s2i/builder.go index 4412ed362..a4eb2e17c 100644 --- a/s2i/builder.go +++ b/s2i/builder.go @@ -53,9 +53,10 @@ type DockerClient interface { // Builder of Functions using the s2i subsystem. type Builder struct { - verbose bool - impl build.Builder // S2I builder implementation (aka "Strategy") - cli DockerClient + verbose bool + impl build.Builder // S2I builder implementation (aka "Strategy") + cli DockerClient + platform string } type Option func(*Builder) @@ -82,6 +83,12 @@ func WithDockerClient(cli DockerClient) Option { } } +func WithPlatform(platform string) Option { + return func(b *Builder) { + b.platform = platform + } +} + // NewBuilder creates a new instance of a Builder with static defaults. func NewBuilder(options ...Option) *Builder { b := &Builder{} @@ -100,6 +107,13 @@ func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) { return } + if b.platform != "" { + builderImage, err = docker.GetPlatformImage(builderImage, b.platform) + if err != nil { + return fmt.Errorf("cannot get platform specific image reference: %w", err) + } + } + // Build Config cfg := &api.Config{} cfg.Quiet = !b.verbose diff --git a/vendor/modules.txt b/vendor/modules.txt index 1dce83dbb..16d813972 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -178,6 +178,7 @@ github.com/cloudevents/sdk-go/v2/types # github.com/containerd/cgroups v1.0.3 github.com/containerd/cgroups/stats/v1 # github.com/containerd/containerd v1.6.0 +## explicit github.com/containerd/containerd/errdefs github.com/containerd/containerd/log github.com/containerd/containerd/pkg/userns