diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 7e8180a..6f2ad66 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -1,6 +1,8 @@ package commands import ( + "strconv" + "github.com/jfrog/jfrog-cli-application/apptrust/model" pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" @@ -18,6 +20,8 @@ const ( VersionUpdateSources = "version-update-sources" VersionDistribute = "version-distribute" VersionRemoteDelete = "version-delete-remote" + VersionExport = "version-export" + VersionImport = "version-import" PackageBind = "package-bind" PackageUnbind = "package-unbind" AppCreate = "app-create" @@ -77,6 +81,14 @@ const ( MappingPatternFlag = "mapping-pattern" MappingTargetFlag = "mapping-target" QuietFlag = "quiet" + MinSplitFlag = "min-split" + SplitCountFlag = "split-count" + UnpromotedFlag = "unpromoted" +) + +const ( + DefaultDownloadMinSplitKb = 5120 + DefaultDownloadSplitCount = 3 ) // Flag keys mapped to their corresponding components.Flag definition. @@ -131,6 +143,9 @@ var flagsMap = map[string]components.Flag{ MappingPatternFlag: components.NewStringFlag(MappingPatternFlag, "Specify along with "+MappingTargetFlag+" to distribute artifacts to a different path on the edge node. You can use wildcards to specify multiple artifacts.", func(f *components.StringFlag) { f.Mandatory = false }), MappingTargetFlag: components.NewStringFlag(MappingTargetFlag, "The target path for distributed artifacts on the edge node. If not specified, the artifacts will have the same path and name on the edge node, as on the source Artifactory server. For flexibility in specifying the distribution path, you can include placeholders in the form of {1}, {2} which are replaced by corresponding tokens in the pattern path that are enclosed in parenthesis.", func(f *components.StringFlag) { f.Mandatory = false }), QuietFlag: components.NewBoolFlag(QuietFlag, "Set to true to skip the confirmation message. When $CI is true, the default value is true.", components.WithBoolDefaultValueFalse()), + MinSplitFlag: components.NewStringFlag(MinSplitFlag, "Minimum file size in KB to split into ranges when downloading. Set to -1 for no splits.", func(f *components.StringFlag) { f.DefaultValue = strconv.Itoa(DefaultDownloadMinSplitKb) }), + SplitCountFlag: components.NewStringFlag(SplitCountFlag, "Number of parts to split a file when downloading. Set to 0 for no splits.", func(f *components.StringFlag) { f.DefaultValue = strconv.Itoa(DefaultDownloadSplitCount) }), + UnpromotedFlag: components.NewBoolFlag(UnpromotedFlag, "Import the version without placing its artifacts in any repository.", components.WithBoolDefaultValueFalse()), } var commandFlags = map[string][]string{ @@ -248,6 +263,23 @@ var commandFlags = map[string][]string{ CityFlag, CountryCodesFlag, }, + VersionExport: { + url, + user, + accessToken, + serverId, + MinSplitFlag, + SplitCountFlag, + }, + VersionImport: { + url, + user, + accessToken, + serverId, + MappingPatternFlag, + MappingTargetFlag, + UnpromotedFlag, + }, PackageBind: { url, diff --git a/apptrust/commands/version/export_app_version_cmd.go b/apptrust/commands/version/export_app_version_cmd.go new file mode 100644 index 0000000..bc5d8c9 --- /dev/null +++ b/apptrust/commands/version/export_app_version_cmd.go @@ -0,0 +1,239 @@ +package version + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/jfrog/jfrog-cli-application/apptrust/app" + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" + artUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + artServices "github.com/jfrog/jfrog-client-go/artifactory/services" + artServicesUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/httputils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const ( + exportStatusCompleted = "COMPLETED" + exportStatusInProgress = "IN_PROGRESS" + exportStatusNotTriggered = "NOT_TRIGGERED" + exportStatusFailed = "FAILED" + + exportPollInterval = 10 * time.Second + exportPollTimeout = 60 * time.Minute + + downloadMaxSplitCount = 15 +) + +type exportAppVersionCommand struct { + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + applicationKey string + version string + targetPath string + minSplitSize int64 + splitCount int +} + +func (eac *exportAppVersionCommand) Run() error { + ctx, err := service.NewContext(*eac.serverDetails) + if err != nil { + return err + } + + log.Info("Exporting application version archive...") + if err = eac.versionService.TriggerExport(ctx, eac.applicationKey, eac.version); err != nil { + return err + } + + status, err := eac.waitForExport(ctx) + if err != nil { + return err + } + + log.Debug("Downloading the exported application version archive...") + downloadParams := artServices.DownloadParams{ + CommonParams: &artServicesUtils.CommonParams{ + Pattern: strings.TrimPrefix(status.RelativeDownloadURL, "/"), + Target: eac.targetPath, + }, + MinSplitSize: eac.minSplitSize, + SplitCount: eac.splitCount, + } + downloaded, failed, err := downloadExportedArchive(eac.serverDetails, downloadParams) + if err != nil { + return err + } + if failed > 0 || downloaded < 1 { + return errorutils.CheckErrorf("failed to download exported application version archive") + } + + log.Info("Successfully downloaded application version archive") + return nil +} + +func (eac *exportAppVersionCommand) waitForExport(ctx service.Context) (*model.AppVersionExportStatus, error) { + var status *model.AppVersionExportStatus + pollingExecutor := &httputils.PollingExecutor{ + Timeout: exportPollTimeout, + PollingInterval: exportPollInterval, + MsgPrefix: fmt.Sprintf("Getting exported application version %s/%s status...", eac.applicationKey, eac.version), + PollingAction: func() (shouldStop bool, responseBody []byte, err error) { + status, err = eac.versionService.GetExportStatus(ctx, eac.applicationKey, eac.version) + if err != nil { + return true, nil, err + } + stop, err := shouldStopExportPolling(status) + return stop, nil, err + }, + } + _, err := pollingExecutor.Execute() + if err != nil { + return nil, err + } + return status, nil +} + +func shouldStopExportPolling(status *model.AppVersionExportStatus) (shouldStop bool, err error) { + switch status.Status { + case exportStatusInProgress, exportStatusNotTriggered: + return false, nil + case exportStatusFailed: + return true, errorutils.CheckErrorf("application version export failed: %s", status.Message) + case exportStatusCompleted: + return true, nil + default: + return true, errorutils.CheckErrorf("received unexpected export status: '%s'", status.Status) + } +} + +func downloadExportedArchive(serverDetails *coreConfig.ServerDetails, params artServices.DownloadParams) (int, int, error) { + serviceManager, err := artUtils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + return 0, 0, err + } + return serviceManager.DownloadFiles(params) +} + +func (eac *exportAppVersionCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return eac.serverDetails, nil +} + +func (eac *exportAppVersionCommand) CommandName() string { + return commands.VersionExport +} + +func (eac *exportAppVersionCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) < 2 || len(ctx.Arguments) > 3 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + eac.applicationKey = ctx.Arguments[0] + eac.version = ctx.Arguments[1] + if len(ctx.Arguments) == 3 { + eac.targetPath = ctx.Arguments[2] + } + + var err error + eac.minSplitSize, eac.splitCount, err = parseExportDownloadFlags(ctx) + if err != nil { + return err + } + + eac.serverDetails, err = utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + + return commonCLiCommands.Exec(eac) +} + +func parseExportDownloadFlags(ctx *components.Context) (minSplitSize int64, splitCount int, err error) { + minSplitSize = commands.DefaultDownloadMinSplitKb + if ctx.GetStringFlagValue(commands.MinSplitFlag) != "" { + minSplitSize, err = strconv.ParseInt(ctx.GetStringFlagValue(commands.MinSplitFlag), 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("the '--%s' option should have a numeric value", commands.MinSplitFlag) + } + } + + splitCount = commands.DefaultDownloadSplitCount + if ctx.GetStringFlagValue(commands.SplitCountFlag) != "" { + splitCount, err = strconv.Atoi(ctx.GetStringFlagValue(commands.SplitCountFlag)) + if err != nil { + return 0, 0, fmt.Errorf("the '--%s' option should have a numeric value", commands.SplitCountFlag) + } + if splitCount > downloadMaxSplitCount { + return 0, 0, fmt.Errorf("the '--%s' option value is limited to a maximum of %d", commands.SplitCountFlag, downloadMaxSplitCount) + } + if splitCount < 0 { + return 0, 0, fmt.Errorf("the '--%s' option cannot have a negative value", commands.SplitCountFlag) + } + } + return minSplitSize, splitCount, nil +} + +func GetExportAppVersionCommand(appContext app.Context) components.Command { + cmd := &exportAppVersionCommand{ + versionService: appContext.GetVersionService(), + } + return components.Command{ + Name: commands.VersionExport, + Description: "Triggers the export process and downloads the application version archive", + AIDescription: `Export an application version as a ZIP archive for air-gap transfer. + +When to use: +- Copy an application version between disconnected AppTrust instances. + +Prerequisites: +- The application version must exist. +- Configured server and permission to export the version. +- Export is not available on Edge nodes. + +Common patterns: + $ jf apptrust version-export my-app 1.0.0 + $ jf apptrust version-export my-app 1.0.0 ./exports/ + $ jf apptrust version-export my-app 1.0.0 ./my-app-1.0.0.zip + $ jf at vexp my-app 1.0.0 ./exports/ --server-id=my-server + +Gotchas: +- The export is asynchronous: the CLI polls until the archive is ready, then downloads it from Artifactory. +- Trailing slash on target = directory (original archive name is written inside); no slash = rename to that file. +- The server-side export is not deleted after download. + +Related: jf apptrust version-import, jf apptrust app-export`, + Category: common.CategoryVersion, + Aliases: []string{"vexp"}, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The key of the application whose version to export.", + Optional: false, + }, + { + Name: "version", + Description: "The version to export.", + Optional: false, + }, + { + Name: "target pattern", + Description: "Local filesystem target path. If it ends with a slash, it is assumed to be a directory and the export archive is written into it. If there is no terminal slash, the target path is assumed to be a file to which the export file should be renamed.", + Optional: true, + }, + }, + Flags: commands.GetCommandFlags(commands.VersionExport), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/apptrust/commands/version/export_app_version_cmd_test.go b/apptrust/commands/version/export_app_version_cmd_test.go new file mode 100644 index 0000000..3ee5573 --- /dev/null +++ b/apptrust/commands/version/export_app_version_cmd_test.go @@ -0,0 +1,220 @@ +package version + +import ( + "errors" + "testing" + + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestExportAppVersionCommand_WrongNumberOfArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + }{ + {name: "no arguments", arguments: []string{}}, + {name: "single argument", arguments: []string{"app-key"}}, + {name: "too many arguments", arguments: []string{"app-key", "1.0.0", "./out/", "extra"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + PrintCommandHelp: func(string) error { return nil }, + } + ctx.Arguments = tt.arguments + + cmd := &exportAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + }) + } +} + +func TestParseExportDownloadFlags(t *testing.T) { + tests := []struct { + name string + ctxSetup func(*components.Context) + wantMinSplit int64 + wantSplitCnt int + errorContains string + }{ + { + name: "defaults", + ctxSetup: func(*components.Context) {}, + wantMinSplit: 5120, + wantSplitCnt: 3, + }, + { + name: "custom values", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.MinSplitFlag, "1024") + ctx.AddStringFlag(commands.SplitCountFlag, "5") + }, + wantMinSplit: 1024, + wantSplitCnt: 5, + }, + { + name: "non-numeric min-split", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.MinSplitFlag, "abc") + }, + errorContains: "the '--min-split' option should have a numeric value", + }, + { + name: "non-numeric split-count", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SplitCountFlag, "abc") + }, + errorContains: "the '--split-count' option should have a numeric value", + }, + { + name: "split-count above max", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SplitCountFlag, "16") + }, + errorContains: "maximum of 15", + }, + { + name: "negative split-count", + ctxSetup: func(ctx *components.Context) { + ctx.AddStringFlag(commands.SplitCountFlag, "-1") + }, + errorContains: "cannot have a negative value", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + tt.ctxSetup(ctx) + minSplit, splitCount, err := parseExportDownloadFlags(ctx) + if tt.errorContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorContains) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantMinSplit, minSplit) + assert.Equal(t, tt.wantSplitCnt, splitCount) + }) + } +} + +func TestShouldStopExportPolling(t *testing.T) { + tests := []struct { + name string + status *model.AppVersionExportStatus + wantStop bool + wantErr string + }{ + { + name: "in progress keeps polling", + status: &model.AppVersionExportStatus{Status: "IN_PROGRESS"}, + wantStop: false, + }, + { + name: "not triggered keeps polling", + status: &model.AppVersionExportStatus{Status: "NOT_TRIGGERED"}, + wantStop: false, + }, + { + name: "completed stops", + status: &model.AppVersionExportStatus{ + Status: "COMPLETED", + RelativeDownloadURL: "/repo/archive.zip", + }, + wantStop: true, + }, + { + name: "failed stops", + status: &model.AppVersionExportStatus{ + Status: "FAILED", + Message: "zip creation failed", + }, + wantStop: true, + wantErr: "zip creation failed", + }, + { + name: "unexpected status stops with error", + status: &model.AppVersionExportStatus{Status: "UNKNOWN"}, + wantStop: true, + wantErr: "unexpected export status", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotStop, err := shouldStopExportPolling(tt.status) + assert.Equal(t, tt.wantStop, gotStop) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + assert.NoError(t, err) + }) + } +} + +func TestExportAppVersionCommand_Run_FailedStatus(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + version := "1.0.0" + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().TriggerExport(gomock.Any(), applicationKey, version).Return(nil).Times(1) + mockVersionService.EXPECT().GetExportStatus(gomock.Any(), applicationKey, version).Return(&model.AppVersionExportStatus{ + Status: "FAILED", + Message: "zip creation failed", + }, nil).Times(1) + + cmd := &exportAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Contains(t, err.Error(), "zip creation failed") +} + +func TestExportAppVersionCommand_Run_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + version := "1.0.0" + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().TriggerExport(gomock.Any(), applicationKey, version). + Return(errors.New("export error")).Times(1) + + cmd := &exportAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + version: version, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Equal(t, "export error", err.Error()) +} diff --git a/apptrust/commands/version/import_app_version_cmd.go b/apptrust/commands/version/import_app_version_cmd.go new file mode 100644 index 0000000..ffa16bd --- /dev/null +++ b/apptrust/commands/version/import_app_version_cmd.go @@ -0,0 +1,170 @@ +package version + +import ( + "encoding/json" + "fmt" + + "github.com/jfrog/jfrog-cli-application/apptrust/app" + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/commands/utils" + "github.com/jfrog/jfrog-cli-application/apptrust/common" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + "github.com/jfrog/jfrog-cli-application/apptrust/service" + "github.com/jfrog/jfrog-cli-application/apptrust/service/versions" + commonCLiCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type importAppVersionCommand struct { + versionService versions.VersionService + serverDetails *coreConfig.ServerDetails + applicationKey string + archivePath string + options *model.ImportAppVersionOptions +} + +func (iac *importAppVersionCommand) Run() error { + ctx, err := service.NewContext(*iac.serverDetails) + if err != nil { + return err + } + + responseBody, err := iac.versionService.ImportAppVersion(ctx, iac.applicationKey, iac.archivePath, iac.options) + if err != nil { + return err + } + + result, err := parseImportAppVersionResponse(responseBody) + if err != nil { + return err + } + + log.Info(fmt.Sprintf("Import of application version '%s/%s' was successful.", result.Name, result.Version)) + return nil +} + +type importAppVersionResponse struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func parseImportAppVersionResponse(responseBody []byte) (*importAppVersionResponse, error) { + var result importAppVersionResponse + if err := json.Unmarshal(responseBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse import response: %w", err) + } + return &result, nil +} + +func (iac *importAppVersionCommand) ServerDetails() (*coreConfig.ServerDetails, error) { + return iac.serverDetails, nil +} + +func (iac *importAppVersionCommand) CommandName() string { + return commands.VersionImport +} + +func (iac *importAppVersionCommand) prepareAndRunCommand(ctx *components.Context) error { + if len(ctx.Arguments) != 2 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + + iac.applicationKey = ctx.Arguments[0] + iac.archivePath = ctx.Arguments[1] + + exists, err := fileutils.IsFileExists(iac.archivePath, false) + if err != nil { + return err + } + if !exists { + return errorutils.CheckErrorf("file not found: %s", iac.archivePath) + } + + iac.options, err = iac.buildRequestPayload(ctx) + if err != nil { + return err + } + + iac.serverDetails, err = utils.ServerDetailsByFlags(ctx) + if err != nil { + return err + } + + return commonCLiCommands.Exec(iac) +} + +func (iac *importAppVersionCommand) buildRequestPayload(ctx *components.Context) (*model.ImportAppVersionOptions, error) { + unpromoted := ctx.GetBoolFlagValue(commands.UnpromotedFlag) + mappingSet := ctx.IsFlagSet(commands.MappingPatternFlag) || ctx.IsFlagSet(commands.MappingTargetFlag) + if unpromoted && mappingSet { + return nil, errorutils.CheckErrorf("the --%s option can't be used with --%s or --%s", + commands.UnpromotedFlag, commands.MappingPatternFlag, commands.MappingTargetFlag) + } + if unpromoted { + return &model.ImportAppVersionOptions{Mode: model.ImportModeUnpromoted}, nil + } + + modifications, err := ParseDistributionModifications(ctx) + if err != nil { + return nil, err + } + + options := &model.ImportAppVersionOptions{Mode: model.ImportModePathMapping} + if modifications != nil { + options.PathMappings = modifications.PathMappings + } + return options, nil +} + +func GetImportAppVersionCommand(appContext app.Context) components.Command { + cmd := &importAppVersionCommand{ + versionService: appContext.GetVersionService(), + } + return components.Command{ + Name: commands.VersionImport, + Description: "Import an application version archive into AppTrust.", + AIDescription: `Import an application version ZIP archive into an existing application for air-gap transfer. + +When to use: +- Restore or copy an application version produced by jf apptrust version-export. + +Prerequisites: +- The target application must already exist. +- A ZIP archive produced by version-export. +- Configured server and permission to import the version. + +Common patterns: + $ jf apptrust version-import my-app ./my-app-1.0.0.zip + $ jf apptrust version-import my-app ./archive.zip --mapping-pattern="repo/(*)" --mapping-target="target/{1}" + $ jf apptrust version-import my-app ./archive.zip --unpromoted + $ jf at vimp my-app ./archive.zip --server-id=my-server + +Gotchas: +- By default the artifacts are copied into Artifactory repositories on import (optionally remapped with --mapping-pattern and --mapping-target). With --unpromoted they are not placed in any repository until the version is promoted to a stage. Neither mode promotes the version. +- --mapping-pattern and --mapping-target must be provided together and cannot be combined with --unpromoted. +- A successful result means the import was accepted (HTTP 202). Artifact copy or path mapping may still run asynchronously. + +Related: jf apptrust version-export, jf apptrust app-import`, + Category: common.CategoryVersion, + Aliases: []string{"vimp"}, + Arguments: []components.Argument{ + { + Name: "application-key", + Description: "The key of the application to import the version into.", + Optional: false, + }, + { + Name: "path to archive", + Description: "Local path to the application version ZIP archive.", + Optional: false, + }, + }, + Flags: commands.GetCommandFlags(commands.VersionImport), + Action: cmd.prepareAndRunCommand, + } +} diff --git a/apptrust/commands/version/import_app_version_cmd_test.go b/apptrust/commands/version/import_app_version_cmd_test.go new file mode 100644 index 0000000..7db62fb --- /dev/null +++ b/apptrust/commands/version/import_app_version_cmd_test.go @@ -0,0 +1,248 @@ +package version + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/jfrog/jfrog-cli-application/apptrust/commands" + "github.com/jfrog/jfrog-cli-application/apptrust/model" + mockversions "github.com/jfrog/jfrog-cli-application/apptrust/service/versions/mocks" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestImportAppVersionCommand_WrongNumberOfArguments(t *testing.T) { + tests := []struct { + name string + arguments []string + }{ + {name: "no arguments", arguments: []string{}}, + {name: "single argument", arguments: []string{"app-key"}}, + {name: "too many arguments", arguments: []string{"app-key", "./archive.zip", "extra"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{ + PrintCommandHelp: func(string) error { return nil }, + } + ctx.Arguments = tt.arguments + + cmd := &importAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + }) + } +} + +func TestImportAppVersionCommand_MissingFile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + ctx.Arguments = []string{"app-key", filepath.Join(t.TempDir(), "does-not-exist.zip")} + ctx.AddStringFlag("url", "https://example.com") + + cmd := &importAppVersionCommand{ + versionService: mockversions.NewMockVersionService(ctrl), + } + + err := cmd.prepareAndRunCommand(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "file not found") +} + +func TestImportAppVersionCommand_FlagsSuite(t *testing.T) { + archivePath := filepath.Join(t.TempDir(), "archive.zip") + require.NoError(t, os.WriteFile(archivePath, []byte("not-inspected"), 0o600)) + + tests := []struct { + name string + ctxSetup func(*components.Context) + expectsError bool + errorContains string + expectsOptions *model.ImportAppVersionOptions + }{ + { + name: "defaults to path_mapping", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", archivePath} + }, + expectsOptions: &model.ImportAppVersionOptions{Mode: model.ImportModePathMapping}, + }, + { + name: "mapping pattern and target", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", archivePath} + ctx.AddStringFlag(commands.MappingPatternFlag, "my-repo/(*)") + ctx.AddStringFlag(commands.MappingTargetFlag, "edge/{1}") + }, + expectsOptions: &model.ImportAppVersionOptions{ + Mode: model.ImportModePathMapping, + PathMappings: []model.DistributionPathMapping{ + {Input: "^my-repo/(.*)$", Output: "edge/$1"}, + }, + }, + }, + { + name: "unpromoted", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", archivePath} + ctx.AddBoolFlag(commands.UnpromotedFlag, true) + }, + expectsOptions: &model.ImportAppVersionOptions{Mode: model.ImportModeUnpromoted}, + }, + { + name: "unpromoted with mapping flags", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", archivePath} + ctx.AddBoolFlag(commands.UnpromotedFlag, true) + ctx.AddStringFlag(commands.MappingPatternFlag, "my-repo/(*)") + ctx.AddStringFlag(commands.MappingTargetFlag, "edge/{1}") + }, + expectsError: true, + errorContains: "can't be used with", + }, + { + name: "only mapping-pattern", + ctxSetup: func(ctx *components.Context) { + ctx.Arguments = []string{"app-key", archivePath} + ctx.AddStringFlag(commands.MappingPatternFlag, "my-repo/(*)") + }, + expectsError: true, + errorContains: "must be provided together", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ctx := &components.Context{} + tt.ctxSetup(ctx) + ctx.AddStringFlag("url", "https://example.com") + + var actualOptions *model.ImportAppVersionOptions + mockVersionService := mockversions.NewMockVersionService(ctrl) + if !tt.expectsError { + mockVersionService.EXPECT().ImportAppVersion(gomock.Any(), "app-key", archivePath, gomock.Any()). + DoAndReturn(func(_ interface{}, _ string, _ string, options *model.ImportAppVersionOptions) ([]byte, error) { + actualOptions = options + return []byte(`{"name":"app-key","version":"1.0.0"}`), nil + }).Times(1) + } + + cmd := &importAppVersionCommand{ + versionService: mockVersionService, + } + + err := cmd.prepareAndRunCommand(ctx) + if tt.expectsError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectsOptions, actualOptions) + }) + } +} + +func TestParseImportAppVersionResponse(t *testing.T) { + tests := []struct { + name string + responseBody []byte + want importAppVersionResponse + errorContains string + }{ + { + name: "success", + responseBody: []byte(`{"name":"app-key","version":"1.0.0"}`), + want: importAppVersionResponse{Name: "app-key", Version: "1.0.0"}, + }, + { + name: "invalid json", + responseBody: []byte(`not-json`), + errorContains: "failed to parse import response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseImportAppVersionResponse(tt.responseBody) + if tt.errorContains != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorContains) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, *got) + }) + } +} + +func TestImportAppVersionCommand_Run(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + archivePath := "/tmp/archive.zip" + options := &model.ImportAppVersionOptions{Mode: model.ImportModePathMapping} + response := []byte(`{"name":"app-key","version":"1.0.0"}`) + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().ImportAppVersion(gomock.Any(), applicationKey, archivePath, options). + Return(response, nil).Times(1) + + cmd := &importAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + archivePath: archivePath, + options: options, + } + + err := cmd.Run() + assert.NoError(t, err) +} + +func TestImportAppVersionCommand_Run_Error(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + serverDetails := &config.ServerDetails{Url: "https://example.com"} + applicationKey := "app-key" + archivePath := "/tmp/archive.zip" + options := &model.ImportAppVersionOptions{Mode: model.ImportModePathMapping} + + mockVersionService := mockversions.NewMockVersionService(ctrl) + mockVersionService.EXPECT().ImportAppVersion(gomock.Any(), applicationKey, archivePath, options). + Return(nil, errors.New("import error")).Times(1) + + cmd := &importAppVersionCommand{ + versionService: mockVersionService, + serverDetails: serverDetails, + applicationKey: applicationKey, + archivePath: archivePath, + options: options, + } + + err := cmd.Run() + assert.Error(t, err) + assert.Equal(t, "import error", err.Error()) +} diff --git a/apptrust/http/http_client.go b/apptrust/http/http_client.go index 555f0f1..78a2513 100644 --- a/apptrust/http/http_client.go +++ b/apptrust/http/http_client.go @@ -5,7 +5,12 @@ package http import ( "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" + "net/textproto" + "os" + "path/filepath" "github.com/jfrog/jfrog-client-go/utils/log" @@ -21,9 +26,18 @@ import ( const apptrustApiPath = "apptrust/api" +type MultipartPart struct { + Name string + Filename string + ContentType string + Body []byte + Path string +} + type ApptrustHttpClient interface { GetHttpClient() *jfroghttpclient.JfrogHttpClient Post(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) + PostMultipart(path string, parts []MultipartPart, params map[string]string) (resp *http.Response, body []byte, err error) Get(path string, params map[string]string) (resp *http.Response, body []byte, err error) Patch(path string, requestBody interface{}, params map[string]string) (resp *http.Response, body []byte, err error) Delete(path string, params map[string]string) (resp *http.Response, body []byte, err error) @@ -101,6 +115,84 @@ func (c *apptrustHttpClient) Post(path string, requestBody interface{}, params m return c.client.SendPost(url, requestContent, c.getJsonHttpClientDetails()) } +func (c *apptrustHttpClient) PostMultipart(path string, parts []MultipartPart, params map[string]string) (resp *http.Response, body []byte, err error) { + url, err := utils.BuildUrl(c.serverDetails.Url, apptrustApiPath+path, params) + if err != nil { + return nil, nil, err + } + + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + contentType := mw.FormDataContentType() + writeErrCh := make(chan error, 1) + go func() { + writeErr := writeMultipartParts(mw, parts) + _ = pw.CloseWithError(writeErr) + writeErrCh <- writeErr + }() + + log.Debug("Sending multipart POST request to:", url) + resp, body, err = c.sendMultipartPost(url, pr, contentType) + _ = pr.Close() + if writeErr := <-writeErrCh; writeErr != nil { + return nil, nil, writeErr + } + return resp, body, err +} + +func writeMultipartParts(mw *multipart.Writer, parts []MultipartPart) error { + for _, part := range parts { + if err := writeMultipartPart(mw, part); err != nil { + return err + } + } + return mw.Close() +} + +func writeMultipartPart(mw *multipart.Writer, part MultipartPart) (err error) { + filename := part.Filename + if filename == "" && part.Path != "" { + filename = filepath.Base(part.Path) + } + + header := make(textproto.MIMEHeader) + disposition := fmt.Sprintf(`form-data; name=%q`, part.Name) + if filename != "" { + disposition += fmt.Sprintf(`; filename=%q`, filename) + } + header.Set("Content-Disposition", disposition) + if part.ContentType != "" { + header.Set("Content-Type", part.ContentType) + } + writer, err := mw.CreatePart(header) + if err != nil { + return err + } + if part.Path == "" { + _, err = writer.Write(part.Body) + return err + } + + // File part + file, err := os.Open(part.Path) + if err != nil { + return err + } + defer func() { + if cerr := file.Close(); cerr != nil && err == nil { + err = cerr + } + }() + _, err = io.Copy(writer, file) + return err +} + +func (c *apptrustHttpClient) sendMultipartPost(url string, body io.Reader, contentType string) (*http.Response, []byte, error) { + details := c.authDetails.CreateHttpClientDetails() + details.AddHeader("Content-Type", contentType) + return c.client.SendPostFromReader(url, body, &details) +} + func (c *apptrustHttpClient) Get(path string, params map[string]string) (resp *http.Response, body []byte, err error) { url, err := utils.BuildUrl(c.serverDetails.Url, apptrustApiPath+path, params) if err != nil { diff --git a/apptrust/http/http_client_test.go b/apptrust/http/http_client_test.go new file mode 100644 index 0000000..4a4b0bc --- /dev/null +++ b/apptrust/http/http_client_test.go @@ -0,0 +1,82 @@ +package http + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + commonCliConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostMultipart(t *testing.T) { + fileContent := []byte("PK zip-bytes") + fieldBody := []byte(`{"mode":"path_mapping"}`) + fileName := "payload.zip" + + var gotField, gotFile []byte + var gotFilename, gotFieldContentType, gotFileContentType string + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.Header.Get("Content-Type"), "multipart/form-data") + + reader, err := r.MultipartReader() + require.NoError(t, err) + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + + body, err := io.ReadAll(part) + require.NoError(t, err) + + switch part.FormName() { + case "metadata": + gotField = body + gotFieldContentType = part.Header.Get("Content-Type") + case "payload": + gotFile = body + gotFilename = part.FileName() + gotFileContentType = part.Header.Get("Content-Type") + default: + t.Errorf("unexpected multipart part %q", part.FormName()) + } + } + + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(server.Close) + + filePath := filepath.Join(t.TempDir(), fileName) + require.NoError(t, os.WriteFile(filePath, fileContent, 0o600)) + + client, err := NewAppHttpClient(&commonCliConfig.ServerDetails{Url: server.URL + "/"}) + require.NoError(t, err) + + parts := []MultipartPart{ + {Name: "metadata", ContentType: "application/json", Body: fieldBody}, + {Name: "payload", ContentType: "application/zip", Path: filePath}, + } + resp, body, err := client.PostMultipart("/v1/resources/upload", parts, nil) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + assert.Equal(t, `{"ok":true}`, string(body)) + assert.Equal(t, "/apptrust/api/v1/resources/upload", gotPath) + assert.Equal(t, fieldBody, gotField) + assert.Equal(t, "application/json", gotFieldContentType) + assert.Equal(t, fileContent, gotFile) + assert.Equal(t, fileName, gotFilename) + assert.Equal(t, "application/zip", gotFileContentType) +} diff --git a/apptrust/http/mocks/http_client_mock.go b/apptrust/http/mocks/http_client_mock.go index 107ae4a..c51dcdd 100644 --- a/apptrust/http/mocks/http_client_mock.go +++ b/apptrust/http/mocks/http_client_mock.go @@ -10,9 +10,10 @@ package mock_http import ( - http "net/http" + http0 "net/http" reflect "reflect" + http "github.com/jfrog/jfrog-cli-application/apptrust/http" jfroghttpclient "github.com/jfrog/jfrog-client-go/http/jfroghttpclient" gomock "go.uber.org/mock/gomock" ) @@ -42,10 +43,10 @@ func (m *MockApptrustHttpClient) EXPECT() *MockApptrustHttpClientMockRecorder { } // Delete mocks base method. -func (m *MockApptrustHttpClient) Delete(path string, params map[string]string) (*http.Response, []byte, error) { +func (m *MockApptrustHttpClient) Delete(path string, params map[string]string) (*http0.Response, []byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Delete", path, params) - ret0, _ := ret[0].(*http.Response) + ret0, _ := ret[0].(*http0.Response) ret1, _ := ret[1].([]byte) ret2, _ := ret[2].(error) return ret0, ret1, ret2 @@ -58,10 +59,10 @@ func (mr *MockApptrustHttpClientMockRecorder) Delete(path, params any) *gomock.C } // Get mocks base method. -func (m *MockApptrustHttpClient) Get(path string, params map[string]string) (*http.Response, []byte, error) { +func (m *MockApptrustHttpClient) Get(path string, params map[string]string) (*http0.Response, []byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Get", path, params) - ret0, _ := ret[0].(*http.Response) + ret0, _ := ret[0].(*http0.Response) ret1, _ := ret[1].([]byte) ret2, _ := ret[2].(error) return ret0, ret1, ret2 @@ -88,10 +89,10 @@ func (mr *MockApptrustHttpClientMockRecorder) GetHttpClient() *gomock.Call { } // Patch mocks base method. -func (m *MockApptrustHttpClient) Patch(path string, requestBody any, params map[string]string) (*http.Response, []byte, error) { +func (m *MockApptrustHttpClient) Patch(path string, requestBody any, params map[string]string) (*http0.Response, []byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Patch", path, requestBody, params) - ret0, _ := ret[0].(*http.Response) + ret0, _ := ret[0].(*http0.Response) ret1, _ := ret[1].([]byte) ret2, _ := ret[2].(error) return ret0, ret1, ret2 @@ -104,10 +105,10 @@ func (mr *MockApptrustHttpClientMockRecorder) Patch(path, requestBody, params an } // Post mocks base method. -func (m *MockApptrustHttpClient) Post(path string, requestBody any, params map[string]string) (*http.Response, []byte, error) { +func (m *MockApptrustHttpClient) Post(path string, requestBody any, params map[string]string) (*http0.Response, []byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Post", path, requestBody, params) - ret0, _ := ret[0].(*http.Response) + ret0, _ := ret[0].(*http0.Response) ret1, _ := ret[1].([]byte) ret2, _ := ret[2].(error) return ret0, ret1, ret2 @@ -118,3 +119,19 @@ func (mr *MockApptrustHttpClientMockRecorder) Post(path, requestBody, params any mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Post", reflect.TypeOf((*MockApptrustHttpClient)(nil).Post), path, requestBody, params) } + +// PostMultipart mocks base method. +func (m *MockApptrustHttpClient) PostMultipart(path string, parts []http.MultipartPart, params map[string]string) (*http0.Response, []byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostMultipart", path, parts, params) + ret0, _ := ret[0].(*http0.Response) + ret1, _ := ret[1].([]byte) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// PostMultipart indicates an expected call of PostMultipart. +func (mr *MockApptrustHttpClientMockRecorder) PostMultipart(path, parts, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostMultipart", reflect.TypeOf((*MockApptrustHttpClient)(nil).PostMultipart), path, parts, params) +} diff --git a/apptrust/model/export_app_version.go b/apptrust/model/export_app_version.go new file mode 100644 index 0000000..9257d18 --- /dev/null +++ b/apptrust/model/export_app_version.go @@ -0,0 +1,8 @@ +package model + +type AppVersionExportStatus struct { + Status string `json:"status"` + DownloadURL string `json:"download_url,omitempty"` + RelativeDownloadURL string `json:"relative_download_url,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/apptrust/model/import_app_version.go b/apptrust/model/import_app_version.go new file mode 100644 index 0000000..b6f2220 --- /dev/null +++ b/apptrust/model/import_app_version.go @@ -0,0 +1,11 @@ +package model + +const ( + ImportModePathMapping = "path_mapping" + ImportModeUnpromoted = "unpromoted" +) + +type ImportAppVersionOptions struct { + Mode string `json:"mode,omitempty"` + PathMappings []DistributionPathMapping `json:"path_mappings,omitempty"` +} diff --git a/apptrust/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index fb10857..cc94557 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -84,6 +84,36 @@ func (mr *MockVersionServiceMockRecorder) DistributeAppVersion(ctx, applicationK return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DistributeAppVersion", reflect.TypeOf((*MockVersionService)(nil).DistributeAppVersion), ctx, applicationKey, version, request) } +// GetExportStatus mocks base method. +func (m *MockVersionService) GetExportStatus(ctx service.Context, applicationKey, version string) (*model.AppVersionExportStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExportStatus", ctx, applicationKey, version) + ret0, _ := ret[0].(*model.AppVersionExportStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExportStatus indicates an expected call of GetExportStatus. +func (mr *MockVersionServiceMockRecorder) GetExportStatus(ctx, applicationKey, version any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExportStatus", reflect.TypeOf((*MockVersionService)(nil).GetExportStatus), ctx, applicationKey, version) +} + +// ImportAppVersion mocks base method. +func (m *MockVersionService) ImportAppVersion(ctx service.Context, applicationKey, archivePath string, options *model.ImportAppVersionOptions) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ImportAppVersion", ctx, applicationKey, archivePath, options) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ImportAppVersion indicates an expected call of ImportAppVersion. +func (mr *MockVersionServiceMockRecorder) ImportAppVersion(ctx, applicationKey, archivePath, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ImportAppVersion", reflect.TypeOf((*MockVersionService)(nil).ImportAppVersion), ctx, applicationKey, archivePath, options) +} + // PromoteAppVersion mocks base method. func (m *MockVersionService) PromoteAppVersion(ctx service.Context, applicationKey, version string, payload *model.PromoteAppVersionRequest, sync bool) ([]byte, error) { m.ctrl.T.Helper() @@ -143,6 +173,20 @@ func (mr *MockVersionServiceMockRecorder) RollbackAppVersion(ctx, applicationKey return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollbackAppVersion", reflect.TypeOf((*MockVersionService)(nil).RollbackAppVersion), ctx, applicationKey, version, request, sync) } +// TriggerExport mocks base method. +func (m *MockVersionService) TriggerExport(ctx service.Context, applicationKey, version string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TriggerExport", ctx, applicationKey, version) + ret0, _ := ret[0].(error) + return ret0 +} + +// TriggerExport indicates an expected call of TriggerExport. +func (mr *MockVersionServiceMockRecorder) TriggerExport(ctx, applicationKey, version any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TriggerExport", reflect.TypeOf((*MockVersionService)(nil).TriggerExport), ctx, applicationKey, version) +} + // UpdateAppVersion mocks base method. func (m *MockVersionService) UpdateAppVersion(ctx service.Context, applicationKey, version string, request *model.UpdateAppVersionRequest) ([]byte, error) { m.ctrl.T.Helper() diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index 06fd0e9..71ac99d 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -3,6 +3,7 @@ package versions //go:generate ${PROJECT_DIR}/scripts/mockgen.sh ${GOFILE} import ( + "encoding/json" "fmt" "net/http" "strconv" @@ -24,6 +25,9 @@ type VersionService interface { UpdateAppVersionSources(ctx service.Context, applicationKey string, version string, request *model.UpdateVersionSourcesRequest, sync bool, dryRun bool, failFast bool) ([]byte, error) DistributeAppVersion(ctx service.Context, applicationKey string, version string, request *model.DistributeAppVersionRequest) error RemoteDeleteAppVersion(ctx service.Context, applicationKey string, version string, request *model.RemoteDeleteAppVersionRequest) error + TriggerExport(ctx service.Context, applicationKey string, version string) error + GetExportStatus(ctx service.Context, applicationKey string, version string) (*model.AppVersionExportStatus, error) + ImportAppVersion(ctx service.Context, applicationKey string, archivePath string, options *model.ImportAppVersionOptions) ([]byte, error) } type versionService struct{} @@ -192,6 +196,65 @@ func (vs *versionService) RemoteDeleteAppVersion(ctx service.Context, applicatio return nil } +func (vs *versionService) TriggerExport(ctx service.Context, applicationKey, version string) error { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/export", applicationKey, version) + response, responseBody, err := ctx.GetHttpClient().Post(endpoint, map[string]any{}, nil) + if err != nil { + return err + } + + if response.StatusCode != http.StatusAccepted { + return fmt.Errorf("failed to trigger application version export. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + return nil +} + +func (vs *versionService) GetExportStatus(ctx service.Context, applicationKey, version string) (*model.AppVersionExportStatus, error) { + endpoint := fmt.Sprintf("/v1/applications/%s/versions/%s/export/status", applicationKey, version) + response, responseBody, err := ctx.GetHttpClient().Get(endpoint, nil) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get application version export status. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + var status model.AppVersionExportStatus + if err = json.Unmarshal(responseBody, &status); err != nil { + return nil, fmt.Errorf("failed to parse application version export status: %w", err) + } + + return &status, nil +} + +func (vs *versionService) ImportAppVersion(ctx service.Context, applicationKey, archivePath string, options *model.ImportAppVersionOptions) ([]byte, error) { + optionsJSON, err := json.Marshal(options) + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("/v1/applications/%s/versions/import", applicationKey) + parts := []apphttp.MultipartPart{ + {Name: "options", ContentType: "application/json", Body: optionsJSON}, + {Name: "file", ContentType: "application/zip", Path: archivePath}, + } + response, responseBody, err := ctx.GetHttpClient().PostMultipart(endpoint, parts, nil) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("failed to import application version. Status code: %d. \n%s", + response.StatusCode, responseBody) + } + + return responseBody, nil +} + func logSuccessMessage(sync bool, request *model.CreateAppVersionRequest, dryRun bool) { if !sync { log.Info(fmt.Sprintf("Application version creation initiated: %s:%s", request.ApplicationKey, request.Version)) diff --git a/apptrust/service/versions/version_service_test.go b/apptrust/service/versions/version_service_test.go index e8faab5..e6522f8 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -1,11 +1,13 @@ package versions import ( + "encoding/json" "errors" "net/http" "strconv" "testing" + apphttp "github.com/jfrog/jfrog-cli-application/apptrust/http" mockhttp "github.com/jfrog/jfrog-cli-application/apptrust/http/mocks" mockservice "github.com/jfrog/jfrog-cli-application/apptrust/service/mocks" "go.uber.org/mock/gomock" @@ -849,3 +851,221 @@ func TestRemoteDeleteAppVersion(t *testing.T) { }) } } + +func TestTriggerExport(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + emptyBody := map[string]any{} + + tests := []struct { + name string + applicationKey string + version string + mockResponse *http.Response + mockResponseBody string + mockError error + expectedError string + }{ + { + name: "success", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusAccepted}, + mockResponseBody: `{"export_id":"test-app-application-versions/test-app/1.0.0"}`, + expectedError: "", + }, + { + name: "failure", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockResponseBody: "error", + expectedError: "failed to trigger application version export", + }, + { + name: "http client error", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: nil, + mockError: errors.New("http client error"), + expectedError: "http client error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedEndpoint := "/v1/applications/" + tt.applicationKey + "/versions/" + tt.version + "/export" + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Post(expectedEndpoint, emptyBody, nil). + Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + err := service.TriggerExport(mockCtx, tt.applicationKey, tt.version) + if tt.expectedError == "" { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} + +func TestGetExportStatus(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + + tests := []struct { + name string + applicationKey string + version string + mockResponse *http.Response + mockResponseBody string + mockError error + expectedError string + expectedStatus *model.AppVersionExportStatus + }{ + { + name: "success - status of completed export", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockResponseBody: `{"status":"COMPLETED","download_url":"https://example.com/artifactory/repo/test-app/1.0.0/test-app-1.0.0.zip","relative_download_url":"/repo/test-app/1.0.0/test-app-1.0.0.zip"}`, + expectedStatus: &model.AppVersionExportStatus{ + Status: "COMPLETED", + DownloadURL: "https://example.com/artifactory/repo/test-app/1.0.0/test-app-1.0.0.zip", + RelativeDownloadURL: "/repo/test-app/1.0.0/test-app-1.0.0.zip", + }, + }, + { + name: "success - status of failed export", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockResponseBody: `{"status":"FAILED","message":"export failed"}`, + expectedStatus: &model.AppVersionExportStatus{ + Status: "FAILED", + Message: "export failed", + }, + }, + { + name: "failure", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusNotFound}, + mockResponseBody: "not found", + expectedError: "failed to get application version export status", + }, + { + name: "http client error", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: nil, + mockError: errors.New("http client error"), + expectedError: "http client error", + }, + { + name: "invalid json", + applicationKey: "test-app", + version: "1.0.0", + mockResponse: &http.Response{StatusCode: http.StatusOK}, + mockResponseBody: `{not-json`, + expectedError: "failed to parse application version export status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedEndpoint := "/v1/applications/" + tt.applicationKey + "/versions/" + tt.version + "/export/status" + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().Get(expectedEndpoint, nil). + Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + status, err := service.GetExportStatus(mockCtx, tt.applicationKey, tt.version) + if tt.expectedError == "" { + assert.NoError(t, err) + assert.Equal(t, tt.expectedStatus, status) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} + +func TestImportAppVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := NewVersionService() + options := &model.ImportAppVersionOptions{Mode: "path_mapping"} + optionsJSON, err := json.Marshal(options) + assert.NoError(t, err) + archivePath := "/tmp/archive.zip" + expectedParts := []apphttp.MultipartPart{ + {Name: "options", ContentType: "application/json", Body: optionsJSON}, + {Name: "file", ContentType: "application/zip", Path: archivePath}, + } + + tests := []struct { + name string + applicationKey string + mockResponse *http.Response + mockResponseBody string + mockError error + expectedError string + expectedBody string + }{ + { + name: "success", + applicationKey: "test-app", + mockResponse: &http.Response{StatusCode: http.StatusAccepted}, + mockResponseBody: `{"name":"test-app","version":"1.0.0"}`, + expectedBody: `{"name":"test-app","version":"1.0.0"}`, + }, + { + name: "failure", + applicationKey: "test-app", + mockResponse: &http.Response{StatusCode: http.StatusBadRequest}, + mockResponseBody: "application key does not match archive", + expectedError: "failed to import application version", + }, + { + name: "http client error", + applicationKey: "test-app", + mockResponse: nil, + mockError: errors.New("http client error"), + expectedError: "http client error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expectedEndpoint := "/v1/applications/" + tt.applicationKey + "/versions/import" + mockHttpClient := mockhttp.NewMockApptrustHttpClient(ctrl) + mockHttpClient.EXPECT().PostMultipart(expectedEndpoint, expectedParts, nil). + Return(tt.mockResponse, []byte(tt.mockResponseBody), tt.mockError).Times(1) + + mockCtx := mockservice.NewMockContext(ctrl) + mockCtx.EXPECT().GetHttpClient().Return(mockHttpClient).Times(1) + + body, err := service.ImportAppVersion(mockCtx, tt.applicationKey, archivePath, options) + if tt.expectedError == "" { + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + } else { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } + }) + } +} diff --git a/cli/cli.go b/cli/cli.go index 1627198..7507818 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -50,6 +50,8 @@ Related: jf rt, jf release-bundle commands.`, version.GetRemoteDeleteAppVersionCommand(appContext), version.GetUpdateAppVersionCommand(appContext), version.GetUpdateAppVersionSourcesCommand(appContext), + version.GetExportAppVersionCommand(appContext), + version.GetImportAppVersionCommand(appContext), packagecmds.GetBindPackageCommand(appContext), packagecmds.GetUnbindPackageCommand(appContext), application.GetCreateAppCommand(appContext), diff --git a/go.mod b/go.mod index b96101d..0cddf2f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.5 require ( github.com/jfrog/build-info-go v1.13.1-0.20260429070557-93b98034d295 github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260601130310-8d52a530da18 - github.com/jfrog/jfrog-client-go v1.55.1-0.20251223101502-1a13a993b0c7 + github.com/jfrog/jfrog-client-go v1.55.1-0.20260915142606-7325e7217b20 github.com/stretchr/testify v1.11.1 github.com/urfave/cli v1.22.17 go.uber.org/mock v0.6.0 @@ -42,7 +42,7 @@ require ( github.com/gookit/color v1.6.1 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jedib0t/go-pretty/v6 v6.8.0 // indirect - github.com/jfrog/archiver/v3 v3.6.3 // indirect + github.com/jfrog/archiver/v3 v3.6.4 // indirect github.com/jfrog/gofrog v1.7.6 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/compress v1.18.6 // indirect diff --git a/go.sum b/go.sum index 00a7025..82d3e77 100644 --- a/go.sum +++ b/go.sum @@ -93,16 +93,16 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.8.0 h1:fQOTjATVQl5RhssBro6ZuHANFybCkmJ7FjYPo4b7sEY= github.com/jedib0t/go-pretty/v6 v6.8.0/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= -github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI= -github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= +github.com/jfrog/archiver/v3 v3.6.4 h1:qHAWCLKwo3+ocHNNoWzGZ8ESl8QQk/lR3W09Pt+ROvE= +github.com/jfrog/archiver/v3 v3.6.4/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg= github.com/jfrog/build-info-go v1.13.1-0.20260429070557-93b98034d295 h1:EH0h86KwGvNHWyEBQoHoU9WfMMKy1GJ6jJQNmfy6E0U= github.com/jfrog/build-info-go v1.13.1-0.20260429070557-93b98034d295/go.mod h1:+OCtMb22/D+u7Wne5lzkjJjaWr0LRZcHlDwTH86Mpwo= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260601130310-8d52a530da18 h1:tPv7XscDFAZaijVwMQNb+HmuucUMYQdjuA5frdGzhF0= github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260601130310-8d52a530da18/go.mod h1:9R90mhbczGXwW5EGlDs7F08ejQU/xdoDhYHMvzBiqgE= -github.com/jfrog/jfrog-client-go v1.55.1-0.20251223101502-1a13a993b0c7 h1:5JUiqmBV9ikFOZEH+ZgvJLHshT1aAuw08bfdJOLHbzQ= -github.com/jfrog/jfrog-client-go v1.55.1-0.20251223101502-1a13a993b0c7/go.mod h1:USb7bfWSE7bGKsJ4nR0lxGILvmtnCcR5OO4biSUItMs= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260915142606-7325e7217b20 h1:1n8o5KSoRq2vTSMSvw6YQnqqGDADQdlHPQ3MJBZGNWM= +github.com/jfrog/jfrog-client-go v1.55.1-0.20260915142606-7325e7217b20/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=