Skip to content

Commit 602e3c7

Browse files
committed
feat: enable mirroring on non premium instances
add a git operations helper to clone the git content with minimal resources update compatibility prechecks add new means of auth for the git over https part update tests update docs Closes #13
1 parent eb56d82 commit 602e3c7

File tree

11 files changed

+441
-137
lines changed

11 files changed

+441
-137
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ It is designed to be used in a CI/CD pipeline to automate the process of keeping
1919
## Features
2020

2121
- Synchronize projects / groups between two GitLab instances
22-
- Enable Pull Mirroring for projects (requires GitLab Premium)
22+
- Recreates your git repository content in another location:
23+
- Enable Pull Mirroring for projects (requires GitLab Premium)
24+
- Clone the repository content from the source GitLab instance to the destination GitLab instance (on GitLab Free)
2325
- Can add projects to CI/CD catalog
2426
- Full copy of the project (description, icon, topics,...). Can also copy issues
2527

go.mod

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,41 @@
11
module gitlab-sync
22

3-
go 1.24.3
3+
go 1.24.4
44

55
require (
66
github.com/Masterminds/semver/v3 v3.3.1
7+
github.com/go-git/go-git/v5 v5.16.0
78
github.com/hashicorp/go-retryablehttp v0.7.7
89
github.com/spf13/cobra v1.9.1
910
gitlab.com/gitlab-org/api/client-go v0.128.0
1011
go.uber.org/zap v1.27.0
1112
)
1213

1314
require (
15+
dario.cat/mergo v1.0.0 // indirect
16+
github.com/Microsoft/go-winio v0.6.2 // indirect
17+
github.com/ProtonMail/go-crypto v1.1.6 // indirect
18+
github.com/cloudflare/circl v1.6.1 // indirect
19+
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
20+
github.com/emirpasic/gods v1.18.1 // indirect
21+
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
22+
github.com/go-git/go-billy/v5 v5.6.2 // indirect
23+
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
1424
github.com/google/go-querystring v1.1.0 // indirect
1525
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
1626
github.com/inconshreveable/mousetrap v1.1.0 // indirect
27+
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
28+
github.com/kevinburke/ssh_config v1.2.0 // indirect
29+
github.com/pjbgf/sha1cd v0.3.2 // indirect
30+
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
31+
github.com/skeema/knownhosts v1.3.1 // indirect
1732
github.com/spf13/pflag v1.0.6 // indirect
33+
github.com/xanzy/ssh-agent v0.3.3 // indirect
1834
go.uber.org/multierr v1.11.0 // indirect
35+
golang.org/x/crypto v0.37.0 // indirect
36+
golang.org/x/net v0.39.0 // indirect
1937
golang.org/x/oauth2 v0.30.0 // indirect
38+
golang.org/x/sys v0.32.0 // indirect
2039
golang.org/x/time v0.11.0 // indirect
40+
gopkg.in/warnings.v0 v0.1.2 // indirect
2141
)

internal/mirroring/get.go

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,39 +86,38 @@ func checkPathMatchesFilters(resourcePath string, projectFilters *map[string]str
8686
return "", false
8787
}
8888

