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 }} 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..ffcb10b 100644 --- a/internal/mirroring/get.go +++ b/internal/mirroring/get.go @@ -7,9 +7,16 @@ import ( "strings" "sync" + "github.com/Masterminds/semver/v3" "go.uber.org/zap" ) +const ( + INSTANCE_SEMVER_THRESHOLD = "17.6" + ULTIMATE_PLAN = "ultimate" + PREMIUM_PLAN = "premium" +) + // 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 +84,40 @@ 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 +} + +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 1d9e5d9..5e5466f 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,114 @@ 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) + } + }) + } +} + +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 8a03a71..0c0d5c8 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,21 +554,6 @@ 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, - }) - if err != nil { - t.Fatalf("Failed to create Gitlab instance: %v", err) - } - return gitlabInstance -} - func TestReverseGroupMirrorMap(t *testing.T) { tests := []struct { name string diff --git a/internal/mirroring/main.go b/internal/mirroring/main.go index d86b86d..a3e31cd 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.CheckDestinationInstance() + if err != nil { + return []error{err} + } sourceProjectFilters, sourceGroupFilters, destinationProjectFilters, destinationGroupFilters := processFilters(gitlabMirrorArgs.MirrorMapping) @@ -139,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 +}