Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion Containerfile
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
44 changes: 44 additions & 0 deletions internal/mirroring/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
115 changes: 113 additions & 2 deletions internal/mirroring/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mirroring

import (
"gitlab-sync/internal/utils"
"net/http"
"testing"
)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
})
}
}
38 changes: 23 additions & 15 deletions internal/mirroring/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,29 @@
}`}
)

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.
Expand Down Expand Up @@ -313,13 +336,13 @@
// Set response status to 200 OK
w.WriteHeader(http.StatusOK)
// Return a mock response for the list of groups
fmt.Fprint(w, TEST_GROUPS_STRING)

Check failure on line 339 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprint` is not checked (errcheck)

Check failure on line 339 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprint` is not checked (errcheck)
case http.MethodPost:
// Set response status to 201 Created
w.WriteHeader(http.StatusCreated)
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
// Return a mock response for the created group
fmt.Fprint(w, TEST_GROUP_STRING)

Check failure on line 345 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprint` is not checked (errcheck)

Check failure on line 345 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprint` is not checked (errcheck)
default:
// Set response status to 405 Method Not Allowed
w.WriteHeader(http.StatusMethodNotAllowed)
Expand All @@ -341,7 +364,7 @@
}
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, stringResponse)

Check failure on line 367 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprint` is not checked (errcheck)

Check failure on line 367 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprint` is not checked (errcheck)
})
// Setup the get group response from the group ID
mux.HandleFunc(fmt.Sprintf("/api/v4/groups/%d", group.ID), func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -368,7 +391,7 @@
// Write a fake image response
w.Header().Set(HEADER_CONTENT_TYPE, "image/png")
w.WriteHeader(http.StatusOK)
w.Write([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) // PNG header

Check failure on line 394 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `w.Write` is not checked (errcheck)

Check failure on line 394 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `w.Write` is not checked (errcheck)
})
}

Expand Down Expand Up @@ -430,7 +453,7 @@
return
}
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
fmt.Fprintf(w, `[%s]`, TEST_GROUP_2_STRING)

Check failure on line 456 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprintf` is not checked (errcheck)

Check failure on line 456 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
})

// Subgroups of the TEST_GROUP_2
Expand All @@ -451,7 +474,7 @@
return
}
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
fmt.Fprintf(w, `[%s]`, TEST_PROJECT_STRING)

Check failure on line 477 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprintf` is not checked (errcheck)

Check failure on line 477 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
})

// Projects of the TEST_GROUP_2
Expand All @@ -461,7 +484,7 @@
return
}
w.Header().Set(HEADER_CONTENT_TYPE, HEADER_ACCEPT)
fmt.Fprintf(w, "[%s]", TEST_PROJECT_2_STRING)

Check failure on line 487 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprintf` is not checked (errcheck)

Check failure on line 487 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
})
}

Expand Down Expand Up @@ -501,7 +524,7 @@
// Write a fake image response
w.Header().Set(HEADER_CONTENT_TYPE, "image/png")
w.WriteHeader(http.StatusOK)
w.Write([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) // PNG header

Check failure on line 527 in internal/mirroring/helper_test.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `w.Write` is not checked (errcheck)
})
// Setup the get project releases response from the project ID
mux.HandleFunc(fmt.Sprintf("/api/v4/projects/%d/releases", project.ID), func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -531,21 +554,6 @@
})
}

// 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
Expand Down
15 changes: 15 additions & 0 deletions internal/mirroring/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Loading