diff --git a/cmd/auto-complete.go b/cmd/auto-complete.go index 81be755f..96b0793a 100644 --- a/cmd/auto-complete.go +++ b/cmd/auto-complete.go @@ -488,6 +488,7 @@ var completeCmds = map[string]complete.Predictor{ "/support/top/drive": aliasCompleter, "/support/top/disk": aliasCompleter, "/support/top/net": aliasCompleter, + "/support/upload": aliasCompleter, "/license/register": aliasCompleter, "/license/info": aliasCompleter, diff --git a/cmd/subnet-utils.go b/cmd/subnet-utils.go index a955bee4..05ed0238 100644 --- a/cmd/subnet-utils.go +++ b/cmd/subnet-utils.go @@ -35,6 +35,7 @@ import ( "time" "github.com/google/uuid" + "github.com/klauspost/compress/zstd" "github.com/minio/cli" "github.com/minio/madmin-go/v3" "github.com/minio/mc/pkg/probe" @@ -61,12 +62,16 @@ func subnetBaseURL() string { return subnet.BaseURL(globalDevMode) } +func subnetIssueURL(issueNum int) string { + return fmt.Sprintf("%s/issues/%d", subnetBaseURL(), issueNum) +} + func subnetLogWebhookURL() string { return subnetBaseURL() + "/api/logs" } -func subnetUploadURL(uploadType, filename string) string { - return fmt.Sprintf("%s/api/%s/upload?filename=%s", subnetBaseURL(), uploadType, filename) +func subnetUploadURL(uploadType string) string { + return fmt.Sprintf("%s/api/%s/upload", subnetBaseURL(), uploadType) } func subnetRegisterURL() string { @@ -708,28 +713,61 @@ func prepareSubnetUploadURL(uploadURL, alias, apiKey string) (string, map[string return reqURL, headers } -func uploadFileToSubnet(alias, filename, reqURL string, headers map[string]string) (string, error) { - req, e := subnetUploadReq(reqURL, filename) +type subnetFileUploader struct { + alias string + filePath string + filename string + reqURL string + headers subnetHeaders + autoCompress bool + deleteAfterUpload bool + params url.Values +} + +func (i *subnetFileUploader) uploadFileToSubnet() (string, error) { + req, e := i.subnetUploadReq() if e != nil { return "", e } - resp, e := subnetReqDo(req, headers) + resp, e := subnetReqDo(req, i.headers) if e != nil { return "", e } - // Delete the file after successful upload - os.Remove(filename) + if i.deleteAfterUpload { + os.Remove(i.filePath) + } // ensure that both api-key and license from // SUBNET response are saved in the config - extractAndSaveSubnetCreds(alias, resp) + extractAndSaveSubnetCreds(i.alias, resp) return resp, e } -func subnetUploadReq(url, filename string) (*http.Request, error) { +func (i *subnetFileUploader) updateParams() { + if i.params == nil { + i.params = url.Values{} + } + + if i.filename == "" { + i.filename = filepath.Base(i.filePath) + } + + i.autoCompress = i.autoCompress && !strings.HasSuffix(strings.ToLower(i.filePath), ".zst") + if i.autoCompress { + i.filename += ".zst" + i.params.Add("auto-compression", "zstd") + } + + i.params.Add("filename", i.filename) + i.reqURL += "?" + i.params.Encode() +} + +func (i *subnetFileUploader) subnetUploadReq() (*http.Request, error) { + i.updateParams() + r, w := io.Pipe() mwriter := multipart.NewWriter(w) contentType := mwriter.FormDataContentType() @@ -744,21 +782,27 @@ func subnetUploadReq(url, filename string) (*http.Request, error) { w.CloseWithError(e) }() - part, e = mwriter.CreateFormFile("file", filepath.Base(filename)) + part, e = mwriter.CreateFormFile("file", i.filename) if e != nil { return } - file, e := os.Open(filename) + file, e := os.Open(i.filePath) if e != nil { return } defer file.Close() - _, e = io.Copy(part, file) + if i.autoCompress { + z, _ := zstd.NewWriter(part, zstd.WithEncoderConcurrency(2)) + defer z.Close() + _, e = z.ReadFrom(file) + } else { + _, e = io.Copy(part, file) + } }() - req, e := http.NewRequest(http.MethodPost, url, r) + req, e := http.NewRequest(http.MethodPost, i.reqURL, r) if e != nil { return nil, e } diff --git a/cmd/support-diag.go b/cmd/support-diag.go index 47a5c742..19af028f 100644 --- a/cmd/support-diag.go +++ b/cmd/support-diag.go @@ -205,7 +205,7 @@ func execSupportDiag(ctx *cli.Context, client *madmin.AdminClient, alias, apiKey if !globalAirgapped { // Retrieve subnet credentials (login/license) beforehand as // it can take a long time to fetch the health information - uploadURL := subnetUploadURL("health", filename) + uploadURL := subnetUploadURL("health") reqURL, headers = prepareSubnetUploadURL(uploadURL, alias, apiKey) } @@ -228,7 +228,13 @@ func execSupportDiag(ctx *cli.Context, client *madmin.AdminClient, alias, apiKey fatalIf(probe.NewError(e), "Unable to save MinIO diagnostics report") if !globalAirgapped { - _, e := uploadFileToSubnet(alias, filename, reqURL, headers) + _, e := (&subnetFileUploader{ + alias: alias, + filePath: filename, + reqURL: reqURL, + headers: headers, + deleteAfterUpload: true, + }).uploadFileToSubnet() fatalIf(probe.NewError(e), "Unable to upload MinIO diagnostics report to SUBNET portal") printMsg(supportDiagMessage{}) diff --git a/cmd/support-inspect.go b/cmd/support-inspect.go index c0d609ae..a008ae20 100644 --- a/cmd/support-inspect.go +++ b/cmd/support-inspect.go @@ -196,10 +196,18 @@ func mainSupportInspect(ctx *cli.Context) error { return nil } - uploadURL := subnetUploadURL("inspect", inspectOutputFilename) + uploadURL := subnetUploadURL("inspect") reqURL, headers := prepareSubnetUploadURL(uploadURL, alias, apiKey) - _, e = uploadFileToSubnet(alias, tmpFile.Name(), reqURL, headers) + tmpFileName := tmpFile.Name() + _, e = (&subnetFileUploader{ + alias: alias, + filePath: tmpFileName, + filename: inspectOutputFilename, + reqURL: reqURL, + headers: headers, + deleteAfterUpload: true, + }).uploadFileToSubnet() if e != nil { console.Errorln("Unable to upload inspect data to SUBNET portal: " + e.Error()) saveInspectDataFile(key, tmpFile) diff --git a/cmd/support-perf.go b/cmd/support-perf.go index ff870462..fd879fd3 100644 --- a/cmd/support-perf.go +++ b/cmd/support-perf.go @@ -490,10 +490,16 @@ func execSupportPerf(ctx *cli.Context, aliasedURL, perfType string) { return } - uploadURL := subnetUploadURL("perf", tmpFileName) + uploadURL := subnetUploadURL("perf") reqURL, headers := prepareSubnetUploadURL(uploadURL, alias, apiKey) - _, e = uploadFileToSubnet(alias, tmpFileName, reqURL, headers) + _, e = (&subnetFileUploader{ + alias: alias, + filePath: tmpFileName, + reqURL: reqURL, + headers: headers, + deleteAfterUpload: true, + }).uploadFileToSubnet() if e != nil { errorIf(probe.NewError(e), "Unable to upload performance results to SUBNET portal") savePerfResultFile(tmpFileName, resultFileNamePfx) diff --git a/cmd/support-profile.go b/cmd/support-profile.go index d8e39fa3..f1785ea9 100644 --- a/cmd/support-profile.go +++ b/cmd/support-profile.go @@ -226,7 +226,7 @@ func execSupportProfile(ctx *cli.Context, client *madmin.AdminClient, alias, api if !globalAirgapped { // Retrieve subnet credentials (login/license) beforehand as // it can take a long time to fetch the profile data - uploadURL := subnetUploadURL("profile", profileFile) + uploadURL := subnetUploadURL("profile") reqURL, headers = prepareSubnetUploadURL(uploadURL, alias, apiKey) } @@ -239,7 +239,13 @@ func execSupportProfile(ctx *cli.Context, client *madmin.AdminClient, alias, api saveProfileFile(data) if !globalAirgapped { - _, e = uploadFileToSubnet(alias, profileFile, reqURL, headers) + _, e = (&subnetFileUploader{ + alias: alias, + filePath: profileFile, + reqURL: reqURL, + headers: headers, + deleteAfterUpload: true, + }).uploadFileToSubnet() if e != nil { printMsg(supportProfileMessage{ Status: "error", diff --git a/cmd/support-upload.go b/cmd/support-upload.go new file mode 100644 index 00000000..aaaa10c1 --- /dev/null +++ b/cmd/support-upload.go @@ -0,0 +1,146 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "fmt" + "net/url" + + "github.com/minio/cli" + "github.com/minio/mc/pkg/probe" + "github.com/minio/pkg/v2/console" +) + +// profile command flags. +var ( + uploadFlags = append(globalFlags, + cli.IntFlag{ + Name: "issue", + Usage: "SUBNET issue number to which the file is to be uploaded", + }, + cli.StringFlag{ + Name: "comment", + Usage: "comment to be posted on the issue along with the file", + }, + cli.BoolFlag{ + Name: "dev", + Usage: "Development mode", + Hidden: true, + }, + ) +) + +type supportUploadMessage struct { + Status string `json:"status"` + IssueNum int `json:"-"` + IssueURL string `json:"issueUrl"` +} + +// String colorized upload message +func (s supportUploadMessage) String() string { + msg := fmt.Sprintf("File uploaded to SUBNET successfully. Click here to visit the issue: %s", subnetIssueURL(s.IssueNum)) + return console.Colorize(supportSuccessMsgTag, msg) +} + +// JSON jsonified upload message +func (s supportUploadMessage) JSON() string { + return toJSON(s) +} + +var supportUploadCmd = cli.Command{ + Name: "upload", + Usage: "upload file to a SUBNET issue", + Action: mainSupportUpload, + OnUsageError: onUsageError, + Before: setGlobalsFromContext, + Flags: uploadFlags, + HideHelpCommand: true, + CustomHelpTemplate: `NAME: + {{.HelpName}} - {{.Usage}} + +USAGE: + {{.HelpName}} [FLAGS] ALIAS FILE + +FLAGS: + {{range .VisibleFlags}}{{.}} + {{end}} +EXAMPLES: + 1. Upload file './trace.log' for cluster 'myminio' to SUBNET issue number 10 + {{.Prompt}} {{.HelpName}} --issue 10 myminio ./trace.log + + 2. Upload file './trace.log' for cluster 'myminio' to SUBNET issue number 10 with comment 'here is the trace log' + {{.Prompt}} {{.HelpName}} --issue 10 --comment "here is the trace log" myminio ./trace.log +`, +} + +func checkSupportUploadSyntax(ctx *cli.Context) { + if len(ctx.Args()) != 2 { + showCommandHelpAndExit(ctx, 1) // last argument is exit code + } + + if ctx.Int("issue") <= 0 { + fatal(errDummy().Trace(), "Invalid issue number") + } +} + +// mainSupportUpload is the handle for "mc support upload" command. +func mainSupportUpload(ctx *cli.Context) error { + // Check for command syntax + checkSupportUploadSyntax(ctx) + setSuccessMessageColor() + + // Get the alias parameter from cli + aliasedURL := ctx.Args().Get(0) + alias, apiKey := initSubnetConnectivity(ctx, aliasedURL, true) + if len(apiKey) == 0 { + // api key not passed as flag. Check that the cluster is registered. + apiKey = validateClusterRegistered(alias, true) + } + + // Main execution + execSupportUpload(ctx, alias, apiKey) + return nil +} + +func execSupportUpload(ctx *cli.Context, alias, apiKey string) { + filePath := ctx.Args().Get(1) + issueNum := ctx.Int("issue") + msg := ctx.String("comment") + + params := url.Values{} + params.Add("issueNumber", fmt.Sprintf("%d", issueNum)) + if len(msg) > 0 { + params.Add("message", msg) + } + + uploadURL := subnetUploadURL("attachment") + reqURL, headers := prepareSubnetUploadURL(uploadURL, alias, apiKey) + + _, e := (&subnetFileUploader{ + alias: alias, + filePath: filePath, + reqURL: reqURL, + headers: headers, + autoCompress: true, + params: params, + }).uploadFileToSubnet() + if e != nil { + fatalIf(probe.NewError(e), "Unable to upload file to SUBNET") + } + printMsg(supportUploadMessage{IssueNum: issueNum, Status: "success", IssueURL: subnetIssueURL(issueNum)}) +} diff --git a/cmd/support.go b/cmd/support.go index 9cba3868..ce469740 100644 --- a/cmd/support.go +++ b/cmd/support.go @@ -55,6 +55,7 @@ var supportSubcommands = []cli.Command{ supportProfileCmd, supportTopCmd, supportProxyCmd, + supportUploadCmd, } var supportCmd = cli.Command{