| // Copyright 2015 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // The docker2boot command converts a Docker image into a bootable GCE | |
| // VM image. | |
| package main | |
| import ( | |
| "flag" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net/http" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| "regexp" | |
| "runtime" | |
| "strconv" | |
| "strings" | |
| "time" | |
| ) | |
| var ( | |
| numGB = flag.Int("gb", 2, "size of raw disk, in gigabytes") | |
| rawFile = flag.String("disk", "disk.raw", "temporary raw disk file to create and delete") | |
| img = flag.String("image", "", "Docker image to convert. Required.") | |
| outFile = flag.String("out", "image.tar.gz", "GCE output .tar.gz image file to create") | |
| justRaw = flag.Bool("justraw", false, "If true, stop after preparing the raw file, but before creating the tar.gz") | |
| ) | |
| // This is a Linux kernel and initrd that boots on GCE. It's the | |
| // standard one that comes with the GCE Debian image. | |
| const ( | |
| bootTarURL = "https://storage.googleapis.com/go-builder-data/boot-linux-3.16-0.bpo.3-amd64.tar.gz" | |
| // bootUUID is the filesystem UUID in the bootTarURL snapshot. | |
| // TODO(bradfitz): parse this out of boot/grub/grub.cfg | |
| // instead, or write that file completely, so this doesn't | |
| // need to exist and stay in sync with the kernel snapshot. | |
| bootUUID = "906181f7-4e10-4a4e-8fd8-43b20ec980ff" | |
| ) | |
| func main() { | |
| flag.Parse() | |
| defer os.Exit(1) // otherwise we call os.Exit(0) at the bottom | |
| if runtime.GOOS != "linux" { | |
| failf("docker2boot only runs on Linux") | |
| } | |
| if *img == "" { | |
| failf("Missing required --image Docker image flag.") | |
| } | |
| if *outFile == "" { | |
| failf("Missing required --out flag") | |
| } | |
| if strings.Contains(slurpFile("/proc/mounts"), "nbd0p1") { | |
| failf("/proc/mounts shows nbd0p1 already mounted. Unmount that first.") | |
| } | |
| checkDeps() | |
| mntDir, err := os.MkdirTemp("", "docker2boot") | |
| if err != nil { | |
| failf("Failed to create mount temp dir: %v", err) | |
| } | |
| defer os.RemoveAll(mntDir) | |
| out, err := exec.Command("docker", "run", "-d", *img, "/bin/true").CombinedOutput() | |
| if err != nil { | |
| failf("Error creating container to snapshot: %v, %s", err, out) | |
| } | |
| container := strings.TrimSpace(string(out)) | |
| if os.Getenv("USER") != "root" { | |
| failf("this tool requires root. Re-run with sudo.") | |
| } | |
| // Install the kernel's network block device driver, if it's not already. | |
| // The qemu-nbd command would probably do this too, but this is a good place | |
| // to fail early if it's not available. | |
| run("modprobe", "nbd") | |
| if strings.Contains(slurpFile("/proc/partitions"), "nbd0") { | |
| // TODO(bradfitz): make the nbd device configurable, | |
| // or auto-select a free one. Hard-coding the first | |
| // one is lazy, but works. Who uses NBD anyway? | |
| failf("Looks like /dev/nbd0 is already in use. Maybe a previous run failed in the middle? Try sudo qemu-nbd -d /dev/nbd0") | |
| } | |
| if _, err := os.Stat(*rawFile); !os.IsNotExist(err) { | |
| failf("File %s already exists. Delete it and try again, or use a different --disk flag value.", *rawFile) | |
| } | |
| defer os.Remove(*rawFile) | |
| // Make a big empty file full of zeros. Using fallocate to make a sparse | |
| // file is much quicker (~immediate) than using dd to write from /dev/zero. | |
| // GCE requires disk images to be sized by the gigabyte. | |
| run("fallocate", "-l", strconv.Itoa(*numGB)+"G", *rawFile) | |
| // Start a NBD server so the kernel's /dev/nbd0 reads/writes | |
| // from our disk image, currently all zeros. | |
| run("qemu-nbd", "-c", "/dev/nbd0", "--format=raw", *rawFile) | |
| defer exec.Command("qemu-nbd", "-d", "/dev/nbd0").Run() | |
| // Put a MS-DOS partition table on it (GCE requirement), with | |
| // the first partition's initial sector far enough in to leave | |
| // room for the grub boot loader. | |
| fdisk := exec.Command("/sbin/fdisk", "/dev/nbd0") | |
| fdisk.Stdin = strings.NewReader("o\nn\np\n1\n2048\n\nw\n") | |
| out, err = fdisk.CombinedOutput() | |
| if err != nil { | |
| failf("fdisk: %v, %s", err, out) | |
| } | |
| // Wait for the kernel to notice the partition. fdisk does an ioctl | |
| // to make the kernel rescan for partitions. | |
| deadline := time.Now().Add(5 * time.Second) | |
| for !strings.Contains(slurpFile("/proc/partitions"), "nbd0p1") { | |
| if time.Now().After(deadline) { | |
| failf("timeout waiting for nbd0p1 to appear") | |
| } | |
| time.Sleep(50 * time.Millisecond) | |
| } | |
| // Now that the partition is available, make a filesystem on it. | |
| run("mkfs.ext4", "/dev/nbd0p1") | |
| run("mount", "/dev/nbd0p1", mntDir) | |
| defer exec.Command("umount", mntDir).Run() | |
| log.Printf("Populating /boot/ partition from %s", bootTarURL) | |
| pipeInto(httpGet(bootTarURL), "tar", "-zx", "-C", mntDir) | |
| log.Printf("Exporting Docker container %s into fs", container) | |
| exp := exec.Command("docker", "export", container) | |
| tarPipe, err := exp.StdoutPipe() | |
| if err != nil { | |
| failf("Pipe: %v", err) | |
| } | |
| if err := exp.Start(); err != nil { | |
| failf("docker export: %v", err) | |
| } | |
| pipeInto(tarPipe, "tar", "-x", "-C", mntDir) | |
| if err := exp.Wait(); err != nil { | |
| failf("docker export: %v", err) | |
| } | |
| // Docker normally provides these etc files, so they're not in | |
| // the export and we have to include them ourselves. | |
| writeFile(filepath.Join(mntDir, "etc", "hosts"), "127.0.0.1\tlocalhost\n") | |
| writeFile(filepath.Join(mntDir, "etc", "resolv.conf"), "nameserver 8.8.8.8\n") | |
| // Append the source image id & docker version to /etc/issue. | |
| issue, err := os.ReadFile("/etc/issue") | |
| if err != nil && !os.IsNotExist(err) { | |
| failf("Failed to read /etc/issue: %v", err) | |
| } | |
| out, err = exec.Command("docker", "inspect", "-f", "{{.Id}}", *img).CombinedOutput() | |
| if err != nil { | |
| failf("Error getting image id: %v, %s", err, out) | |
| } | |
| id := strings.TrimSpace(string(out)) | |
| out, err = exec.Command("docker", "-v").CombinedOutput() | |
| if err != nil { | |
| failf("Error getting docker version: %v, %s", err, out) | |
| } | |
| dockerVersion := strings.TrimSpace(string(out)) | |
| d2bissue := fmt.Sprintf("%s\nPrepared by docker2boot\nSource Docker image: %s %s\n%s\n", issue, *img, id, dockerVersion) | |
| writeFile(filepath.Join(mntDir, "etc", "issue"), d2bissue) | |
| // Install grub. Adjust the grub.cfg to have the correct | |
| // filesystem UUID of the filesystem made above. | |
| fsUUID := filesystemUUID() | |
| grubCfgFile := filepath.Join(mntDir, "boot/grub/grub.cfg") | |
| writeFile(grubCfgFile, strings.Replace(slurpFile(grubCfgFile), bootUUID, fsUUID, -1)) | |
| run("rm", filepath.Join(mntDir, "boot/grub/device.map")) | |
| run("grub-install", "--boot-directory="+filepath.Join(mntDir, "boot"), "/dev/nbd0") | |
| fstabFile := filepath.Join(mntDir, "etc/fstab") | |
| writeFile(fstabFile, fmt.Sprintf("UUID=%s / ext4 errors=remount-ro 0 1", fsUUID)) | |
| // Set some password for testing. | |
| run("chroot", mntDir, "/bin/bash", "-c", "echo root:r | chpasswd") | |
| run("umount", mntDir) | |
| run("qemu-nbd", "-d", "/dev/nbd0") | |
| if *justRaw { | |
| log.Printf("Stopping, and leaving %s alone.\nRun with:\n\n$ qemu-system-x86_64 -machine accel=kvm -nographic -curses -nodefconfig -smp 2 -drive if=virtio,file=%s -net nic,model=virtio -net user -boot once=d\n\n", *rawFile, *rawFile) | |
| os.Exit(0) | |
| } | |
| // Write out a sparse tarball. GCE creates images from sparse | |
| // tarballs on Google Cloud Storage. | |
| run("tar", "-Szcf", *outFile, *rawFile) | |
| os.Remove(*rawFile) | |
| os.Exit(0) | |
| } | |
| func checkDeps() { | |
| var missing []string | |
| for _, cmd := range []string{ | |
| "docker", | |
| "dumpe2fs", | |
| "fallocate", | |
| "grub-install", | |
| "mkfs.ext4", | |
| "modprobe", | |
| "mount", | |
| "qemu-nbd", | |
| "rm", | |
| "tar", | |
| "umount", | |
| } { | |
| if _, err := exec.LookPath(cmd); err != nil { | |
| missing = append(missing, cmd) | |
| } | |
| } | |
| if len(missing) > 0 { | |
| failf("Missing dependency programs: %v", missing) | |
| } | |
| } | |
| func filesystemUUID() string { | |
| e2fs, err := exec.Command("dumpe2fs", "/dev/nbd0p1").Output() | |
| if err != nil { | |
| failf("dumpe2fs: %v", err) | |
| } | |
| m := regexp.MustCompile(`Filesystem UUID:\s+(\S+)`).FindStringSubmatch(string(e2fs)) | |
| if m == nil || m[1] == "" { | |
| failf("failed to find filesystem UUID") | |
| } | |
| return m[1] | |
| } | |
| // failf is like log.Fatalf, but runs deferred functions. | |
| func failf(msg string, args ...any) { | |
| log.Printf(msg, args...) | |
| runtime.Goexit() | |
| } | |
| func httpGet(u string) io.Reader { | |
| res, err := http.Get(u) | |
| if err != nil { | |
| failf("Get %s: %v", u, err) | |
| } | |
| if res.StatusCode != 200 { | |
| failf("Get %s: %v", u, res.Status) | |
| } | |
| // Yeah, not closing it. This program is short-lived. | |
| return res.Body | |
| } | |
| func slurpFile(file string) string { | |
| v, err := os.ReadFile(file) | |
| if err != nil { | |
| failf("Failed to read %s: %v", file, err) | |
| } | |
| return string(v) | |
| } | |
| func writeFile(file, contents string) { | |
| if err := os.WriteFile(file, []byte(contents), 0644); err != nil { | |
| failf("writeFile %s: %v", file, err) | |
| } | |
| } | |
| func run(cmd string, args ...string) { | |
| log.Printf("Running %s %s", cmd, args) | |
| out, err := exec.Command(cmd, args...).CombinedOutput() | |
| if err != nil { | |
| failf("Error running %s %v: %v, %s", cmd, args, err, out) | |
| } | |
| } | |
| func pipeInto(stdin io.Reader, cmd string, args ...string) { | |
| log.Printf("Running %s %s", cmd, args) | |
| c := exec.Command(cmd, args...) | |
| c.Stdin = stdin | |
| out, err := c.CombinedOutput() | |
| if err != nil { | |
| failf("Error running %s %v: %v, %s", cmd, args, err, out) | |
| } | |
| } |