mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-26 21:27:40 -05:00
[MM-63556] mmctl: Add compliance export download cmd (#30576)
* add mmctl compliance export download command and tests - Introduced `ComplianceExportDownloadCmd` to facilitate downloading compliance export files. - Implemented the `DownloadComplianceExport` method in the Client interface for handling file downloads. - Added unit tests for the download command, covering successful downloads, error handling for non-existent jobs, and retries on failure. - Included end-to-end tests to validate the command's functionality. - Updated documentation to include usage examples and options for the new command. * don't know why this was left out * PR comments * adjust test for new retry logic * refactored download fn for compliance_export and export * fix test due to fixed logic * docs
This commit is contained in:
@@ -9,6 +9,7 @@ func (api *API) InitJobLocal() {
|
||||
api.BaseRoutes.Jobs.Handle("", api.APILocal(getJobs)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Jobs.Handle("", api.APILocal(createJob)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}", api.APILocal(getJob)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/download", api.APILocal(downloadJob)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/cancel", api.APILocal(cancelJob)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Jobs.Handle("/type/{job_type:[A-Za-z0-9_-]+}", api.APILocal(getJobsByType)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Jobs.Handle("/{job_id:[A-Za-z0-9]+}/status", api.APILocal(updateJobStatus)).Methods(http.MethodPatch)
|
||||
|
||||
@@ -152,6 +152,7 @@ type Client interface {
|
||||
ListExports(ctx context.Context) ([]string, *model.Response, error)
|
||||
DeleteExport(ctx context.Context, name string) (*model.Response, error)
|
||||
DownloadExport(ctx context.Context, name string, wr io.Writer, offset int64) (int64, *model.Response, error)
|
||||
DownloadComplianceExport(ctx context.Context, jobID string, wr io.Writer) (string, error)
|
||||
GeneratePresignedURL(ctx context.Context, name string) (*model.PresignURLResponse, *model.Response, error)
|
||||
ResetSamlAuthDataToEmail(ctx context.Context, includeDeleted bool, dryRun bool, userIDs []string) (int64, *model.Response, error)
|
||||
GenerateSupportPacket(ctx context.Context) (io.ReadCloser, string, *model.Response, error)
|
||||
|
||||
@@ -6,8 +6,10 @@ package commands
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
|
||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -40,15 +42,26 @@ var ComplianceExportCancelCmd = &cobra.Command{
|
||||
RunE: withClient(complianceExportCancelCmdF),
|
||||
}
|
||||
|
||||
var ComplianceExportDownloadCmd = &cobra.Command{
|
||||
Use: "download [complianceExportJobID] [output filepath (optional)]",
|
||||
Example: " compliance_export download o98rj3ur83dp5dppfyk5yk6osy",
|
||||
Short: "Download compliance export file",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: withClient(complianceExportDownloadCmdF),
|
||||
}
|
||||
|
||||
func init() {
|
||||
ComplianceExportListCmd.Flags().Int("page", 0, "Page number to fetch for the list of compliance export jobs")
|
||||
ComplianceExportListCmd.Flags().Int("per-page", DefaultPageSize, "Number of compliance export jobs to be fetched")
|
||||
ComplianceExportListCmd.Flags().Bool("all", false, "Fetch all compliance export jobs. --page flag will be ignored if provided")
|
||||
|
||||
ComplianceExportDownloadCmd.Flags().Int("num-retries", 5, "Number of retries if the download fails")
|
||||
|
||||
ComplianceExportCmd.AddCommand(
|
||||
ComplianceExportListCmd,
|
||||
ComplianceExportShowCmd,
|
||||
ComplianceExportCancelCmd,
|
||||
ComplianceExportDownloadCmd,
|
||||
)
|
||||
RootCmd.AddCommand(ComplianceExportCmd)
|
||||
}
|
||||
@@ -75,3 +88,41 @@ func complianceExportCancelCmdF(c client.Client, command *cobra.Command, args []
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func complianceExportDownloadCmdF(c client.Client, command *cobra.Command, args []string) error {
|
||||
jobID := args[0]
|
||||
var path string
|
||||
if len(args) > 1 {
|
||||
path = args[1]
|
||||
} else {
|
||||
path = jobID + ".zip"
|
||||
}
|
||||
|
||||
retries, _ := command.Flags().GetInt("num-retries")
|
||||
|
||||
downloadFn := func(outFile *os.File) (string, error) {
|
||||
return c.DownloadComplianceExport(context.TODO(), jobID, outFile)
|
||||
}
|
||||
|
||||
suggestedFilename, err := downloadFile(path, downloadFn, retries, "compliance export")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If we didn't provide a path and got a suggested filename, rename the file
|
||||
if len(args) == 1 && suggestedFilename != "" && suggestedFilename != path {
|
||||
// If the suggested name already exists, don't overwrite
|
||||
if _, err := os.Stat(suggestedFilename); err == nil {
|
||||
printer.PrintWarning(fmt.Sprintf("File with the server's suggested name %q already exists, keeping %q", suggestedFilename, path))
|
||||
} else {
|
||||
if err := os.Rename(path, suggestedFilename); err != nil {
|
||||
printer.PrintWarning(fmt.Sprintf("Could not rename file to the server's suggested name %q: %v", suggestedFilename, err))
|
||||
} else {
|
||||
path = suggestedFilename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
printer.Print(fmt.Sprintf("Compliance export file downloaded to %q", path))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8"
|
||||
st "github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
|
||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (s *MmctlE2ETestSuite) TestComplianceExportListCmdE2E() {
|
||||
@@ -301,3 +308,280 @@ func (s *MmctlE2ETestSuite) TestComplianceExportCancelCmdE2E() {
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MmctlE2ETestSuite) TestComplianceExportDownloadCmdE2E() {
|
||||
s.SetupMessageExportTestHelper()
|
||||
|
||||
s.Run("no permissions", func() {
|
||||
printer.Clean()
|
||||
|
||||
now := model.GetMillis()
|
||||
// Create a job
|
||||
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
|
||||
Id: st.NewTestID(),
|
||||
CreateAt: now - 1000,
|
||||
Status: model.JobStatusSuccess,
|
||||
Type: model.JobTypeMessageExport,
|
||||
StartAt: now - 1000,
|
||||
LastActivityAt: now - 1000,
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
defer func() {
|
||||
// Ensure job is deleted from the database
|
||||
var result string
|
||||
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
|
||||
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
|
||||
}()
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 0, "")
|
||||
err = complianceExportDownloadCmdF(s.th.Client, cmd, []string{job.Id})
|
||||
s.Require().EqualError(err, "failed to download compliance export file: You do not have the appropriate permissions.")
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
|
||||
s.RunForSystemAdminAndLocal("Download non-existent job", func(c client.Client) {
|
||||
printer.Clean()
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 0, "")
|
||||
err := complianceExportDownloadCmdF(c, cmd, []string{"non-existent-job-id"})
|
||||
s.Require().EqualError(err, "failed to download compliance export file: Sorry, we could not find the page., There doesn't appear to be an api call for the url='/api/v4/jobs/non-existent-job-id/download'. Typo? are you missing a team_id or user_id as part of the url?")
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
|
||||
s.RunForSystemAdminAndLocal("existing, non empty compliance export", func(c client.Client) {
|
||||
printer.Clean()
|
||||
|
||||
importFilePath := filepath.Join(server.GetPackagePath(), "test.zip")
|
||||
f, err := os.Create(importFilePath)
|
||||
s.Require().Nil(err)
|
||||
_, err = f.WriteString("test data")
|
||||
s.Require().Nil(err)
|
||||
_ = f.Close()
|
||||
|
||||
defer func() {
|
||||
_ = os.Remove(importFilePath)
|
||||
}()
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Int("num-retries", 0, "")
|
||||
|
||||
err = complianceExportDownloadCmdF(c, cmd, []string{"jobId", importFilePath})
|
||||
s.Require().EqualError(err, "compliance export file already exists")
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
|
||||
s.RunForSystemAdminAndLocal("download with explicit path", func(c client.Client) {
|
||||
printer.Clean()
|
||||
|
||||
downloadPath := "explicit_path.zip"
|
||||
defer os.Remove(downloadPath)
|
||||
|
||||
serverDataDir, err := filepath.Abs(*s.th.App.Config().FileSettings.Directory)
|
||||
s.Require().Nil(err)
|
||||
|
||||
exportDir := "job_test_export"
|
||||
// Create a compliance export with two zip files
|
||||
exportFilePath := filepath.Join(serverDataDir, exportDir)
|
||||
err = os.Mkdir(exportFilePath, 0755)
|
||||
s.Require().Nil(err)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(exportFilePath)
|
||||
}()
|
||||
|
||||
// Create first zip file
|
||||
zipPath1 := exportFilePath + "/export1.zip"
|
||||
f1, err := os.Create(zipPath1)
|
||||
s.Require().Nil(err)
|
||||
_, err = f1.WriteString("test data 1")
|
||||
s.Require().Nil(err)
|
||||
_ = f1.Close()
|
||||
|
||||
// Create second zip file
|
||||
zipPath2 := exportFilePath + "/export2.zip"
|
||||
f2, err := os.Create(zipPath2)
|
||||
s.Require().Nil(err)
|
||||
_, err = f2.WriteString("test data 2")
|
||||
s.Require().Nil(err)
|
||||
_ = f2.Close()
|
||||
|
||||
defer func() {
|
||||
_ = os.RemoveAll(exportFilePath)
|
||||
}()
|
||||
|
||||
now := model.GetMillis()
|
||||
// Create a job
|
||||
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
|
||||
Id: st.NewTestID(),
|
||||
CreateAt: now - 1000,
|
||||
Status: model.JobStatusSuccess,
|
||||
Type: model.JobTypeMessageExport,
|
||||
StartAt: now - 1000,
|
||||
LastActivityAt: now - 1000,
|
||||
Data: model.StringMap{"export_dir": exportDir, "is_downloadable": "true"},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
defer func() {
|
||||
// Ensure job is deleted from the database
|
||||
var result string
|
||||
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
|
||||
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
|
||||
}()
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 0, "")
|
||||
|
||||
err = complianceExportDownloadCmdF(c, cmd, []string{job.Id, downloadPath})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Contains(printer.GetLines()[0], fmt.Sprintf("Compliance export file downloaded to %q", downloadPath))
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
|
||||
// Verify the file was downloaded
|
||||
_, err = os.Stat(downloadPath)
|
||||
s.Require().Nil(err)
|
||||
defer os.Remove(downloadPath)
|
||||
|
||||
// Verify the file is a zip with a directory with two zip files
|
||||
zipReader, err := zip.OpenReader(downloadPath)
|
||||
s.Require().NoError(err)
|
||||
defer zipReader.Close()
|
||||
|
||||
// Check that we have the expected files in the zip
|
||||
foundExport1 := false
|
||||
foundExport2 := false
|
||||
for _, file := range zipReader.File {
|
||||
if file.Name == "export1.zip" {
|
||||
foundExport1 = true
|
||||
// Verify contents of export1.zip
|
||||
rc, err := file.Open()
|
||||
s.Require().NoError(err)
|
||||
content, err := io.ReadAll(rc)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("test data 1", string(content))
|
||||
rc.Close()
|
||||
} else if file.Name == "export2.zip" {
|
||||
foundExport2 = true
|
||||
// Verify contents of export2.zip
|
||||
rc, err := file.Open()
|
||||
s.Require().NoError(err)
|
||||
content, err := io.ReadAll(rc)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("test data 2", string(content))
|
||||
rc.Close()
|
||||
} else {
|
||||
s.Failf("unexpected file found in downloaded zip", file.Name)
|
||||
}
|
||||
}
|
||||
s.Require().True(foundExport1, "export1.zip not found in downloaded file")
|
||||
s.Require().True(foundExport2, "export2.zip not found in downloaded file")
|
||||
})
|
||||
|
||||
s.RunForSystemAdminAndLocal("download with explicit path", func(c client.Client) {
|
||||
printer.Clean()
|
||||
|
||||
serverDataDir, err := filepath.Abs(*s.th.App.Config().FileSettings.Directory)
|
||||
s.Require().Nil(err)
|
||||
|
||||
exportDir := "job_test_export"
|
||||
expectedDownloadPath := exportDir + ".zip"
|
||||
// Create a compliance export with two zip files
|
||||
exportFilePath := filepath.Join(serverDataDir, exportDir)
|
||||
err = os.Mkdir(exportFilePath, 0755)
|
||||
s.Require().Nil(err)
|
||||
defer func() {
|
||||
_ = os.RemoveAll(exportFilePath)
|
||||
|
||||
// also remove the downloaded file
|
||||
defer os.Remove(expectedDownloadPath)
|
||||
}()
|
||||
|
||||
// Create first zip file
|
||||
zipPath1 := exportFilePath + "/export1.zip"
|
||||
f1, err := os.Create(zipPath1)
|
||||
s.Require().Nil(err)
|
||||
_, err = f1.WriteString("test data 1")
|
||||
s.Require().Nil(err)
|
||||
_ = f1.Close()
|
||||
|
||||
// Create second zip file
|
||||
zipPath2 := exportFilePath + "/export2.zip"
|
||||
f2, err := os.Create(zipPath2)
|
||||
s.Require().Nil(err)
|
||||
_, err = f2.WriteString("test data 2")
|
||||
s.Require().Nil(err)
|
||||
_ = f2.Close()
|
||||
|
||||
defer func() {
|
||||
_ = os.RemoveAll(exportFilePath)
|
||||
}()
|
||||
|
||||
now := model.GetMillis()
|
||||
// Create a job
|
||||
job, _, err := s.th.SystemAdminClient.CreateJob(context.Background(), &model.Job{
|
||||
Id: st.NewTestID(),
|
||||
CreateAt: now - 1000,
|
||||
Status: model.JobStatusSuccess,
|
||||
Type: model.JobTypeMessageExport,
|
||||
StartAt: now - 1000,
|
||||
LastActivityAt: now - 1000,
|
||||
Data: model.StringMap{"export_dir": exportDir, "is_downloadable": "true"},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
defer func() {
|
||||
// Ensure job is deleted from the database
|
||||
var result string
|
||||
result, err = s.th.App.Srv().Store().Job().Delete(job.Id)
|
||||
s.Require().NoError(err, "Failed to delete job (result: %v)", result)
|
||||
}()
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 0, "")
|
||||
|
||||
err = complianceExportDownloadCmdF(c, cmd, []string{job.Id})
|
||||
s.Require().NoError(err)
|
||||
s.Require().Contains(printer.GetLines()[0], fmt.Sprintf("Compliance export file downloaded to %q", expectedDownloadPath))
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
|
||||
// Verify the file was downloaded
|
||||
_, err = os.Stat(expectedDownloadPath)
|
||||
s.Require().Nil(err)
|
||||
|
||||
// Verify the file is a zip with a directory with two zip files
|
||||
zipReader, err := zip.OpenReader(expectedDownloadPath)
|
||||
s.Require().NoError(err)
|
||||
defer zipReader.Close()
|
||||
|
||||
// Check that we have the expected files in the zip
|
||||
foundExport1 := false
|
||||
foundExport2 := false
|
||||
for _, file := range zipReader.File {
|
||||
if file.Name == "export1.zip" {
|
||||
foundExport1 = true
|
||||
// Verify contents of export1.zip
|
||||
rc, err := file.Open()
|
||||
s.Require().NoError(err)
|
||||
content, err := io.ReadAll(rc)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("test data 1", string(content))
|
||||
rc.Close()
|
||||
} else if file.Name == "export2.zip" {
|
||||
foundExport2 = true
|
||||
// Verify contents of export2.zip
|
||||
rc, err := file.Open()
|
||||
s.Require().NoError(err)
|
||||
content, err := io.ReadAll(rc)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("test data 2", string(content))
|
||||
rc.Close()
|
||||
} else {
|
||||
s.Failf("unexpected file found in downloaded zip", file.Name)
|
||||
}
|
||||
}
|
||||
s.Require().True(foundExport1, "export1.zip not found in downloaded file")
|
||||
s.Require().True(foundExport2, "export2.zip not found in downloaded file")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -224,6 +227,77 @@ func (s *MmctlUnitTestSuite) TestComplianceExportCancelCmdF() {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *MmctlUnitTestSuite) TestComplianceExportDownloadCmdF() {
|
||||
mockJob := &model.Job{
|
||||
Id: model.NewId(),
|
||||
CreateAt: model.GetMillis(),
|
||||
Type: model.JobTypeMessageExport,
|
||||
}
|
||||
|
||||
s.Run("download job file successfully", func() {
|
||||
printer.Clean()
|
||||
defer func() {
|
||||
_ = os.Remove("suggested-filename.zip")
|
||||
}()
|
||||
|
||||
s.client.
|
||||
EXPECT().
|
||||
DownloadComplianceExport(gomock.Any(), mockJob.Id, gomock.Any()).
|
||||
Return("suggested-filename.zip", nil).
|
||||
Times(1)
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 5, "")
|
||||
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id})
|
||||
s.Require().Nil(err)
|
||||
s.Len(printer.GetLines(), 1)
|
||||
s.Len(printer.GetErrorLines(), 0)
|
||||
s.Equal(fmt.Sprintf("Compliance export file downloaded to %q", "suggested-filename.zip"), printer.GetLines()[0])
|
||||
})
|
||||
|
||||
s.Run("download job file with explicit path", func() {
|
||||
printer.Clean()
|
||||
defer func() {
|
||||
_ = os.Remove("custom-path.zip")
|
||||
}()
|
||||
|
||||
s.client.
|
||||
EXPECT().
|
||||
DownloadComplianceExport(context.TODO(), mockJob.Id, gomock.Any()).
|
||||
Return("", nil).
|
||||
Times(1)
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 5, "")
|
||||
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id, "custom-path.zip"})
|
||||
s.Require().Nil(err)
|
||||
s.Len(printer.GetLines(), 1)
|
||||
s.Len(printer.GetErrorLines(), 0)
|
||||
s.Equal(fmt.Sprintf("Compliance export file downloaded to %q", "custom-path.zip"), printer.GetLines()[0])
|
||||
})
|
||||
|
||||
s.Run("download job with error", func() {
|
||||
printer.Clean()
|
||||
mockError := &model.AppError{
|
||||
Message: "failed to download file",
|
||||
}
|
||||
|
||||
s.client.
|
||||
EXPECT().
|
||||
DownloadComplianceExport(context.TODO(), mockJob.Id, gomock.Any()).
|
||||
Return("", mockError).
|
||||
Times(6) // Initial attempt + 5 retries
|
||||
|
||||
cmd := makeCmd()
|
||||
cmd.Flags().Int("num-retries", 5, "")
|
||||
err := complianceExportDownloadCmdF(s.client, cmd, []string{mockJob.Id})
|
||||
s.Require().NotNil(err)
|
||||
s.EqualError(err, "failed to download compliance export after 5 retries: failed to download file")
|
||||
s.Len(printer.GetLines(), 0)
|
||||
s.Len(printer.GetErrorLines(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
func makeCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Int("page", 0, "")
|
||||
|
||||
@@ -225,15 +225,36 @@ func exportDownloadCmdF(c client.Client, command *cobra.Command, args []string)
|
||||
|
||||
retries, _ := command.Flags().GetInt("num-retries")
|
||||
|
||||
downloadFn := func(outFile *os.File) (string, error) {
|
||||
off, err := outFile.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to seek file: %w", err)
|
||||
}
|
||||
|
||||
_, _, err = c.DownloadExport(context.TODO(), name, outFile, off)
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err := downloadFile(path, downloadFn, retries, "export")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printer.Print(fmt.Sprintf("Export file downloaded to %q", path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFile handles the common logic for downloading files in export and compliance_export commands
|
||||
func downloadFile(path string, downloadFn func(*os.File) (string, error), retries int, fileType string) (string, error) {
|
||||
var outFile *os.File
|
||||
info, err := os.Stat(path)
|
||||
switch {
|
||||
case err != nil && !os.IsNotExist(err):
|
||||
// some error occurred and not because file doesn't exist
|
||||
return fmt.Errorf("failed to stat export file: %w", err)
|
||||
return "", fmt.Errorf("failed to stat %s file: %w", fileType, err)
|
||||
case err == nil && info.Size() > 0:
|
||||
// we exit to avoid overwriting an existing non-empty file
|
||||
return fmt.Errorf("export file already exists")
|
||||
return "", fmt.Errorf("%s file already exists", fileType)
|
||||
case err != nil:
|
||||
// file does not exist, we create it
|
||||
outFile, err = os.Create(path)
|
||||
@@ -243,30 +264,24 @@ func exportDownloadCmdF(c client.Client, command *cobra.Command, args []string)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/open export file: %w", err)
|
||||
return "", fmt.Errorf("failed to create/open %s file: %w", fileType, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
i := 0
|
||||
for i < retries+1 {
|
||||
off, err := outFile.Seek(0, io.SeekEnd)
|
||||
var suggestedFilename string
|
||||
for i := range retries + 1 { // need to include the first attempt
|
||||
suggestedFilename, err = downloadFn(outFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seek export file: %w", err)
|
||||
}
|
||||
|
||||
if _, _, err := c.DownloadExport(context.TODO(), name, outFile, off); err != nil {
|
||||
printer.PrintWarning(fmt.Sprintf("failed to download export file: %v. Retrying...", err))
|
||||
i++
|
||||
if i >= retries {
|
||||
return "", fmt.Errorf("failed to download %s after %d retries: %w", fileType, retries, err)
|
||||
}
|
||||
printer.PrintWarning(fmt.Sprintf("Download attempt %d/%d failed. Retrying...", i+1, retries+1))
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if retries != 0 && i == retries+1 {
|
||||
return fmt.Errorf("failed to download export after %d retries", retries)
|
||||
}
|
||||
|
||||
return nil
|
||||
return suggestedFilename, nil
|
||||
}
|
||||
|
||||
func exportJobListCmdF(c client.Client, command *cobra.Command, args []string) error {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8"
|
||||
@@ -193,7 +194,7 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
||||
cmd.Flags().Int("num-retries", 5, "")
|
||||
|
||||
err := exportDownloadCmdF(s.th.Client, cmd, []string{exportName})
|
||||
s.Require().EqualError(err, "failed to download export after 5 retries")
|
||||
s.Require().EqualError(err, "failed to download export after 5 retries: You do not have the appropriate permissions.")
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
@@ -227,7 +228,7 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
||||
defer os.Remove(downloadPath)
|
||||
|
||||
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
||||
s.Require().EqualError(err, "failed to download export after 5 retries")
|
||||
s.Require().EqualError(err, "failed to download export after 5 retries: Unable to find export file.")
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
@@ -252,7 +253,8 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
||||
|
||||
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
||||
s.Require().Nil(err)
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Len(printer.GetLines(), 1)
|
||||
s.Require().True(strings.HasPrefix(printer.GetLines()[0].(string), "Export file downloaded to "))
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
})
|
||||
|
||||
@@ -273,7 +275,8 @@ func (s *MmctlE2ETestSuite) TestExportDownloadCmdF() {
|
||||
|
||||
err = exportDownloadCmdF(c, cmd, []string{exportName, downloadPath})
|
||||
s.Require().Nil(err)
|
||||
s.Require().Empty(printer.GetLines())
|
||||
s.Require().Len(printer.GetLines(), 1)
|
||||
s.Require().True(strings.HasPrefix(printer.GetLines()[0].(string), "Export file downloaded to "))
|
||||
s.Require().Empty(printer.GetErrorLines())
|
||||
|
||||
expected, err := os.ReadFile(exportFilePath)
|
||||
|
||||
@@ -79,6 +79,9 @@ func (s *MmctlE2ETestSuite) SetupMessageExportTestHelper() *api4.TestHelper {
|
||||
s.th.App.Srv().SetLicense(model.NewTestLicense("message_export"))
|
||||
messageExportImpl := message_export.MessageExportJobInterfaceImpl{Server: s.th.App.Srv()}
|
||||
s.th.App.Srv().Jobs.RegisterJobType(model.JobTypeMessageExport, messageExportImpl.MakeWorker(), messageExportImpl.MakeScheduler())
|
||||
s.th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.MessageExportSettings.DownloadExportResults = true
|
||||
})
|
||||
|
||||
err := s.th.App.Srv().Jobs.StartWorkers()
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
@@ -38,6 +38,7 @@ SEE ALSO
|
||||
|
||||
* `mmctl <mmctl.rst>`_ - Remote client for the Open Source, self-hosted Slack-alternative
|
||||
* `mmctl compliance-export cancel <mmctl_compliance-export_cancel.rst>`_ - Cancel compliance export job
|
||||
* `mmctl compliance-export download <mmctl_compliance-export_download.rst>`_ - Download compliance export file
|
||||
* `mmctl compliance-export list <mmctl_compliance-export_list.rst>`_ - List compliance export jobs, sorted by creation date descending (newest first)
|
||||
* `mmctl compliance-export show <mmctl_compliance-export_show.rst>`_ - Show compliance export job
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
.. _mmctl_compliance-export_download:
|
||||
|
||||
mmctl compliance-export download
|
||||
--------------------------------
|
||||
|
||||
Download compliance export file
|
||||
|
||||
Synopsis
|
||||
~~~~~~~~
|
||||
|
||||
|
||||
Download compliance export file
|
||||
|
||||
::
|
||||
|
||||
mmctl compliance-export download [complianceExportJobID] [output filepath (optional)] [flags]
|
||||
|
||||
Examples
|
||||
~~~~~~~~
|
||||
|
||||
::
|
||||
|
||||
compliance_export download o98rj3ur83dp5dppfyk5yk6osy
|
||||
|
||||
Options
|
||||
~~~~~~~
|
||||
|
||||
::
|
||||
|
||||
-h, --help help for download
|
||||
--num-retries int Number of retries if the download fails (default 5)
|
||||
|
||||
Options inherited from parent commands
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
::
|
||||
|
||||
--config string path to the configuration file (default "$XDG_CONFIG_HOME/mmctl/config")
|
||||
--disable-pager disables paged output
|
||||
--insecure-sha1-intermediate allows to use insecure TLS protocols, such as SHA-1
|
||||
--insecure-tls-version allows to use TLS versions 1.0 and 1.1
|
||||
--json the output format will be in json format
|
||||
--local allows communicating with the server through a unix socket
|
||||
--quiet prevent mmctl to generate output for the commands
|
||||
--strict will only run commands if the mmctl version matches the server one
|
||||
--suppress-warnings disables printing warning messages
|
||||
|
||||
SEE ALSO
|
||||
~~~~~~~~
|
||||
|
||||
* `mmctl compliance-export <mmctl_compliance-export.rst>`_ - Management of compliance exports
|
||||
|
||||
@@ -523,6 +523,21 @@ func (mr *MockClientMockRecorder) DoAPIPost(arg0, arg1, arg2 interface{}) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoAPIPost", reflect.TypeOf((*MockClient)(nil).DoAPIPost), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// DownloadComplianceExport mocks base method.
|
||||
func (m *MockClient) DownloadComplianceExport(arg0 context.Context, arg1 string, arg2 io.Writer) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DownloadComplianceExport", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DownloadComplianceExport indicates an expected call of DownloadComplianceExport.
|
||||
func (mr *MockClientMockRecorder) DownloadComplianceExport(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadComplianceExport", reflect.TypeOf((*MockClient)(nil).DownloadComplianceExport), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// DownloadExport mocks base method.
|
||||
func (m *MockClient) DownloadExport(arg0 context.Context, arg1 string, arg2 io.Writer, arg3 int64) (int64, *model.Response, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -1512,6 +1512,7 @@ func (c *Client4) GetUsersByIdsWithOptions(ctx context.Context, userIds []string
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var list []*User
|
||||
if err := json.NewDecoder(r.Body).Decode(&list); err != nil {
|
||||
return nil, nil, NewAppError("GetUsersByIdsWithOptions", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
@@ -3207,7 +3208,7 @@ func (c *Client4) PatchChannel(ctx context.Context, channelId string, patch *Cha
|
||||
var ch *Channel
|
||||
err = json.NewDecoder(r.Body).Decode(&ch)
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), NewAppError("PatchChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return nil, BuildResponse(r), NewAppError("PatchChannel", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return ch, BuildResponse(r), nil
|
||||
}
|
||||
@@ -8927,6 +8928,31 @@ func (c *Client4) GetUserThreads(ctx context.Context, userId, teamId string, opt
|
||||
return &threads, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) DownloadComplianceExport(ctx context.Context, jobId string, wr io.Writer) (string, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.jobsRoute()+fmt.Sprintf("/%s/download", jobId), "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
// Try to get the filename from the Content-Disposition header
|
||||
var filename string
|
||||
if cd := r.Header.Get("Content-Disposition"); cd != "" {
|
||||
var params map[string]string
|
||||
if _, params, err = mime.ParseMediaType(cd); err == nil {
|
||||
if params["filename"] != "" {
|
||||
filename = params["filename"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = io.Copy(wr, r.Body)
|
||||
if err != nil {
|
||||
return filename, NewAppError("DownloadComplianceExport", "model.client.copy.app_error", nil, "", r.StatusCode).Wrap(err)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetUserThread(ctx context.Context, userId, teamId, threadId string, extended bool) (*ThreadResponse, *Response, error) {
|
||||
url := c.userThreadRoute(userId, teamId, threadId)
|
||||
if extended {
|
||||
|
||||
Reference in New Issue
Block a user