From d8fafcebbd2f3b560ba1bde4b1b149c216d79a2b Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Tue, 19 Nov 2024 10:34:55 +0100 Subject: [PATCH 01/14] feat: change s3 config --- internal/core/config_test.go | 7 +------ internal/task/config.go | 7 +------ test/testdata/valid_config.yaml | 7 +------ 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 84afb15..6411a21 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -14,12 +14,7 @@ func createExpectedValidConfigS3() task.TransferConfig { return task.TransferConfig{ Method: "S3", S3: task.S3TransferConfig{ - Endpoint: "s3:9000", - Bucket: "landingzone", - Location: "eu-west-1", - User: "minio_user", - Password: "minio_pass", - Checksum: true, + Endpoint: "http://localhost:8000", }, } } diff --git a/internal/task/config.go b/internal/task/config.go index 9b95b04..3078099 100644 --- a/internal/task/config.go +++ b/internal/task/config.go @@ -1,12 +1,7 @@ package task type S3TransferConfig struct { - Endpoint string `string:"Endpoint" validate:"hostname_port"` - Bucket string `string:"Bucket"` - Location string `string:"Location"` - User string `string:"User"` - Password string `string:"Password"` - Checksum bool `bool:"Checksum"` + Endpoint string `string:"Endpoint" validate:"http_url"` } type GlobusTransferConfig struct { diff --git a/test/testdata/valid_config.yaml b/test/testdata/valid_config.yaml index e169d96..25a5a64 100644 --- a/test/testdata/valid_config.yaml +++ b/test/testdata/valid_config.yaml @@ -14,12 +14,7 @@ Transfer: # Scopes: # - "urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/[collection_id1]/data_access]" S3: - Endpoint: s3:9000 - Bucket: landingzone - Checksum: true - Location: "eu-west-1" - User: "minio_user" - Password: "minio_pass" + Endpoint: http://localhost:8000 Misc: ConcurrencyLimit: 2 Port: 8888 From 1dee458bbc8d9f4acb48c46b467d49a6ca343753 Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Tue, 19 Nov 2024 15:01:30 +0100 Subject: [PATCH 02/14] refactor: update progress notifications using percentage directly --- cmd/openem-ingestor-app/app.go | 6 +++--- internal/core/globus.go | 8 ++++---- internal/core/ingestdataset.go | 6 +++++- internal/core/loggingnotifier.go | 4 ++-- internal/core/progressnotifier.go | 2 +- internal/core/taskqueue.go | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cmd/openem-ingestor-app/app.go b/cmd/openem-ingestor-app/app.go index 06d65bb..256fe5d 100644 --- a/cmd/openem-ingestor-app/app.go +++ b/cmd/openem-ingestor-app/app.go @@ -44,9 +44,9 @@ func (w *WailsNotifier) OnTaskCompleted(id uuid.UUID, seconds_elapsed int) { w.loggingNotifier.OnTaskCompleted(id, seconds_elapsed) runtime.EventsEmit(w.AppContext, "upload-completed", id, seconds_elapsed) } -func (w *WailsNotifier) OnTaskProgress(id uuid.UUID, current_file int, total_files int, elapsed_seconds int) { - w.loggingNotifier.OnTaskProgress(id, current_file, total_files, elapsed_seconds) - runtime.EventsEmit(w.AppContext, "progress-update", id, current_file, total_files, elapsed_seconds) +func (w *WailsNotifier) OnTaskProgress(id uuid.UUID, percentage float32, elapsedSeconds int) { + w.loggingNotifier.OnTaskProgress(id, percentage, elapsedSeconds) + runtime.EventsEmit(w.AppContext, "progress-update", id, percentage, elapsedSeconds) } // App struct diff --git a/internal/core/globus.go b/internal/core/globus.go index a08fba8..96b1c14 100644 --- a/internal/core/globus.go +++ b/internal/core/globus.go @@ -104,7 +104,7 @@ func globusCheckTransfer(globusTaskId string) (bytesTransferred int, filesTransf } } -func GlobusTransfer(globusConf task.GlobusTransferConfig, task task.IngestionTask, taskCtx context.Context, localTaskId uuid.UUID, datasetFolder string, fileList []datasetIngestor.Datafile, notifier ProgressNotifier) error { +func GlobusTransfer(globusConf task.GlobusTransferConfig, task task.IngestionTask, taskCtx context.Context, localTaskId uuid.UUID, datasetFolder string, fileList []datasetIngestor.Datafile, notifier task.ProgressNotifier) error { // transfer given filelist var filePathList []string var fileIsSymlinkList []bool @@ -151,7 +151,7 @@ func GlobusTransfer(globusConf task.GlobusTransferConfig, task task.IngestionTas if taskCompleted { return nil } - notifier.OnTaskProgress(localTaskId, filesTransferred, totalFiles, int(time.Since(startTime).Seconds())) + notifier.OnTaskProgress(localTaskId, float32(filesTransferred)/float32(totalFiles)*100, int(time.Since(startTime).Seconds())) timerUpdater := time.After(1 * time.Second) transferUpdater := time.After(1 * time.Minute) @@ -173,7 +173,7 @@ func GlobusTransfer(globusConf task.GlobusTransferConfig, task task.IngestionTas case <-timerUpdater: // update timer every second timerUpdater = time.After(1 * time.Second) - notifier.OnTaskProgress(localTaskId, filesTransferred, totalFiles, int(time.Since(startTime).Seconds())) + notifier.OnTaskProgress(localTaskId, float32(filesTransferred)/float32(totalFiles)*100, int(time.Since(startTime).Seconds())) case <-transferUpdater: // check state of transfer transferUpdater = time.After(1 * time.Minute) @@ -186,7 +186,7 @@ func GlobusTransfer(globusConf task.GlobusTransferConfig, task task.IngestionTas } task.SetStatus(&bytesTransferred, nil, &filesTransferred, &totalFiles, nil, nil, nil, nil) - notifier.OnTaskProgress(localTaskId, filesTransferred, totalFiles, int(time.Since(startTime).Seconds())) + notifier.OnTaskProgress(localTaskId, float32(filesTransferred)/float32(totalFiles)*100, int(time.Since(startTime).Seconds())) if taskCompleted { return nil // we're done! diff --git a/internal/core/ingestdataset.go b/internal/core/ingestdataset.go index e9f7c34..c5e7909 100644 --- a/internal/core/ingestdataset.go +++ b/internal/core/ingestdataset.go @@ -201,7 +201,11 @@ func IngestDataset( switch ingestionTask.TransferMethod { case task.TransferS3: - _, err = UploadS3(task_context, datasetId, datasetFolder, ingestionTask.DatasetFolder.Id, config.Transfer.S3, notifier) + fileList := []string{} + for _, f := range fullFileArray { + fileList = append(fileList, f.Path) + } + err = UploadS3(task_context, datasetId, datasetFolder, fileList, ingestionTask.DatasetFolder.Id, config.Transfer.S3, notifier) case task.TransferGlobus: // globus doesn't work with absolute folders, this library uses sourcePrefix to adapt the path to the globus' own path from a relative path relativeDatasetFolder := strings.TrimPrefix(datasetFolder, config.WebServer.CollectionLocation) diff --git a/internal/core/loggingnotifier.go b/internal/core/loggingnotifier.go index ca5cccc..5e65ad5 100644 --- a/internal/core/loggingnotifier.go +++ b/internal/core/loggingnotifier.go @@ -26,6 +26,6 @@ func (n *LoggingNotifier) OnTaskFailed(id uuid.UUID, err error) { func (n *LoggingNotifier) OnTaskCompleted(id uuid.UUID, elapsedSeconds int) { slog.Info("Task completed", "id", id, "elapsed", elapsedSeconds) } -func (n *LoggingNotifier) OnTaskProgress(id uuid.UUID, currentFile int, totalFiles int, elapsedSeconds int) { - slog.Info("Task progress", "id", id, "percentage", currentFile/totalFiles*100, "elapsed", elapsedSeconds) +func (n *LoggingNotifier) OnTaskProgress(id uuid.UUID, percentage float32, elapsedSeconds int) { + slog.Info("Task progress", "id", id, "percentage", percentage, "elapsed", elapsedSeconds) } diff --git a/internal/core/progressnotifier.go b/internal/core/progressnotifier.go index 9bef38c..75d7471 100644 --- a/internal/core/progressnotifier.go +++ b/internal/core/progressnotifier.go @@ -10,5 +10,5 @@ type ProgressNotifier interface { OnTaskRemoved(id uuid.UUID) OnTaskFailed(id uuid.UUID, err error) OnTaskCompleted(id uuid.UUID, seconds_elapsed int) - OnTaskProgress(id uuid.UUID, current_file int, total_file int, elapsed_seconds int) + OnTaskProgress(id uuid.UUID, percentage float32, elapsed_seconds int) } diff --git a/internal/core/taskqueue.go b/internal/core/taskqueue.go index baac764..9df1d50 100644 --- a/internal/core/taskqueue.go +++ b/internal/core/taskqueue.go @@ -241,7 +241,7 @@ func TestIngestionFunction(task_context context.Context, task task.IngestionTask time.Sleep(time.Second * 1) now := time.Now() elapsed := now.Sub(start) - notifier.OnTaskProgress(task.DatasetFolder.Id, i+1, 10, int(elapsed.Seconds())) + notifier.OnTaskProgress(task.DatasetFolder.Id, float32(i)/10.0*100, int(elapsed.Seconds())) } return "1", nil From 510aa5a716a2e5dcb0a2ba53a0dc6ef2a6dfe01f Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Tue, 19 Nov 2024 18:39:56 +0100 Subject: [PATCH 03/14] feat: Add s3 upload based on presigned urls Add pond as worker pool for multipart uploads --- .../frontend/src/App.svelte | 13 +- go.mod | 1 + go.sum | 2 + internal/core/httpuploader.go | 257 ++++++++++++++++++ internal/core/s3upload.go | 140 ++++------ internal/core/taskqueue.go | 18 +- internal/task/task.go | 1 + 7 files changed, 329 insertions(+), 103 deletions(-) create mode 100644 internal/core/httpuploader.go diff --git a/cmd/openem-ingestor-app/frontend/src/App.svelte b/cmd/openem-ingestor-app/frontend/src/App.svelte index 336b8af..88375d2 100644 --- a/cmd/openem-ingestor-app/frontend/src/App.svelte +++ b/cmd/openem-ingestor-app/frontend/src/App.svelte @@ -106,15 +106,10 @@ items = items; }); - EventsOn( - "progress-update", - (id, current_file, total_files, elapsed_seconds) => { - const perc = (parseFloat(current_file) / parseFloat(total_files)) * 100; - items[id].progress = perc.toFixed(0); - items[id].status += - "\n" + "Uploading... " + secondsToStr(elapsed_seconds); - }, - ); + EventsOn("progress-update", (id, percentage, elapsed_seconds) => { + items[id].progress = percentage.toFixed(0); + items[id].status += "\n" + "Uploading... " + secondsToStr(elapsed_seconds); + });
diff --git a/go.mod b/go.mod index fc53fc5..8ab8fcb 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ toolchain go1.22.9 require ( github.com/SwissOpenEM/globus v0.1.1 + github.com/alitto/pond/v2 v2.1.4 github.com/fatih/color v1.17.0 github.com/getkin/kin-openapi v0.124.0 github.com/gin-contrib/cors v1.7.2 diff --git a/go.sum b/go.sum index 119fe03..27f320c 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/SwissOpenEM/globus v0.1.1 h1:e6wzJkr1iMxYPGbKvosNLI+4uI39HHBAPzUkIZ7vC5E= github.com/SwissOpenEM/globus v0.1.1/go.mod h1:HiMwPdtUdztPpnA0TamNWBBRPGYjEJWXSRUIV5vjqXc= +github.com/alitto/pond/v2 v2.1.4 h1:FLVRXHjQBpyMdgn6Ua3NWLy8B/4swn9XoB2S3W7UkMQ= +github.com/alitto/pond/v2 v2.1.4/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= diff --git a/internal/core/httpuploader.go b/internal/core/httpuploader.go new file mode 100644 index 0000000..0947e81 --- /dev/null +++ b/internal/core/httpuploader.go @@ -0,0 +1,257 @@ +package core + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "sync" + + "github.com/alitto/pond/v2" + "github.com/minio/minio-go/v7" +) + +const ( + chunkSize = 5 * 1024 * 1024 // 5 MB + server = "http://localhost:8888" + presigned_url_path = "/presignedUrls" + complete_upload_path = "/completeUpload" +) + +type presignedUrlBody struct { + ObjectName string `json:"object_name"` + Parts int `json:"parts"` +} + +type presignedUrlResp struct { + UploadID string `json:"uploadID"` + Urls []string `json:"urls"` +} + +type completeUploadBody struct { + ObjectName string `json:"object_name"` + UploadID string `json:"uploadID"` + Parts []minio.CompletePart `json:"parts"` +} + +type MultipartInput struct { + File *os.File + PartCount int +} + +type HttpUploader struct { + Pool pond.Pool +} + +var instance *HttpUploader +var once sync.Once + +func GetHttpUploader() *HttpUploader { + once.Do(func() { + instance = &HttpUploader{Pool: pond.NewPool(100)} + }) + return instance +} + +// Fetches presigned url(s) from API server. If parts > 1, multipart upload +// is initiated +func getPresignedUrls(object_name string, parts int, endpoint string) (string, []string, error) { + body := presignedUrlBody{ + ObjectName: object_name, + Parts: parts, + } + jsonBody, _ := json.Marshal(body) + bodyReader := bytes.NewReader(jsonBody) + + req, err := http.NewRequest("POST", endpoint+presigned_url_path, bodyReader) + + if err != nil { + return "", []string{}, fmt.Errorf("error creating request for %s. error: %s", object_name, err.Error()) + } + + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + defer resp.Body.Close() + if err != nil { + return "", []string{}, fmt.Errorf("error executing request for %s. error: %s", object_name, err.Error()) + } + + resBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", []string{}, fmt.Errorf("failed to read response body for %s. error: %s", object_name, err.Error()) + } + + var result presignedUrlResp + if err := json.Unmarshal(resBody, &result); err != nil { + return "", []string{}, fmt.Errorf("error unmarshalling JSON for %s. error: %s", object_name, err.Error()) + } + + return result.UploadID, result.Urls, nil + +} + +func completeMultiPartUpload(object_name string, uploadID string, parts []minio.CompletePart) error { + body := completeUploadBody{ + ObjectName: object_name, + UploadID: uploadID, + Parts: parts, + } + jsonBody, _ := json.Marshal(body) + fmt.Println(string(jsonBody)) + bodyReader := bytes.NewReader(jsonBody) + req, _ := http.NewRequest("POST", server+complete_upload_path, bodyReader) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + // return errors.New("Fail") + } + + return nil +} + +func uploadFile(ctx context.Context, filePath string, objectName string, endpoint string, notifier *TransferNotifier) error { + // Open the file + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("error opening file: %w", err) + } + + defer file.Close() + + // Get the file size + fileInfo, err := file.Stat() + if err != nil { + return fmt.Errorf("error getting file info: %w", err) + } + + totalSize := fileInfo.Size() + fmt.Printf("Uploading file: %s (%d bytes)\n", filePath, totalSize) + + if totalSize < chunkSize { + // do normal upload + err := doUploadSingleFile(ctx, objectName, file, endpoint, notifier) + return err + + } + + err = doUploadMultipart(ctx, totalSize, objectName, file, endpoint, notifier) + return err + +} + +func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { + + _, url, err := getPresignedUrls(objectName, 1, endpoint) + if err != nil { + return err + } + data, err := os.ReadFile(file.Name()) + if err != nil { + return err + } + + base64hash := calculateHashB64(&data) + _, err = uploadData(ctx, &data, url[0], base64hash) + if err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + notifier.AddUploadedBytes(int64(len(data))) + notifier.UpdateTaskProgress() + return nil +} + +func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { + partCount := int(math.Ceil(float64(totalSize) / float64(chunkSize))) + + uploadID, presignedURLs, err := getPresignedUrls(objectName, partCount, endpoint) + if err != nil { + return err + } + + uploader := GetHttpUploader() + + group := uploader.Pool.NewGroupContext(ctx) + parts := make([]minio.CompletePart, partCount) + + for partNumber := 0; partNumber < partCount; partNumber++ { + group.SubmitErr(func() error { + partData := make([]byte, chunkSize) + n, _ := file.ReadAt(partData, int64(partNumber)*chunkSize) + partData = partData[:n] + + base64hash := calculateHashB64(&partData) + resp, err := uploadData(ctx, &partData, presignedURLs[partNumber], base64hash) + if err != nil { + return err + } + + notifier.AddUploadedBytes(int64(n)) + if partNumber%2 == 0 { + notifier.UpdateTaskProgress() + } + parts[partNumber] = minio.CompletePart{ETag: resp.Header.Get("ETag"), PartNumber: partNumber + 1, ChecksumSHA256: base64hash} + + fmt.Printf("Uploaded part %d\n", partNumber+1) + return nil + }) + } + + group.Wait() + + if ctx.Err() != nil { + return ctx.Err() + } + err = completeMultiPartUpload(objectName, uploadID, parts) + if err != nil { + return fmt.Errorf("error completing multipart upload: %w", err) + } + + fmt.Println("Multipart upload completed successfully.") + return nil +} + +func calculateHashB64(data *[]byte) string { + hash := sha256.Sum256(*data) + base64hash := base64.StdEncoding.EncodeToString(hash[:]) + return base64hash +} + +func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64hash string) (*http.Response, error) { + + decoded_url, _ := base64.StdEncoding.DecodeString(presignedURL) + req, err := http.NewRequestWithContext(ctx, "PUT", string(decoded_url), bytes.NewReader(*data)) + + if err != nil { + return nil, err + } + + // The checksum algorithm needs to match the one defined in the presigned url + req.Header.Set("x-amz-checksum-sha256", base64hash) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return resp, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return resp, fmt.Errorf("upload failed: %d %s", resp.StatusCode, resp.Status) + } + return resp, nil +} diff --git a/internal/core/s3upload.go b/internal/core/s3upload.go index 9ca6778..f8b0c18 100644 --- a/internal/core/s3upload.go +++ b/internal/core/s3upload.go @@ -2,112 +2,82 @@ package core import ( "context" - "log" "os" "path" + "sync" + "sync/atomic" "time" "github.com/SwissOpenEM/Ingestor/internal/task" "github.com/google/uuid" - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" ) // Progress notifier object for Minio upload -type MinioProgressNotifier struct { - total_file_size int64 - current_size int64 - files_count int - current_file int - previous_percentage float64 - start_time time.Time - id uuid.UUID - notifier ProgressNotifier +type TransferNotifier struct { + totalBytes int64 + bytesTansfered int64 + FilesCount int + startTime time.Time + id uuid.UUID + notifier ProgressNotifier + TaskStatus *task.TaskStatus } -// Callback that gets called by fputobject. -// Note: does not work for multipart uploads -func (pn *MinioProgressNotifier) Read(p []byte) (n int, err error) { - n = len(p) - pn.current_size += int64(n) - - pn.notifier.OnTaskProgress(pn.id, pn.current_file, pn.files_count, int(time.Since(pn.start_time).Seconds())) - return +func (pn *TransferNotifier) AddUploadedBytes(numBytes int64) { + atomic.AddInt64(&pn.bytesTansfered, numBytes) } -// Upload all files in a folder to a minio bucket -func UploadS3(task_ctx context.Context, dataset_pid string, datasetSourceFolder string, uploadId uuid.UUID, options task.S3TransferConfig, notifier ProgressNotifier) (string, error) { - accessKeyID := options.User - secretAccessKey := options.Password - creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "") - useSSL := false - - log.Printf("Using endpoint %s\n", options.Endpoint) +func (pn *TransferNotifier) UpdateTaskProgress() { + t := time.Since(pn.startTime) + pn.notifier.OnTaskProgress(pn.id, float32(pn.bytesTansfered)/float32(pn.totalBytes)*100, int(t.Seconds())) +} - // Initialize minio client object. - minioClient, err := minio.New(options.Endpoint, &minio.Options{ - Creds: creds, - Secure: useSSL, - }) +// Upload all files in a folder using presinged urls +func UploadS3(ctx context.Context, datasetPID string, datasetSourceFolder string, fileList []string, uploadId uuid.UUID, options task.S3TransferConfig, notifier ProgressNotifier) error { - if err != nil { - log.Fatalln(err) + if len(fileList) == 0 { + return nil } - // Make a new bucket called testbucket. - bucketName := options.Bucket - - err = minioClient.MakeBucket(task_ctx, bucketName, minio.MakeBucketOptions{Region: options.Location}) - if err != nil { - // Check to see if we already own this bucket (which happens if you run this twice) - exists, errBucketExists := minioClient.BucketExists(task_ctx, bucketName) - if errBucketExists == nil && exists { - log.Printf("We already own %s\n", bucketName) - } else { - log.Fatalln(err) - } - } else { - log.Printf("Successfully created %s\n", bucketName) + totalBytes := int64(0) + for _, f := range fileList { + s, _ := os.Stat(path.Join(datasetSourceFolder, f)) + totalBytes += s.Size() } - contentType := "application/octet-stream" - - entries, err := os.ReadDir(datasetSourceFolder) - if err != nil { - return "", err + transferNotifier := TransferNotifier{totalBytes: totalBytes, bytesTansfered: 0, startTime: time.Now(), id: uploadId, notifier: notifier} + + wg := sync.WaitGroup{} + filesChannel := make(chan string, len(fileList)) + nWorkers := max(1, len(fileList)) + // start the workers + for t := 0; t < nWorkers; t++ { + wg.Add(1) + go func(filesChannel <-chan string, wg *sync.WaitGroup) { + for f := range filesChannel { + select { + case <-ctx.Done(): + transferNotifier.notifier.OnTaskCanceled(uploadId) + wg.Done() + return + default: + filePath := path.Join(datasetSourceFolder, f) + objectName := "openem-network/datasets/" + datasetPID + "/raw_files/" + f + uploadFile(ctx, filePath, objectName, options.Endpoint, &transferNotifier) + } + } + wg.Done() + }(filesChannel, &wg) } + for _, f := range fileList { + filesChannel <- f + } + close(filesChannel) + wg.Wait() - pn := MinioProgressNotifier{files_count: len(entries), previous_percentage: 0.0, start_time: time.Now(), id: uploadId, notifier: notifier} - - for idx, f := range entries { - select { - case <-task_ctx.Done(): - pn.notifier.OnTaskCanceled(uploadId) - return "Upload canceled", nil - - default: - filePath := path.Join(datasetSourceFolder, f.Name()) - objectName := "openem-network/datasets/" + dataset_pid + "/raw_files/" + f.Name() - - pn.current_file = idx + 1 - fileinfo, _ := os.Stat(filePath) - pn.total_file_size = fileinfo.Size() - - notifier.OnTaskProgress(uploadId, pn.current_file, pn.files_count, 0) - - _, err := minioClient.FPutObject(task_ctx, bucketName, objectName, filePath, minio.PutObjectOptions{ - ContentType: contentType, - Progress: &pn, - SendContentMd5: true, - NumThreads: 4, - DisableMultipart: false, - ConcurrentStreamParts: true, - }) - if err != nil { - return dataset_pid, err - } - } + if ctx.Err() != nil { + return ctx.Err() } - return dataset_pid, nil + return nil } diff --git a/internal/core/taskqueue.go b/internal/core/taskqueue.go index 9df1d50..f21f87b 100644 --- a/internal/core/taskqueue.go +++ b/internal/core/taskqueue.go @@ -95,11 +95,8 @@ func (w *TaskQueue) CreateTaskFromMetadata(id uuid.UUID, metadataMap map[string] // Go routine that listens on the channel continously for upload requests and executes uploads. func (w *TaskQueue) startWorker() { for ingestionTask := range w.inputChannel { - task_context, cancel := context.WithCancel(w.AppContext) - ingestionTask.Cancel = cancel - - result := w.IngestDataset(task_context, ingestionTask) + result := w.IngestDataset(ingestionTask.Context, ingestionTask) if result.Error == nil { falseVal := false trueVal := true @@ -150,12 +147,15 @@ func (w *TaskQueue) RemoveTask(id uuid.UUID) error { func (w *TaskQueue) ScheduleTask(id uuid.UUID) { w.taskListLock.RLock() - ingestionTask, found := w.datasetUploadTasks.Get(id) + ingestionTask := w.datasetUploadTasks.GetElement(id) w.taskListLock.RUnlock() - if !found { + if ingestionTask == nil { fmt.Println("Scheduling upload failed for: ", id) return } + task_context, cancel := context.WithCancel(w.AppContext) + ingestionTask.Value.Context = task_context + ingestionTask.Value.Cancel = cancel // Go routine to handle result and errors go func(id uuid.UUID) { @@ -167,7 +167,7 @@ func (w *TaskQueue) ScheduleTask(id uuid.UUID) { w.Notifier.OnTaskCompleted(id, taskResult.Elapsed_seconds) println(taskResult.Dataset_PID, taskResult.Elapsed_seconds) } - }(ingestionTask.DatasetFolder.Id) + }(ingestionTask.Value.DatasetFolder.Id) // Go routine to schedule the upload asynchronously go func(folder task.DatasetFolder) { @@ -175,8 +175,8 @@ func (w *TaskQueue) ScheduleTask(id uuid.UUID) { w.Notifier.OnTaskScheduled(folder.Id) // this channel is read by the go routines that does the actual upload - w.inputChannel <- ingestionTask - }(ingestionTask.DatasetFolder) + w.inputChannel <- ingestionTask.Value + }(ingestionTask.Value.DatasetFolder) } func (w *TaskQueue) GetTaskStatus(id uuid.UUID) (task.TaskStatus, error) { diff --git a/internal/task/task.go b/internal/task/task.go index 94909b0..4f07fbc 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -39,6 +39,7 @@ type IngestionTask struct { DatasetFolder DatasetMetadata map[string]interface{} TransferMethod TransferMethod + Context context.Context Cancel context.CancelFunc status *TaskStatus statusLock *sync.RWMutex From adb80eb06d6a984d00dfdde0899c2b22b7b02c3f Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Tue, 19 Nov 2024 18:42:17 +0100 Subject: [PATCH 04/14] Fix: improved icon for app --- .../images/android-chrome-512x512_trans.png | Bin 0 -> 283433 bytes cmd/openem-ingestor-app/main.go | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 cmd/openem-ingestor-app/frontend/src/assets/images/android-chrome-512x512_trans.png diff --git a/cmd/openem-ingestor-app/frontend/src/assets/images/android-chrome-512x512_trans.png b/cmd/openem-ingestor-app/frontend/src/assets/images/android-chrome-512x512_trans.png new file mode 100644 index 0000000000000000000000000000000000000000..d59e14eee55534f9fe8152d3423c48fbc5eb0c10 GIT binary patch literal 283433 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 Yudl{zGlcF@5f71(mYe#VzUI;Y50P8hi~s-t literal 0 HcmV?d00001 diff --git a/cmd/openem-ingestor-app/main.go b/cmd/openem-ingestor-app/main.go index 0876207..bf8e035 100644 --- a/cmd/openem-ingestor-app/main.go +++ b/cmd/openem-ingestor-app/main.go @@ -16,7 +16,7 @@ import ( //go:embed all:frontend/dist var assets embed.FS -//go:embed frontend/src/assets/images/android-chrome-512x512.png +//go:embed frontend/src/assets/images/android-chrome-512x512_trans.png var icon []byte // String can be overwritten by using linker flags: -ldflags "-X main.version=VERSION" From cf156db11d87a5fad8c50a1970a3decf80e679cc Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 22 Nov 2024 10:23:24 +0100 Subject: [PATCH 05/14] Fix: checksum calculation of whole object It's composed of the checksums of the parts and not the whole object's digest --- internal/core/httpuploader.go | 134 +++++++++++++++++++++++----------- 1 file changed, 93 insertions(+), 41 deletions(-) diff --git a/internal/core/httpuploader.go b/internal/core/httpuploader.go index 0947e81..48c573e 100644 --- a/internal/core/httpuploader.go +++ b/internal/core/httpuploader.go @@ -6,11 +6,15 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "log/slog" "math" "net/http" "os" + "runtime" + "strings" "sync" "github.com/alitto/pond/v2" @@ -19,7 +23,6 @@ import ( const ( chunkSize = 5 * 1024 * 1024 // 5 MB - server = "http://localhost:8888" presigned_url_path = "/presignedUrls" complete_upload_path = "/completeUpload" ) @@ -29,15 +32,20 @@ type presignedUrlBody struct { Parts int `json:"parts"` } -type presignedUrlResp struct { +type presignedUrlRespMultipart struct { UploadID string `json:"uploadID"` Urls []string `json:"urls"` } +type presignedUrlResp struct { + Url string `json:"url"` +} + type completeUploadBody struct { - ObjectName string `json:"object_name"` - UploadID string `json:"uploadID"` - Parts []minio.CompletePart `json:"parts"` + ObjectName string `json:"object_name"` + UploadID string `json:"uploadID"` + Parts []minio.CompletePart `json:"parts"` + ChecksumSHA256 string `json:"checksumSHA256"` } type MultipartInput struct { @@ -46,7 +54,8 @@ type MultipartInput struct { } type HttpUploader struct { - Pool pond.Pool + Pool pond.Pool + Client http.Client } var instance *HttpUploader @@ -61,61 +70,70 @@ func GetHttpUploader() *HttpUploader { // Fetches presigned url(s) from API server. If parts > 1, multipart upload // is initiated -func getPresignedUrls(object_name string, parts int, endpoint string) (string, []string, error) { +func getPresignedUrlsMultipart(object_name string, part int, endpoint string) (string, []string, error) { body := presignedUrlBody{ ObjectName: object_name, - Parts: parts, + Parts: part, } jsonBody, _ := json.Marshal(body) - bodyReader := bytes.NewReader(jsonBody) - - req, err := http.NewRequest("POST", endpoint+presigned_url_path, bodyReader) + resBody, err := doPresignedUrlRequest(jsonBody, endpoint) if err != nil { - return "", []string{}, fmt.Errorf("error creating request for %s. error: %s", object_name, err.Error()) + return "", []string{}, err } - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - defer resp.Body.Close() - if err != nil { - return "", []string{}, fmt.Errorf("error executing request for %s. error: %s", object_name, err.Error()) + var result presignedUrlRespMultipart + if err := json.Unmarshal(resBody, &result); err != nil { + return "", []string{}, fmt.Errorf("error unmarshalling JSON for %s. error: %s", object_name, err.Error()) } - resBody, err := io.ReadAll(resp.Body) + return result.UploadID, result.Urls, nil +} + +// Fetches presigned url(s) from API server. If parts > 1, multipart upload +// is initiated +func getPresignedUrl(object_name string, endpoint string) (string, error) { + body := presignedUrlBody{ + ObjectName: object_name, + Parts: 1, + } + jsonBody, _ := json.Marshal(body) + resBody, err := doPresignedUrlRequest(jsonBody, endpoint) + if err != nil { - return "", []string{}, fmt.Errorf("failed to read response body for %s. error: %s", object_name, err.Error()) + return "", err } var result presignedUrlResp if err := json.Unmarshal(resBody, &result); err != nil { - return "", []string{}, fmt.Errorf("error unmarshalling JSON for %s. error: %s", object_name, err.Error()) + return "", fmt.Errorf("error unmarshalling JSON for %s. error: %s", object_name, err.Error()) } - return result.UploadID, result.Urls, nil + return result.Url, err } -func completeMultiPartUpload(object_name string, uploadID string, parts []minio.CompletePart) error { +func completeMultiPartUpload(object_name string, uploadID string, endpoint string, parts []minio.CompletePart, full_file_checksum string) error { body := completeUploadBody{ - ObjectName: object_name, - UploadID: uploadID, - Parts: parts, + ObjectName: object_name, + UploadID: uploadID, + Parts: parts, + ChecksumSHA256: full_file_checksum, } jsonBody, _ := json.Marshal(body) fmt.Println(string(jsonBody)) bodyReader := bytes.NewReader(jsonBody) - req, _ := http.NewRequest("POST", server+complete_upload_path, bodyReader) + req, _ := http.NewRequest("POST", endpoint+complete_upload_path, bodyReader) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := GetHttpUploader().Client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { - // return errors.New("Fail") + return errors.New("Fail") } return nil @@ -153,7 +171,7 @@ func uploadFile(ctx context.Context, filePath string, objectName string, endpoin func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { - _, url, err := getPresignedUrls(objectName, 1, endpoint) + url, err := getPresignedUrl(objectName, endpoint) if err != nil { return err } @@ -162,8 +180,8 @@ func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, e return err } - base64hash := calculateHashB64(&data) - _, err = uploadData(ctx, &data, url[0], base64hash) + base64hash, _ := calculateHashB64(&data) + _, err = uploadData(ctx, &data, url, base64hash) if err != nil { return err } @@ -178,7 +196,7 @@ func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, e func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { partCount := int(math.Ceil(float64(totalSize) / float64(chunkSize))) - uploadID, presignedURLs, err := getPresignedUrls(objectName, partCount, endpoint) + uploadID, presignedURLs, err := getPresignedUrlsMultipart(objectName, partCount, endpoint) if err != nil { return err } @@ -187,6 +205,7 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, group := uploader.Pool.NewGroupContext(ctx) parts := make([]minio.CompletePart, partCount) + partChecksums := make([]string, partCount) for partNumber := 0; partNumber < partCount; partNumber++ { group.SubmitErr(func() error { @@ -194,17 +213,19 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, n, _ := file.ReadAt(partData, int64(partNumber)*chunkSize) partData = partData[:n] - base64hash := calculateHashB64(&partData) + base64hash, hash := calculateHashB64(&partData) + partChecksums[partNumber] = string(hash[:]) resp, err := uploadData(ctx, &partData, presignedURLs[partNumber], base64hash) if err != nil { return err } notifier.AddUploadedBytes(int64(n)) - if partNumber%2 == 0 { - notifier.UpdateTaskProgress() - } - parts[partNumber] = minio.CompletePart{ETag: resp.Header.Get("ETag"), PartNumber: partNumber + 1, ChecksumSHA256: base64hash} + + notifier.UpdateTaskProgress() + + etag := strings.Replace(resp.Header.Get("ETag"), "\"", "", -1) + parts[partNumber] = minio.CompletePart{ETag: etag, PartNumber: partNumber + 1, ChecksumSHA256: base64hash} fmt.Printf("Uploaded part %d\n", partNumber+1) return nil @@ -216,7 +237,12 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, if ctx.Err() != nil { return ctx.Err() } - err = completeMultiPartUpload(objectName, uploadID, parts) + c := strings.Join(partChecksums, "") + n := sha256.Sum256([]byte(c)) + base64hash := base64.StdEncoding.EncodeToString(n[:]) + slog.Info("Calculated file digest", "file", file.Name(), "sha256", base64hash) + + err = completeMultiPartUpload(objectName, uploadID, endpoint, parts, base64hash) if err != nil { return fmt.Errorf("error completing multipart upload: %w", err) } @@ -225,10 +251,10 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, return nil } -func calculateHashB64(data *[]byte) string { +func calculateHashB64(data *[]byte) (string, [32]byte) { hash := sha256.Sum256(*data) base64hash := base64.StdEncoding.EncodeToString(hash[:]) - return base64hash + return base64hash, hash } func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64hash string) (*http.Response, error) { @@ -244,7 +270,7 @@ func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64ha req.Header.Set("x-amz-checksum-sha256", base64hash) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := GetHttpUploader().Client.Do(req) if err != nil { return resp, err } @@ -255,3 +281,29 @@ func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64ha } return resp, nil } + +func doPresignedUrlRequest(jsonBody []byte, endpoint string) ([]byte, error) { + bodyReader := bytes.NewReader(jsonBody) + req, err := http.NewRequest("POST", endpoint+presigned_url_path, bodyReader) + + if err != nil { + return []byte{}, err + } + + req.Header.Set("Content-Type", "application/json") + resp, err := GetHttpUploader().Client.Do(req) + if err != nil { + return []byte{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return []byte{}, err + } + + resBody, err := io.ReadAll(resp.Body) + if err != nil { + return []byte{}, err + } + return resBody, nil +} From 0c0cbea451e356201d6ac3093b29d80be940b8bf Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 22 Nov 2024 13:34:47 +0100 Subject: [PATCH 06/14] s3upload: improve error handling, add abort call for failed multipart uploads --- internal/core/httpuploader.go | 61 +++++++++++++++++++++---------- internal/core/s3upload.go | 68 ++++++++++++++++++++--------------- 2 files changed, 81 insertions(+), 48 deletions(-) diff --git a/internal/core/httpuploader.go b/internal/core/httpuploader.go index 48c573e..cb18239 100644 --- a/internal/core/httpuploader.go +++ b/internal/core/httpuploader.go @@ -22,9 +22,10 @@ import ( ) const ( - chunkSize = 5 * 1024 * 1024 // 5 MB - presigned_url_path = "/presignedUrls" - complete_upload_path = "/completeUpload" + chunkSize = 5 * 1024 * 1024 // 5 MB + presignedUrlPath = "/presignedUrls" + completeUploadPath = "/completeUpload" + abortMultiPartUplaodPath = "/abortMultipartUpload" ) type presignedUrlBody struct { @@ -41,12 +42,16 @@ type presignedUrlResp struct { Url string `json:"url"` } -type completeUploadBody struct { +type completeMultipartUploadBody struct { ObjectName string `json:"object_name"` UploadID string `json:"uploadID"` Parts []minio.CompletePart `json:"parts"` ChecksumSHA256 string `json:"checksumSHA256"` } +type abortMultipartUploadBody struct { + ObjectName string `json:"object_name"` + UploadID string `json:"uploadID"` +} type MultipartInput struct { File *os.File @@ -76,7 +81,7 @@ func getPresignedUrlsMultipart(object_name string, part int, endpoint string) (s Parts: part, } jsonBody, _ := json.Marshal(body) - resBody, err := doPresignedUrlRequest(jsonBody, endpoint) + resBody, err := doRequest("POST", jsonBody, presignedUrlPath, endpoint) if err != nil { return "", []string{}, err @@ -98,7 +103,7 @@ func getPresignedUrl(object_name string, endpoint string) (string, error) { Parts: 1, } jsonBody, _ := json.Marshal(body) - resBody, err := doPresignedUrlRequest(jsonBody, endpoint) + resBody, err := doRequest("POST", jsonBody, presignedUrlPath, endpoint) if err != nil { return "", err @@ -114,7 +119,7 @@ func getPresignedUrl(object_name string, endpoint string) (string, error) { } func completeMultiPartUpload(object_name string, uploadID string, endpoint string, parts []minio.CompletePart, full_file_checksum string) error { - body := completeUploadBody{ + body := completeMultipartUploadBody{ ObjectName: object_name, UploadID: uploadID, Parts: parts, @@ -123,7 +128,7 @@ func completeMultiPartUpload(object_name string, uploadID string, endpoint strin jsonBody, _ := json.Marshal(body) fmt.Println(string(jsonBody)) bodyReader := bytes.NewReader(jsonBody) - req, _ := http.NewRequest("POST", endpoint+complete_upload_path, bodyReader) + req, _ := http.NewRequest("POST", endpoint+completeUploadPath, bodyReader) req.Header.Set("Content-Type", "application/json") resp, err := GetHttpUploader().Client.Do(req) @@ -164,11 +169,28 @@ func uploadFile(ctx context.Context, filePath string, objectName string, endpoin } - err = doUploadMultipart(ctx, totalSize, objectName, file, endpoint, notifier) + uploadID, err := doUploadMultipart(ctx, totalSize, objectName, file, endpoint, notifier) + if err != nil { + abortMultipartUpload(uploadID, objectName, endpoint) + if err != nil { + slog.Error("Failed to abort mulitpart upload", "uploadID", uploadID, "object", objectName) + } + } return err } +func abortMultipartUpload(uploadID string, objectName string, endpoint string) error { + body := abortMultipartUploadBody{ + ObjectName: objectName, + UploadID: uploadID, + } + + jsonBody, _ := json.Marshal(body) + _, err := doRequest("POST", jsonBody, abortMultiPartUplaodPath, endpoint) + return err +} + func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { url, err := getPresignedUrl(objectName, endpoint) @@ -193,12 +215,12 @@ func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, e return nil } -func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { +func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) (string, error) { partCount := int(math.Ceil(float64(totalSize) / float64(chunkSize))) uploadID, presignedURLs, err := getPresignedUrlsMultipart(objectName, partCount, endpoint) if err != nil { - return err + return uploadID, err } uploader := GetHttpUploader() @@ -232,11 +254,12 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, }) } - group.Wait() + err = group.Wait() - if ctx.Err() != nil { - return ctx.Err() + if err != nil { + return uploadID, ctx.Err() } + c := strings.Join(partChecksums, "") n := sha256.Sum256([]byte(c)) base64hash := base64.StdEncoding.EncodeToString(n[:]) @@ -244,11 +267,11 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, err = completeMultiPartUpload(objectName, uploadID, endpoint, parts, base64hash) if err != nil { - return fmt.Errorf("error completing multipart upload: %w", err) + return uploadID, fmt.Errorf("error completing multipart upload: %w", err) } fmt.Println("Multipart upload completed successfully.") - return nil + return uploadID, nil } func calculateHashB64(data *[]byte) (string, [32]byte) { @@ -282,9 +305,9 @@ func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64ha return resp, nil } -func doPresignedUrlRequest(jsonBody []byte, endpoint string) ([]byte, error) { +func doRequest(method string, jsonBody []byte, path string, endpoint string) ([]byte, error) { bodyReader := bytes.NewReader(jsonBody) - req, err := http.NewRequest("POST", endpoint+presigned_url_path, bodyReader) + req, err := http.NewRequest(method, endpoint+presignedUrlPath, bodyReader) if err != nil { return []byte{}, err @@ -298,7 +321,7 @@ func doPresignedUrlRequest(jsonBody []byte, endpoint string) ([]byte, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return []byte{}, err + return []byte{}, fmt.Errorf("%s request failed: %s%s", method, endpoint, path) } resBody, err := io.ReadAll(resp.Body) diff --git a/internal/core/s3upload.go b/internal/core/s3upload.go index f8b0c18..cc99cc9 100644 --- a/internal/core/s3upload.go +++ b/internal/core/s3upload.go @@ -2,14 +2,15 @@ package core import ( "context" + "fmt" "os" "path" - "sync" "sync/atomic" "time" "github.com/SwissOpenEM/Ingestor/internal/task" "github.com/google/uuid" + "golang.org/x/sync/errgroup" ) // Progress notifier object for Minio upload @@ -32,52 +33,61 @@ func (pn *TransferNotifier) UpdateTaskProgress() { pn.notifier.OnTaskProgress(pn.id, float32(pn.bytesTansfered)/float32(pn.totalBytes)*100, int(t.Seconds())) } +type S3Objects struct { + Files []string + ObjectNames []string + TotalBytes int64 +} + // Upload all files in a folder using presinged urls func UploadS3(ctx context.Context, datasetPID string, datasetSourceFolder string, fileList []string, uploadId uuid.UUID, options task.S3TransferConfig, notifier ProgressNotifier) error { if len(fileList) == 0 { - return nil + return fmt.Errorf("empty file list provided") } - totalBytes := int64(0) + s3Objects := S3Objects{} for _, f := range fileList { s, _ := os.Stat(path.Join(datasetSourceFolder, f)) - totalBytes += s.Size() + s3Objects.TotalBytes += s.Size() + s3Objects.Files = append(s3Objects.Files, path.Join(datasetSourceFolder, f)) + s3Objects.ObjectNames = append(s3Objects.ObjectNames, "openem-network/datasets/"+datasetPID+"/raw_files/"+f) } - transferNotifier := TransferNotifier{totalBytes: totalBytes, bytesTansfered: 0, startTime: time.Now(), id: uploadId, notifier: notifier} + transferNotifier := TransferNotifier{totalBytes: s3Objects.TotalBytes, bytesTansfered: 0, startTime: time.Now(), id: uploadId, notifier: notifier} + + err := uploadFiles(ctx, &s3Objects, options, &transferNotifier, uploadId) + return err +} + +func uploadFiles(ctx context.Context, s3Objects *S3Objects, options task.S3TransferConfig, transferNotifier *TransferNotifier, uploadId uuid.UUID) error { + errorGroup, ctx := errgroup.WithContext(ctx) + objectsChannel := make(chan int, len(s3Objects.Files)) + + nWorkers := max(2, len(s3Objects.Files)) - wg := sync.WaitGroup{} - filesChannel := make(chan string, len(fileList)) - nWorkers := max(1, len(fileList)) - // start the workers for t := 0; t < nWorkers; t++ { - wg.Add(1) - go func(filesChannel <-chan string, wg *sync.WaitGroup) { - for f := range filesChannel { + errorGroup.Go( + func() error { + for idx := range objectsChannel { select { case <-ctx.Done(): transferNotifier.notifier.OnTaskCanceled(uploadId) - wg.Done() - return + return ctx.Err() default: - filePath := path.Join(datasetSourceFolder, f) - objectName := "openem-network/datasets/" + datasetPID + "/raw_files/" + f - uploadFile(ctx, filePath, objectName, options.Endpoint, &transferNotifier) + err := uploadFile(ctx, s3Objects.Files[idx], s3Objects.ObjectNames[idx], options.Endpoint, transferNotifier) + if err != nil { + return err + } + } } - } - wg.Done() - }(filesChannel, &wg) - } - for _, f := range fileList { - filesChannel <- f + return nil + }) } - close(filesChannel) - wg.Wait() - - if ctx.Err() != nil { - return ctx.Err() + for idx := range s3Objects.Files { + objectsChannel <- idx } + close(objectsChannel) + return errorGroup.Wait() - return nil } From 33b97e0a1a22e93bfa06bd2a4f2dcdabb39f7386 Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 22 Nov 2024 16:02:43 +0100 Subject: [PATCH 07/14] s3upload: move into separate package --- go.mod | 5 - go.sum | 9 - internal/core/ingestdataset.go | 6 +- internal/core/taskqueue.go | 6 +- internal/{core => s3upload}/httpuploader.go | 180 +++++++------------- internal/{core => s3upload}/s3upload.go | 14 +- internal/{core => task}/progressnotifier.go | 2 +- 7 files changed, 80 insertions(+), 142 deletions(-) rename internal/{core => s3upload}/httpuploader.go (56%) rename internal/{core => s3upload}/s3upload.go (91%) rename internal/{core => task}/progressnotifier.go (96%) diff --git a/go.mod b/go.mod index 8ab8fcb..c2284a8 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/go-github v17.0.0+incompatible github.com/google/uuid v1.6.0 - github.com/minio/minio-go/v7 v7.0.76 github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 github.com/oapi-codegen/runtime v1.1.1 @@ -85,12 +84,10 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/coreos/go-oidc/v3 v3.11.0 github.com/creack/pty v1.1.23 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v2 v2.4.0 github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gin-contrib/sessions v1.0.1 github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-ini/ini v1.67.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -119,7 +116,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 // indirect - github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -127,7 +123,6 @@ require ( github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rs/xid v1.6.0 // indirect github.com/samber/lo v1.38.1 // indirect github.com/tkrajina/go-reflector v0.5.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index 27f320c..34daeb1 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v2 v2.4.0 h1:6tUmMwD9F998FNpwFxA5E6NQvSpk2PVw7RKsVq3+2Cw= github.com/elliotchance/orderedmap/v2 v2.4.0/go.mod h1:85lZyVbpGaGvHvnKa7Qhx7zncAdBIBq6u56Hb1PRU5Q= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -203,7 +201,6 @@ github.com/kdomanski/iso9660 v0.3.3/go.mod h1:K+UlIGxKgtrdAWyoigPnFbeQLVs/Xudz4i github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -245,10 +242,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 h1:YocNLcTBdEdvY3iDK6jfWXvEaM5OCKkjxPKoJRdB3Gg= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= -github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= -github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.76 h1:9nxHH2XDai61cT/EFhyIw/wW4vJfpPNvl7lSFpRt+Ng= -github.com/minio/minio-go/v7 v7.0.76/go.mod h1:AVM3IUN6WwKzmwBxVdjzhH8xq+f57JSbbvzqvUzR6eg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -292,8 +285,6 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/core/ingestdataset.go b/internal/core/ingestdataset.go index c5e7909..d5fa8c9 100644 --- a/internal/core/ingestdataset.go +++ b/internal/core/ingestdataset.go @@ -14,6 +14,7 @@ import ( "time" "github.com/SwissOpenEM/Ingestor/internal/metadataextractor" + "github.com/SwissOpenEM/Ingestor/internal/s3upload" "github.com/SwissOpenEM/Ingestor/internal/task" "github.com/fatih/color" "github.com/paulscherrerinstitute/scicat-cli/v3/datasetIngestor" @@ -116,7 +117,7 @@ func IngestDataset( task_context context.Context, ingestionTask task.IngestionTask, config Config, - notifier ProgressNotifier, + notifier task.ProgressNotifier, ) (string, error) { var http_client = &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, @@ -205,12 +206,13 @@ func IngestDataset( for _, f := range fullFileArray { fileList = append(fileList, f.Path) } - err = UploadS3(task_context, datasetId, datasetFolder, fileList, ingestionTask.DatasetFolder.Id, config.Transfer.S3, notifier) + err = s3upload.UploadS3(task_context, datasetId, datasetFolder, fileList, ingestionTask.DatasetFolder.Id, config.Transfer.S3, notifier) case task.TransferGlobus: // globus doesn't work with absolute folders, this library uses sourcePrefix to adapt the path to the globus' own path from a relative path relativeDatasetFolder := strings.TrimPrefix(datasetFolder, config.WebServer.CollectionLocation) err = GlobusTransfer(config.Transfer.Globus, ingestionTask, task_context, ingestionTask.DatasetFolder.Id, relativeDatasetFolder, fullFileArray, notifier) _: + return "", fmt.Errorf("unknown transfer method: %s", ingestionTask.TransferMethod) } if err != nil { diff --git a/internal/core/taskqueue.go b/internal/core/taskqueue.go index f21f87b..80912a3 100644 --- a/internal/core/taskqueue.go +++ b/internal/core/taskqueue.go @@ -23,7 +23,7 @@ type TaskQueue struct { resultChannel chan task.Result // The result of the upload is put into this channel AppContext context.Context Config Config - Notifier ProgressNotifier + Notifier task.ProgressNotifier } func (w *TaskQueue) Startup() { @@ -234,7 +234,7 @@ func (w *TaskQueue) GetTaskFolder(id uuid.UUID) string { return "" } -func TestIngestionFunction(task_context context.Context, task task.IngestionTask, config Config, notifier ProgressNotifier) (string, error) { +func TestIngestionFunction(task_context context.Context, task task.IngestionTask, config Config, notifier task.ProgressNotifier) (string, error) { start := time.Now() for i := 0; i < 10; i++ { @@ -261,6 +261,8 @@ func (w *TaskQueue) getTransferMethod() (transferMethod task.TransferMethod) { transferMethod = task.TransferGlobus case "s3": transferMethod = task.TransferS3 + default: + panic("unknown transfer method") } return transferMethod } diff --git a/internal/core/httpuploader.go b/internal/s3upload/httpuploader.go similarity index 56% rename from internal/core/httpuploader.go rename to internal/s3upload/httpuploader.go index cb18239..2638ff0 100644 --- a/internal/core/httpuploader.go +++ b/internal/s3upload/httpuploader.go @@ -1,14 +1,11 @@ -package core +package s3upload import ( "bytes" "context" "crypto/sha256" "encoding/base64" - "encoding/json" - "errors" "fmt" - "io" "log/slog" "math" "net/http" @@ -18,41 +15,12 @@ import ( "sync" "github.com/alitto/pond/v2" - "github.com/minio/minio-go/v7" ) const ( - chunkSize = 5 * 1024 * 1024 // 5 MB - presignedUrlPath = "/presignedUrls" - completeUploadPath = "/completeUpload" - abortMultiPartUplaodPath = "/abortMultipartUpload" + chunkSize = 5 * 1024 * 1024 // 5 MB ) -type presignedUrlBody struct { - ObjectName string `json:"object_name"` - Parts int `json:"parts"` -} - -type presignedUrlRespMultipart struct { - UploadID string `json:"uploadID"` - Urls []string `json:"urls"` -} - -type presignedUrlResp struct { - Url string `json:"url"` -} - -type completeMultipartUploadBody struct { - ObjectName string `json:"object_name"` - UploadID string `json:"uploadID"` - Parts []minio.CompletePart `json:"parts"` - ChecksumSHA256 string `json:"checksumSHA256"` -} -type abortMultipartUploadBody struct { - ObjectName string `json:"object_name"` - UploadID string `json:"uploadID"` -} - type MultipartInput struct { File *os.File PartCount int @@ -68,79 +36,54 @@ var once sync.Once func GetHttpUploader() *HttpUploader { once.Do(func() { - instance = &HttpUploader{Pool: pond.NewPool(100)} + instance = &HttpUploader{Pool: pond.NewPool(runtime.NumCPU()), Client: http.Client{}} }) return instance } -// Fetches presigned url(s) from API server. If parts > 1, multipart upload -// is initiated -func getPresignedUrlsMultipart(object_name string, part int, endpoint string) (string, []string, error) { - body := presignedUrlBody{ - ObjectName: object_name, - Parts: part, - } - jsonBody, _ := json.Marshal(body) - resBody, err := doRequest("POST", jsonBody, presignedUrlPath, endpoint) +var presignedUrlServerClient *ClientWithResponses +var once_presignedUrlServerClient sync.Once - if err != nil { - return "", []string{}, err - } - - var result presignedUrlRespMultipart - if err := json.Unmarshal(resBody, &result); err != nil { - return "", []string{}, fmt.Errorf("error unmarshalling JSON for %s. error: %s", object_name, err.Error()) - } - - return result.UploadID, result.Urls, nil +func GetPresignedUrlServer() *ClientWithResponses { + once_presignedUrlServerClient.Do(func() { + presignedUrlServerClient, _ = NewClientWithResponses("http://localhost:8888") + }) + return presignedUrlServerClient } // Fetches presigned url(s) from API server. If parts > 1, multipart upload // is initiated -func getPresignedUrl(object_name string, endpoint string) (string, error) { - body := presignedUrlBody{ +func getPresignedUrls(object_name string, part int, endpoint string) (string, []string, error) { + + r, err := GetPresignedUrlServer().GetPresignedUrlsWithResponse(context.Background(), PresignedUrlBody{ ObjectName: object_name, - Parts: 1, - } - jsonBody, _ := json.Marshal(body) - resBody, err := doRequest("POST", jsonBody, presignedUrlPath, endpoint) + Parts: part, + }) if err != nil { - return "", err + return "", []string{}, err } - - var result presignedUrlResp - if err := json.Unmarshal(resBody, &result); err != nil { - return "", fmt.Errorf("error unmarshalling JSON for %s. error: %s", object_name, err.Error()) + if r.StatusCode() != http.StatusOK { + return "", []string{}, fmt.Errorf("") } - return result.Url, err - + return r.JSON200.UploadID, r.JSON200.Urls, err } -func completeMultiPartUpload(object_name string, uploadID string, endpoint string, parts []minio.CompletePart, full_file_checksum string) error { - body := completeMultipartUploadBody{ +func completeMultiPartUpload(object_name string, uploadID string, endpoint string, parts []CompletePart, full_file_checksum string) error { + r, err := GetPresignedUrlServer().CompleteUploadWithResponse(context.Background(), CompleteUploadBody{ ObjectName: object_name, UploadID: uploadID, Parts: parts, ChecksumSHA256: full_file_checksum, - } - jsonBody, _ := json.Marshal(body) - fmt.Println(string(jsonBody)) - bodyReader := bytes.NewReader(jsonBody) - req, _ := http.NewRequest("POST", endpoint+completeUploadPath, bodyReader) - req.Header.Set("Content-Type", "application/json") + }) - resp, err := GetHttpUploader().Client.Do(req) if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return errors.New("Fail") + if r.StatusCode() != http.StatusOK { + return fmt.Errorf("") } - return nil } @@ -171,7 +114,7 @@ func uploadFile(ctx context.Context, filePath string, objectName string, endpoin uploadID, err := doUploadMultipart(ctx, totalSize, objectName, file, endpoint, notifier) if err != nil { - abortMultipartUpload(uploadID, objectName, endpoint) + err = abortMultipartUpload(uploadID, objectName, endpoint) if err != nil { slog.Error("Failed to abort mulitpart upload", "uploadID", uploadID, "object", objectName) } @@ -181,19 +124,24 @@ func uploadFile(ctx context.Context, filePath string, objectName string, endpoin } func abortMultipartUpload(uploadID string, objectName string, endpoint string) error { - body := abortMultipartUploadBody{ + r, err := GetPresignedUrlServer().AbortMultipartUploadWithResponse(context.Background(), AbortUploadBody{ ObjectName: objectName, UploadID: uploadID, + }) + + if err != nil { + return err } + if r.StatusCode() != http.StatusOK { + return fmt.Errorf("") + } + return nil - jsonBody, _ := json.Marshal(body) - _, err := doRequest("POST", jsonBody, abortMultiPartUplaodPath, endpoint) - return err } func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { - url, err := getPresignedUrl(objectName, endpoint) + _, urls, err := getPresignedUrls(objectName, 1, endpoint) if err != nil { return err } @@ -203,7 +151,7 @@ func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, e } base64hash, _ := calculateHashB64(&data) - _, err = uploadData(ctx, &data, url, base64hash) + _, err = uploadData(ctx, &data, urls[0], base64hash) if err != nil { return err } @@ -218,7 +166,7 @@ func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, e func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) (string, error) { partCount := int(math.Ceil(float64(totalSize) / float64(chunkSize))) - uploadID, presignedURLs, err := getPresignedUrlsMultipart(objectName, partCount, endpoint) + uploadID, presignedURLs, err := getPresignedUrls(objectName, partCount, endpoint) if err != nil { return uploadID, err } @@ -226,7 +174,7 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, uploader := GetHttpUploader() group := uploader.Pool.NewGroupContext(ctx) - parts := make([]minio.CompletePart, partCount) + parts := make([]CompletePart, partCount) partChecksums := make([]string, partCount) for partNumber := 0; partNumber < partCount; partNumber++ { @@ -247,7 +195,7 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, notifier.UpdateTaskProgress() etag := strings.Replace(resp.Header.Get("ETag"), "\"", "", -1) - parts[partNumber] = minio.CompletePart{ETag: etag, PartNumber: partNumber + 1, ChecksumSHA256: base64hash} + parts[partNumber] = CompletePart{ETag: etag, PartNumber: partNumber + 1, ChecksumSHA256: base64hash} fmt.Printf("Uploaded part %d\n", partNumber+1) return nil @@ -305,28 +253,28 @@ func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64ha return resp, nil } -func doRequest(method string, jsonBody []byte, path string, endpoint string) ([]byte, error) { - bodyReader := bytes.NewReader(jsonBody) - req, err := http.NewRequest(method, endpoint+presignedUrlPath, bodyReader) - - if err != nil { - return []byte{}, err - } - - req.Header.Set("Content-Type", "application/json") - resp, err := GetHttpUploader().Client.Do(req) - if err != nil { - return []byte{}, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return []byte{}, fmt.Errorf("%s request failed: %s%s", method, endpoint, path) - } - - resBody, err := io.ReadAll(resp.Body) - if err != nil { - return []byte{}, err - } - return resBody, nil -} +// func doRequest(method string, jsonBody []byte, path string, endpoint string) ([]byte, error) { +// bodyReader := bytes.NewReader(jsonBody) +// req, err := http.NewRequest(method, endpoint+path, bodyReader) + +// if err != nil { +// return []byte{}, err +// } + +// req.Header.Set("Content-Type", "application/json") +// resp, err := GetHttpUploader().Client.Do(req) +// if err != nil { +// return []byte{}, err +// } +// defer resp.Body.Close() + +// if resp.StatusCode != http.StatusOK { +// return []byte{}, fmt.Errorf("%s request failed: %s%s", method, endpoint, path) +// } + +// resBody, err := io.ReadAll(resp.Body) +// if err != nil { +// return []byte{}, err +// } +// return resBody, nil +// } diff --git a/internal/core/s3upload.go b/internal/s3upload/s3upload.go similarity index 91% rename from internal/core/s3upload.go rename to internal/s3upload/s3upload.go index cc99cc9..a73d329 100644 --- a/internal/core/s3upload.go +++ b/internal/s3upload/s3upload.go @@ -1,4 +1,4 @@ -package core +package s3upload import ( "context" @@ -20,7 +20,7 @@ type TransferNotifier struct { FilesCount int startTime time.Time id uuid.UUID - notifier ProgressNotifier + notifier task.ProgressNotifier TaskStatus *task.TaskStatus } @@ -40,7 +40,7 @@ type S3Objects struct { } // Upload all files in a folder using presinged urls -func UploadS3(ctx context.Context, datasetPID string, datasetSourceFolder string, fileList []string, uploadId uuid.UUID, options task.S3TransferConfig, notifier ProgressNotifier) error { +func UploadS3(ctx context.Context, datasetPID string, datasetSourceFolder string, fileList []string, uploadId uuid.UUID, options task.S3TransferConfig, notifier task.ProgressNotifier) error { if len(fileList) == 0 { return fmt.Errorf("empty file list provided") @@ -70,11 +70,11 @@ func uploadFiles(ctx context.Context, s3Objects *S3Objects, options task.S3Trans errorGroup.Go( func() error { for idx := range objectsChannel { - select { - case <-ctx.Done(): - transferNotifier.notifier.OnTaskCanceled(uploadId) + select { + case <-ctx.Done(): + transferNotifier.notifier.OnTaskCanceled(uploadId) return ctx.Err() - default: + default: err := uploadFile(ctx, s3Objects.Files[idx], s3Objects.ObjectNames[idx], options.Endpoint, transferNotifier) if err != nil { return err diff --git a/internal/core/progressnotifier.go b/internal/task/progressnotifier.go similarity index 96% rename from internal/core/progressnotifier.go rename to internal/task/progressnotifier.go index 75d7471..4a77a1f 100644 --- a/internal/core/progressnotifier.go +++ b/internal/task/progressnotifier.go @@ -1,4 +1,4 @@ -package core +package task import "github.com/google/uuid" From 130de632427f08a33e6f09b19eaed07d3749d15a Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 22 Nov 2024 16:17:42 +0100 Subject: [PATCH 08/14] s3upload: add openapi specs to generate client code --- internal/s3upload/cfg.yaml | 6 + internal/s3upload/httpuploader.go | 36 +---- internal/s3upload/openapi.yaml | 234 ++++++++++++++++++++++++++++++ internal/s3upload/s3client.go | 5 + 4 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 internal/s3upload/cfg.yaml create mode 100644 internal/s3upload/openapi.yaml create mode 100644 internal/s3upload/s3client.go diff --git a/internal/s3upload/cfg.yaml b/internal/s3upload/cfg.yaml new file mode 100644 index 0000000..e6f7e8e --- /dev/null +++ b/internal/s3upload/cfg.yaml @@ -0,0 +1,6 @@ +package: s3upload +generate: + client: true + models: true + embedded-spec: true +output: client.gen.go \ No newline at end of file diff --git a/internal/s3upload/httpuploader.go b/internal/s3upload/httpuploader.go index 2638ff0..27b0279 100644 --- a/internal/s3upload/httpuploader.go +++ b/internal/s3upload/httpuploader.go @@ -44,9 +44,9 @@ func GetHttpUploader() *HttpUploader { var presignedUrlServerClient *ClientWithResponses var once_presignedUrlServerClient sync.Once -func GetPresignedUrlServer() *ClientWithResponses { +func GetPresignedUrlServer(endpoint string) *ClientWithResponses { once_presignedUrlServerClient.Do(func() { - presignedUrlServerClient, _ = NewClientWithResponses("http://localhost:8888") + presignedUrlServerClient, _ = NewClientWithResponses(endpoint) }) return presignedUrlServerClient } @@ -55,7 +55,7 @@ func GetPresignedUrlServer() *ClientWithResponses { // is initiated func getPresignedUrls(object_name string, part int, endpoint string) (string, []string, error) { - r, err := GetPresignedUrlServer().GetPresignedUrlsWithResponse(context.Background(), PresignedUrlBody{ + r, err := GetPresignedUrlServer(endpoint).GetPresignedUrlsWithResponse(context.Background(), PresignedUrlBody{ ObjectName: object_name, Parts: part, }) @@ -71,7 +71,7 @@ func getPresignedUrls(object_name string, part int, endpoint string) (string, [] } func completeMultiPartUpload(object_name string, uploadID string, endpoint string, parts []CompletePart, full_file_checksum string) error { - r, err := GetPresignedUrlServer().CompleteUploadWithResponse(context.Background(), CompleteUploadBody{ + r, err := GetPresignedUrlServer(endpoint).CompleteUploadWithResponse(context.Background(), CompleteUploadBody{ ObjectName: object_name, UploadID: uploadID, Parts: parts, @@ -124,7 +124,7 @@ func uploadFile(ctx context.Context, filePath string, objectName string, endpoin } func abortMultipartUpload(uploadID string, objectName string, endpoint string) error { - r, err := GetPresignedUrlServer().AbortMultipartUploadWithResponse(context.Background(), AbortUploadBody{ + r, err := GetPresignedUrlServer(endpoint).AbortMultipartUploadWithResponse(context.Background(), AbortUploadBody{ ObjectName: objectName, UploadID: uploadID, }) @@ -252,29 +252,3 @@ func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64ha } return resp, nil } - -// func doRequest(method string, jsonBody []byte, path string, endpoint string) ([]byte, error) { -// bodyReader := bytes.NewReader(jsonBody) -// req, err := http.NewRequest(method, endpoint+path, bodyReader) - -// if err != nil { -// return []byte{}, err -// } - -// req.Header.Set("Content-Type", "application/json") -// resp, err := GetHttpUploader().Client.Do(req) -// if err != nil { -// return []byte{}, err -// } -// defer resp.Body.Close() - -// if resp.StatusCode != http.StatusOK { -// return []byte{}, fmt.Errorf("%s request failed: %s%s", method, endpoint, path) -// } - -// resBody, err := io.ReadAll(resp.Body) -// if err != nil { -// return []byte{}, err -// } -// return resBody, nil -// } diff --git a/internal/s3upload/openapi.yaml b/internal/s3upload/openapi.yaml new file mode 100644 index 0000000..39d3efc --- /dev/null +++ b/internal/s3upload/openapi.yaml @@ -0,0 +1,234 @@ +openapi: 3.1.0 +info: + title: S3 Presigned Url Endpoint + version: 0.1.0 +servers: +- url: / +paths: + /presignedUrls: + post: + operationId: get_presigned_urls + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PresignedUrlBody' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PresignedUrlResp' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Get Presigned Urls + tags: + - presignedUrls + /completeUpload: + post: + operationId: complete_upload + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteUploadBody' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/CompleteUploadBody' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Complete Upload + tags: + - presignedUrls + /abortMultipartUpload: + post: + operationId: abort_multipart_upload + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AbortUploadBody' + required: true + responses: + "200": + content: + application/json: + schema: {} + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Abort Multipart Upload + tags: + - presignedUrls +components: + schemas: + AbortUploadBody: + example: + UploadID: UploadID + ObjectName: ObjectName + properties: + UploadID: + title: Uploadid + type: string + ObjectName: + title: Objectname + type: string + required: + - ObjectName + - UploadID + title: AbortUploadBody + CompletePart: + example: + PartNumber: 0 + ETag: ETag + ChecksumSHA256: ChecksumSHA256 + properties: + PartNumber: + title: Partnumber + type: integer + ETag: + title: Etag + type: string + ChecksumSHA256: + title: Checksumsha256 + type: string + required: + - ChecksumSHA256 + - ETag + - PartNumber + title: CompletePart + CompleteUploadBody: + example: + Parts: + - PartNumber: 0 + ETag: ETag + ChecksumSHA256: ChecksumSHA256 + - PartNumber: 0 + ETag: ETag + ChecksumSHA256: ChecksumSHA256 + UploadID: UploadID + ObjectName: ObjectName + ChecksumSHA256: ChecksumSHA256 + properties: + ObjectName: + title: Objectname + type: string + UploadID: + title: Uploadid + type: string + Parts: + items: + $ref: '#/components/schemas/CompletePart' + title: Parts + type: array + ChecksumSHA256: + title: Checksumsha256 + type: string + required: + - ChecksumSHA256 + - ObjectName + - Parts + - UploadID + title: CompleteUploadBody + HTTPValidationError: + example: + detail: + - msg: msg + loc: + - ValidationError_loc_inner + - ValidationError_loc_inner + type: type + - msg: msg + loc: + - ValidationError_loc_inner + - ValidationError_loc_inner + type: type + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + title: detail + type: array + title: HTTPValidationError + PresignedUrlBody: + example: + Parts: 0 + ObjectName: ObjectName + properties: + ObjectName: + title: Objectname + type: string + Parts: + title: Parts + type: integer + required: + - ObjectName + - Parts + title: PresignedUrlBody + PresignedUrlResp: + example: + Urls: + - Urls + - Urls + UploadID: UploadID + properties: + UploadID: + title: Uploadid + type: string + Urls: + items: + type: string + title: Urls + type: array + required: + - UploadID + - Urls + title: PresignedUrlResp + ValidationError: + example: + msg: msg + loc: + - ValidationError_loc_inner + - ValidationError_loc_inner + type: type + properties: + loc: + items: + $ref: '#/components/schemas/ValidationError_loc_inner' + title: loc + type: array + msg: + title: Message + type: string + type: + title: Error Type + type: string + required: + - loc + - msg + - type + title: ValidationError + ValidationError_loc_inner: + anyOf: + - type: string + - type: integer + title: ValidationError_loc_inner diff --git a/internal/s3upload/s3client.go b/internal/s3upload/s3client.go new file mode 100644 index 0000000..6e9744b --- /dev/null +++ b/internal/s3upload/s3client.go @@ -0,0 +1,5 @@ +//go:build go1.22 + +package s3upload + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -include-tags presignedUrls --config=cfg.yaml openapi.yaml From f4d391cd55996c64a17d48d28e1435553ec0b5af Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 22 Nov 2024 16:18:34 +0100 Subject: [PATCH 09/14] s3upload: add generated client --- internal/s3upload/client.gen.go | 799 ++++++++++++++++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 internal/s3upload/client.gen.go diff --git a/internal/s3upload/client.gen.go b/internal/s3upload/client.gen.go new file mode 100644 index 0000000..633de78 --- /dev/null +++ b/internal/s3upload/client.gen.go @@ -0,0 +1,799 @@ +// Package s3upload provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.3.0 DO NOT EDIT. +package s3upload + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/oapi-codegen/runtime" +) + +// AbortUploadBody defines model for AbortUploadBody. +type AbortUploadBody struct { + ObjectName string `json:"ObjectName"` + UploadID string `json:"UploadID"` +} + +// CompletePart defines model for CompletePart. +type CompletePart struct { + ChecksumSHA256 string `json:"ChecksumSHA256"` + ETag string `json:"ETag"` + PartNumber int `json:"PartNumber"` +} + +// CompleteUploadBody defines model for CompleteUploadBody. +type CompleteUploadBody struct { + ChecksumSHA256 string `json:"ChecksumSHA256"` + ObjectName string `json:"ObjectName"` + Parts []CompletePart `json:"Parts"` + UploadID string `json:"UploadID"` +} + +// HTTPValidationError defines model for HTTPValidationError. +type HTTPValidationError struct { + Detail *[]ValidationError `json:"detail,omitempty"` +} + +// PresignedUrlBody defines model for PresignedUrlBody. +type PresignedUrlBody struct { + ObjectName string `json:"ObjectName"` + Parts int `json:"Parts"` +} + +// PresignedUrlResp defines model for PresignedUrlResp. +type PresignedUrlResp struct { + UploadID string `json:"UploadID"` + Urls []string `json:"Urls"` +} + +// ValidationError defines model for ValidationError. +type ValidationError struct { + Loc []ValidationErrorLocInner `json:"loc"` + Msg string `json:"msg"` + Type string `json:"type"` +} + +// ValidationErrorLocInner defines model for ValidationError_loc_inner. +type ValidationErrorLocInner struct { + union json.RawMessage +} + +// ValidationErrorLocInner0 defines model for . +type ValidationErrorLocInner0 = string + +// ValidationErrorLocInner1 defines model for . +type ValidationErrorLocInner1 = int + +// AbortMultipartUploadJSONRequestBody defines body for AbortMultipartUpload for application/json ContentType. +type AbortMultipartUploadJSONRequestBody = AbortUploadBody + +// CompleteUploadJSONRequestBody defines body for CompleteUpload for application/json ContentType. +type CompleteUploadJSONRequestBody = CompleteUploadBody + +// GetPresignedUrlsJSONRequestBody defines body for GetPresignedUrls for application/json ContentType. +type GetPresignedUrlsJSONRequestBody = PresignedUrlBody + +// AsValidationErrorLocInner0 returns the union data inside the ValidationErrorLocInner as a ValidationErrorLocInner0 +func (t ValidationErrorLocInner) AsValidationErrorLocInner0() (ValidationErrorLocInner0, error) { + var body ValidationErrorLocInner0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromValidationErrorLocInner0 overwrites any union data inside the ValidationErrorLocInner as the provided ValidationErrorLocInner0 +func (t *ValidationErrorLocInner) FromValidationErrorLocInner0(v ValidationErrorLocInner0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeValidationErrorLocInner0 performs a merge with any union data inside the ValidationErrorLocInner, using the provided ValidationErrorLocInner0 +func (t *ValidationErrorLocInner) MergeValidationErrorLocInner0(v ValidationErrorLocInner0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsValidationErrorLocInner1 returns the union data inside the ValidationErrorLocInner as a ValidationErrorLocInner1 +func (t ValidationErrorLocInner) AsValidationErrorLocInner1() (ValidationErrorLocInner1, error) { + var body ValidationErrorLocInner1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromValidationErrorLocInner1 overwrites any union data inside the ValidationErrorLocInner as the provided ValidationErrorLocInner1 +func (t *ValidationErrorLocInner) FromValidationErrorLocInner1(v ValidationErrorLocInner1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeValidationErrorLocInner1 performs a merge with any union data inside the ValidationErrorLocInner, using the provided ValidationErrorLocInner1 +func (t *ValidationErrorLocInner) MergeValidationErrorLocInner1(v ValidationErrorLocInner1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t ValidationErrorLocInner) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *ValidationErrorLocInner) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // AbortMultipartUploadWithBody request with any body + AbortMultipartUploadWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AbortMultipartUpload(ctx context.Context, body AbortMultipartUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CompleteUploadWithBody request with any body + CompleteUploadWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CompleteUpload(ctx context.Context, body CompleteUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetPresignedUrlsWithBody request with any body + GetPresignedUrlsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + GetPresignedUrls(ctx context.Context, body GetPresignedUrlsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) AbortMultipartUploadWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAbortMultipartUploadRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AbortMultipartUpload(ctx context.Context, body AbortMultipartUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAbortMultipartUploadRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CompleteUploadWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCompleteUploadRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CompleteUpload(ctx context.Context, body CompleteUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCompleteUploadRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetPresignedUrlsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPresignedUrlsRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetPresignedUrls(ctx context.Context, body GetPresignedUrlsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPresignedUrlsRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewAbortMultipartUploadRequest calls the generic AbortMultipartUpload builder with application/json body +func NewAbortMultipartUploadRequest(server string, body AbortMultipartUploadJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAbortMultipartUploadRequestWithBody(server, "application/json", bodyReader) +} + +// NewAbortMultipartUploadRequestWithBody generates requests for AbortMultipartUpload with any type of body +func NewAbortMultipartUploadRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/abortMultipartUpload") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewCompleteUploadRequest calls the generic CompleteUpload builder with application/json body +func NewCompleteUploadRequest(server string, body CompleteUploadJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCompleteUploadRequestWithBody(server, "application/json", bodyReader) +} + +// NewCompleteUploadRequestWithBody generates requests for CompleteUpload with any type of body +func NewCompleteUploadRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/completeUpload") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetPresignedUrlsRequest calls the generic GetPresignedUrls builder with application/json body +func NewGetPresignedUrlsRequest(server string, body GetPresignedUrlsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewGetPresignedUrlsRequestWithBody(server, "application/json", bodyReader) +} + +// NewGetPresignedUrlsRequestWithBody generates requests for GetPresignedUrls with any type of body +func NewGetPresignedUrlsRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/presignedUrls") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // AbortMultipartUploadWithBodyWithResponse request with any body + AbortMultipartUploadWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AbortMultipartUploadResponse, error) + + AbortMultipartUploadWithResponse(ctx context.Context, body AbortMultipartUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*AbortMultipartUploadResponse, error) + + // CompleteUploadWithBodyWithResponse request with any body + CompleteUploadWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CompleteUploadResponse, error) + + CompleteUploadWithResponse(ctx context.Context, body CompleteUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*CompleteUploadResponse, error) + + // GetPresignedUrlsWithBodyWithResponse request with any body + GetPresignedUrlsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*GetPresignedUrlsResponse, error) + + GetPresignedUrlsWithResponse(ctx context.Context, body GetPresignedUrlsJSONRequestBody, reqEditors ...RequestEditorFn) (*GetPresignedUrlsResponse, error) +} + +type AbortMultipartUploadResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *interface{} + JSON422 *HTTPValidationError +} + +// Status returns HTTPResponse.Status +func (r AbortMultipartUploadResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AbortMultipartUploadResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type CompleteUploadResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CompleteUploadBody + JSON422 *HTTPValidationError +} + +// Status returns HTTPResponse.Status +func (r CompleteUploadResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CompleteUploadResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetPresignedUrlsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PresignedUrlResp + JSON422 *HTTPValidationError +} + +// Status returns HTTPResponse.Status +func (r GetPresignedUrlsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPresignedUrlsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// AbortMultipartUploadWithBodyWithResponse request with arbitrary body returning *AbortMultipartUploadResponse +func (c *ClientWithResponses) AbortMultipartUploadWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AbortMultipartUploadResponse, error) { + rsp, err := c.AbortMultipartUploadWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAbortMultipartUploadResponse(rsp) +} + +func (c *ClientWithResponses) AbortMultipartUploadWithResponse(ctx context.Context, body AbortMultipartUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*AbortMultipartUploadResponse, error) { + rsp, err := c.AbortMultipartUpload(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAbortMultipartUploadResponse(rsp) +} + +// CompleteUploadWithBodyWithResponse request with arbitrary body returning *CompleteUploadResponse +func (c *ClientWithResponses) CompleteUploadWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CompleteUploadResponse, error) { + rsp, err := c.CompleteUploadWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCompleteUploadResponse(rsp) +} + +func (c *ClientWithResponses) CompleteUploadWithResponse(ctx context.Context, body CompleteUploadJSONRequestBody, reqEditors ...RequestEditorFn) (*CompleteUploadResponse, error) { + rsp, err := c.CompleteUpload(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCompleteUploadResponse(rsp) +} + +// GetPresignedUrlsWithBodyWithResponse request with arbitrary body returning *GetPresignedUrlsResponse +func (c *ClientWithResponses) GetPresignedUrlsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*GetPresignedUrlsResponse, error) { + rsp, err := c.GetPresignedUrlsWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPresignedUrlsResponse(rsp) +} + +func (c *ClientWithResponses) GetPresignedUrlsWithResponse(ctx context.Context, body GetPresignedUrlsJSONRequestBody, reqEditors ...RequestEditorFn) (*GetPresignedUrlsResponse, error) { + rsp, err := c.GetPresignedUrls(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPresignedUrlsResponse(rsp) +} + +// ParseAbortMultipartUploadResponse parses an HTTP response from a AbortMultipartUploadWithResponse call +func ParseAbortMultipartUploadResponse(rsp *http.Response) (*AbortMultipartUploadResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AbortMultipartUploadResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest interface{} + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest HTTPValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + +// ParseCompleteUploadResponse parses an HTTP response from a CompleteUploadWithResponse call +func ParseCompleteUploadResponse(rsp *http.Response) (*CompleteUploadResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CompleteUploadResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CompleteUploadBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest HTTPValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + +// ParseGetPresignedUrlsResponse parses an HTTP response from a GetPresignedUrlsWithResponse call +func ParseGetPresignedUrlsResponse(rsp *http.Response) (*GetPresignedUrlsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPresignedUrlsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PresignedUrlResp + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest HTTPValidationError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON422 = &dest + + } + + return response, nil +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/9RWS2/bOBD+K8LsHoXY6+zuQbc0NZIc8kBi9xIYASONbaYSyZJUUcPwfy9IWRJNSnk5", + "LpCLJY/IeXzfxxmuIeWF4AyZVpCsQaVLLIh9PXnkUk9Fzkn2hWcrY8JfpBA5mtfrxydM9RUpEBL3TwzV", + "louvkLSvmxiE5AKlpqj87WvQVOetH1b50SthbEpLyhbGQ+u43VHZaBau38Qg8UdJJWaQ3PekOIsbT365", + "mxhOualW4w2R2iv/dInpd1UWd+cno//+h8Q3xDCekAUk1SMG4+KqLB5RQjIM0PC9tfXVX9SSVG4DVKo4", + "7Y6xtgGDdW4G7WpjZZW12UOZxgXKAMLuEnc8O3jugOeA2auolyHt15wJoiC535uYfR3MXqn/fRh/z9nZ", + "ArQGqrGwL39LnEMCfw3aBjDYnv6Bz52rFtW6J1KS1d4n8zma4yZk15HtkNQmhvPJ5OYbyWlGNOVsLCWX", + "ntIy1ITmVi45T00O3vqHnKcPlDF7Kvq/zWIolJGC+W3KtA+jpIP5ngVyqit6Jb8+PA7FW08+x86KLnyN", + "wiQqumCYTWX+pnmxlebwQ2ZEo/Meyfb1to6MHKEFtXn13qISXr1dXSCGqcxNm6qe278hm287TrVXh/tg", + "ReOlihtw60Lhp9uDg615E0jYg+Fw58vDzAZ6n/ydiA5UxmHY6Ww+LSuXqBRZdCqxMjhz2YSKJsb6Ukes", + "QjtFOxR0nLz+cpI1ELa6nttW52e4Ds5EfxgXIpMtZXPuFnd3HDXaiKYyj8YsE5wyDTH8RKkoZ5DA8Oif", + "o6HJmAtkRFBI4NiaYhBELy1vA2LuYZdlrqkg9YXMfBBc2UuYId0mdpHVtzZ/dYUmKl33oZQzjcxuJ0Lk", + "NLUOBk/KpFVfel9STXBB3KVNyxKtQQnOVCXK0XD4lvDWY4YqlVToCrK7Mk1RqXmZR7dbzwbAf0ejD6ur", + "s5+HmbRLonpNDKosCiJXNRFRw0TUUKHJwrY84fQO0/Q2Mdhs2gHez/LuoD8Qv123iQ+n+F0ZfFJJ1OW8", + "Tgu7tl4pnKG+2Vl5GDGEE//PSiGctJ9YCGeod8eDek4LZitKMzXs2CplDgkMzK33dwAAAP//aD4jzq0Q", + "AAA=", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} From 6358dbffca77f9a0275b891510c7810fc3d1be9c Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Wed, 18 Dec 2024 16:29:55 +0100 Subject: [PATCH 10/14] feat(s3upload): add parameters and retries --- .../frontend/wailsjs/go/models.ts | 6 +- configs/openem-ingestor-config.yaml | 5 ++ go.mod | 2 + go.sum | 6 ++ internal/core/config_test.go | 5 +- internal/core/ingestdataset.go | 14 +-- internal/s3upload/httpuploader.go | 85 ++++++++++--------- internal/s3upload/s3upload.go | 4 +- internal/task/api.go | 3 +- internal/task/config.go | 5 +- test/testdata/valid_config_s3.yaml | 10 +-- 11 files changed, 87 insertions(+), 58 deletions(-) diff --git a/cmd/openem-ingestor-app/frontend/wailsjs/go/models.ts b/cmd/openem-ingestor-app/frontend/wailsjs/go/models.ts index 32e4eff..c0915e9 100755 --- a/cmd/openem-ingestor-app/frontend/wailsjs/go/models.ts +++ b/cmd/openem-ingestor-app/frontend/wailsjs/go/models.ts @@ -1,7 +1,8 @@ export namespace main { export class ExtractionMethod { - + Name: string; + Schema: string; static createFrom(source: any = {}) { return new ExtractionMethod(source); @@ -9,7 +10,8 @@ export namespace main { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); - + this.Name = source["Name"]; + this.Schema = source["Schema"]; } } diff --git a/configs/openem-ingestor-config.yaml b/configs/openem-ingestor-config.yaml index 26afc7a..4838c70 100644 --- a/configs/openem-ingestor-config.yaml +++ b/configs/openem-ingestor-config.yaml @@ -13,6 +13,11 @@ Transfer: RefreshToken: "refresh_token" Scopes: - "urn:globus:auth:scope:transfer.api.globus.org:all[*https://auth.globus.org/scopes/[collection_id1]/data_access]" + S3: + Endpoint: http://scopem-openem.ethz.ch/api/v1 + ChunkSizeMB: 64 + ConcurrentFiles: 4 + PoolSize: 8 Misc: ConcurrencyLimit: 2 Port: 8888 diff --git a/go.mod b/go.mod index c2284a8..56baa21 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/go-github v17.0.0+incompatible github.com/google/uuid v1.6.0 + github.com/hashicorp/go-retryablehttp v0.7.7 github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 github.com/oapi-codegen/runtime v1.1.1 @@ -45,6 +46,7 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.2.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/kdomanski/iso9660 v0.3.3 // indirect diff --git a/go.sum b/go.sum index 34daeb1..7df7344 100644 --- a/go.sum +++ b/go.sum @@ -176,8 +176,14 @@ github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8L github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 6411a21..5b134b5 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -14,7 +14,10 @@ func createExpectedValidConfigS3() task.TransferConfig { return task.TransferConfig{ Method: "S3", S3: task.S3TransferConfig{ - Endpoint: "http://localhost:8000", + Endpoint: "https://endpoint/api/v1", + ChunkSizeMB: 64, + ConcurrentFiles: 4, + PoolSize: 8, }, } } diff --git a/internal/core/ingestdataset.go b/internal/core/ingestdataset.go index d5fa8c9..6f247db 100644 --- a/internal/core/ingestdataset.go +++ b/internal/core/ingestdataset.go @@ -3,20 +3,18 @@ package core import ( "bufio" "context" - "crypto/tls" "errors" "fmt" "log" - "net/http" "os" "path/filepath" "strings" - "time" "github.com/SwissOpenEM/Ingestor/internal/metadataextractor" "github.com/SwissOpenEM/Ingestor/internal/s3upload" "github.com/SwissOpenEM/Ingestor/internal/task" "github.com/fatih/color" + "github.com/hashicorp/go-retryablehttp" "github.com/paulscherrerinstitute/scicat-cli/v3/datasetIngestor" "github.com/paulscherrerinstitute/scicat-cli/v3/datasetUtils" ) @@ -119,9 +117,7 @@ func IngestDataset( config Config, notifier task.ProgressNotifier, ) (string, error) { - var http_client = &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, - Timeout: 120 * time.Second} + var http_client = retryablehttp.NewClient().StandardClient() SCICAT_API_URL := config.Scicat.Host @@ -207,12 +203,16 @@ func IngestDataset( fileList = append(fileList, f.Path) } err = s3upload.UploadS3(task_context, datasetId, datasetFolder, fileList, ingestionTask.DatasetFolder.Id, config.Transfer.S3, notifier) + if err != nil { + return datasetId, err + } + case task.TransferGlobus: // globus doesn't work with absolute folders, this library uses sourcePrefix to adapt the path to the globus' own path from a relative path relativeDatasetFolder := strings.TrimPrefix(datasetFolder, config.WebServer.CollectionLocation) err = GlobusTransfer(config.Transfer.Globus, ingestionTask, task_context, ingestionTask.DatasetFolder.Id, relativeDatasetFolder, fullFileArray, notifier) _: - return "", fmt.Errorf("unknown transfer method: %s", ingestionTask.TransferMethod) + return datasetId, fmt.Errorf("unknown transfer method: %d", ingestionTask.TransferMethod) } if err != nil { diff --git a/internal/s3upload/httpuploader.go b/internal/s3upload/httpuploader.go index 27b0279..cc7a646 100644 --- a/internal/s3upload/httpuploader.go +++ b/internal/s3upload/httpuploader.go @@ -6,19 +6,21 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "io" "log/slog" "math" "net/http" "os" - "runtime" "strings" "sync" + "github.com/SwissOpenEM/Ingestor/internal/task" "github.com/alitto/pond/v2" + "github.com/hashicorp/go-retryablehttp" ) const ( - chunkSize = 5 * 1024 * 1024 // 5 MB + MiB = 1024 * 1024 ) type MultipartInput struct { @@ -28,15 +30,20 @@ type MultipartInput struct { type HttpUploader struct { Pool pond.Pool - Client http.Client + Client *http.Client } var instance *HttpUploader var once sync.Once -func GetHttpUploader() *HttpUploader { +func GetHttpUploader(poolSize int) *HttpUploader { once.Do(func() { - instance = &HttpUploader{Pool: pond.NewPool(runtime.NumCPU()), Client: http.Client{}} + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 10 + retryClient.Backoff = retryablehttp.DefaultBackoff + + standardClient := retryClient.StandardClient() + instance = &HttpUploader{Pool: pond.NewPool(poolSize), Client: standardClient} }) return instance } @@ -64,7 +71,7 @@ func getPresignedUrls(object_name string, part int, endpoint string) (string, [] return "", []string{}, err } if r.StatusCode() != http.StatusOK { - return "", []string{}, fmt.Errorf("") + return "", []string{}, fmt.Errorf(r.Status()) } return r.JSON200.UploadID, r.JSON200.Urls, err @@ -87,7 +94,7 @@ func completeMultiPartUpload(object_name string, uploadID string, endpoint strin return nil } -func uploadFile(ctx context.Context, filePath string, objectName string, endpoint string, notifier *TransferNotifier) error { +func uploadFile(ctx context.Context, filePath string, objectName string, options task.S3TransferConfig, notifier *TransferNotifier) error { // Open the file file, err := os.Open(filePath) if err != nil { @@ -105,26 +112,27 @@ func uploadFile(ctx context.Context, filePath string, objectName string, endpoin totalSize := fileInfo.Size() fmt.Printf("Uploading file: %s (%d bytes)\n", filePath, totalSize) - if totalSize < chunkSize { - // do normal upload - err := doUploadSingleFile(ctx, objectName, file, endpoint, notifier) + httpClient := GetHttpUploader(options.PoolSize) + + if totalSize < options.ChunkSizeMB*MiB { + err := doUploadSingleFile(ctx, objectName, file, httpClient, options.Endpoint, notifier) return err } - uploadID, err := doUploadMultipart(ctx, totalSize, objectName, file, endpoint, notifier) + uploadID, err := doUploadMultipart(ctx, totalSize, objectName, file, httpClient, options, notifier) if err != nil { - err = abortMultipartUpload(uploadID, objectName, endpoint) - if err != nil { - slog.Error("Failed to abort mulitpart upload", "uploadID", uploadID, "object", objectName) + err_upload := fmt.Errorf("failed to do multipart upload: uploadID=%s, objectName=%s, error=%s", uploadID, objectName, err.Error()) + err_abort := abortMultipartUpload(uploadID, objectName, options.Endpoint) + if err_abort != nil { + return fmt.Errorf("while aborting a multipart upload an error occured: %s. Previous error: %s", err_abort.Error(), err_upload.Error()) } } return err - } func abortMultipartUpload(uploadID string, objectName string, endpoint string) error { - r, err := GetPresignedUrlServer(endpoint).AbortMultipartUploadWithResponse(context.Background(), AbortUploadBody{ + response, err := GetPresignedUrlServer(endpoint).AbortMultipartUploadWithResponse(context.Background(), AbortUploadBody{ ObjectName: objectName, UploadID: uploadID, }) @@ -132,60 +140,60 @@ func abortMultipartUpload(uploadID string, objectName string, endpoint string) e if err != nil { return err } - if r.StatusCode() != http.StatusOK { + if response.StatusCode() != http.StatusOK { return fmt.Errorf("") } return nil } -func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) error { +func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, httpClient *HttpUploader, endpoint string, notifier *TransferNotifier) error { _, urls, err := getPresignedUrls(objectName, 1, endpoint) if err != nil { return err } - data, err := os.ReadFile(file.Name()) + + data, err := io.ReadAll(file) if err != nil { return err } + n := len(data) base64hash, _ := calculateHashB64(&data) - _, err = uploadData(ctx, &data, urls[0], base64hash) + _, err = uploadData(ctx, &data, urls[0], httpClient, base64hash) if err != nil { return err } if ctx.Err() != nil { return ctx.Err() } - notifier.AddUploadedBytes(int64(len(data))) + notifier.AddUploadedBytes(int64(n)) notifier.UpdateTaskProgress() return nil } -func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, endpoint string, notifier *TransferNotifier) (string, error) { - partCount := int(math.Ceil(float64(totalSize) / float64(chunkSize))) +func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, file *os.File, httpClient *HttpUploader, options task.S3TransferConfig, notifier *TransferNotifier) (string, error) { + partCount := int(math.Ceil(float64(totalSize) / float64(options.ChunkSizeMB*MiB))) - uploadID, presignedURLs, err := getPresignedUrls(objectName, partCount, endpoint) + uploadID, presignedURLs, err := getPresignedUrls(objectName, partCount, options.Endpoint) if err != nil { return uploadID, err } - uploader := GetHttpUploader() - - group := uploader.Pool.NewGroupContext(ctx) + group := httpClient.Pool.NewGroupContext(ctx) parts := make([]CompletePart, partCount) partChecksums := make([]string, partCount) for partNumber := 0; partNumber < partCount; partNumber++ { group.SubmitErr(func() error { - partData := make([]byte, chunkSize) - n, _ := file.ReadAt(partData, int64(partNumber)*chunkSize) + partData := make([]byte, options.ChunkSizeMB*MiB) + n, _ := file.ReadAt(partData, int64(partNumber)*options.ChunkSizeMB*MiB) partData = partData[:n] base64hash, hash := calculateHashB64(&partData) partChecksums[partNumber] = string(hash[:]) - resp, err := uploadData(ctx, &partData, presignedURLs[partNumber], base64hash) + etag, err := uploadData(ctx, &partData, presignedURLs[partNumber], httpClient, base64hash) if err != nil { return err } @@ -194,7 +202,6 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, notifier.UpdateTaskProgress() - etag := strings.Replace(resp.Header.Get("ETag"), "\"", "", -1) parts[partNumber] = CompletePart{ETag: etag, PartNumber: partNumber + 1, ChecksumSHA256: base64hash} fmt.Printf("Uploaded part %d\n", partNumber+1) @@ -213,7 +220,7 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, base64hash := base64.StdEncoding.EncodeToString(n[:]) slog.Info("Calculated file digest", "file", file.Name(), "sha256", base64hash) - err = completeMultiPartUpload(objectName, uploadID, endpoint, parts, base64hash) + err = completeMultiPartUpload(objectName, uploadID, options.Endpoint, parts, base64hash) if err != nil { return uploadID, fmt.Errorf("error completing multipart upload: %w", err) } @@ -228,27 +235,29 @@ func calculateHashB64(data *[]byte) (string, [32]byte) { return base64hash, hash } -func uploadData(ctx context.Context, data *[]byte, presignedURL string, base64hash string) (*http.Response, error) { +func uploadData(ctx context.Context, data *[]byte, presignedURL string, httpClient *HttpUploader, base64hash string) (string, error) { decoded_url, _ := base64.StdEncoding.DecodeString(presignedURL) req, err := http.NewRequestWithContext(ctx, "PUT", string(decoded_url), bytes.NewReader(*data)) if err != nil { - return nil, err + return "", err } // The checksum algorithm needs to match the one defined in the presigned url req.Header.Set("x-amz-checksum-sha256", base64hash) req.Header.Set("Content-Type", "application/json") - resp, err := GetHttpUploader().Client.Do(req) + resp, err := httpClient.Client.Do(req) if err != nil { - return resp, err + return "", err } + defer resp.Body.Close() + etag := strings.Replace(resp.Header.Get("ETag"), "\"", "", -1) if resp.StatusCode != http.StatusOK { - return resp, fmt.Errorf("upload failed: %d %s", resp.StatusCode, resp.Status) + return "", fmt.Errorf("upload failed: %d %s", resp.StatusCode, resp.Status) } - return resp, nil + return etag, nil } diff --git a/internal/s3upload/s3upload.go b/internal/s3upload/s3upload.go index a73d329..5b3ae69 100644 --- a/internal/s3upload/s3upload.go +++ b/internal/s3upload/s3upload.go @@ -64,7 +64,7 @@ func uploadFiles(ctx context.Context, s3Objects *S3Objects, options task.S3Trans errorGroup, ctx := errgroup.WithContext(ctx) objectsChannel := make(chan int, len(s3Objects.Files)) - nWorkers := max(2, len(s3Objects.Files)) + nWorkers := max(options.ConcurrentFiles, len(s3Objects.Files)) for t := 0; t < nWorkers; t++ { errorGroup.Go( @@ -75,7 +75,7 @@ func uploadFiles(ctx context.Context, s3Objects *S3Objects, options task.S3Trans transferNotifier.notifier.OnTaskCanceled(uploadId) return ctx.Err() default: - err := uploadFile(ctx, s3Objects.Files[idx], s3Objects.ObjectNames[idx], options.Endpoint, transferNotifier) + err := uploadFile(ctx, s3Objects.Files[idx], s3Objects.ObjectNames[idx], options, transferNotifier) if err != nil { return err } diff --git a/internal/task/api.go b/internal/task/api.go index a2b5553..a676735 100644 --- a/internal/task/api.go +++ b/internal/task/api.go @@ -2,6 +2,7 @@ package task import ( "context" + "path" "github.com/google/uuid" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -26,6 +27,6 @@ func SelectFolder(context context.Context) (DatasetFolder, error) { id := uuid.New() - selected_folder := DatasetFolder{FolderPath: folder, Id: id} + selected_folder := DatasetFolder{FolderPath: path.Clean(folder), Id: id} return selected_folder, nil } diff --git a/internal/task/config.go b/internal/task/config.go index 3078099..a4a6183 100644 --- a/internal/task/config.go +++ b/internal/task/config.go @@ -1,7 +1,10 @@ package task type S3TransferConfig struct { - Endpoint string `string:"Endpoint" validate:"http_url"` + Endpoint string `string:"Endpoint" validate:"http_url"` + ChunkSizeMB int64 `int64:"ChunkSizeMB" validate:"required"` + ConcurrentFiles int `int:"ConcurrentFiles" validate:"required"` + PoolSize int `int:"PoolSize" validate:"required"` } type GlobusTransferConfig struct { diff --git a/test/testdata/valid_config_s3.yaml b/test/testdata/valid_config_s3.yaml index cb2c113..bf29c52 100644 --- a/test/testdata/valid_config_s3.yaml +++ b/test/testdata/valid_config_s3.yaml @@ -4,12 +4,10 @@ Scicat: Transfer: Method: S3 S3: - Endpoint: s3:9000 - Bucket: landingzone - Checksum: true - Location: "eu-west-1" - User: "minio_user" - Password: "minio_pass" + Endpoint: https://endpoint/api/v1 + ChunkSizeMB: 64 + ConcurrentFiles: 4 + PoolSize: 8 Misc: ConcurrencyLimit: 2 Port: 8888 From 30a454adaab9b38ed1b9256127a772c6dc84bee1 Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 13 Dec 2024 11:00:48 +0100 Subject: [PATCH 11/14] chore: update openapi specs of s3 client --- internal/s3upload/client.gen.go | 96 ++++++++++++++++++++++++++------- internal/s3upload/openapi.yaml | 83 ++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 23 deletions(-) diff --git a/internal/s3upload/client.gen.go b/internal/s3upload/client.gen.go index 633de78..de51475 100644 --- a/internal/s3upload/client.gen.go +++ b/internal/s3upload/client.gen.go @@ -15,6 +15,7 @@ import ( "net/url" "path" "strings" + "time" "github.com/getkin/kin-openapi/openapi3" "github.com/oapi-codegen/runtime" @@ -26,6 +27,13 @@ type AbortUploadBody struct { UploadID string `json:"UploadID"` } +// AbortUploadResp defines model for AbortUploadResp. +type AbortUploadResp struct { + Message string `json:"Message"` + ObjectName string `json:"ObjectName"` + UploadID string `json:"UploadID"` +} + // CompletePart defines model for CompletePart. type CompletePart struct { ChecksumSHA256 string `json:"ChecksumSHA256"` @@ -41,11 +49,32 @@ type CompleteUploadBody struct { UploadID string `json:"UploadID"` } +// CompleteUploadResp defines model for CompleteUploadResp. +type CompleteUploadResp struct { + Key string `json:"Key"` + Location string `json:"Location"` +} + // HTTPValidationError defines model for HTTPValidationError. type HTTPValidationError struct { Detail *[]ValidationError `json:"detail,omitempty"` } +// InternalError defines model for InternalError. +type InternalError struct { + // Code A specific error code indicating the error type + Code string `json:"code"` + + // Details Additional context or information about the error + Details *string `json:"details,omitempty"` + + // Message A human-readable message providing more details about the error + Message string `json:"message"` + + // Timestamp The time when the error occurred + Timestamp *time.Time `json:"timestamp,omitempty"` +} + // PresignedUrlBody defines model for PresignedUrlBody. type PresignedUrlBody struct { ObjectName string `json:"ObjectName"` @@ -490,8 +519,9 @@ type ClientWithResponsesInterface interface { type AbortMultipartUploadResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *interface{} + JSON200 *AbortUploadResp JSON422 *HTTPValidationError + JSON500 *InternalError } // Status returns HTTPResponse.Status @@ -513,8 +543,9 @@ func (r AbortMultipartUploadResponse) StatusCode() int { type CompleteUploadResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *CompleteUploadBody + JSON200 *CompleteUploadResp JSON422 *HTTPValidationError + JSON500 *InternalError } // Status returns HTTPResponse.Status @@ -538,6 +569,7 @@ type GetPresignedUrlsResponse struct { HTTPResponse *http.Response JSON200 *PresignedUrlResp JSON422 *HTTPValidationError + JSON500 *InternalError } // Status returns HTTPResponse.Status @@ -622,7 +654,7 @@ func ParseAbortMultipartUploadResponse(rsp *http.Response) (*AbortMultipartUploa switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest interface{} + var dest AbortUploadResp if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -635,6 +667,13 @@ func ParseAbortMultipartUploadResponse(rsp *http.Response) (*AbortMultipartUploa } response.JSON422 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + } return response, nil @@ -655,7 +694,7 @@ func ParseCompleteUploadResponse(rsp *http.Response) (*CompleteUploadResponse, e switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest CompleteUploadBody + var dest CompleteUploadResp if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -668,6 +707,13 @@ func ParseCompleteUploadResponse(rsp *http.Response) (*CompleteUploadResponse, e } response.JSON422 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + } return response, nil @@ -701,6 +747,13 @@ func ParseGetPresignedUrlsResponse(rsp *http.Response) (*GetPresignedUrlsRespons } response.JSON422 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + } return response, nil @@ -709,21 +762,26 @@ func ParseGetPresignedUrlsResponse(rsp *http.Response) (*GetPresignedUrlsRespons // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/9RWS2/bOBD+K8LsHoXY6+zuQbc0NZIc8kBi9xIYASONbaYSyZJUUcPwfy9IWRJNSnk5", - "LpCLJY/IeXzfxxmuIeWF4AyZVpCsQaVLLIh9PXnkUk9Fzkn2hWcrY8JfpBA5mtfrxydM9RUpEBL3TwzV", - "louvkLSvmxiE5AKlpqj87WvQVOetH1b50SthbEpLyhbGQ+u43VHZaBau38Qg8UdJJWaQ3PekOIsbT365", - "mxhOualW4w2R2iv/dInpd1UWd+cno//+h8Q3xDCekAUk1SMG4+KqLB5RQjIM0PC9tfXVX9SSVG4DVKo4", - "7Y6xtgGDdW4G7WpjZZW12UOZxgXKAMLuEnc8O3jugOeA2auolyHt15wJoiC535uYfR3MXqn/fRh/z9nZ", - "ArQGqrGwL39LnEMCfw3aBjDYnv6Bz52rFtW6J1KS1d4n8zma4yZk15HtkNQmhvPJ5OYbyWlGNOVsLCWX", - "ntIy1ITmVi45T00O3vqHnKcPlDF7Kvq/zWIolJGC+W3KtA+jpIP5ngVyqit6Jb8+PA7FW08+x86KLnyN", - "wiQqumCYTWX+pnmxlebwQ2ZEo/Meyfb1to6MHKEFtXn13qISXr1dXSCGqcxNm6qe278hm287TrVXh/tg", - "ReOlihtw60Lhp9uDg615E0jYg+Fw58vDzAZ6n/ydiA5UxmHY6Ww+LSuXqBRZdCqxMjhz2YSKJsb6Ukes", - "QjtFOxR0nLz+cpI1ELa6nttW52e4Ds5EfxgXIpMtZXPuFnd3HDXaiKYyj8YsE5wyDTH8RKkoZ5DA8Oif", - "o6HJmAtkRFBI4NiaYhBELy1vA2LuYZdlrqkg9YXMfBBc2UuYId0mdpHVtzZ/dYUmKl33oZQzjcxuJ0Lk", - "NLUOBk/KpFVfel9STXBB3KVNyxKtQQnOVCXK0XD4lvDWY4YqlVToCrK7Mk1RqXmZR7dbzwbAf0ejD6ur", - "s5+HmbRLonpNDKosCiJXNRFRw0TUUKHJwrY84fQO0/Q2Mdhs2gHez/LuoD8Qv123iQ+n+F0ZfFJJ1OW8", - "Tgu7tl4pnKG+2Vl5GDGEE//PSiGctJ9YCGeod8eDek4LZitKMzXs2CplDgkMzK33dwAAAP//aD4jzq0Q", - "AAA=", + "H4sIAAAAAAAC/+xYzXLbNhB+FQzaIxMpTtMDb27qiT1tEo8t91CPJwOTKxEJCaAAaEej0bt3FuAPRIKx", + "E1mZHHKxSHCxP99+u1h4QzNZKSlAWEPTDTVZARVzj8e3UtsrVUqW/yHzNS7BZ1apEvDx/e1HyOw7VgFN", + "w5eE+i1nf9K0f9wmVGmpQFsOZrh9Qy23Za9HeD12rXDNWM3FCjX0ivsdfo3nY/ltQjX8V3MNOU2vJ1y8", + "STpNw3C3Sbh0AUYNEHgLxrAVbr2sswwgB/RiT1w6pX2I7VIEkR8ExaTzMY6nA2+b0NcSsbNwzrQdgPm6", + "gOyTqavL0+OjV7/TdLiQ0JMFW9HU/yQUVbyrq1vQNJ2PUBxq6yNtv5iCebUjfLydfseJdQZHcqEHvTSu", + "Cr/a7eHCwgr0CMx4iDuaAzx3wAvAnKzQhyGd5ioaMTS93jsx+yq4eWTd7JPxb6miBqAN5RYq9/CrhiVN", + "6S+zvqHOmm46G+YuZIvp1TOt2XrvGv1SmpPOZKwFRig1IlqkEf4F6yF//pYZs1wKmtLCWpXOZhUXXM6k", + "Exqnz6noY8XXCOi91l60W3sIlkAQ1U+G3Tar08Xi/B9W8tztOtFa6kHcOVjGS1clpczQxkD+QymzD1wI", + "1wymv90ktDJYAfi3C8P9YAEdTPfNKA1tRI+k9RCegNmNpiG1A4kYvtuEngkLWrCyA3zXw0zmDfYm01w1", + "HDsmRkHGlzwjgPsIihEuco45FytiC2i+OH8i3PIOm4juPOf4yEqSSWHhsyVSEy6WUlfOdcJuZW17EzHt", + "VX+sDz0v6oqJZxpYzm5LII0kUVre8Rx9r6QG0rj3GFuWV2Asq9TY2qIAgp/JfQEiAEVmWa21m198WJhB", + "ZuEZCj9YWS4nfYw3nXxf7ecaDF8JyK90+VXDZNNn508yQHZNG5tRVVc0fTHZi6cO7Yh3QSsZxTmIPdI9", + "Y8dbQq80cvHa/zav43r9unOi1RpUd4Q8jRZvd1S9IRRDdydwaBvql5vp4TroADNn6NsaXGAxgAoVjo9w", + "58+jhni/EAycriQX0T41yIA3HQQdpCDSW6fDSTeUifX7pTvMhh5uRjUxbSaECL3FPjnuQxcnlwtyfH5G", + "QORKcmGbdgeaLKUmqmUPuXxJascywkROmM4Kfoc98V7qT8tS3hPMVF6X6Gjv08ni9F9y7IRBk0vQdzxD", + "MO9AG+/A/PmL53OERCoQTHGa0pduKaGK2cIRY8bwBvO2Li1XrL3KuBNJGnd9QVa5yM/y9r4zlPbpAmPb", + "pueOEOG2M6VK7meS2UfjpxpPvIdoObqq7vLC6hrcglFSGM/6o/n8EOZ9bW/d+Rkm2N2IjVnWJblo3EC0", + "fzs6ejIvovPD2JNehHR18OoJwdidVyIOtAKOh6BbL7YJNXVVMb1uuUM68pCOPZat3DGggn6KB8E2oc6Z", + "fmydJubueHsgSsauDt+XlbEp/icx9yRmC+rjGLm7NknIN2DPdyQPQ8nxLPZ9CTmegX7ScU86vgFLOlhJ", + "O6NOMRK3Ol3+P1m1LmlKZ3jv/T8AAP//Yr2x2vYWAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/s3upload/openapi.yaml b/internal/s3upload/openapi.yaml index 39d3efc..22b8265 100644 --- a/internal/s3upload/openapi.yaml +++ b/internal/s3upload/openapi.yaml @@ -1,6 +1,7 @@ openapi: 3.1.0 info: - title: S3 Presigned Url Endpoint + title: ETHZ Archiver Service + description: REST API endpoint provider for presigned S3 upload and archiving workflow scheduling version: 0.1.0 servers: - url: / @@ -27,6 +28,12 @@ paths: schema: $ref: '#/components/schemas/HTTPValidationError' description: Validation Error + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/InternalError' + description: Internal Server Error summary: Get Presigned Urls tags: - presignedUrls @@ -44,7 +51,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CompleteUploadBody' + $ref: '#/components/schemas/CompleteUploadResp' description: Successful Response "422": content: @@ -52,6 +59,12 @@ paths: schema: $ref: '#/components/schemas/HTTPValidationError' description: Validation Error + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/InternalError' + description: Internal Server Error summary: Complete Upload tags: - presignedUrls @@ -68,7 +81,8 @@ paths: "200": content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/AbortUploadResp' description: Successful Response "422": content: @@ -76,6 +90,12 @@ paths: schema: $ref: '#/components/schemas/HTTPValidationError' description: Validation Error + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/InternalError' + description: Internal Server Error summary: Abort Multipart Upload tags: - presignedUrls @@ -95,7 +115,27 @@ components: required: - ObjectName - UploadID - title: AbortUploadBody + title: AbortUploadBody + AbortUploadResp: + example: + UploadID: UploadID + ObjectName: ObjectName + Message: Succeeded + properties: + Message: + title: Message + type: string + UploadID: + title: Uploadid + type: string + ObjectName: + title: Objectname + type: string + required: + - ObjectName + - UploadID + - Message + title: AbortUploadResp CompletePart: example: PartNumber: 0 @@ -149,6 +189,21 @@ components: - Parts - UploadID title: CompleteUploadBody + CompleteUploadResp: + example: + Location: http://minio/object + Key: ObjectName + properties: + Location: + title: Location + type: string + Key: + title: Key + type: string + required: + - Location + - Key + title: CompleteUploadResp HTTPValidationError: example: detail: @@ -169,6 +224,25 @@ components: title: detail type: array title: HTTPValidationError + InternalError: + type: object + required: + - code + - message + properties: + code: + type: string + description: A specific error code indicating the error type + message: + type: string + description: A human-readable message providing more details about the error + details: + type: string + description: Additional context or information about the error + timestamp: + type: string + format: date-time + description: The time when the error occurred PresignedUrlBody: example: Parts: 0 @@ -180,6 +254,7 @@ components: Parts: title: Parts type: integer + minimum: 1 required: - ObjectName - Parts From de4c5e9012f66db8d6223fb5ab60a7534f1915d0 Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Fri, 13 Dec 2024 11:01:46 +0100 Subject: [PATCH 12/14] fix(s3): improve error handling and logging --- go.sum | 2 -- internal/s3upload/httpuploader.go | 43 +++++++++++++++++++------------ internal/s3upload/s3upload.go | 8 +++--- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/go.sum b/go.sum index 7df7344..7f9a648 100644 --- a/go.sum +++ b/go.sum @@ -96,8 +96,6 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= diff --git a/internal/s3upload/httpuploader.go b/internal/s3upload/httpuploader.go index cc7a646..f627f0c 100644 --- a/internal/s3upload/httpuploader.go +++ b/internal/s3upload/httpuploader.go @@ -7,7 +7,6 @@ import ( "encoding/base64" "fmt" "io" - "log/slog" "math" "net/http" "os" @@ -62,7 +61,7 @@ func GetPresignedUrlServer(endpoint string) *ClientWithResponses { // is initiated func getPresignedUrls(object_name string, part int, endpoint string) (string, []string, error) { - r, err := GetPresignedUrlServer(endpoint).GetPresignedUrlsWithResponse(context.Background(), PresignedUrlBody{ + response, err := GetPresignedUrlServer(endpoint).GetPresignedUrlsWithResponse(context.Background(), PresignedUrlBody{ ObjectName: object_name, Parts: part, }) @@ -70,15 +69,23 @@ func getPresignedUrls(object_name string, part int, endpoint string) (string, [] if err != nil { return "", []string{}, err } - if r.StatusCode() != http.StatusOK { - return "", []string{}, fmt.Errorf(r.Status()) + + if response.StatusCode() == http.StatusInternalServerError { + return "", []string{}, fmt.Errorf("%s: %s", response.JSON500.Message, *response.JSON500.Details) } + if response.StatusCode() == http.StatusUnprocessableEntity { + err_string := "" + for _, d := range *response.JSON422.Detail { + err_string += " " + d.Msg + } - return r.JSON200.UploadID, r.JSON200.Urls, err + return "", []string{}, fmt.Errorf("%s", err_string) + } + return response.JSON200.UploadID, response.JSON200.Urls, err } func completeMultiPartUpload(object_name string, uploadID string, endpoint string, parts []CompletePart, full_file_checksum string) error { - r, err := GetPresignedUrlServer(endpoint).CompleteUploadWithResponse(context.Background(), CompleteUploadBody{ + response, err := GetPresignedUrlServer(endpoint).CompleteUploadWithResponse(context.Background(), CompleteUploadBody{ ObjectName: object_name, UploadID: uploadID, Parts: parts, @@ -88,14 +95,22 @@ func completeMultiPartUpload(object_name string, uploadID string, endpoint strin if err != nil { return err } - if r.StatusCode() != http.StatusOK { - return fmt.Errorf("") + + if response.StatusCode() == http.StatusInternalServerError { + return fmt.Errorf("%s: %s", response.JSON500.Message, *response.JSON500.Details) + } + if response.StatusCode() == http.StatusUnprocessableEntity { + err_string := "" + for _, d := range *response.JSON422.Detail { + err_string += " " + d.Msg + } + + return fmt.Errorf("%s", err_string) } return nil } func uploadFile(ctx context.Context, filePath string, objectName string, options task.S3TransferConfig, notifier *TransferNotifier) error { - // Open the file file, err := os.Open(filePath) if err != nil { return fmt.Errorf("error opening file: %w", err) @@ -103,15 +118,12 @@ func uploadFile(ctx context.Context, filePath string, objectName string, options defer file.Close() - // Get the file size fileInfo, err := file.Stat() if err != nil { return fmt.Errorf("error getting file info: %w", err) } totalSize := fileInfo.Size() - fmt.Printf("Uploading file: %s (%d bytes)\n", filePath, totalSize) - httpClient := GetHttpUploader(options.PoolSize) if totalSize < options.ChunkSizeMB*MiB { @@ -127,6 +139,7 @@ func uploadFile(ctx context.Context, filePath string, objectName string, options if err_abort != nil { return fmt.Errorf("while aborting a multipart upload an error occured: %s. Previous error: %s", err_abort.Error(), err_upload.Error()) } + return err_upload } return err } @@ -144,7 +157,6 @@ func abortMultipartUpload(uploadID string, objectName string, endpoint string) e return fmt.Errorf("") } return nil - } func doUploadSingleFile(ctx context.Context, objectName string, file *os.File, httpClient *HttpUploader, endpoint string, notifier *TransferNotifier) error { @@ -204,7 +216,6 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, parts[partNumber] = CompletePart{ETag: etag, PartNumber: partNumber + 1, ChecksumSHA256: base64hash} - fmt.Printf("Uploaded part %d\n", partNumber+1) return nil }) } @@ -218,14 +229,12 @@ func doUploadMultipart(ctx context.Context, totalSize int64, objectName string, c := strings.Join(partChecksums, "") n := sha256.Sum256([]byte(c)) base64hash := base64.StdEncoding.EncodeToString(n[:]) - slog.Info("Calculated file digest", "file", file.Name(), "sha256", base64hash) err = completeMultiPartUpload(objectName, uploadID, options.Endpoint, parts, base64hash) if err != nil { - return uploadID, fmt.Errorf("error completing multipart upload: %w", err) + return uploadID, fmt.Errorf("error completing multipart upload: %s", err.Error()) } - fmt.Println("Multipart upload completed successfully.") return uploadID, nil } diff --git a/internal/s3upload/s3upload.go b/internal/s3upload/s3upload.go index 5b3ae69..c22dca6 100644 --- a/internal/s3upload/s3upload.go +++ b/internal/s3upload/s3upload.go @@ -61,7 +61,7 @@ func UploadS3(ctx context.Context, datasetPID string, datasetSourceFolder string } func uploadFiles(ctx context.Context, s3Objects *S3Objects, options task.S3TransferConfig, transferNotifier *TransferNotifier, uploadId uuid.UUID) error { - errorGroup, ctx := errgroup.WithContext(ctx) + errorGroup, context := errgroup.WithContext(ctx) objectsChannel := make(chan int, len(s3Objects.Files)) nWorkers := max(options.ConcurrentFiles, len(s3Objects.Files)) @@ -71,11 +71,11 @@ func uploadFiles(ctx context.Context, s3Objects *S3Objects, options task.S3Trans func() error { for idx := range objectsChannel { select { - case <-ctx.Done(): + case <-context.Done(): transferNotifier.notifier.OnTaskCanceled(uploadId) - return ctx.Err() + return context.Err() default: - err := uploadFile(ctx, s3Objects.Files[idx], s3Objects.ObjectNames[idx], options, transferNotifier) + err := uploadFile(context, s3Objects.Files[idx], s3Objects.ObjectNames[idx], options, transferNotifier) if err != nil { return err } From 657a72f5a1bf027bfc4b45daafd290277378fbe0 Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Mon, 6 Jan 2025 17:16:48 +0100 Subject: [PATCH 13/14] s3upload: update api to new prefixes --- internal/s3upload/client.gen.go | 46 ++++++++++++++++----------------- internal/s3upload/openapi.yaml | 6 ++--- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/s3upload/client.gen.go b/internal/s3upload/client.gen.go index de51475..9cb0817 100644 --- a/internal/s3upload/client.gen.go +++ b/internal/s3upload/client.gen.go @@ -357,7 +357,7 @@ func NewAbortMultipartUploadRequestWithBody(server string, contentType string, b return nil, err } - operationPath := fmt.Sprintf("/abortMultipartUpload") + operationPath := fmt.Sprintf("/s3/abortMultipartUpload") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -397,7 +397,7 @@ func NewCompleteUploadRequestWithBody(server string, contentType string, body io return nil, err } - operationPath := fmt.Sprintf("/completeUpload") + operationPath := fmt.Sprintf("/s3/completeUpload") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -437,7 +437,7 @@ func NewGetPresignedUrlsRequestWithBody(server string, contentType string, body return nil, err } - operationPath := fmt.Sprintf("/presignedUrls") + operationPath := fmt.Sprintf("/s3/presignedUrls") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -762,26 +762,26 @@ func ParseGetPresignedUrlsResponse(rsp *http.Response) (*GetPresignedUrlsRespons // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/s3upload/openapi.yaml b/internal/s3upload/openapi.yaml index 22b8265..755214e 100644 --- a/internal/s3upload/openapi.yaml +++ b/internal/s3upload/openapi.yaml @@ -6,7 +6,7 @@ info: servers: - url: / paths: - /presignedUrls: + /s3/presignedUrls: post: operationId: get_presigned_urls requestBody: @@ -37,7 +37,7 @@ paths: summary: Get Presigned Urls tags: - presignedUrls - /completeUpload: + /s3/completeUpload: post: operationId: complete_upload requestBody: @@ -68,7 +68,7 @@ paths: summary: Complete Upload tags: - presignedUrls - /abortMultipartUpload: + /s3/abortMultipartUpload: post: operationId: abort_multipart_upload requestBody: From 3871a8c5d9da9b5cd5fbcb87fa69040c6d6d65c1 Mon Sep 17 00:00:00 2001 From: Philipp Wissmann Date: Wed, 8 Jan 2025 12:30:45 +0100 Subject: [PATCH 14/14] chore: update go mod --- go.mod | 2 +- go.sum | 20 ++------------------ 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 56baa21..841df9d 100644 --- a/go.mod +++ b/go.mod @@ -138,7 +138,7 @@ require ( golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sync v0.8.0 // indirect + golang.org/x/sync v0.8.0 golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/tools v0.26.0 // indirect diff --git a/go.sum b/go.sum index 7f9a648..c7a66cf 100644 --- a/go.sum +++ b/go.sum @@ -263,12 +263,8 @@ github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 h1:rICjNsHbPP1LttefanBPnwsSwl09Sq github.com/oapi-codegen/oapi-codegen/v2 v2.3.0/go.mod h1:4k+cJeSq5ntkwlcpQSxLxICCxQzCL772o30PxdibRt4= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/paulscherrerinstitute/scicat-cli/v3 v3.0.0-alpha3.0.20240923114554-40b0ae5da12e h1:11aZpGkmjsANwEr3LG3fAhIvySK7rwOyve1IDOH/MhI= -github.com/paulscherrerinstitute/scicat-cli/v3 v3.0.0-alpha3.0.20240923114554-40b0ae5da12e/go.mod h1:MXUPH5xJe/5ZydWzB+UaTwyfNDCxVAG10hXgrTlkRLw= github.com/paulscherrerinstitute/scicat-cli/v3 v3.0.0-alpha3.0.20241104135519-f2e1a334d152 h1:TOoDq3+wA87HxX42tM62N36ZcWisYinXjdBRLzFHbg4= github.com/paulscherrerinstitute/scicat-cli/v3 v3.0.0-alpha3.0.20241104135519-f2e1a334d152/go.mod h1:0isvmkPG0GVviHOaTk+PrenP6NaPqzHjaKcJo9J+VYE= -github.com/paulscherrerinstitute/scicat-cli/v3 v3.0.0-alpha3.0.20241104135519-ff8966010bde h1:zdM+5QFYrp+tnRqPxjJ929U0RbBqWT9AnQCc1dBiciY= -github.com/paulscherrerinstitute/scicat-cli/v3 v3.0.0-alpha3.0.20241104135519-ff8966010bde/go.mod h1:MXUPH5xJe/5ZydWzB+UaTwyfNDCxVAG10hXgrTlkRLw= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= @@ -369,8 +365,6 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -381,8 +375,6 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -422,8 +414,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -465,15 +455,13 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -482,8 +470,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -515,8 +501,6 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= -golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=