89-
func (g *GitlabInstance) CheckVersion() error {
89+
// IsVersionGreaterThanThreshold checks if the GitLab instance version is below the defined threshold.
90+
// It retrieves the metadata from the GitLab instance and compares the version
91+
// with the INSTANCE_SEMVER_THRESHOLD.
92+
func (g *GitlabInstance) IsVersionGreaterThanThreshold() (bool, error) {
9093
metadata, _, err := g.Gitlab.Metadata.GetMetadata()
9194
if err != nil {
92-
return fmt.Errorf("failed to get GitLab version: %w", err)
95+
return false, fmt.Errorf("failed to get GitLab version: %w", err)
9396
}
9497
zap.L().Debug("GitLab Instance version", zap.String(ROLE, g.Role), zap.String("version", metadata.Version))
9598

9699
currentVer, err := semver.NewVersion(metadata.Version)
97100
if err != nil {
98-
return fmt.Errorf("failed to parse GitLab version: %w", err)
101+
return false, fmt.Errorf("failed to parse GitLab version: %w", err)
99102
}
100103
thresholdVer, err := semver.NewVersion(INSTANCE_SEMVER_THRESHOLD)
101104
if err != nil {
102-
return fmt.Errorf("failed to parse version threshold: %w", err)
105+
return false, fmt.Errorf("failed to parse version threshold: %w", err)
103106
}
104107

105-
if currentVer.LessThan(thresholdVer) {
106-
return fmt.Errorf("GitLab version %s is below required threshold %s", currentVer, thresholdVer)
107-
}
108-
return nil
108+
return currentVer.GreaterThanEqual(thresholdVer), nil
109109
}
110110

111-
func (g *GitlabInstance) CheckLicense() error {
111+
// IsLicensePremium checks if the GitLab instance has a premium license.
112+
// It retrieves the license information and checks the plan type.
113+
func (g *GitlabInstance) IsLicensePremium() (bool, error) {
112114
license, _, err := g.Gitlab.License.GetLicense()
113115
if err != nil {
114-
return fmt.Errorf("failed to get GitLab license: %w", err)
116+
return false, fmt.Errorf("failed to get GitLab license: %w", err)
115117
}
116-
if license.Plan != ULTIMATE_PLAN && license.Plan != PREMIUM_PLAN {
117-
return fmt.Errorf("GitLab license plan %s is not supported, only %s and %s are supported", license.Plan, ULTIMATE_PLAN, PREMIUM_PLAN)
118-
} else if license.Expired {
119-
return fmt.Errorf("GitLab license is expired")
118+
zap.L().Info("GitLab Instance license", zap.String(ROLE, g.Role), zap.String("plan", license.Plan))
119+
if license.Plan != ULTIMATE_PLAN && license.Plan != PREMIUM_PLAN || license.Expired {
120+
return false, nil
120121
}
121-
122-
zap.L().Debug("GitLab Instance license", zap.String(ROLE, g.Role), zap.String("plan", license.Plan))
123-
return nil
122+
return true, nil
124123
}

internal/mirroring/get_test.go

Lines changed: 88 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -195,36 +195,50 @@ func TestFetchAll(t *testing.T) {
195195

196196
}
197197

198-
func TestCheckVersion(t *testing.T) {
198+
func TestIsVersionGreaterThanThreshold(t *testing.T) {
199199
tests := []struct {
200-
name string
201-
version string
202-
expectedError bool
200+
name string
201+
version string
202+
expectedError bool
203+
expectedResponse bool
204+
noApiResponse bool
203205
}{
204206
{
205-
name: "Valid version under threshold",
206-
version: "15.0.0",
207-
expectedError: true,
207+
name: "Valid version under threshold",
208+
version: "15.0.0",
209+
expectedError: false,
210+
expectedResponse: false,
208211
},
209212
{
210-
name: "Valid version above threshold",
211-
version: "17.9.3-ce.0",
212-
expectedError: false,
213+
name: "Valid version above threshold",
214+
version: "17.9.3-ce.0",
215+
expectedError: false,
216+
expectedResponse: true,
213217
},
214218
{
215-
name: "Invalid version format with 1 dot",
216-
version: "invalid.version",
217-
expectedError: true,
219+
name: "Invalid version format with 1 dot",
220+
version: "invalid.version",
221+
expectedError: true,
222+
expectedResponse: false,
218223
},
219224
{
220-
name: "Invalid version format with 2 dots",
221-
version: "invalid.version.1",
222-
expectedError: true,
225+
name: "Invalid version format with 2 dots",
226+
version: "invalid.version.1",
227+
expectedError: true,
228+
expectedResponse: false,
223229
},
224230
{
225-
name: "Invalid empty version",
226-
version: "",
227-
expectedError: true,
231+
name: "Invalid empty version",
232+
version: "",
233+
expectedError: true,
234+
expectedResponse: false,
235+
},
236+
{
237+
name: "No API response",
238+
version: "",
239+
expectedError: true,
240+
expectedResponse: false,
241+
noApiResponse: true,
228242
},
229243
}
230244

@@ -234,53 +248,64 @@ func TestCheckVersion(t *testing.T) {
234248
t.Parallel()
235249

236250
mux, gitlabInstance := setupEmptyTestServer(t, ROLE_DESTINATION, INSTANCE_SIZE_SMALL)
237-
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, r *http.Request) {
238-
w.Header().Set("Content-Type", "application/json")
239-
w.WriteHeader(http.StatusOK)
240-
_, err := w.Write([]byte(`{"version": "` + test.version + `"}`))
241-
if err != nil {
242-
t.Errorf("failed to write response: %v", err)
243-
}
244-
})
251+
if !test.noApiResponse {
252+
mux.HandleFunc("/api/v4/metadata", func(w http.ResponseWriter, r *http.Request) {
253+
w.Header().Set("Content-Type", "application/json")
254+
w.WriteHeader(http.StatusOK)
255+
_, err := w.Write([]byte(`{"version": "` + test.version + `"}`))
256+
if err != nil {
257+
t.Errorf("failed to write response: %v", err)
258+
}
259+
})
260+
}
245261

246-
err := gitlabInstance.CheckVersion()
262+
thresholdOk, err := gitlabInstance.IsVersionGreaterThanThreshold()
247263
if (err != nil) != test.expectedError {
248-
t.Errorf("expected error: %v, got: %v", test.expectedError, err)
264+
t.Fatalf("expected error: %v, got: %v", test.expectedError, err)
265+
}
266+
if thresholdOk != test.expectedResponse {
267+
t.Errorf("expected thresholdOk: %v, got: %v", test.expectedResponse, thresholdOk)
249268
}
250269
})
251270
}
252271
}
253272

