diff --git a/pkg/build/addimage/build.go b/pkg/build/addimage/build.go new file mode 100644 index 0000000000..f36746a61b --- /dev/null +++ b/pkg/build/addimage/build.go @@ -0,0 +1,60 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage + +import ( + "runtime" + + "sigs.k8s.io/kind/pkg/apis/config/defaults" + "sigs.k8s.io/kind/pkg/log" +) + +// Build builds a node image using the supplied options +func Build(options ...Option) error { + // default options + ctx := &buildContext{ + image: DefaultImage, + baseImage: defaults.Image, + logger: log.NoopLogger{}, + arch: runtime.GOARCH, + } + + // apply user options + for _, option := range options { + if err := option.apply(ctx); err != nil { + return err + } + } + + // verify that we're using a supported arch + if !supportedArch(ctx.arch) { + ctx.logger.Warnf("unsupported architecture %q", ctx.arch) + } + return ctx.Build() +} + +func supportedArch(arch string) bool { + switch arch { + default: + return false + // currently we nominally support building node images for these + case "amd64": + case "arm64": + case "ppc64le": + } + return true +} diff --git a/pkg/build/addimage/buildcontext.go b/pkg/build/addimage/buildcontext.go new file mode 100644 index 0000000000..86493c7e74 --- /dev/null +++ b/pkg/build/addimage/buildcontext.go @@ -0,0 +1,177 @@ +/* +Copyrigh. 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage + +import ( + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "sigs.k8s.io/kind/pkg/build/addimage/internal/container/docker" + "sigs.k8s.io/kind/pkg/errors" + "sigs.k8s.io/kind/pkg/exec" + "sigs.k8s.io/kind/pkg/fs" + "sigs.k8s.io/kind/pkg/log" +) + +// buildContext is used to build the kind node image, and contains +// build configuration +type buildContext struct { + // option fields + image string + baseImage string + additionalImages []string + logger log.Logger + arch string +} + +// Build builds the cluster node image, the sourcedir must be set on +// the buildContext +func (c *buildContext) Build() (err error) { + return c.addImages() +} + +func (c *buildContext) addImages() error { + + c.logger.V(0).Info("Starting to add images to base image") + // pull images to local docker + for _, imageName := range c.additionalImages { + // continue if image already exists, no need to pull + _, err := docker.ImageID(imageName) + if err == nil { + continue + } + + err = docker.Pull(c.logger, imageName, dockerBuildOsAndArch(c.arch), 3) + if err != nil { + c.logger.Errorf("Add image build Failed! Failed to pull image %v: %v", imageName, err) + } + } + + // create build container + c.logger.V(0).Info("Creating build container based on " + c.baseImage) + // pull images to local docker + containerID, err := c.createBuildContainer() + // ensure we will delete it + if containerID != "" { + defer func() { + _ = exec.Command("docker", "rm", "-f", "-v", containerID).Run() + }() + } + if err != nil { + c.logger.Errorf("Image build Failed! Failed to create build container: %v", err) + return err + } + c.logger.V(0).Info("Building in " + containerID) + + // For kubernetes v1.15+ (actually 1.16 alpha versions) we may need to + // drop the arch suffix from images to get the expected image + archSuffix := "-" + c.arch + fixRepository := func(repository string) string { + if strings.HasSuffix(repository, archSuffix) { + fixed := strings.TrimSuffix(repository, archSuffix) + c.logger.V(1).Info("fixed: " + repository + " -> " + fixed) + repository = fixed + } + return repository + } + + // Tar up the images to make the load easier (and follow the current load pattern) + // Setup the tar path where the images will be saved + dir, err := fs.TempDir("", "images-tar") + if err != nil { + return errors.Wrap(err, "failed to create tempdir") + } + defer os.RemoveAll(dir) + imagesTarFile := filepath.Join(dir, "images.tar") + // Save the images into a tar file + c.logger.V(0).Info("Saving images into tar file at " + imagesTarFile) + err = docker.SaveImages(c.additionalImages, imagesTarFile) + if err != nil { + return err + } + + // setup image importer + cmder := docker.ContainerCmder(containerID) + importer := newContainerdImporter(cmder) + + f, err := os.Open(imagesTarFile) + if err != nil { + return err + } + defer f.Close() + //return importer.LoadCommand().SetStdout(os.Stdout).SetStderr(os.Stderr).SetStdin(f).Run() + // we will rewrite / correct the tags as we load the image + c.logger.V(0).Info("Importing images into build container " + containerID) + if err := exec.RunWithStdinWriter(importer.LoadCommand().SetStdout(os.Stdout).SetStderr(os.Stdout), func(w io.Writer) error { + return docker.EditArchive(f, w, fixRepository, c.arch) + }); err != nil { + return err + } + + // Save the image changes to a new image + c.logger.V(0).Info("Saving new image " + c.image) + saveCmd := exec.Command( + "docker", "commit", + // we need to put this back after changing it when running the image + "--change", `ENTRYPOINT [ "/usr/local/bin/entrypoint", "/sbin/init" ]`, + containerID, c.image, + ) + exec.InheritOutput(saveCmd) + if err = saveCmd.Run(); err != nil { + c.logger.Errorf("Add image build Failed! Failed to save image: %v", err) + return err + } + + c.logger.V(0).Info("Add image build completed.") + return nil +} + +func (c *buildContext) createBuildContainer() (id string, err error) { + // attempt to explicitly pull the image if it doesn't exist locally + // we don't care if this errors, we'll still try to run which also pulls + _ = docker.Pull(c.logger, c.baseImage, dockerBuildOsAndArch(c.arch), 4) + // this should be good enough: a specific prefix, the current unix time, + // and a little random bits in case we have multiple builds simultaneously + random := rand.New(rand.NewSource(time.Now().UnixNano())).Int31() + id = fmt.Sprintf("kind-build-%d-%d", time.Now().UTC().Unix(), random) + err = docker.Run( + c.baseImage, + []string{ + "-d", // make the client exit while the container continues to run + // run containerd so that the cri command works + "--entrypoint=/usr/local/bin/containerd", + "--name=" + id, + "--platform=" + dockerBuildOsAndArch(c.arch), + }, + []string{ + "", + }, + ) + if err != nil { + return id, errors.Wrap(err, "failed to create build container") + } + return id, nil +} + +func dockerBuildOsAndArch(arch string) string { + return "linux/" + arch +} diff --git a/pkg/build/addimage/const.go b/pkg/build/addimage/const.go new file mode 100644 index 0000000000..51f8aef58e --- /dev/null +++ b/pkg/build/addimage/const.go @@ -0,0 +1,17 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage diff --git a/pkg/build/addimage/defaults.go b/pkg/build/addimage/defaults.go new file mode 100644 index 0000000000..93a6a26ba3 --- /dev/null +++ b/pkg/build/addimage/defaults.go @@ -0,0 +1,20 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage + +// DefaultImage is the default name:tag for the built image +const DefaultImage = "kindest/custom-node:latest" diff --git a/pkg/build/addimage/imageimporter.go b/pkg/build/addimage/imageimporter.go new file mode 100644 index 0000000000..27b152ca95 --- /dev/null +++ b/pkg/build/addimage/imageimporter.go @@ -0,0 +1,38 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage + +import ( + "sigs.k8s.io/kind/pkg/exec" +) + +type containerdImporter struct { + containerCmder exec.Cmder +} + +func newContainerdImporter(containerCmder exec.Cmder) *containerdImporter { + return &containerdImporter{ + containerCmder: containerCmder, + } +} + +func (c *containerdImporter) LoadCommand() exec.Cmd { + return c.containerCmder.Command( + // TODO: ideally we do not need this in the future. we have fixed at least one image + "ctr", "--namespace=k8s.io", "images", "import", "--all-platforms", "--no-unpack", "-", + ) +} diff --git a/pkg/build/addimage/internal/container/docker/archive.go b/pkg/build/addimage/internal/container/docker/archive.go new file mode 100644 index 0000000000..384b20cb2f --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/archive.go @@ -0,0 +1,167 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package docker contains helpers for working with docker +// This package has no stability guarantees whatsoever! +package docker + +import ( + "archive/tar" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "strings" +) + +// EditArchiveRepositories applies edit to reader's image repositories, +// IE the repository part of repository:tag in image tags +// This supports v1 / v1.1 / v1.2 Docker Image Archives +// +// editRepositories should be a function that returns the input or an edited +// form, where the input is the image repository +// +// https://github.com/moby/moby/blob/master/image/spec/v1.md +// https://github.com/moby/moby/blob/master/image/spec/v1.1.md +// https://github.com/moby/moby/blob/master/image/spec/v1.2.md +func EditArchive(reader io.Reader, writer io.Writer, editRepositories func(string) string, architectureOverride string) error { + tarReader := tar.NewReader(reader) + tarWriter := tar.NewWriter(writer) + // iterate all entries in the tarball + for { + // read an entry + hdr, err := tarReader.Next() + if err == io.EOF { + return tarWriter.Close() + } else if err != nil { + return err + } + b, err := ioutil.ReadAll(tarReader) + if err != nil { + return err + } + + // edit the repostories and manifests files when we find them + if hdr.Name == "repositories" { + b, err = editRepositoriesFile(b, editRepositories) + if err != nil { + return err + } + hdr.Size = int64(len(b)) + } else if hdr.Name == "manifest.json" { + b, err = editManifestRepositories(b, editRepositories) + if err != nil { + return err + } + hdr.Size = int64(len(b)) + // edit image config when we find that + } else if strings.HasSuffix(hdr.Name, ".json") { + if architectureOverride != "" { + b, err = editConfigArchitecture(b, architectureOverride) + if err != nil { + return err + } + hdr.Size = int64(len(b)) + } + } + + // write to the output tarball + if err := tarWriter.WriteHeader(hdr); err != nil { + return err + } + if len(b) > 0 { + if _, err := tarWriter.Write(b); err != nil { + return err + } + } + } +} + +/* helpers */ + +func editConfigArchitecture(raw []byte, architectureOverride string) ([]byte, error) { + var cfg map[string]interface{} + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + const architecture = "architecture" + if _, ok := cfg[architecture]; !ok { + return raw, nil + } + cfg[architecture] = architectureOverride + return json.Marshal(cfg) +} + +// archiveRepositories represents repository:tag:ref +// +// https://github.com/moby/moby/blob/master/image/spec/v1.md +// https://github.com/moby/moby/blob/master/image/spec/v1.1.md +// https://github.com/moby/moby/blob/master/image/spec/v1.2.md +type archiveRepositories map[string]map[string]string + +func editRepositoriesFile(raw []byte, editRepositories func(string) string) ([]byte, error) { + tags, err := parseRepositories(raw) + if err != nil { + return nil, err + } + + fixed := make(archiveRepositories) + for repository, tagsToRefs := range tags { + fixed[editRepositories(repository)] = tagsToRefs + } + + return json.Marshal(fixed) +} + +// https://github.com/moby/moby/blob/master/image/spec/v1.2.md#combined-image-json--filesystem-changeset-format +type metadataEntry struct { + Config string `json:"Config"` + RepoTags []string `json:"RepoTags"` + Layers []string `json:"Layers"` +} + +// applies +func editManifestRepositories(raw []byte, editRepositories func(string) string) ([]byte, error) { + var entries []metadataEntry + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, err + } + + for i, entry := range entries { + fixed := make([]string, len(entry.RepoTags)) + for i, tag := range entry.RepoTags { + parts := strings.Split(tag, ":") + if len(parts) > 2 { + return nil, fmt.Errorf("invalid repotag: %s", entry) + } + parts[0] = editRepositories(parts[0]) + fixed[i] = strings.Join(parts, ":") + } + + entries[i].RepoTags = fixed + } + + return json.Marshal(entries) +} + +// returns repository:tag:ref +func parseRepositories(data []byte) (archiveRepositories, error) { + var repoTags archiveRepositories + if err := json.Unmarshal(data, &repoTags); err != nil { + return nil, err + } + return repoTags, nil +} diff --git a/pkg/build/addimage/internal/container/docker/exec.go b/pkg/build/addimage/internal/container/docker/exec.go new file mode 100644 index 0000000000..346e201234 --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/exec.go @@ -0,0 +1,132 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +import ( + "context" + "io" + + "sigs.k8s.io/kind/pkg/exec" +) + +// containerCmder implements exec.Cmder for docker containers +type containerCmder struct { + nameOrID string +} + +// ContainerCmder creates a new exec.Cmder against a docker container +func ContainerCmder(containerNameOrID string) exec.Cmder { + return &containerCmder{ + nameOrID: containerNameOrID, + } +} + +func (c *containerCmder) Command(command string, args ...string) exec.Cmd { + return &containerCmd{ + nameOrID: c.nameOrID, + command: command, + args: args, + } +} + +func (c *containerCmder) CommandContext(ctx context.Context, command string, args ...string) exec.Cmd { + return &containerCmd{ + nameOrID: c.nameOrID, + command: command, + args: args, + ctx: ctx, + } +} + +// containerCmd implements exec.Cmd for docker containers +type containerCmd struct { + nameOrID string // the container name or ID + command string + args []string + env []string + stdin io.Reader + stdout io.Writer + stderr io.Writer + ctx context.Context +} + +func (c *containerCmd) Run() error { + args := []string{ + "exec", + // run with privileges so we can remount etc.. + // this might not make sense in the most general sense, but it is + // important to many kind commands + "--privileged", + } + if c.stdin != nil { + args = append(args, + "-i", // interactive so we can supply input + ) + } + // set env + for _, env := range c.env { + args = append(args, "-e", env) + } + // specify the container and command, after this everything will be + // args the command in the container rather than to docker + args = append( + args, + c.nameOrID, // ... against the container + c.command, // with the command specified + ) + args = append( + args, + // finally, with the caller args + c.args..., + ) + var cmd exec.Cmd + if c.ctx != nil { + cmd = exec.CommandContext(c.ctx, "docker", args...) + } else { + cmd = exec.Command("docker", args...) + } + if c.stdin != nil { + cmd.SetStdin(c.stdin) + } + if c.stderr != nil { + cmd.SetStderr(c.stderr) + } + if c.stdout != nil { + cmd.SetStdout(c.stdout) + } + return cmd.Run() +} + +func (c *containerCmd) SetEnv(env ...string) exec.Cmd { + c.env = env + return c +} + +func (c *containerCmd) SetStdin(r io.Reader) exec.Cmd { + c.stdin = r + return c +} + +func (c *containerCmd) SetStdout(w io.Writer) exec.Cmd { + c.stdout = w + return c +} + +func (c *containerCmd) SetStderr(w io.Writer) exec.Cmd { + c.stderr = w + return c +} diff --git a/pkg/build/addimage/internal/container/docker/image.go b/pkg/build/addimage/internal/container/docker/image.go new file mode 100644 index 0000000000..cc179dcc67 --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/image.go @@ -0,0 +1,86 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +import ( + "fmt" + "strings" + + "sigs.k8s.io/kind/pkg/errors" + "sigs.k8s.io/kind/pkg/exec" +) + +// SplitImage splits an image into (registry,tag) following these cases: +// +// alpine -> (alpine, latest) +// +// alpine:latest -> (alpine, latest) +// +// alpine@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 -> (alpine, latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913) +// +// alpine:latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 -> (alpine, latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913) +// +// NOTE: for our purposes we consider the sha to be part of the tag, and we +// resolve the implicit :latest +func SplitImage(image string) (registry, tag string, err error) { + // we are looking for ':' and '@' + firstColon := strings.IndexByte(image, 58) + firstAt := strings.IndexByte(image, 64) + + // there should be a registry before the tag, and @/: should not be the last + // character, these cases are assumed not to exist by the rest of the code + if firstColon == 0 || firstAt == 0 || firstColon+1 == len(image) || firstAt+1 == len(image) { + return "", "", fmt.Errorf("unexpected image: %q", image) + } + + // NOTE: The order of these cases matters + // case: alpine + if firstColon == -1 && firstAt == -1 { + return image, "latest", nil + } + + // case: alpine@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 + if firstAt != -1 && firstAt < firstColon { + return image[:firstAt], "latest" + image[firstAt:], nil + } + + // case: alpine:latest + // case: alpine:latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 + return image[:firstColon], image[firstColon+1:], nil +} + +// ImageInspect return low-level information on containers images +func ImageInspect(containerNameOrID, format string) ([]string, error) { + cmd := exec.Command("docker", "image", "inspect", + "-f", format, + containerNameOrID, // ... against the container + ) + + return exec.OutputLines(cmd) +} + +// ImageID return the Id of the container image +func ImageID(containerNameOrID string) (string, error) { + lines, err := ImageInspect(containerNameOrID, "{{ .Id }}") + if err != nil { + return "", err + } + if len(lines) != 1 { + return "", errors.Errorf("Docker image ID should only be one line, got %d lines", len(lines)) + } + return lines[0], nil +} diff --git a/pkg/build/addimage/internal/container/docker/image_test.go b/pkg/build/addimage/internal/container/docker/image_test.go new file mode 100644 index 0000000000..2048ba2fe8 --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/image_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +import "testing" + +func TestSplitImage(t *testing.T) { + t.Parallel() + /* + alpine -> (alpine, latest) + + alpine:latest -> (alpine, latest) + + alpine@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 -> (alpine, latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913) + + alpine:latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913 -> (alpine, latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913) + */ + cases := []struct { + Image string + ExpectedRegistry string + ExpectedTag string + ExpectError bool + }{ + { + Image: "alpine", + ExpectedRegistry: "alpine", + ExpectedTag: "latest", + ExpectError: false, + }, + { + Image: "alpine:latest", + ExpectedRegistry: "alpine", + ExpectedTag: "latest", + ExpectError: false, + }, + { + Image: "alpine@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectedRegistry: "alpine", + ExpectedTag: "latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectError: false, + }, + { + Image: "alpine:latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectedRegistry: "alpine", + ExpectedTag: "latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectError: false, + }, + { + Image: "k8s.gcr.io/coredns:1.1.3", + ExpectedRegistry: "k8s.gcr.io/coredns", + ExpectedTag: "1.1.3", + ExpectError: false, + }, + { + Image: "k8s.gcr.io/coredns:1.1.3@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectedRegistry: "k8s.gcr.io/coredns", + ExpectedTag: "1.1.3@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectError: false, + }, + { + Image: "k8s.gcr.io/coredns:latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectedRegistry: "k8s.gcr.io/coredns", + ExpectedTag: "latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectError: false, + }, + { + Image: "k8s.gcr.io/coredns@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectedRegistry: "k8s.gcr.io/coredns", + ExpectedTag: "latest@sha256:28ef97b8686a0b5399129e9b763d5b7e5ff03576aa5580d6f4182a49c5fe1913", + ExpectError: false, + }, + { + Image: ":", + ExpectedRegistry: "", + ExpectedTag: "", + ExpectError: true, + }, + { + Image: "@", + ExpectedRegistry: "", + ExpectedTag: "", + ExpectError: true, + }, + { + Image: "a@", + ExpectedRegistry: "", + ExpectedTag: "", + ExpectError: true, + }, + { + Image: "a:", + ExpectedRegistry: "", + ExpectedTag: "", + ExpectError: true, + }, + } + + for _, tc := range cases { + tc := tc // capture tc + t.Run(tc.Image, func(t *testing.T) { + t.Parallel() + + registry, tag, err := SplitImage(tc.Image) + if err != nil && !tc.ExpectError { + t.Fatalf("Unexpected error: %q", err) + } else if err == nil && tc.ExpectError { + t.Fatalf("Expected error but got nil") + } + if registry != tc.ExpectedRegistry { + t.Fatalf("ExpectedRegistry %q != %q", tc.ExpectedRegistry, registry) + } + if tag != tc.ExpectedTag { + t.Fatalf("ExpectedTag %q != %q", tc.ExpectedTag, tag) + } + }) + } +} diff --git a/pkg/build/addimage/internal/container/docker/pull.go b/pkg/build/addimage/internal/container/docker/pull.go new file mode 100644 index 0000000000..0ac49de6b4 --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/pull.go @@ -0,0 +1,43 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +import ( + "time" + + "sigs.k8s.io/kind/pkg/exec" + "sigs.k8s.io/kind/pkg/log" +) + +// Pull pulls an image, retrying up to retries times +func Pull(logger log.Logger, image string, platform string, retries int) error { + logger.V(1).Infof("Pulling image: %s for platform %s ...", image, platform) + err := exec.Command("docker", "pull", "--platform="+platform, image).Run() + // retry pulling up to retries times if necessary + if err != nil { + for i := 0; i < retries; i++ { + time.Sleep(time.Second * time.Duration(i+1)) + logger.V(1).Infof("Trying again to pull image: %q ... %v", image, err) + // TODO(bentheelder): add some backoff / sleep? + err = exec.Command("docker", "pull", "--platform="+platform, image).Run() + if err == nil { + break + } + } + } + return err +} diff --git a/pkg/build/addimage/internal/container/docker/run.go b/pkg/build/addimage/internal/container/docker/run.go new file mode 100644 index 0000000000..54e8a42b4e --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/run.go @@ -0,0 +1,32 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +import ( + "sigs.k8s.io/kind/pkg/exec" +) + +// Run creates a container with "docker run", with some error handling +func Run(image string, runArgs []string, containerArgs []string) error { + // construct the actual docker run argv + args := []string{"run"} + args = append(args, runArgs...) + args = append(args, image) + args = append(args, containerArgs...) + cmd := exec.Command("docker", args...) + return cmd.Run() +} diff --git a/pkg/build/addimage/internal/container/docker/save.go b/pkg/build/addimage/internal/container/docker/save.go new file mode 100644 index 0000000000..5fbf9ef44a --- /dev/null +++ b/pkg/build/addimage/internal/container/docker/save.go @@ -0,0 +1,27 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package docker + +import ( + "sigs.k8s.io/kind/pkg/exec" +) + +// Save saves images to dest, as in `docker save` +func SaveImages(images []string, dest string) error { + commandArgs := append([]string{"save", "-o", dest}, images...) + return exec.Command("docker", commandArgs...).Run() +} diff --git a/pkg/build/addimage/options.go b/pkg/build/addimage/options.go new file mode 100644 index 0000000000..b6f7db736c --- /dev/null +++ b/pkg/build/addimage/options.go @@ -0,0 +1,74 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage + +import ( + "sigs.k8s.io/kind/pkg/log" +) + +// Option is a configuration option supplied to Build +type Option interface { + apply(*buildContext) error +} + +type optionAdapter func(*buildContext) error + +func (c optionAdapter) apply(o *buildContext) error { + return c(o) +} + +// WithImage configures a build to tag the built image with `image` +func WithImage(image string) Option { + return optionAdapter(func(b *buildContext) error { + b.image = image + return nil + }) +} + +// WithBaseImage configures a build to use `image` as the base image +func WithBaseImage(image string) Option { + return optionAdapter(func(b *buildContext) error { + b.baseImage = image + return nil + }) +} + +// WithAdditionalImages configures a build to add images to the node image +func WithAdditonalImages(images []string) Option { + return optionAdapter(func(b *buildContext) error { + b.additionalImages = images + return nil + }) +} + +// WithLogger sets the logger +func WithLogger(logger log.Logger) Option { + return optionAdapter(func(b *buildContext) error { + b.logger = logger + return nil + }) +} + +// WithArch sets the architecture to build for +func WithArch(arch string) Option { + return optionAdapter(func(b *buildContext) error { + if arch != "" { + b.arch = arch + } + return nil + }) +} diff --git a/pkg/cmd/kind/build/addimage/addimage.go b/pkg/cmd/kind/build/addimage/addimage.go new file mode 100644 index 0000000000..79ae6efb9f --- /dev/null +++ b/pkg/cmd/kind/build/addimage/addimage.go @@ -0,0 +1,84 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package addimage + +import ( + "github.com/spf13/cobra" + + "sigs.k8s.io/kind/pkg/apis/config/defaults" + "sigs.k8s.io/kind/pkg/build/addimage" + "sigs.k8s.io/kind/pkg/cmd" + "sigs.k8s.io/kind/pkg/errors" + "sigs.k8s.io/kind/pkg/log" +) + +type flagpole struct { + Image string + BaseImage string + Arch string +} + +// NewCommand returns a new cobra.Command for adding images to the node image +func NewCommand(logger log.Logger, streams cmd.IOStreams) *cobra.Command { + flags := &flagpole{} + cmd := &cobra.Command{ + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("a list of image names is required") + } + return nil + }, + // TODO(bentheelder): more detailed usage + Use: "add-image [IMAGE...]", + Short: "Add images to a kind node image and build a custom node image", + Long: "Add images to a kind node image and build a custom node image", + RunE: func(cmd *cobra.Command, args []string) error { + return runE(logger, flags, args) + }, + } + + cmd.Flags().StringVar( + &flags.Image, "image", + addimage.DefaultImage, + "name:tag of the resulting image to be built", + ) + cmd.Flags().StringVar( + &flags.BaseImage, "base-image", + defaults.Image, + "name:tag of the base image to use for the build", + ) + cmd.Flags().StringVar( + &flags.Arch, "arch", + "", + "architecture to build for, defaults to the host architecture", + ) + return cmd +} + +func runE(logger log.Logger, flags *flagpole, args []string) error { + + if err := addimage.Build( + addimage.WithImage(flags.Image), + addimage.WithBaseImage(flags.BaseImage), + addimage.WithAdditonalImages(args), + addimage.WithLogger(logger), + addimage.WithArch(flags.Arch), + ); err != nil { + return errors.Wrap(err, "error adding images to node image") + } + return nil +} diff --git a/pkg/cmd/kind/build/build.go b/pkg/cmd/kind/build/build.go index 1c62df609c..4f1cc1cab6 100644 --- a/pkg/cmd/kind/build/build.go +++ b/pkg/cmd/kind/build/build.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "sigs.k8s.io/kind/pkg/cmd" + "sigs.k8s.io/kind/pkg/cmd/kind/build/addimage" "sigs.k8s.io/kind/pkg/cmd/kind/build/nodeimage" "sigs.k8s.io/kind/pkg/log" ) @@ -45,5 +46,6 @@ func NewCommand(logger log.Logger, streams cmd.IOStreams) *cobra.Command { } // add subcommands cmd.AddCommand(nodeimage.NewCommand(logger, streams)) + cmd.AddCommand(addimage.NewCommand(logger, streams)) return cmd }