github.com/sleungcy-sap/cli@v7.1.0+incompatible/api/cloudcontroller/ccv2/job.go (about) 1 package ccv2 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "io" 7 "mime/multipart" 8 "net/url" 9 "time" 10 11 "code.cloudfoundry.org/cli/api/cloudcontroller" 12 "code.cloudfoundry.org/cli/api/cloudcontroller/ccerror" 13 "code.cloudfoundry.org/cli/api/cloudcontroller/ccv2/constant" 14 "code.cloudfoundry.org/cli/api/cloudcontroller/ccv2/internal" 15 ) 16 17 //go:generate counterfeiter . Reader 18 19 // Reader is an io.Reader. 20 type Reader interface { 21 io.Reader 22 } 23 24 // Job represents a Cloud Controller Job. 25 type Job struct { 26 27 // Error is the error a job returns if it failed. It is otherwise empty. 28 Error string 29 30 // ErrorDetails is a detailed description of a job failure returned by the 31 // Cloud Controller. 32 ErrorDetails struct { 33 Description string 34 } 35 36 // GUID is the unique job identifier. 37 GUID string 38 39 // Status is the current state of the job. 40 Status constant.JobStatus 41 } 42 43 // Failed returns true when the job has completed with an error/failure. 44 func (job Job) Failed() bool { 45 return job.Status == constant.JobStatusFailed 46 } 47 48 // Finished returns true when the job has completed successfully. 49 func (job Job) Finished() bool { 50 return job.Status == constant.JobStatusFinished 51 } 52 53 // UnmarshalJSON helps unmarshal a Cloud Controller Job response. 54 func (job *Job) UnmarshalJSON(data []byte) error { 55 var ccJob struct { 56 Entity struct { 57 Error string `json:"error"` 58 ErrorDetails struct { 59 Description string `json:"description"` 60 } `json:"error_details"` 61 GUID string `json:"guid"` 62 Status string `json:"status"` 63 } `json:"entity"` 64 Metadata internal.Metadata `json:"metadata"` 65 } 66 err := cloudcontroller.DecodeJSON(data, &ccJob) 67 if err != nil { 68 return err 69 } 70 71 job.Error = ccJob.Entity.Error 72 job.ErrorDetails.Description = ccJob.Entity.ErrorDetails.Description 73 job.GUID = ccJob.Entity.GUID 74 job.Status = constant.JobStatus(ccJob.Entity.Status) 75 return nil 76 } 77 78 // DeleteOrganizationJob deletes the Organization associated with the provided 79 // GUID. It will return the Cloud Controller job that is assigned to the 80 // Organization deletion. 81 func (client *Client) DeleteOrganizationJob(guid string) (Job, Warnings, error) { 82 request, err := client.newHTTPRequest(requestOptions{ 83 RequestName: internal.DeleteOrganizationRequest, 84 URIParams: Params{"organization_guid": guid}, 85 Query: url.Values{ 86 "recursive": {"true"}, 87 "async": {"true"}, 88 }, 89 }) 90 if err != nil { 91 return Job{}, nil, err 92 } 93 94 var job Job 95 response := cloudcontroller.Response{ 96 DecodeJSONResponseInto: &job, 97 } 98 99 err = client.connection.Make(request, &response) 100 return job, response.Warnings, err 101 } 102 103 // DeleteSpaceJob deletes the Space associated with the provided GUID. It will 104 // return the Cloud Controller job that is assigned to the Space deletion. 105 func (client *Client) DeleteSpaceJob(guid string) (Job, Warnings, error) { 106 request, err := client.newHTTPRequest(requestOptions{ 107 RequestName: internal.DeleteSpaceRequest, 108 URIParams: Params{"space_guid": guid}, 109 Query: url.Values{ 110 "recursive": {"true"}, 111 "async": {"true"}, 112 }, 113 }) 114 if err != nil { 115 return Job{}, nil, err 116 } 117 118 var job Job 119 response := cloudcontroller.Response{ 120 DecodeJSONResponseInto: &job, 121 } 122 123 err = client.connection.Make(request, &response) 124 return job, response.Warnings, err 125 } 126 127 // GetJob returns a job for the provided GUID. 128 func (client *Client) GetJob(jobGUID string) (Job, Warnings, error) { 129 request, err := client.newHTTPRequest(requestOptions{ 130 RequestName: internal.GetJobRequest, 131 URIParams: Params{"job_guid": jobGUID}, 132 }) 133 if err != nil { 134 return Job{}, nil, err 135 } 136 137 var job Job 138 response := cloudcontroller.Response{ 139 DecodeJSONResponseInto: &job, 140 } 141 142 err = client.connection.Make(request, &response) 143 return job, response.Warnings, err 144 } 145 146 // PollJob will keep polling the given job until the job has terminated, an 147 // error is encountered, or config.OverallPollingTimeout is reached. In the 148 // last case, a JobTimeoutError is returned. 149 func (client *Client) PollJob(job Job) (Warnings, error) { 150 originalJobGUID := job.GUID 151 152 var ( 153 err error 154 warnings Warnings 155 allWarnings Warnings 156 ) 157 158 startTime := time.Now() 159 for time.Now().Sub(startTime) < client.jobPollingTimeout { 160 job, warnings, err = client.GetJob(job.GUID) 161 allWarnings = append(allWarnings, Warnings(warnings)...) 162 if err != nil { 163 return allWarnings, err 164 } 165 166 if job.Failed() { 167 return allWarnings, ccerror.V2JobFailedError{ 168 JobGUID: originalJobGUID, 169 Message: job.ErrorDetails.Description, 170 } 171 } 172 173 if job.Finished() { 174 return allWarnings, nil 175 } 176 177 time.Sleep(client.jobPollingInterval) 178 } 179 180 return allWarnings, ccerror.JobTimeoutError{ 181 JobGUID: originalJobGUID, 182 Timeout: client.jobPollingTimeout, 183 } 184 } 185 186 // UploadApplicationPackage uploads the newResources and a list of existing 187 // resources to the cloud controller. A job that combines the requested/newly 188 // uploaded bits is returned. The function will act differently given the 189 // following Readers: 190 // io.ReadSeeker: Will function properly on retry. 191 // io.Reader: Will return a ccerror.PipeSeekError on retry. 192 // nil: Will not add the "application" section to the request. 193 // newResourcesLength is ignored in this case. 194 func (client *Client) UploadApplicationPackage(appGUID string, existingResources []Resource, newResources Reader, newResourcesLength int64) (Job, Warnings, error) { 195 if existingResources == nil { 196 return Job{}, nil, ccerror.NilObjectError{Object: "existingResources"} 197 } 198 199 if newResources == nil { 200 return client.uploadExistingResourcesOnly(appGUID, existingResources) 201 } 202 203 return client.uploadNewAndExistingResources(appGUID, existingResources, newResources, newResourcesLength) 204 } 205 206 // UploadDroplet defines and uploads a previously staged droplet that an 207 // application will run, using a multipart PUT request. The uploaded file 208 // should be a gzipped tar file. 209 func (client *Client) UploadDroplet(appGUID string, droplet io.Reader, dropletLength int64) (Job, Warnings, error) { 210 contentLength, err := client.calculateDropletRequestSize(dropletLength) 211 if err != nil { 212 return Job{}, nil, err 213 } 214 215 contentType, body, writeErrors := client.createMultipartBodyAndHeaderForDroplet(droplet) 216 217 request, err := client.newHTTPRequest(requestOptions{ 218 RequestName: internal.PutDropletRequest, 219 URIParams: Params{"app_guid": appGUID}, 220 Body: body, 221 }) 222 if err != nil { 223 return Job{}, nil, err 224 } 225 226 request.Header.Set("Content-Type", contentType) 227 request.ContentLength = contentLength 228 229 return client.uploadAsynchronously(request, writeErrors) 230 } 231 232 func (*Client) calculateAppBitsRequestSize(existingResources []Resource, newResourcesLength int64) (int64, error) { 233 body := &bytes.Buffer{} 234 form := multipart.NewWriter(body) 235 236 jsonResources, err := json.Marshal(existingResources) 237 if err != nil { 238 return 0, err 239 } 240 err = form.WriteField("resources", string(jsonResources)) 241 if err != nil { 242 return 0, err 243 } 244 _, err = form.CreateFormFile("application", "application.zip") 245 if err != nil { 246 return 0, err 247 } 248 err = form.Close() 249 if err != nil { 250 return 0, err 251 } 252 253 return int64(body.Len()) + newResourcesLength, nil 254 } 255 256 func (*Client) calculateDropletRequestSize(dropletSize int64) (int64, error) { 257 body := &bytes.Buffer{} 258 form := multipart.NewWriter(body) 259 260 _, err := form.CreateFormFile("droplet", "droplet.tgz") 261 if err != nil { 262 return 0, err 263 } 264 265 err = form.Close() 266 if err != nil { 267 return 0, err 268 } 269 270 return int64(body.Len()) + dropletSize, nil 271 } 272 273 func (*Client) createMultipartBodyAndHeaderForAppBits(existingResources []Resource, newResources io.Reader, newResourcesLength int64) (string, io.ReadSeeker, <-chan error) { 274 writerOutput, writerInput := cloudcontroller.NewPipeBomb() 275 form := multipart.NewWriter(writerInput) 276 277 writeErrors := make(chan error) 278 279 go func() { 280 defer close(writeErrors) 281 defer writerInput.Close() 282 283 jsonResources, err := json.Marshal(existingResources) 284 if err != nil { 285 writeErrors <- err 286 return 287 } 288 289 err = form.WriteField("resources", string(jsonResources)) 290 if err != nil { 291 writeErrors <- err 292 return 293 } 294 295 writer, err := form.CreateFormFile("application", "application.zip") 296 if err != nil { 297 writeErrors <- err 298 return 299 } 300 301 if newResourcesLength != 0 { 302 _, err = io.Copy(writer, newResources) 303 if err != nil { 304 writeErrors <- err 305 return 306 } 307 } 308 309 err = form.Close() 310 if err != nil { 311 writeErrors <- err 312 } 313 }() 314 315 return form.FormDataContentType(), writerOutput, writeErrors 316 } 317 318 func (*Client) createMultipartBodyAndHeaderForDroplet(droplet io.Reader) (string, io.ReadSeeker, <-chan error) { 319 writerOutput, writerInput := cloudcontroller.NewPipeBomb() 320 form := multipart.NewWriter(writerInput) 321 322 writeErrors := make(chan error) 323 324 go func() { 325 defer close(writeErrors) 326 defer writerInput.Close() 327 328 writer, err := form.CreateFormFile("droplet", "droplet.tgz") 329 if err != nil { 330 writeErrors <- err 331 return 332 } 333 334 _, err = io.Copy(writer, droplet) 335 if err != nil { 336 writeErrors <- err 337 return 338 } 339 340 err = form.Close() 341 if err != nil { 342 writeErrors <- err 343 } 344 }() 345 346 return form.FormDataContentType(), writerOutput, writeErrors 347 } 348 349 func (client *Client) uploadAsynchronously(request *cloudcontroller.Request, writeErrors <-chan error) (Job, Warnings, error) { 350 var job Job 351 response := cloudcontroller.Response{ 352 DecodeJSONResponseInto: &job, 353 } 354 355 httpErrors := make(chan error) 356 357 go func() { 358 defer close(httpErrors) 359 360 err := client.connection.Make(request, &response) 361 if err != nil { 362 httpErrors <- err 363 } 364 }() 365 366 // The following section makes the following assumptions: 367 // 1) If an error occurs during file reading, an EOF is sent to the request 368 // object. Thus ending the request transfer. 369 // 2) If an error occurs during request transfer, an EOF is sent to the pipe. 370 // Thus ending the writing routine. 371 var firstError error 372 var writeClosed, httpClosed bool 373 374 for { 375 select { 376 case writeErr, ok := <-writeErrors: 377 if !ok { 378 writeClosed = true 379 break // for select 380 } 381 if firstError == nil { 382 firstError = writeErr 383 } 384 case httpErr, ok := <-httpErrors: 385 if !ok { 386 httpClosed = true 387 break // for select 388 } 389 if firstError == nil { 390 firstError = httpErr 391 } 392 } 393 394 if writeClosed && httpClosed { 395 break // for for 396 } 397 } 398 399 return job, response.Warnings, firstError 400 } 401 402 func (client *Client) uploadExistingResourcesOnly(appGUID string, existingResources []Resource) (Job, Warnings, error) { 403 jsonResources, err := json.Marshal(existingResources) 404 if err != nil { 405 return Job{}, nil, err 406 } 407 408 body := bytes.NewBuffer(nil) 409 form := multipart.NewWriter(body) 410 err = form.WriteField("resources", string(jsonResources)) 411 if err != nil { 412 return Job{}, nil, err 413 } 414 415 err = form.Close() 416 if err != nil { 417 return Job{}, nil, err 418 } 419 420 request, err := client.newHTTPRequest(requestOptions{ 421 RequestName: internal.PutAppBitsRequest, 422 URIParams: Params{"app_guid": appGUID}, 423 Query: url.Values{ 424 "async": {"true"}, 425 }, 426 Body: bytes.NewReader(body.Bytes()), 427 }) 428 if err != nil { 429 return Job{}, nil, err 430 } 431 432 request.Header.Set("Content-Type", form.FormDataContentType()) 433 434 var job Job 435 response := cloudcontroller.Response{ 436 DecodeJSONResponseInto: &job, 437 } 438 439 err = client.connection.Make(request, &response) 440 return job, response.Warnings, err 441 } 442 443 func (client *Client) uploadNewAndExistingResources(appGUID string, existingResources []Resource, newResources Reader, newResourcesLength int64) (Job, Warnings, error) { 444 contentLength, err := client.calculateAppBitsRequestSize(existingResources, newResourcesLength) 445 if err != nil { 446 return Job{}, nil, err 447 } 448 449 contentType, body, writeErrors := client.createMultipartBodyAndHeaderForAppBits(existingResources, newResources, newResourcesLength) 450 451 request, err := client.newHTTPRequest(requestOptions{ 452 RequestName: internal.PutAppBitsRequest, 453 URIParams: Params{"app_guid": appGUID}, 454 Query: url.Values{ 455 "async": {"true"}, 456 }, 457 Body: body, 458 }) 459 if err != nil { 460 return Job{}, nil, err 461 } 462 463 request.Header.Set("Content-Type", contentType) 464 request.ContentLength = contentLength 465 466 return client.uploadAsynchronously(request, writeErrors) 467 }