github.com/billybanfield/evergreen@v0.0.0-20170525200750-eeee692790f7/plugin/builtin/s3copy/s3_copy_plugin.go (about)

     1  package s3copy
     2  
     3  import (
     4  	"fmt"
     5  	"io/ioutil"
     6  	"net/http"
     7  	"path/filepath"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/evergreen-ci/evergreen/model"
    12  	"github.com/evergreen-ci/evergreen/model/artifact"
    13  	"github.com/evergreen-ci/evergreen/model/version"
    14  	"github.com/evergreen-ci/evergreen/plugin"
    15  	"github.com/evergreen-ci/evergreen/thirdparty"
    16  	"github.com/evergreen-ci/evergreen/util"
    17  	"github.com/goamz/goamz/aws"
    18  	"github.com/goamz/goamz/s3"
    19  	"github.com/mitchellh/mapstructure"
    20  	"github.com/mongodb/grip"
    21  	"github.com/mongodb/grip/slogger"
    22  	"github.com/pkg/errors"
    23  )
    24  
    25  func init() {
    26  	plugin.Publish(&S3CopyPlugin{})
    27  }
    28  
    29  const (
    30  	s3CopyCmd         = "copy"
    31  	s3CopyPluginName  = "s3Copy"
    32  	s3CopyAPIEndpoint = "s3Copy"
    33  	s3baseURL         = "https://s3.amazonaws.com/"
    34  
    35  	s3CopyRetrySleepTimeSec = 5
    36  	s3CopyRetryNumRetries   = 5
    37  )
    38  
    39  // S3CopyRequest holds information necessary for the API server to
    40  // complete an S3 copy request; namely, an S3 key/secret, a source and
    41  // a destination path
    42  type S3CopyRequest struct {
    43  	AwsKey              string `json:"aws_key"`
    44  	AwsSecret           string `json:"aws_secret"`
    45  	S3SourceBucket      string `json:"s3_source_bucket"`
    46  	S3SourcePath        string `json:"s3_source_path"`
    47  	S3DestinationBucket string `json:"s3_destination_bucket"`
    48  	S3DestinationPath   string `json:"s3_destination_path"`
    49  	S3DisplayName       string `json:"display_name"`
    50  }
    51  
    52  // The S3CopyPlugin consists of zero or more files that are to be copied
    53  // from one location in S3 to the other.
    54  type S3CopyCommand struct {
    55  	// AwsKey & AwsSecret are provided to make it possible to transfer
    56  	// files to/from any bucket using the appropriate keys for each
    57  	AwsKey    string `mapstructure:"aws_key" plugin:"expand" json:"aws_key"`
    58  	AwsSecret string `mapstructure:"aws_secret" plugin:"expand" json:"aws_secret"`
    59  
    60  	// An array of file copy configurations
    61  	S3CopyFiles []*s3CopyFile `mapstructure:"s3_copy_files" plugin:"expand"`
    62  }
    63  
    64  // S3CopyPlugin is used to copy files around in s3
    65  type S3CopyPlugin struct{}
    66  
    67  type s3CopyFile struct {
    68  	// Each source and destination is specified in the
    69  	// following manner:
    70  	//  bucket: <s3 bucket>
    71  	//  path: <path to file>
    72  	//
    73  	// e.g.
    74  	//  bucket: mciuploads
    75  	//  path: linux-64/x86_64/artifact.tgz
    76  	Source      s3Loc `mapstructure:"source" plugin:"expand"`
    77  	Destination s3Loc `mapstructure:"destination" plugin:"expand"`
    78  
    79  	// BuildVariants is a slice of build variants for which
    80  	// a specified file is to be copied. An empty slice indicates it is to be
    81  	// copied for all build variants
    82  	BuildVariants []string `mapstructure:"build_variants" plugin:"expand"`
    83  
    84  	//DisplayName is the name of the file
    85  	DisplayName string `mapstructure:"display_name" plugin:"expand"`
    86  
    87  	// Optional, when true suppresses the error state for the file.
    88  	Optional bool `mapstructure:"optional"`
    89  }
    90  
    91  // s3Loc is a format for describing the location of a file in
    92  // Amazon's S3. It contains an entry for the bucket name and another
    93  // describing the path name of the file within the bucket
    94  type s3Loc struct {
    95  	// the s3 bucket for the file
    96  	Bucket string `mapstructure:"bucket" plugin:"expand"`
    97  
    98  	// the file path within the bucket
    99  	Path string `mapstructure:"path" plugin:"expand"`
   100  }
   101  
   102  // Name returns the name of this plugin - it serves to satisfy
   103  // the 'Plugin' interface
   104  func (scp *S3CopyPlugin) Name() string {
   105  	return s3CopyPluginName
   106  }
   107  
   108  func (scp *S3CopyPlugin) GetAPIHandler() http.Handler {
   109  	r := http.NewServeMux()
   110  	r.HandleFunc(fmt.Sprintf("/%v", s3CopyAPIEndpoint), S3CopyHandler) // POST
   111  	r.HandleFunc("/", http.NotFound)
   112  	return r
   113  }
   114  
   115  func (self *S3CopyPlugin) Configure(map[string]interface{}) error {
   116  	return nil
   117  }
   118  
   119  // NewCommand returns the S3CopyPlugin - this is to satisfy the
   120  // 'Plugin' interface
   121  func (scp *S3CopyPlugin) NewCommand(cmdName string) (plugin.Command, error) {
   122  	if cmdName != s3CopyCmd {
   123  		return nil, errors.Errorf("No such %v command: %v",
   124  			s3CopyPluginName, cmdName)
   125  	}
   126  	return &S3CopyCommand{}, nil
   127  }
   128  
   129  func (scc *S3CopyCommand) Name() string {
   130  	return s3CopyCmd
   131  }
   132  
   133  func (scc *S3CopyCommand) Plugin() string {
   134  	return s3CopyPluginName
   135  }
   136  
   137  // ParseParams decodes the S3 push command parameters that are
   138  // specified as part of an S3CopyPlugin command; this is required
   139  // to satisfy the 'Command' interface
   140  func (scc *S3CopyCommand) ParseParams(params map[string]interface{}) error {
   141  	if err := mapstructure.Decode(params, scc); err != nil {
   142  		return errors.Wrapf(err, "error decoding %v params", scc.Name())
   143  	}
   144  	if err := scc.validateParams(); err != nil {
   145  		return errors.Wrapf(err, "error validating %v params", scc.Name())
   146  	}
   147  	return nil
   148  }
   149  
   150  // validateParams is a helper function that ensures all
   151  // the fields necessary for carrying out an S3 copy operation are present
   152  func (scc *S3CopyCommand) validateParams() (err error) {
   153  	if scc.AwsKey == "" {
   154  		return errors.New("s3 AWS key cannot be blank")
   155  	}
   156  	if scc.AwsSecret == "" {
   157  		return errors.New("s3 AWS secret cannot be blank")
   158  	}
   159  	for _, s3CopyFile := range scc.S3CopyFiles {
   160  		if s3CopyFile.Source.Bucket == "" {
   161  			return errors.New("s3 source bucket cannot be blank")
   162  		}
   163  		if s3CopyFile.Destination.Bucket == "" {
   164  			return errors.New("s3 destination bucket cannot be blank")
   165  		}
   166  		if s3CopyFile.Source.Path == "" {
   167  			return errors.New("s3 source path cannot be blank")
   168  		}
   169  		if s3CopyFile.Destination.Path == "" {
   170  			return errors.New("s3 destination path cannot be blank")
   171  		}
   172  	}
   173  
   174  	// validate the S3 copy parameters before running the task
   175  	if err := scc.validateS3CopyParams(); err != nil {
   176  		return errors.WithStack(err)
   177  	}
   178  	return nil
   179  }
   180  
   181  // validateS3CopyParams validates the s3 copy params right before executing
   182  func (scc *S3CopyCommand) validateS3CopyParams() (err error) {
   183  	for _, s3CopyFile := range scc.S3CopyFiles {
   184  		err := validateS3BucketName(s3CopyFile.Source.Bucket)
   185  		if err != nil {
   186  			return errors.Wrapf(err, "source bucket '%v' is invalid",
   187  				s3CopyFile.Source.Bucket)
   188  		}
   189  
   190  		err = validateS3BucketName(s3CopyFile.Destination.Bucket)
   191  		if err != nil {
   192  			return errors.Wrapf(err, "destination bucket '%v' is invalid",
   193  				s3CopyFile.Destination.Bucket)
   194  		}
   195  	}
   196  	return nil
   197  }
   198  
   199  // Execute carries out the S3CopyCommand command - this is required
   200  // to satisfy the 'Command' interface
   201  func (scc *S3CopyCommand) Execute(pluginLogger plugin.Logger,
   202  	pluginCom plugin.PluginCommunicator,
   203  	taskConfig *model.TaskConfig,
   204  	stop chan bool) error {
   205  
   206  	// expand the S3 copy parameters before running the task
   207  	if err := plugin.ExpandValues(scc, taskConfig.Expansions); err != nil {
   208  		return errors.WithStack(err)
   209  	}
   210  
   211  	// validate the S3 copy parameters before running the task
   212  	if err := scc.validateS3CopyParams(); err != nil {
   213  		return errors.WithStack(err)
   214  	}
   215  
   216  	errChan := make(chan error)
   217  	go func() {
   218  		errChan <- errors.WithStack(scc.S3Copy(taskConfig, pluginLogger, pluginCom))
   219  	}()
   220  
   221  	select {
   222  	case err := <-errChan:
   223  		return err
   224  	case <-stop:
   225  		pluginLogger.LogExecution(slogger.INFO, "Received signal to terminate"+
   226  			" execution of S3 copy command")
   227  		return nil
   228  	}
   229  }
   230  
   231  // S3Copy is responsible for carrying out the core of the S3CopyPlugin's
   232  // function - it makes an API calls to copy a given staged file to it's final
   233  // production destination
   234  func (scc *S3CopyCommand) S3Copy(taskConfig *model.TaskConfig,
   235  	pluginLogger plugin.Logger, pluginCom plugin.PluginCommunicator) error {
   236  	for _, s3CopyFile := range scc.S3CopyFiles {
   237  		if len(s3CopyFile.BuildVariants) > 0 && !util.SliceContains(
   238  			s3CopyFile.BuildVariants, taskConfig.BuildVariant.Name) {
   239  			continue
   240  		}
   241  
   242  		pluginLogger.LogExecution(slogger.INFO, "Making API push copy call to "+
   243  			"transfer %v/%v => %v/%v", s3CopyFile.Source.Bucket,
   244  			s3CopyFile.Source.Path, s3CopyFile.Destination.Bucket,
   245  			s3CopyFile.Destination.Path)
   246  
   247  		s3CopyReq := S3CopyRequest{
   248  			AwsKey:              scc.AwsKey,
   249  			AwsSecret:           scc.AwsSecret,
   250  			S3SourceBucket:      s3CopyFile.Source.Bucket,
   251  			S3SourcePath:        s3CopyFile.Source.Path,
   252  			S3DestinationBucket: s3CopyFile.Destination.Bucket,
   253  			S3DestinationPath:   s3CopyFile.Destination.Path,
   254  			S3DisplayName:       s3CopyFile.DisplayName,
   255  		}
   256  		resp, err := pluginCom.TaskPostJSON(s3CopyAPIEndpoint, s3CopyReq)
   257  		if resp != nil {
   258  			defer resp.Body.Close()
   259  		}
   260  
   261  		if resp != nil && resp.StatusCode != http.StatusOK {
   262  			body, _ := ioutil.ReadAll(resp.Body)
   263  			err = errors.Errorf("S3 push copy failed (%v): %v", resp.StatusCode,
   264  				string(body))
   265  			if s3CopyFile.Optional {
   266  				pluginLogger.LogExecution(slogger.ERROR,
   267  					"ignoring optional file, which encountered error: %+v",
   268  					err.Error())
   269  				continue
   270  			}
   271  
   272  			return err
   273  		}
   274  		if err != nil {
   275  			body, _ := ioutil.ReadAll(resp.Body)
   276  			err = errors.Wrapf(err, "S3 push copy failed (%v): %v",
   277  				resp.StatusCode, string(body))
   278  			if s3CopyFile.Optional {
   279  				pluginLogger.LogExecution(slogger.ERROR,
   280  					"ignoring optional file, which encountered error: %+v",
   281  					err.Error())
   282  				continue
   283  			}
   284  
   285  			return err
   286  		}
   287  		pluginLogger.LogExecution(slogger.INFO, "API push copy call succeeded")
   288  		err = scc.AttachTaskFiles(pluginLogger, pluginCom, s3CopyReq)
   289  		if err != nil {
   290  			body, readAllErr := ioutil.ReadAll(resp.Body)
   291  			if readAllErr != nil {
   292  				return errors.WithStack(err)
   293  			}
   294  			return errors.Wrapf(err, "Error: %v: %v",
   295  				resp.StatusCode, string(body))
   296  		}
   297  	}
   298  	return nil
   299  }
   300  
   301  // Takes a request for a task's file to be copied from
   302  // one s3 location to another. Ensures that if the destination
   303  // file path already exists, no file copy is performed.
   304  func S3CopyHandler(w http.ResponseWriter, r *http.Request) {
   305  	task := plugin.GetTask(r)
   306  	if task == nil {
   307  		http.Error(w, "task not found", http.StatusNotFound)
   308  		return
   309  	}
   310  	s3CopyReq := &S3CopyRequest{}
   311  	err := util.ReadJSONInto(util.NewRequestReader(r), s3CopyReq)
   312  	if err != nil {
   313  		grip.Errorln("error reading push request:", err)
   314  		http.Error(w, err.Error(), http.StatusBadRequest)
   315  		return
   316  	}
   317  
   318  	// Get the version for this task, so we can check if it has
   319  	// any already-done pushes
   320  	v, err := version.FindOne(version.ById(task.Version))
   321  	if err != nil {
   322  		grip.Errorf("error querying task %s with version id %s: %v",
   323  			task.Id, task.Version, err)
   324  		http.Error(w, err.Error(), http.StatusInternalServerError)
   325  		return
   326  	}
   327  
   328  	// Check for an already-pushed file with this same file path,
   329  	// but from a conflicting or newer commit sequence num
   330  	if v == nil {
   331  		grip.Errorln("no version found for build", task.BuildId)
   332  		http.Error(w, "version not found", http.StatusNotFound)
   333  		return
   334  	}
   335  
   336  	copyFromLocation := strings.Join([]string{s3CopyReq.S3SourceBucket, s3CopyReq.S3SourcePath}, "/")
   337  	copyToLocation := strings.Join([]string{s3CopyReq.S3DestinationBucket, s3CopyReq.S3DestinationPath}, "/")
   338  
   339  	newestPushLog, err := model.FindPushLogAfter(copyToLocation, v.RevisionOrderNumber)
   340  	if err != nil {
   341  		grip.Errorf("error querying for push log at %s: %+v", copyToLocation, err)
   342  		http.Error(w, err.Error(), http.StatusInternalServerError)
   343  		return
   344  	}
   345  
   346  	if newestPushLog != nil {
   347  		grip.Warningln("conflict with existing pushed file:", copyToLocation)
   348  		return
   349  	}
   350  
   351  	// It's now safe to put the file in its permanent location.
   352  	newPushLog := model.NewPushLog(v, task, copyToLocation)
   353  	err = newPushLog.Insert()
   354  	if err != nil {
   355  		grip.Errorf("failed to create new push log: %+v %+v", newPushLog, err)
   356  		http.Error(w, fmt.Sprintf("failed to create push log: %v", err), http.StatusInternalServerError)
   357  		return
   358  	}
   359  
   360  	// Now copy the file into the permanent location
   361  	auth := &aws.Auth{
   362  		AccessKey: s3CopyReq.AwsKey,
   363  		SecretKey: s3CopyReq.AwsSecret,
   364  	}
   365  
   366  	grip.Infof("performing S3 copy: '%s' => '%s'", copyFromLocation, copyToLocation)
   367  
   368  	_, err = util.Retry(func() error {
   369  		err = errors.WithStack(thirdparty.S3CopyFile(auth,
   370  			s3CopyReq.S3SourceBucket,
   371  			s3CopyReq.S3SourcePath,
   372  			s3CopyReq.S3DestinationBucket,
   373  			s3CopyReq.S3DestinationPath,
   374  			string(s3.PublicRead),
   375  		))
   376  		if err != nil {
   377  			grip.Errorf("S3 copy failed for task %s, retrying: %+v", task.Id, err)
   378  			return util.RetriableError{err}
   379  		}
   380  
   381  		err = errors.WithStack(newPushLog.UpdateStatus(model.PushLogSuccess))
   382  		if err != nil {
   383  			grip.Errorf("updating pushlog status failed for task %s: %+v", task.Id, err)
   384  		}
   385  		return err
   386  	}, s3CopyRetryNumRetries, s3CopyRetrySleepTimeSec*time.Second)
   387  
   388  	if err != nil {
   389  		message := fmt.Sprintf("S3 copy failed for task %v: %v", task.Id, err)
   390  		grip.Error(message)
   391  		err = errors.WithStack(newPushLog.UpdateStatus(model.PushLogFailed))
   392  		if err != nil {
   393  			grip.Errorf("updating pushlog status failed: %+v", err)
   394  		}
   395  		http.Error(w, message, http.StatusInternalServerError)
   396  		return
   397  	}
   398  	plugin.WriteJSON(w, http.StatusOK, "S3 copy Successful")
   399  }
   400  
   401  // AttachTaskFiles is responsible for sending the
   402  // specified file to the API Server
   403  func (c *S3CopyCommand) AttachTaskFiles(pluginLogger plugin.Logger,
   404  	pluginCom plugin.PluginCommunicator, request S3CopyRequest) error {
   405  
   406  	remotePath := filepath.ToSlash(request.S3DestinationPath)
   407  	fileLink := s3baseURL + request.S3DestinationBucket + "/" + remotePath
   408  
   409  	displayName := request.S3DisplayName
   410  
   411  	if displayName == "" {
   412  		displayName = filepath.Base(request.S3SourcePath)
   413  	}
   414  
   415  	pluginLogger.LogExecution(slogger.INFO, "attaching file with name %v", displayName)
   416  	file := artifact.File{
   417  		Name: displayName,
   418  		Link: fileLink,
   419  	}
   420  
   421  	files := []*artifact.File{&file}
   422  
   423  	err := pluginCom.PostTaskFiles(files)
   424  	if err != nil {
   425  		return errors.Wrap(err, "Attach files failed")
   426  	}
   427  	pluginLogger.LogExecution(slogger.INFO, "API attach files call succeeded")
   428  	return nil
   429  }