From 544df8db1c7c9af40d93496d75d2ed922f008898 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Fri, 16 May 2025 20:10:48 +0200 Subject: [PATCH 1/3] feat: add version check for destination gitlab The execution now fails gracefully if the version of the destination GitLab instance is not at least 17.6 This is required for pull mirroring via API call NOTE: update go version to current latest (1.24.3) --- Containerfile | 2 +- go.mod | 3 +- internal/mirroring/get.go | 27 ++++++++++++++ internal/mirroring/get_test.go | 60 +++++++++++++++++++++++++++++-- internal/mirroring/helper_test.go | 50 +++++++++++++++++++------- internal/mirroring/main.go | 4 +++ 6 files changed, 130 insertions(+), 16 deletions(-) diff --git a/Containerfile b/Containerfile index cfd1b4b..7df5611 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,4 @@ -FROM docker.io/golang:1.24.2-alpine AS build +FROM docker.io/golang:1.24.3-alpine AS build ARG VERSION="dev" diff --git a/go.mod b/go.mod index 498c26a..8de817b 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,9 @@ module gitlab-sync -go 1.24.2 +go 1.24.3 require ( + github.com/Masterminds/semver/v3 v3.3.1 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/spf13/cobra v1.9.1 gitlab.com/gitlab-org/api/client-go v0.128.0 diff --git a/internal/mirroring/get.go b/internal/mirroring/get.go index 5a90cad..943b07f 100644 --- a/internal/mirroring/get.go +++ b/internal/mirroring/get.go @@ -7,9 +7,14 @@ import ( "strings" "sync" + "github.com/Masterminds/semver/v3" "go.uber.org/zap" ) +const ( + INSTANCE_SEMVER_THRESHOLD = "17.6" +) + // fetchAll retrieves all projects and groups from the GitLab instance // that match the filters and stores them in the instance cache. func (g *GitlabInstance) fetchAll(projectFilters map[string]struct{}, groupFilters map[string]struct{}, mirrorMapping *utils.MirrorMapping) []error { @@ -77,3 +82,25 @@ func checkPathMatchesFilters(resourcePath string, projectFilters *map[string]str } return "", false } + +func (g *GitlabInstance) CheckVersion() error { + metadata, _, err := g.Gitlab.Metadata.GetMetadata() + if err != nil { + return fmt.Errorf("failed to get GitLab version: %w", err) + } + zap.L().Debug("GitLab Instance version", zap.String(ROLE, g.Role), zap.String("version", metadata.Version)) + + currentVer, err := semver.NewVersion(metadata.Version) + if err != nil { + return fmt.Errorf("failed to parse GitLab version: %w", err) + } + thresholdVer, err := semver.NewVersion(INSTANCE_SEMVER_THRESHOLD) + if err != nil { + return fmt.Errorf("failed to parse version threshold: %w", err) + } + + if currentVer.LessThan(thresholdVer) { + return fmt.Errorf("GitLab version %s is below required threshold %s", currentVer, thresholdVer) + } + return nil +} diff --git a/internal/mirroring/get_test.go b/internal/mirroring/get_test.go index 1d9e5d9..dd67c3e 100644 --- a/internal/mirroring/get_test.go +++ b/internal/mirroring/get_test.go @@ -2,6 +2,7 @@ package mirroring import ( "gitlab-sync/internal/utils" + "net/http" "testing" ) @@ -64,11 +65,10 @@ func TestCheckPathMatchesFilters(t *testing.T) { } }) } - } func TestGetParentNamespaceID(t *testing.T) { - gitlabInstance := setupTestGitlabInstance(t, ROLE_DESTINATION, INSTANCE_SIZE_SMALL) + _, gitlabInstance := setupEmptyTestServer(t, ROLE_DESTINATION, INSTANCE_SIZE_SMALL) gitlabInstance.addGroup(TEST_GROUP) gitlabInstance.addProject(TEST_PROJECT) @@ -194,3 +194,59 @@ func TestFetchAll(t *testing.T) { } } + +func TestCheckVersion(t *testing.T) { + tests := []struct { + name string + version string + expectedError bool + }{ + { + name: "Valid version under threshold", + version: "15.0.0", + expectedError: true, + }, + { + name: "Valid version above threshold", + version: "17.9.3-ce.0", + expectedError: false, + }, + { + name: "Invalid version format with 1 dot", + version: "invalid.version", + expectedError: true, + }, + { + name: "Invalid version format with 2 dots", + version: "invalid.version.1", + expectedError: true, + }, + { + name: "Invalid empty version", + version: "", + expectedError: true, + }, + } + + // Iterate over the test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + mux, gitlabInstance := setupEmptyTestServer(t, ROLE_DESTINATION, INSTANCE_SIZE_SMALL) + mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{"version": "` + test.version + `"}`)) + if err != nil { + t.Errorf("failed to write response: %v", err) + } + }) + + err := gitlabInstance.CheckVersion() + if (err != nil) != test.expectedError { + t.Errorf("expected error: %v, got: %v", test.expectedError, err) + } + }) + } +} diff --git a/internal/mirroring/helper_test.go b/internal/mirroring/helper_test.go index 8a03a71..1c264da 100644 --- a/internal/mirroring/helper_test.go +++ b/internal/mirroring/helper_test.go @@ -265,6 +265,29 @@ var ( }`} ) +func setupEmptyTestServer(t *testing.T, role string, instanceSize string) (*http.ServeMux, *GitlabInstance) { + // mux is the HTTP request multiplexer used with the test server. + mux := http.NewServeMux() + + // server is a test HTTP server used to provide mock API responses. + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + gitlabInstance, err := newGitlabInstance(&GitlabInstanceOpts{ + GitlabURL: server.URL, + GitlabToken: "test-token", + Role: role, + InstanceSize: instanceSize, + MaxRetries: 0, + }) + + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + return mux, gitlabInstance +} + // setup sets up a test HTTP server along with a gitlab.Client that is // configured to talk to that test server. Tests should register handlers on // mux which provide mock responses for the API method being tested. @@ -531,19 +554,22 @@ func setupTestProject(mux *http.ServeMux, project *gitlab.Project, stringRespons }) } -// setupTestGitlabInstance sets up a test Gitlab instance with the given role and instance size. -func setupTestGitlabInstance(t *testing.T, role string, instanceSize string) *GitlabInstance { - gitlabInstance, err := newGitlabInstance(&GitlabInstanceOpts{ - GitlabURL: "https://gitlab.example.com", - GitlabToken: "test-token", - Role: role, - InstanceSize: instanceSize, - MaxRetries: 0, +// setupMetadata sets up the test HTTP server with handlers for metadata-related actions. +// This includes the version endpoint. +func setupMetadata(mux *http.ServeMux) { + // Setup the version endpoint to return a mock response. + mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT) + // Set response status to 200 OK + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"version": "18.0.0"}`) + default: + // Set response status to 405 Method Not Allowed + w.WriteHeader(http.StatusMethodNotAllowed) + } }) - if err != nil { - t.Fatalf("Failed to create Gitlab instance: %v", err) - } - return gitlabInstance } func TestReverseGroupMirrorMap(t *testing.T) { diff --git a/internal/mirroring/main.go b/internal/mirroring/main.go index d86b86d..0ccc3d6 100644 --- a/internal/mirroring/main.go +++ b/internal/mirroring/main.go @@ -46,6 +46,10 @@ func MirrorGitlabs(gitlabMirrorArgs *utils.ParserArgs) []error { if err != nil { return []error{err} } + err = destinationGitlabInstance.CheckVersion() + if err != nil { + return []error{err} + } sourceProjectFilters, sourceGroupFilters, destinationProjectFilters, destinationGroupFilters := processFilters(gitlabMirrorArgs.MirrorMapping) From e183e57cdb2559f72c6ad0f3b258b3acde930e42 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Fri, 16 May 2025 20:23:11 +0200 Subject: [PATCH 2/3] feat: add license check for destination gitlab The execution now fails gracefully if the license tier of destination gitlab is not at least premium (required for pull mirroring) --- internal/mirroring/get.go | 17 ++++++++++ internal/mirroring/get_test.go | 55 +++++++++++++++++++++++++++++++ internal/mirroring/helper_test.go | 18 ---------- internal/mirroring/main.go | 13 +++++++- 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/internal/mirroring/get.go b/internal/mirroring/get.go index 943b07f..ffcb10b 100644 --- a/internal/mirroring/get.go +++ b/internal/mirroring/get.go @@ -13,6 +13,8 @@ import ( const ( INSTANCE_SEMVER_THRESHOLD = "17.6" + ULTIMATE_PLAN = "ultimate" + PREMIUM_PLAN = "premium" ) // fetchAll retrieves all projects and groups from the GitLab instance @@ -104,3 +106,18 @@ func (g *GitlabInstance) CheckVersion() error { } return nil } + +func (g *GitlabInstance) CheckLicense() error { + license, _, err := g.Gitlab.License.GetLicense() + if err != nil { + return fmt.Errorf("failed to get GitLab license: %w", err) + } + if license.Plan != ULTIMATE_PLAN && license.Plan != PREMIUM_PLAN { + return fmt.Errorf("GitLab license plan %s is not supported, only %s and %s are supported", license.Plan, ULTIMATE_PLAN, PREMIUM_PLAN) + } else if license.Expired { + return fmt.Errorf("GitLab license is expired") + } + + zap.L().Debug("GitLab Instance license", zap.String(ROLE, g.Role), zap.String("plan", license.Plan)) + return nil +} diff --git a/internal/mirroring/get_test.go b/internal/mirroring/get_test.go index dd67c3e..5e5466f 100644 --- a/internal/mirroring/get_test.go +++ b/internal/mirroring/get_test.go @@ -250,3 +250,58 @@ func TestCheckVersion(t *testing.T) { }) } } + +func TestCheckLicense(t *testing.T) { + tests := []struct { + name string + license string + expectedError bool + }{ + { + name: "Ultimate tier license", + license: "ultimate", + expectedError: false, + }, + { + name: "Premium tier license", + license: "premium", + expectedError: false, + }, + { + name: "Free tier license", + license: "free", + expectedError: true, + }, + { + name: "Invalid license", + license: "invalid", + expectedError: true, + }, + { + name: "Empty license", + license: "", + expectedError: true, + }, + } + // Iterate over the test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + mux, gitlabInstance := setupEmptyTestServer(t, ROLE_DESTINATION, INSTANCE_SIZE_SMALL) + mux.HandleFunc("/api/v4/license", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{"plan": "` + test.license + `"}`)) + if err != nil { + t.Errorf("failed to write response: %v", err) + } + }) + + err := gitlabInstance.CheckLicense() + if (err != nil) != test.expectedError { + t.Errorf("expected error: %v, got: %v", test.expectedError, err) + } + }) + } +} diff --git a/internal/mirroring/helper_test.go b/internal/mirroring/helper_test.go index 1c264da..0c0d5c8 100644 --- a/internal/mirroring/helper_test.go +++ b/internal/mirroring/helper_test.go @@ -554,24 +554,6 @@ func setupTestProject(mux *http.ServeMux, project *gitlab.Project, stringRespons }) } -// setupMetadata sets up the test HTTP server with handlers for metadata-related actions. -// This includes the version endpoint. -func setupMetadata(mux *http.ServeMux) { - // Setup the version endpoint to return a mock response. - mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT) - // Set response status to 200 OK - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, `{"version": "18.0.0"}`) - default: - // Set response status to 405 Method Not Allowed - w.WriteHeader(http.StatusMethodNotAllowed) - } - }) -} - func TestReverseGroupMirrorMap(t *testing.T) { tests := []struct { name string diff --git a/internal/mirroring/main.go b/internal/mirroring/main.go index 0ccc3d6..a3e31cd 100644 --- a/internal/mirroring/main.go +++ b/internal/mirroring/main.go @@ -46,7 +46,7 @@ func MirrorGitlabs(gitlabMirrorArgs *utils.ParserArgs) []error { if err != nil { return []error{err} } - err = destinationGitlabInstance.CheckVersion() + err = destinationGitlabInstance.CheckDestinationInstance() if err != nil { return []error{err} } @@ -143,3 +143,14 @@ func DryRun(sourceGitlabInstance *GitlabInstance, gitlabMirrorArgs *utils.Parser } } } + +func (destinationGitlab *GitlabInstance) CheckDestinationInstance() error { + zap.L().Info("Checking destination GitLab instance") + if err := destinationGitlab.CheckVersion(); err != nil { + return fmt.Errorf("destination GitLab instance version check failed: %w", err) + } + if err := destinationGitlab.CheckVersion(); err != nil { + return fmt.Errorf("destination GitLab instance version check failed: %w", err) + } + return nil +} From 05e2127e7009a31c2c5ead66d209a7e4238a5d73 Mon Sep 17 00:00:00 2001 From: BoxBoxJason Date: Fri, 16 May 2025 20:33:10 +0200 Subject: [PATCH 3/3] ci: fix OCI labels --- .github/workflows/release.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e4f3c1..fdf3507 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,14 +101,14 @@ jobs: build-args: | VERSION=${{ github.ref_name }} labels: | - org.opencontainers.image.title "${{ github.event.repository.name }}" - org.opencontainers.image.description "${{ github.event.repository.description }}" - org.opencontainers.image.source "${{ github.event.repository.html_url }}" - org.opencontainers.image.url "ghcr.io/boxboxjason/gitlab-sync" - org.opencontainers.image.created "${{ github.event }}" - org.opencontainers.image.revision "${{ github.sha }}" - org.opencontainers.image.version "${{ github.ref_name }}" - org.opencontainers.image.vendor "${{ github.repository_owner}}" + org.opencontainers.image.title="${{ github.event.repository.name }}" + org.opencontainers.image.description="${{ github.event.repository.description }}" + org.opencontainers.image.source="${{ github.event.repository.html_url }}" + org.opencontainers.image.url="ghcr.io/boxboxjason/gitlab-sync" + org.opencontainers.image.created="${{ github.event }}" + org.opencontainers.image.revision="${{ github.sha }}" + org.opencontainers.image.version="${{ github.ref_name }}" + org.opencontainers.image.vendor="${{ github.repository_owner}}" tags: | ghcr.io/${{ env.REPO_NAME }}:${{ github.ref_name }} ghcr.io/${{ env.REPO_NAME }}:${{ env.MAJOR }}.${{ env.MINOR }}