From f9d63c418ec23c69b7da6f578f5c35c6c027df85 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Tue, 8 Sep 2026 18:30:49 +0300 Subject: [PATCH 01/12] Export and import application versions --- apptrust/model/export_app_version.go | 8 + .../versions/mocks/version_service_mock.go | 29 ++++ apptrust/service/versions/version_service.go | 38 +++++ .../service/versions/version_service_test.go | 150 ++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 apptrust/model/export_app_version.go 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/service/versions/mocks/version_service_mock.go b/apptrust/service/versions/mocks/version_service_mock.go index fb10857..43c7d81 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -84,6 +84,21 @@ 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) +} + // 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 +158,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..bc62437 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,8 @@ 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) } type versionService struct{} @@ -192,6 +195,41 @@ 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 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..e845b9d 100644 --- a/apptrust/service/versions/version_service_test.go +++ b/apptrust/service/versions/version_service_test.go @@ -849,3 +849,153 @@ 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) + } + }) + } +} From 3f472610ac0b469c5cf8d0b120f63a2c3b17cac2 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Wed, 9 Sep 2026 20:31:16 +0300 Subject: [PATCH 02/12] Export and import application versions --- apptrust/http/http_client.go | 92 +++++++++++++++++++++++++ apptrust/http/http_client_test.go | 82 ++++++++++++++++++++++ apptrust/http/mocks/http_client_mock.go | 35 +++++++--- 3 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 apptrust/http/http_client_test.go diff --git a/apptrust/http/http_client.go b/apptrust/http/http_client.go index 555f0f1..764b07f 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) + filenamePart := "" + if filename != "" { + filenamePart = fmt.Sprintf(`; filename="%s"`, filename) + } + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q%s`, part.Name, filenamePart)) + 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) +} From 48f213aef9f9ee0a143ca131a336642fee22ff71 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Wed, 9 Sep 2026 20:46:49 +0300 Subject: [PATCH 03/12] Export and import application versions --- apptrust/model/import_app_version.go | 6 ++ .../versions/mocks/version_service_mock.go | 15 ++++ apptrust/service/versions/version_service.go | 25 +++++++ .../service/versions/version_service_test.go | 70 +++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 apptrust/model/import_app_version.go diff --git a/apptrust/model/import_app_version.go b/apptrust/model/import_app_version.go new file mode 100644 index 0000000..ad008b5 --- /dev/null +++ b/apptrust/model/import_app_version.go @@ -0,0 +1,6 @@ +package model + +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 43c7d81..cc94557 100644 --- a/apptrust/service/versions/mocks/version_service_mock.go +++ b/apptrust/service/versions/mocks/version_service_mock.go @@ -99,6 +99,21 @@ func (mr *MockVersionServiceMockRecorder) GetExportStatus(ctx, applicationKey, v 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() diff --git a/apptrust/service/versions/version_service.go b/apptrust/service/versions/version_service.go index bc62437..71ac99d 100644 --- a/apptrust/service/versions/version_service.go +++ b/apptrust/service/versions/version_service.go @@ -27,6 +27,7 @@ type VersionService interface { 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{} @@ -230,6 +231,30 @@ func (vs *versionService) GetExportStatus(ctx service.Context, applicationKey, v 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 e845b9d..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" @@ -999,3 +1001,71 @@ func TestGetExportStatus(t *testing.T) { }) } } + +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) + } + }) + } +} From 7abcd382d1152470135d231f7530977e1d162615 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 10 Sep 2026 09:48:45 +0300 Subject: [PATCH 04/12] Export and import application versions --- apptrust/commands/flags.go | 21 +++++++++++++++++++++ cli/cli.go | 1 + 2 files changed, 22 insertions(+) diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 7e8180a..adda782 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,7 @@ const ( VersionUpdateSources = "version-update-sources" VersionDistribute = "version-distribute" VersionRemoteDelete = "version-delete-remote" + VersionExport = "version-export" PackageBind = "package-bind" PackageUnbind = "package-unbind" AppCreate = "app-create" @@ -77,6 +80,14 @@ const ( MappingPatternFlag = "mapping-pattern" MappingTargetFlag = "mapping-target" QuietFlag = "quiet" + MinSplitFlag = "min-split" + SplitCountFlag = "split-count" +) + +const ( + DefaultDownloadMinSplitKb = 5120 + DefaultDownloadSplitCount = 3 + DownloadMaxSplitCount = 15 ) // Flag keys mapped to their corresponding components.Flag definition. @@ -131,6 +142,8 @@ 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) }), } var commandFlags = map[string][]string{ @@ -248,6 +261,14 @@ var commandFlags = map[string][]string{ CityFlag, CountryCodesFlag, }, + VersionExport: { + url, + user, + accessToken, + serverId, + MinSplitFlag, + SplitCountFlag, + }, PackageBind: { url, diff --git a/cli/cli.go b/cli/cli.go index 1627198..b1b5ee2 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -50,6 +50,7 @@ Related: jf rt, jf release-bundle commands.`, version.GetRemoteDeleteAppVersionCommand(appContext), version.GetUpdateAppVersionCommand(appContext), version.GetUpdateAppVersionSourcesCommand(appContext), + version.GetExportAppVersionCommand(appContext), packagecmds.GetBindPackageCommand(appContext), packagecmds.GetUnbindPackageCommand(appContext), application.GetCreateAppCommand(appContext), From b3994e5ee944a338a42b2add8da45eab81fc53d1 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 10 Sep 2026 16:35:44 +0300 Subject: [PATCH 05/12] Export and import application versions --- .../version/export_app_version_cmd.go | 236 ++++++++++++++++++ .../version/export_app_version_cmd_test.go | 215 ++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 apptrust/commands/version/export_app_version_cmd.go create mode 100644 apptrust/commands/version/export_app_version_cmd_test.go 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..199466b --- /dev/null +++ b/apptrust/commands/version/export_app_version_cmd.go @@ -0,0 +1,236 @@ +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" + exportStatusFailed = "FAILED" + + exportPollInterval = 10 * time.Second + exportPollTimeout = 60 * time.Minute +) + +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: + 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 > commands.DownloadMaxSplitCount { + return 0, 0, fmt.Errorf("the '--%s' option value is limited to a maximum of %d", commands.SplitCountFlag, commands.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..11778ce --- /dev/null +++ b/apptrust/commands/version/export_app_version_cmd_test.go @@ -0,0 +1,215 @@ +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: "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()) +} From 33f051c68d09af7d58349da5a91ee60c3884bdaa Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 10 Sep 2026 17:23:49 +0300 Subject: [PATCH 06/12] Export and import application versions --- apptrust/commands/flags.go | 12 ++++++++++++ apptrust/model/import_app_version.go | 5 +++++ cli/cli.go | 1 + 3 files changed, 18 insertions(+) diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index adda782..1ee5b9c 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -21,6 +21,7 @@ const ( VersionDistribute = "version-distribute" VersionRemoteDelete = "version-delete-remote" VersionExport = "version-export" + VersionImport = "version-import" PackageBind = "package-bind" PackageUnbind = "package-unbind" AppCreate = "app-create" @@ -82,6 +83,7 @@ const ( QuietFlag = "quiet" MinSplitFlag = "min-split" SplitCountFlag = "split-count" + UnpromotedFlag = "unpromoted" ) const ( @@ -144,6 +146,7 @@ var flagsMap = map[string]components.Flag{ 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{ @@ -269,6 +272,15 @@ var commandFlags = map[string][]string{ MinSplitFlag, SplitCountFlag, }, + VersionImport: { + url, + user, + accessToken, + serverId, + MappingPatternFlag, + MappingTargetFlag, + UnpromotedFlag, + }, PackageBind: { url, diff --git a/apptrust/model/import_app_version.go b/apptrust/model/import_app_version.go index ad008b5..b6f2220 100644 --- a/apptrust/model/import_app_version.go +++ b/apptrust/model/import_app_version.go @@ -1,5 +1,10 @@ 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/cli/cli.go b/cli/cli.go index b1b5ee2..7507818 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -51,6 +51,7 @@ Related: jf rt, jf release-bundle commands.`, version.GetUpdateAppVersionCommand(appContext), version.GetUpdateAppVersionSourcesCommand(appContext), version.GetExportAppVersionCommand(appContext), + version.GetImportAppVersionCommand(appContext), packagecmds.GetBindPackageCommand(appContext), packagecmds.GetUnbindPackageCommand(appContext), application.GetCreateAppCommand(appContext), From 0c84b7e803b9cd195ee82aa7c989e832d1e63b9b Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 10 Sep 2026 18:45:31 +0300 Subject: [PATCH 07/12] Export and import application versions --- apptrust/commands/flags.go | 1 - .../version/export_app_version_cmd.go | 6 +- .../version/import_app_version_cmd.go | 170 ++++++++++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 apptrust/commands/version/import_app_version_cmd.go diff --git a/apptrust/commands/flags.go b/apptrust/commands/flags.go index 1ee5b9c..6f2ad66 100644 --- a/apptrust/commands/flags.go +++ b/apptrust/commands/flags.go @@ -89,7 +89,6 @@ const ( const ( DefaultDownloadMinSplitKb = 5120 DefaultDownloadSplitCount = 3 - DownloadMaxSplitCount = 15 ) // Flag keys mapped to their corresponding components.Flag definition. diff --git a/apptrust/commands/version/export_app_version_cmd.go b/apptrust/commands/version/export_app_version_cmd.go index 199466b..6cecc17 100644 --- a/apptrust/commands/version/export_app_version_cmd.go +++ b/apptrust/commands/version/export_app_version_cmd.go @@ -32,6 +32,8 @@ const ( exportPollInterval = 10 * time.Second exportPollTimeout = 60 * time.Minute + + downloadMaxSplitCount = 15 ) type exportAppVersionCommand struct { @@ -172,8 +174,8 @@ func parseExportDownloadFlags(ctx *components.Context) (minSplitSize int64, spli if err != nil { return 0, 0, fmt.Errorf("the '--%s' option should have a numeric value", commands.SplitCountFlag) } - if splitCount > commands.DownloadMaxSplitCount { - return 0, 0, fmt.Errorf("the '--%s' option value is limited to a maximum of %d", commands.SplitCountFlag, commands.DownloadMaxSplitCount) + 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) 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, + } +} From 2d650f45d9c66abfdf78a9d8d7ecc9caf8a9ad8e Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 10 Sep 2026 19:00:38 +0300 Subject: [PATCH 08/12] Export and import application versions --- .../version/import_app_version_cmd_test.go | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 apptrust/commands/version/import_app_version_cmd_test.go 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()) +} From 592dfcc0b521e58c536034039ff151ff0e072f3a Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Thu, 10 Sep 2026 19:06:54 +0300 Subject: [PATCH 09/12] Export and import application versions --- go.mod | 4 +++- go.sum | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index b96101d..11f0004 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -86,3 +86,5 @@ require ( gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/jfrog/jfrog-client-go => github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275 diff --git a/go.sum b/go.sum index 00a7025..61eb885 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= @@ -93,15 +94,14 @@ 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/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= From 2e2cac47837d333707079de2618b00099c8240bc Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 14 Sep 2026 14:23:55 +0300 Subject: [PATCH 10/12] Export and import application versions --- go.sum | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.sum b/go.sum index 61eb885..e60cee7 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275 h1:Eft2JH43OErvYsjovyjXScPhieoF2+LEx2PO22i8g8A= github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= @@ -102,7 +103,6 @@ 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/go.mod h1:USb7bfWSE7bGKsJ4nR0lxGILvmtnCcR5OO4biSUItMs= 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= From 2ec8bbdae7311c40913f8d2c987b8101c4deff9d Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Mon, 14 Sep 2026 15:40:25 +0300 Subject: [PATCH 11/12] Export and import application versions --- apptrust/commands/version/export_app_version_cmd.go | 9 +++++---- apptrust/commands/version/export_app_version_cmd_test.go | 5 +++++ apptrust/http/http_client.go | 6 +++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apptrust/commands/version/export_app_version_cmd.go b/apptrust/commands/version/export_app_version_cmd.go index 6cecc17..bc5d8c9 100644 --- a/apptrust/commands/version/export_app_version_cmd.go +++ b/apptrust/commands/version/export_app_version_cmd.go @@ -26,9 +26,10 @@ import ( ) const ( - exportStatusCompleted = "COMPLETED" - exportStatusInProgress = "IN_PROGRESS" - exportStatusFailed = "FAILED" + exportStatusCompleted = "COMPLETED" + exportStatusInProgress = "IN_PROGRESS" + exportStatusNotTriggered = "NOT_TRIGGERED" + exportStatusFailed = "FAILED" exportPollInterval = 10 * time.Second exportPollTimeout = 60 * time.Minute @@ -107,7 +108,7 @@ func (eac *exportAppVersionCommand) waitForExport(ctx service.Context) (*model.A func shouldStopExportPolling(status *model.AppVersionExportStatus) (shouldStop bool, err error) { switch status.Status { - case exportStatusInProgress: + case exportStatusInProgress, exportStatusNotTriggered: return false, nil case exportStatusFailed: return true, errorutils.CheckErrorf("application version export failed: %s", status.Message) diff --git a/apptrust/commands/version/export_app_version_cmd_test.go b/apptrust/commands/version/export_app_version_cmd_test.go index 11778ce..3ee5573 100644 --- a/apptrust/commands/version/export_app_version_cmd_test.go +++ b/apptrust/commands/version/export_app_version_cmd_test.go @@ -125,6 +125,11 @@ func TestShouldStopExportPolling(t *testing.T) { 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{ diff --git a/apptrust/http/http_client.go b/apptrust/http/http_client.go index 764b07f..78a2513 100644 --- a/apptrust/http/http_client.go +++ b/apptrust/http/http_client.go @@ -156,11 +156,11 @@ func writeMultipartPart(mw *multipart.Writer, part MultipartPart) (err error) { } header := make(textproto.MIMEHeader) - filenamePart := "" + disposition := fmt.Sprintf(`form-data; name=%q`, part.Name) if filename != "" { - filenamePart = fmt.Sprintf(`; filename="%s"`, filename) + disposition += fmt.Sprintf(`; filename=%q`, filename) } - header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q%s`, part.Name, filenamePart)) + header.Set("Content-Disposition", disposition) if part.ContentType != "" { header.Set("Content-Type", part.ContentType) } From 06a68e7cdd2bf9fcd33509d04667e568aba6ce41 Mon Sep 17 00:00:00 2001 From: Asaf Gabai Date: Tue, 15 Sep 2026 17:34:18 +0300 Subject: [PATCH 12/12] Export and import application versions --- go.mod | 4 +--- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 11f0004..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 @@ -86,5 +86,3 @@ require ( gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/jfrog/jfrog-client-go => github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275 diff --git a/go.sum b/go.sum index e60cee7..82d3e77 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275 h1:Eft2JH43OErvYsjovyjXScPhieoF2+LEx2PO22i8g8A= -github.com/asafgabai/jfrog-client-go v0.18.1-0.20260909171220-b99ca2968275/go.mod h1:7B7eMRKuMhZ0rOdMItbJVpWjRUe1L//J3Jq+PgjiNxI= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g= @@ -103,6 +101,8 @@ 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.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=