package mcnutils import ( "archive/tar" "bytes" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net" "net/http" "net/url" "os" "path/filepath" "regexp" "github.com/docker/machine/libmachine/log" ) const ( defaultURL = "https://api.github.com/repos/boot2docker/boot2docker/releases/latest" defaultISOFilename = "boot2docker.iso" defaultVolumeIDOffset = int64(0x8028) defaultVolumeIDLength = 32 ) var ( GithubAPIToken string ) var ( errGitHubAPIResponse = errors.New(`Error getting a version tag from the Github API response. You may be getting rate limited by Github.`) ) var ( AUFSBugB2DVersions = map[string]string{ "v1.9.1": "https://github.com/docker/docker/issues/18180", } ) func defaultTimeout(network, addr string) (net.Conn, error) { return net.Dial(network, addr) } func getClient() *http.Client { transport := http.Transport{ DisableKeepAlives: true, Proxy: http.ProxyFromEnvironment, Dial: defaultTimeout, } return &http.Client{ Transport: &transport, } } func getRequest(apiURL string) (*http.Request, error) { req, err := http.NewRequest("GET", apiURL, nil) if err != nil { return nil, err } if GithubAPIToken != "" { req.Header.Add("Authorization", fmt.Sprintf("token %s", GithubAPIToken)) } return req, nil } // releaseGetter is a client that gets release information of a product and downloads it. type releaseGetter interface { // filename returns filename of the product. filename() string // getReleaseTag gets a release tag from the given URL. getReleaseTag(apiURL string) (string, error) // getReleaseURL gets the latest release download URL from the given URL. getReleaseURL(apiURL string) (string, error) // download downloads a file from the given dlURL and saves it under dir. download(dir, file, dlURL string) error } // b2dReleaseGetter implements the releaseGetter interface for getting the release of Boot2Docker. type b2dReleaseGetter struct { isoFilename string } func (b *b2dReleaseGetter) filename() string { if b == nil { return "" } return b.isoFilename } // getReleaseTag gets the release tag of Boot2Docker from apiURL. func (*b2dReleaseGetter) getReleaseTag(apiURL string) (string, error) { if apiURL == "" { apiURL = defaultURL } client := getClient() req, err := getRequest(apiURL) if err != nil { return "", err } rsp, err := client.Do(req) if err != nil { return "", err } defer rsp.Body.Close() var t struct { TagName string `json:"tag_name"` } if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil { return "", err } if t.TagName == "" { return "", errGitHubAPIResponse } return t.TagName, nil } // getReleaseURL gets the latest release URL of Boot2Docker. // FIXME: find or create some other way to get the "latest release" of boot2docker since the GitHub API has a pretty low rate limit on API requests func (b *b2dReleaseGetter) getReleaseURL(apiURL string) (string, error) { if apiURL == "" { apiURL = defaultURL } // match github (enterprise) release urls: // https://api.github.com/repos/../../releases/latest or // https://some.github.enterprise/api/v3/repos/../../releases/latest re := regexp.MustCompile("(https?)://([^/]+)(/api/v3)?/repos/([^/]+)/([^/]+)/releases/latest") matches := re.FindStringSubmatch(apiURL) if len(matches) != 6 { // does not match a github releases api URL return apiURL, nil } scheme, host, org, repo := matches[1], matches[2], matches[4], matches[5] if host == "api.github.com" { host = "github.com" } tag, err := b.getReleaseTag(apiURL) if err != nil { return "", err } log.Infof("Latest release for %s/%s/%s is %s", host, org, repo, tag) bugURL, ok := AUFSBugB2DVersions[tag] if ok { log.Warnf(` Boot2Docker %s has a known issue with AUFS. See here for more details: %s Consider specifying another storage driver (e.g. 'overlay') using '--engine-storage-driver' instead. `, tag, bugURL) } url := fmt.Sprintf("%s://%s/%s/%s/releases/download/%s/%s", scheme, host, org, repo, tag, b.isoFilename) return url, nil } func (*b2dReleaseGetter) download(dir, file, isoURL string) error { u, err := url.Parse(isoURL) var src io.ReadCloser if u.Scheme == "file" || u.Scheme == "" { s, err := os.Open(u.Path) if err != nil { return err } src = s } else { client := getClient() s, err := client.Get(isoURL) if err != nil { return err } src = &ReaderWithProgress{ ReadCloser: s.Body, out: os.Stdout, expectedLength: s.ContentLength, } } defer src.Close() // Download to a temp file first then rename it to avoid partial download. f, err := ioutil.TempFile(dir, file+".tmp") if err != nil { return err } defer func() { if err := removeFileIfExists(f.Name()); err != nil { log.Warnf("Error removing file: %s", err) } }() if _, err := io.Copy(f, src); err != nil { return err } if err := f.Close(); err != nil { return err } // Dest is the final path of the boot2docker.iso file. dest := filepath.Join(dir, file) // Windows can't rename in place, so remove the old file before // renaming the temporary downloaded file. if err := removeFileIfExists(dest); err != nil { return err } return os.Rename(f.Name(), dest) } // iso is an ISO volume. type iso interface { // path returns the path of the ISO. path() string // exists reports whether the ISO exists. exists() bool // version returns version information of the ISO. version() (string, error) } // b2dISO represents a Boot2Docker ISO. It implements the ISO interface. type b2dISO struct { // path of Boot2Docker ISO commonIsoPath string // offset and length of ISO volume ID // cf. http://serverfault.com/questions/361474/is-there-a-way-to-change-a-iso-files-volume-id-from-the-command-line volumeIDOffset int64 volumeIDLength int } func (b *b2dISO) path() string { if b == nil { return "" } return b.commonIsoPath } func (b *b2dISO) exists() bool { if b == nil { return false } _, err := os.Stat(b.commonIsoPath) return !os.IsNotExist(err) } // version scans the volume ID in b and returns its version tag. func (b *b2dISO) version() (string, error) { if b == nil { return "", nil } iso, err := os.Open(b.commonIsoPath) if err != nil { return "", err } defer iso.Close() isoMetadata := make([]byte, b.volumeIDLength) _, err = iso.ReadAt(isoMetadata, b.volumeIDOffset) if err != nil { return "", err } verRegex := regexp.MustCompile(`v\d+\.\d+\.\d+`) ver := string(verRegex.Find(isoMetadata)) log.Debug("local Boot2Docker ISO version: ", ver) return ver, nil } func removeFileIfExists(name string) error { if _, err := os.Stat(name); err == nil { if err := os.Remove(name); err != nil { return fmt.Errorf("Error removing temporary download file: %s", err) } } return nil } type B2dUtils struct { releaseGetter iso storePath string imgCachePath string } func NewB2dUtils(storePath string) *B2dUtils { imgCachePath := filepath.Join(storePath, "cache") return &B2dUtils{ releaseGetter: &b2dReleaseGetter{isoFilename: defaultISOFilename}, iso: &b2dISO{ commonIsoPath: filepath.Join(imgCachePath, defaultISOFilename), volumeIDOffset: defaultVolumeIDOffset, volumeIDLength: defaultVolumeIDLength, }, storePath: storePath, imgCachePath: imgCachePath, } } // DownloadISO downloads boot2docker ISO image for the given tag and save it at dest. func (b *B2dUtils) DownloadISO(dir, file, isoURL string) error { log.Infof("Downloading %s from %s...", b.path(), isoURL) return b.download(dir, file, isoURL) } type ReaderWithProgress struct { io.ReadCloser out io.Writer bytesTransferred int64 expectedLength int64 nextPercentToPrint int64 } func (r *ReaderWithProgress) Read(p []byte) (int, error) { n, err := r.ReadCloser.Read(p) if n > 0 { r.bytesTransferred += int64(n) percentage := r.bytesTransferred * 100 / r.expectedLength for percentage >= r.nextPercentToPrint { if r.nextPercentToPrint%10 == 0 { fmt.Fprintf(r.out, "%d%%", r.nextPercentToPrint) } else if r.nextPercentToPrint%2 == 0 { fmt.Fprint(r.out, ".") } r.nextPercentToPrint += 2 } } return n, err } func (r *ReaderWithProgress) Close() error { fmt.Fprintln(r.out) return r.ReadCloser.Close() } func (b *B2dUtils) DownloadLatestBoot2Docker(apiURL string) error { latestReleaseURL, err := b.getReleaseURL(apiURL) if err != nil { return err } return b.DownloadISOFromURL(latestReleaseURL) } func (b *B2dUtils) DownloadISOFromURL(latestReleaseURL string) error { return b.DownloadISO(b.imgCachePath, b.filename(), latestReleaseURL) } func (b *B2dUtils) UpdateISOCache(isoURL string) error { // recreate the cache dir if it has been manually deleted if _, err := os.Stat(b.imgCachePath); os.IsNotExist(err) { log.Infof("Image cache directory does not exist, creating it at %s...", b.imgCachePath) if err := os.Mkdir(b.imgCachePath, 0700); err != nil { return err } } if isoURL != "" { // Non-default B2D are not cached return nil } exists := b.exists() if !exists { log.Info("No default Boot2Docker ISO found locally, downloading the latest release...") return b.DownloadLatestBoot2Docker("") } latest := b.isLatest() if !latest { log.Info("Default Boot2Docker ISO is out-of-date, downloading the latest release...") return b.DownloadLatestBoot2Docker("") } return nil } func (b *B2dUtils) CopyIsoToMachineDir(isoURL, machineName string) error { if err := b.UpdateISOCache(isoURL); err != nil { return err } // TODO: This is a bit off-color. machineDir := filepath.Join(b.storePath, "machines", machineName) machineIsoPath := filepath.Join(machineDir, b.filename()) // By default just copy the existing "cached" iso to the machine's directory... if isoURL == "" { log.Infof("Copying %s to %s...", b.path(), machineIsoPath) return CopyFile(b.path(), machineIsoPath) } // if ISO is specified, check if it matches a github releases url or fallback to a direct download downloadURL, err := b.getReleaseURL(isoURL) if err != nil { return err } return b.DownloadISO(machineDir, b.filename(), downloadURL) } // isLatest checks the latest release tag and // reports whether the local ISO cache is the latest version. // // It returns false if failing to get the local ISO version // and true if failing to fetch the latest release tag. func (b *B2dUtils) isLatest() bool { localVer, err := b.version() if err != nil { log.Warn("Unable to get the local Boot2Docker ISO version: ", err) return false } latestVer, err := b.getReleaseTag("") if err != nil { log.Warn("Unable to get the latest Boot2Docker ISO release version: ", err) return true } return localVer == latestVer } // MakeDiskImage makes a boot2docker VM disk image. // See https://github.com/boot2docker/boot2docker/blob/master/rootfs/rootfs/etc/rc.d/automount func MakeDiskImage(publicSSHKeyPath string) (*bytes.Buffer, error) { magicString := "boot2docker, please format-me" buf := new(bytes.Buffer) tw := tar.NewWriter(buf) // magicString first so the automount script knows to format the disk file := &tar.Header{Name: magicString, Size: int64(len(magicString))} log.Debug("Writing magic tar header") if err := tw.WriteHeader(file); err != nil { return nil, err } if _, err := tw.Write([]byte(magicString)); err != nil { return nil, err } // .ssh/key.pub => authorized_keys file = &tar.Header{Name: ".ssh", Typeflag: tar.TypeDir, Mode: 0700} if err := tw.WriteHeader(file); err != nil { return nil, err } log.Debug("Writing SSH key tar header") pubKey, err := ioutil.ReadFile(publicSSHKeyPath) if err != nil { return nil, err } file = &tar.Header{Name: ".ssh/authorized_keys", Size: int64(len(pubKey)), Mode: 0644} if err := tw.WriteHeader(file); err != nil { return nil, err } if _, err := tw.Write([]byte(pubKey)); err != nil { return nil, err } file = &tar.Header{Name: ".ssh/authorized_keys2", Size: int64(len(pubKey)), Mode: 0644} if err := tw.WriteHeader(file); err != nil { return nil, err } if _, err := tw.Write([]byte(pubKey)); err != nil { return nil, err } if err := tw.Close(); err != nil { return nil, err } return buf, nil }