plugin/source/http/http.go (165 lines of code) (raw):

package http /* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved */ import ( "errors" "fmt" "io" "mime" "net/http" "os" "path" "path/filepath" "github.com/facebookincubator/go2chef/plugin/lib/certs" "github.com/facebookincubator/go2chef/util/hashfile" "github.com/facebookincubator/go2chef/util/temp" "github.com/facebookincubator/go2chef" "github.com/mholt/archiver/v3" "github.com/mitchellh/mapstructure" ) // TypeName is the name of this source plugin const TypeName = "go2chef.source.http" // Source implements an HTTP source for resource downloads type Source struct { logger go2chef.Logger SourceName string `mapstructure:"name"` Method string `mapstructure:"http_method"` URL string `mapstructure:"url"` ValidStatusCodes []int `mapstructure:"valid_status_codes"` Archive bool `mapstructure:"archive"` OutputFilename string `mapstructure:"output_filename"` SHA256 string `mapstructure:"sha256"` } // String returns a string representation of this func (s *Source) String() string { return "<" } // Name returns the name of this source func (s *Source) Name() string { return s.SourceName } // Type returns "http" func (s *Source) Type() string { return "http" } // SetName sets the name of this source func (s *Source) SetName(n string) { s.SourceName = n } // DownloadToPath downloads a file over HTTP to a given path, handling // archive extraction if the Source.Archive parameter is true. func (s *Source) DownloadToPath(dlPath string) (err error) { // set up start/end events s.logger.WriteEvent(go2chef.NewEvent("HTTP_DOWNLOAD_STARTED", TypeName, s.URL)) defer func() { event := "HTTP_DOWNLOAD_COMPLETE" if err != nil { event = "HTTP_DOWNLOAD_FAILURE" } s.logger.WriteEvent(go2chef.NewEvent(event, TypeName, s.URL)) }() tlsConf, err := certs.TLS.GetTLSClientConf() if err != nil { return err } // Use the client from GlobalConfig so we can get any configured CAs c := &http.Client{ Transport: &http.Transport{ TLSClientConfig: tlsConf, }, } if ex, err := go2chef.PathExists(dlPath); err != nil { return err } else if !ex { s.logger.Debugf(1, "creating download directory %s", dlPath) if err := os.MkdirAll(dlPath, 0755); err != nil { return err } } s.logger.Debugf(1, "%s: 1", s.Name()) req, err := http.NewRequest(s.Method, s.URL, nil) if err != nil { return err } resp, err := c.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() s.logger.Debugf(1, "%s: HTTP %s %s => %d %s", s.Name(), s.Method, s.URL, resp.StatusCode, http.StatusText(resp.StatusCode)) if !s.checkStatusCode(resp) { return fmt.Errorf("non-matching status code: %d", resp.StatusCode) } tmpfile, err := temp.File("", "go2chef-src-http-*") defer func() { _ = tmpfile.Close() }() if err != nil { return err } if _, err = io.Copy(tmpfile, resp.Body); err != nil { return err } /* FILENAME DETERMINATION Filenames are determined like so: - Start with the basename of the request URL path - Check if config["output_filename"] is set and use it if so - If not, check if the Content-Disposition has a download filename set and use that if so */ outputFilename := path.Base(req.URL.Path) if s.OutputFilename != "" { outputFilename = dlPath } else { _, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) if err == nil { if fn, ok := params["filename"]; ok { outputFilename = fn } } } outputPath := filepath.Join(dlPath, outputFilename) if s.SHA256 != "" { s.logger.Debugf(1, "%s: sha256 was provided, validating %s", s.Name(), outputPath) fileHash, err := hashfile.SHA256(tmpfile.Name()) if err != nil { return err } s.logger.Debugf(1, "%s: calculated hash %s", s.Name(), fileHash) s.logger.Debugf(1, "%s: provided hash %s", s.Name(), s.SHA256) // If the hash doesn't match what is provided, return an error. if fileHash != s.SHA256 { return errors.New("sha256 hashes do not match") } } if s.Archive { /* ARCHIVE MODE: If the request is for an archive (using `{"archive": true}` in config) then decompress that archive into the destination. */ _ = tmpfile.Close() s.logger.Debugf(1, "%s: archive mode enabled, extracting %s to %s", s.Name(), tmpfile.Name(), dlPath) extFilename := filepath.Join(filepath.Dir(tmpfile.Name()), outputFilename) if err := os.Rename(tmpfile.Name(), extFilename); err != nil { s.logger.Errorf("failed to relocate output") return err } if err := archiver.Unarchive(extFilename, dlPath); err != nil { return err } } else { /* FILE MODE: If the request isn't for an archive (default), then just close the temp file and move to the output path. */ s.logger.Debugf(1, "%s: direct download to %s, rename to %s", s.Name(), tmpfile.Name(), outputPath) _ = tmpfile.Close() return os.Rename(tmpfile.Name(), outputPath) } return nil } // checkStatusCodes does the logic for checking if non-200 status codes // were marked as okay in config. func (s *Source) checkStatusCode(resp *http.Response) bool { if resp.StatusCode != 200 { for _, code := range s.ValidStatusCodes { if resp.StatusCode == code { return true } } return false } return true } // Loader implements SourceLoader for plugin registration func Loader(config map[string]interface{}) (go2chef.Source, error) { s := &Source{ go2chef.GetGlobalLogger(), "", "GET", "", make([]int, 0), false, "", "", } if err := mapstructure.Decode(config, s); err != nil { return nil, err } if s.SourceName == "" { s.SourceName = "http" } return s, nil } var _ go2chef.Source = &Source{} var _ go2chef.SourceLoader = Loader func init() { go2chef.RegisterSource(TypeName, Loader) }