diff --git a/cli/cluster.go b/cli/cluster.go index aec873a1..b5797e2a 100644 --- a/cli/cluster.go +++ b/cli/cluster.go @@ -68,6 +68,10 @@ func createClusterDir(name string) { if err := createDirIfNotExists(clusterPath); err != nil { log.Fatalf("ERROR: couldn't create cluster directory [%s] -> %+v", clusterPath, err) } + // create subdir for sharing container images + if err := createDirIfNotExists(clusterPath + "/images"); err != nil { + log.Fatalf("ERROR: couldn't create cluster sub-directory [%s] -> %+v", clusterPath+"/images", err) + } } // deleteClusterDir contrary to createClusterDir, this deletes the cluster directory under $HOME/.config/k3d/ diff --git a/cli/commands.go b/cli/commands.go index 6d759a29..4fe0c04f 100644 --- a/cli/commands.go +++ b/cli/commands.go @@ -100,7 +100,7 @@ func CreateCluster(c *cli.Context) error { if c.IsSet("port") { log.Println("INFO: As of v2.0.0 --port will be used for arbitrary port mapping. Please use --api-port/-a instead for configuring the Api Port") } - apiPort, err := parseApiPort(c.String("api-port")) + apiPort, err := parseAPIPort(c.String("api-port")) if err != nil { return err } @@ -111,7 +111,7 @@ func CreateCluster(c *cli.Context) error { if apiPort.Host == "" { apiPort.Host, err = getDockerMachineIp() // IP address is the same as the host - apiPort.HostIp = apiPort.Host + apiPort.HostIP = apiPort.Host // In case of error, Log a warning message, and continue on. Since it more likely caused by a miss configured // DOCKER_MACHINE_NAME environment variable. if err != nil { @@ -137,7 +137,7 @@ func CreateCluster(c *cli.Context) error { clusterSpec := &ClusterSpec{ AgentArgs: []string{}, - ApiPort: *apiPort, + APIPort: *apiPort, AutoRestart: c.Bool("auto-restart"), ClusterName: c.String("name"), Env: env, @@ -151,6 +151,10 @@ func CreateCluster(c *cli.Context) error { // create the server log.Printf("Creating cluster [%s]", c.String("name")) + + // create the directory where we will put the kubeconfig file by default (when running `k3d get-config`) + createClusterDir(c.String("name")) + dockerID, err := createServer(clusterSpec) if err != nil { deleteCluster() @@ -192,10 +196,6 @@ func CreateCluster(c *cli.Context) error { time.Sleep(1 * time.Second) } - // create the directory where we will put the kubeconfig file by default (when running `k3d get-config`) - // TODO: this can probably be moved to `k3d get-config` or be removed in a different approach - createClusterDir(c.String("name")) - // spin up the worker nodes // TODO: do this concurrently in different goroutines if c.Int("workers") > 0 { @@ -362,3 +362,8 @@ func GetKubeConfig(c *cli.Context) error { func Shell(c *cli.Context) error { return subShell(c.String("name"), c.String("shell"), c.String("command")) } + +// ImportImage saves an image locally and imports it into the k3d containers +func ImportImage(c *cli.Context) error { + return importImage(c.String("name"), c.String("image")) +} diff --git a/cli/container.go b/cli/container.go index db3a1c81..5dc09763 100644 --- a/cli/container.go +++ b/cli/container.go @@ -22,7 +22,7 @@ import ( type ClusterSpec struct { AgentArgs []string - ApiPort apiPort + APIPort apiPort AutoRestart bool ClusterName string Env []string @@ -94,14 +94,14 @@ func createServer(spec *ClusterSpec) (string, error) { return "", err } - hostIp := "0.0.0.0" + hostIP := "0.0.0.0" containerLabels["apihost"] = "localhost" - if spec.ApiPort.Host != "" { - hostIp = spec.ApiPort.HostIp - containerLabels["apihost"] = spec.ApiPort.Host + if spec.APIPort.Host != "" { + hostIP = spec.APIPort.HostIP + containerLabels["apihost"] = spec.APIPort.Host } - apiPortSpec := fmt.Sprintf("%s:%s:%s/tcp", hostIp, spec.ApiPort.Port, spec.ApiPort.Port) + apiPortSpec := fmt.Sprintf("%s:%s:%s/tcp", hostIP, spec.APIPort.Port, spec.APIPort.Port) serverPorts = append(serverPorts, apiPortSpec) @@ -123,6 +123,13 @@ func createServer(spec *ClusterSpec) (string, error) { hostConfig.Binds = spec.Volumes } + // we need to mount the clusterDir subdirectory `clusterDir/images` to enable importing images without the need for `docker cp` + clusterDir, err := getClusterDir(spec.ClusterName) + if err != nil { + return "", fmt.Errorf("ERROR: couldn't get cluster dir for mounting\n%+v", err) + } + hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:/images", clusterDir+"/images")) + networkingConfig := &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ k3dNetworkName(spec.ClusterName): { @@ -157,7 +164,7 @@ func createWorker(spec *ClusterSpec, postfix int) (string, error) { containerName := GetContainerName("worker", spec.ClusterName, postfix) - env := append(spec.Env, fmt.Sprintf("K3S_URL=https://k3d-%s-server:%s", spec.ClusterName, spec.ApiPort.Port)) + env := append(spec.Env, fmt.Sprintf("K3S_URL=https://k3d-%s-server:%s", spec.ClusterName, spec.APIPort.Port)) // ports to be assigned to the server belong to roles // all, server or @@ -192,6 +199,13 @@ func createWorker(spec *ClusterSpec, postfix int) (string, error) { hostConfig.Binds = spec.Volumes } + // we need to mount the clusterDir subdirectory `clusterDir/images` to enable importing images without the need for `docker cp` + clusterDir, err := getClusterDir(spec.ClusterName) + if err != nil { + return "", fmt.Errorf("ERROR: couldn't get cluster dir for mounting\n%+v", err) + } + hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:/images", clusterDir+"/images")) + networkingConfig := &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ k3dNetworkName(spec.ClusterName): { diff --git a/cli/image.go b/cli/image.go new file mode 100644 index 00000000..f80b2748 --- /dev/null +++ b/cli/image.go @@ -0,0 +1,131 @@ +package run + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" +) + +const imageBasePathRemote = "/images/" + +func importImage(clusterName, image string) error { + // get a docker client + ctx := context.Background() + docker, err := client.NewEnvClient() + if err != nil { + return fmt.Errorf("ERROR: couldn't create docker client\n%+v", err) + } + + // get cluster directory to temporarily save the image tarball there + imageBasePathLocal, err := getClusterDir(clusterName) + imageBasePathLocal = imageBasePathLocal + "/images/" + if err != nil { + return fmt.Errorf("ERROR: couldn't get cluster directory for cluster [%s]\n%+v", clusterName, err) + } + + // TODO: extend to enable importing a list of images + imageList := []string{image} + + //*** first, save the images using the local docker daemon + log.Printf("INFO: Saving image [%s] from local docker daemon...", image) + imageReader, err := docker.ImageSave(ctx, imageList) + if err != nil { + return fmt.Errorf("ERROR: failed to save image [%s] locally\n%+v", image, err) + } + + // create tarball + imageTarName := strings.ReplaceAll(strings.ReplaceAll(image, ":", "_"), "/", "_") + ".tar" + imageTar, err := os.Create(imageBasePathLocal + imageTarName) + if err != nil { + return err + } + defer imageTar.Close() + + _, err = io.Copy(imageTar, imageReader) + if err != nil { + return fmt.Errorf("ERROR: couldn't save image [%s] to file [%s]\n%+v", image, imageTar.Name(), err) + } + + // TODO: get correct container ID by cluster name + clusters, err := getClusters(false, clusterName) + if err != nil { + return fmt.Errorf("ERROR: couldn't get cluster by name [%s]\n%+v", clusterName, err) + } + containerList := []types.Container{clusters[clusterName].server} + containerList = append(containerList, clusters[clusterName].workers...) + + // *** second, import the images using ctr in the k3d nodes + + // create exec configuration + cmd := []string{"ctr", "image", "import", imageBasePathRemote + imageTarName} + execConfig := types.ExecConfig{ + AttachStderr: true, + AttachStdout: true, + Cmd: cmd, + Tty: true, + Detach: true, + } + + execAttachConfig := types.ExecConfig{ + Tty: true, + } + + execStartConfig := types.ExecStartCheck{ + Tty: true, + } + + // import in each node separately + // TODO: create a shared image cache volume, so we don't need to import it separately + for _, container := range containerList { + + containerName := container.Names[0][1:] // trimming the leading "/" from name + log.Printf("INFO: Importing image [%s] in container [%s]", image, containerName) + + // create exec configuration + execResponse, err := docker.ContainerExecCreate(ctx, container.ID, execConfig) + if err != nil { + return fmt.Errorf("ERROR: Failed to create exec command for container [%s]\n%+v", containerName, err) + } + + // attach to exec process in container + containerConnection, err := docker.ContainerExecAttach(ctx, execResponse.ID, execAttachConfig) + if err != nil { + return fmt.Errorf("ERROR: couldn't attach to container [%s]\n%+v", containerName, err) + } + defer containerConnection.Close() + + // start exec + err = docker.ContainerExecStart(ctx, execResponse.ID, execStartConfig) + if err != nil { + return fmt.Errorf("ERROR: couldn't execute command in container [%s]\n%+v", containerName, err) + } + + // get output from container + content, err := ioutil.ReadAll(containerConnection.Reader) + if err != nil { + return fmt.Errorf("ERROR: couldn't read output from container [%s]\n%+v", containerName, err) + } + + // example output "unpacking image........ ...done" + if !strings.Contains(string(content), "done") { + return fmt.Errorf("ERROR: seems like something went wrong using `ctr image import` in container [%s]. Full output below:\n%s", containerName, string(content)) + } + } + + log.Printf("INFO: Successfully imported image [%s] in all nodes of cluster [%s]", image, clusterName) + + log.Println("INFO: Cleaning up tarball...") + if err := os.Remove(imageBasePathLocal + imageTarName); err != nil { + return fmt.Errorf("ERROR: Couldn't remove tarball [%s]\n%+v", imageBasePathLocal+imageTarName, err) + } + log.Println("INFO: ...Done") + + return nil +} diff --git a/cli/util.go b/cli/util.go index 51f1b18e..6608056b 100644 --- a/cli/util.go +++ b/cli/util.go @@ -11,7 +11,7 @@ import ( type apiPort struct { Host string - HostIp string + HostIP string Port string } @@ -90,7 +90,7 @@ func ValidateHostname(name string) error { return nil } -func parseApiPort(portSpec string) (*apiPort, error) { +func parseAPIPort(portSpec string) (*apiPort, error) { var port *apiPort split := strings.Split(portSpec, ":") if len(split) > 2 { @@ -105,7 +105,7 @@ func parseApiPort(portSpec string) (*apiPort, error) { if err != nil { return nil, err } - port = &apiPort{Host: split[0], HostIp: addrs[0], Port: split[1]} + port = &apiPort{Host: split[0], HostIP: addrs[0], Port: split[1]} } // Verify 'port' is an integer and within port ranges diff --git a/main.go b/main.go index 1f94ee60..30c79244 100644 --- a/main.go +++ b/main.go @@ -214,6 +214,23 @@ func main() { }, Action: run.GetKubeConfig, }, + { + // get-kubeconfig grabs the kubeconfig from the cluster and prints the path to it + Name: "import-image", + Usage: "Import a container image from your local docker daemon into the cluster", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "name, n", + Value: defaultK3sClusterName, + Usage: "Name of the cluster", + }, + cli.StringFlag{ + Name: "image, i", + Usage: "Name of the image that you want to import, e.g. `nginx:local`", + }, + }, + Action: run.ImportImage, + }, } // Global flags