254-
func TestCheckLicense(t *testing.T) {
273+
func TestIsLicensePremium(t *testing.T) {
255274
tests := []struct {
256-
name string
257-
license string
258-
expectedError bool
275+
name string
276+
license string
277+
expectedError bool
278+
expectedResponse bool
259279
}{
260280
{
261-
name: "Ultimate tier license",
262-
license: ULTIMATE_PLAN,
263-
expectedError: false,
281+
name: "Ultimate tier license",
282+
license: ULTIMATE_PLAN,
283+
expectedError: false,
284+
expectedResponse: true,
264285
},
265286
{
266-
name: "Premium tier license",
267-
license: PREMIUM_PLAN,
268-
expectedError: false,
287+
name: "Premium tier license",
288+
license: PREMIUM_PLAN,
289+
expectedError: false,
290+
expectedResponse: true,
269291
},
270292
{
271-
name: "Free tier license",
272-
license: "free",
273-
expectedError: true,
293+
name: "Free tier license",
294+
license: "free",
295+
expectedError: false,
296+
expectedResponse: false,
274297
},
275298
{
276-
name: "Invalid license",
277-
license: "invalid",
278-
expectedError: true,
299+
name: "Invalid license",
300+
license: "invalid",
301+
expectedError: false,
302+
expectedResponse: false,
279303
},
280304
{
281-
name: "Empty license",
282-
license: "",
283-
expectedError: true,
305+
name: "Error API response",
306+
license: "",
307+
expectedError: true,
308+
expectedResponse: false,
284309
},
285310
}
286311
// Iterate over the test cases
@@ -289,18 +314,23 @@ func TestCheckLicense(t *testing.T) {
289314
t.Parallel()
290315

291316
mux, gitlabInstance := setupEmptyTestServer(t, ROLE_DESTINATION, INSTANCE_SIZE_SMALL)
292-
mux.HandleFunc("/api/v4/license", func(w http.ResponseWriter, r *http.Request) {
293-
w.Header().Set("Content-Type", "application/json")
294-
w.WriteHeader(http.StatusOK)
295-
_, err := w.Write([]byte(`{"plan": "` + test.license + `"}`))
296-
if err != nil {
297-
t.Errorf("failed to write response: %v", err)
298-
}
299-
})
317+
if !test.expectedError {
318+
mux.HandleFunc("/api/v4/license", func(w http.ResponseWriter, r *http.Request) {
319+
w.Header().Set("Content-Type", "application/json")
320+
w.WriteHeader(http.StatusOK)
321+
_, err := w.Write([]byte(`{"plan": "` + test.license + `"}`))
322+
if err != nil {
323+
t.Errorf("failed to write response: %v", err)
324+
}
325+
})
326+
}
300327

301-
err := gitlabInstance.CheckLicense()
328+
isPremium, err := gitlabInstance.IsLicensePremium()
302329
if (err != nil) != test.expectedError {
303-
t.Errorf("expected error: %v, got: %v", test.expectedError, err)
330+
t.Fatalf("expected error: %v, got: %v", test.expectedError, err)
331+
}
332+
if isPremium != test.expectedResponse {
333+
t.Errorf("expected isPremium: %v, got: %v", test.expectedResponse, isPremium)
304334
}
305335
})
306336
}

internal/mirroring/instance.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package mirroring
22

