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 }