33
import (
4+
"gitlab-sync/pkg/helpers"
45
"sync"
56

7+
"github.com/go-git/go-git/v5/plumbing/transport"
68
"github.com/hashicorp/go-retryablehttp"
79
gitlab "gitlab.com/gitlab-org/api/client-go"
810
)
@@ -28,6 +30,10 @@ type GitlabInstance struct {
2830
// InstanceSize is the size of the GitLab instance, it can be either "small" or "big"
2931
// It is used to determine the behavior of the fetching process
3032
InstanceSize string
33+
// PullMirrorAvailable is a boolean indicating whether the GitLab instance supports pull mirroring
34+
PullMirrorAvailable bool
35+
// GitAuth is the HTTP authentication used for GitLab git over HTTP operations (only for non premium instances)
36+
GitAuth transport.AuthMethod
3137
}
3238

3339
type GitlabInstanceOpts struct {
@@ -59,6 +65,7 @@ func newGitlabInstance(initArgs *GitlabInstanceOpts) (*GitlabInstance, error) {
5965
Groups: make(map[string]*gitlab.Group),
6066
Role: initArgs.Role,
6167
InstanceSize: initArgs.InstanceSize,
68+
GitAuth: helpers.BuildHTTPAuth("", initArgs.GitlabToken),
6269
}
6370

6471
return gitlabInstance, nil

internal/mirroring/main.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,16 @@ func MirrorGitlabs(gitlabMirrorArgs *utils.ParserArgs) []error {
4848
if err != nil {
4949
return []error{err}
5050
}
51-
err = destinationGitlabInstance.CheckDestinationInstance()
51+
pullMirrorAvailable, err := destinationGitlabInstance.IsPullMirrorAvailable()
5252
if err != nil {
5353
return []error{err}
54+
} else if pullMirrorAvailable {
55+
zap.L().Info("GitLab instance is compatible with the pull mirroring process", zap.String(ROLE, destinationGitlabInstance.Role), zap.String(INSTANCE_SIZE, destinationGitlabInstance.InstanceSize))
56+
} else {
57+
zap.L().Warn("Destination GitLab instance is not compatible with the pull mirroring process (requires a >= 17.6 ; >= Premium destination GitLab instance)", zap.String(ROLE, destinationGitlabInstance.Role), zap.String(INSTANCE_SIZE, destinationGitlabInstance.InstanceSize))
58+
zap.L().Warn("Will use local pull / push mirroring instead (takes a lot longer)", zap.String(ROLE, destinationGitlabInstance.Role), zap.String(INSTANCE_SIZE, destinationGitlabInstance.InstanceSize))
5459
}
60+
destinationGitlabInstance.PullMirrorAvailable = pullMirrorAvailable
5561

5662
sourceProjectFilters, sourceGroupFilters, destinationProjectFilters, destinationGroupFilters := processFilters(gitlabMirrorArgs.MirrorMapping)
5763

@@ -121,7 +127,6 @@ func processFilters(filters *utils.MirrorMapping) (map[string]struct{}, map[stri
121127
destinationGroupFilters[destinationGroupPath] = struct{}{}
122128
mu.Unlock()
123129
}
124-
125130
}
126131
}()
127132

@@ -172,14 +177,18 @@ func (destinationGitlabInstance *GitlabInstance) DryRunReleases(sourceGitlabInst
172177
return nil
173178
}
174179

175-
// CheckDestinationInstance checks the destination GitLab instance for version and license compatibility.
176-
func (g *GitlabInstance) CheckDestinationInstance() error {
180+
// IsPullMirrorAvailable checks the destination GitLab instance for version and license compatibility.
181+
func (g *GitlabInstance) IsPullMirrorAvailable() (bool, error) {
177182
zap.L().Info("Checking destination GitLab instance")
178-
if err := g.CheckVersion(); err != nil {
179-
return fmt.Errorf("destination GitLab instance version check failed: %w", err)
183+
thresholdOk, err := g.IsVersionGreaterThanThreshold()
184+
if err != nil {
185+
return false, fmt.Errorf("destination GitLab instance version check failed: %w", err)
180186
}
181-
if err := g.CheckLicense(); err != nil {
182-
return fmt.Errorf("destination GitLab instance version check failed: %w", err)
187+
188+
isPremium, err := g.IsLicensePremium()
189+
if err != nil {
190+
return false, fmt.Errorf("failed to check if destination GitLab instance is premium: %w", err)
183191
}
184-
return nil
192+
193+
return thresholdOk && isPremium, nil
185194
}

0 commit comments

Comments
 (0)