diff --git a/pkg/git/strategy/strategy_test.go b/pkg/git/strategy/strategy_test.go index 9ce0ea9b..a7423cb5 100644 --- a/pkg/git/strategy/strategy_test.go +++ b/pkg/git/strategy/strategy_test.go @@ -18,14 +18,20 @@ package strategy import ( "context" + "errors" "net/url" "os" + "path/filepath" "strings" "testing" "time" "github.com/fluxcd/pkg/gittestserver" "github.com/fluxcd/pkg/ssh" + extgogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" . "github.com/onsi/gomega" "github.com/fluxcd/source-controller/pkg/git" @@ -198,3 +204,203 @@ func getSSHRepoURL(sshAddress, repoPath string) string { sshURL := strings.Replace(sshAddress, "127.0.0.1", "localhost", 1) return sshURL + "/" + repoPath } + +func TestCheckoutStrategyForImplementation_SemVerCheckout(t *testing.T) { + g := NewWithT(t) + + gitImpls := []git.Implementation{gogit.Implementation, libgit2.Implementation} + + // Setup git server and repo. + gitServer, err := gittestserver.NewTempGitServer() + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(gitServer.Root()) + username := "test-user" + password := "test-password" + gitServer.Auth(username, password) + gitServer.KeyDir(gitServer.Root()) + g.Expect(gitServer.StartHTTP()).ToNot(HaveOccurred()) + defer gitServer.StopHTTP() + + repoPath := "bar/test-reponame" + err = gitServer.InitRepo("testdata/repo1", "master", repoPath) + g.Expect(err).ToNot(HaveOccurred()) + + repoURL := gitServer.HTTPAddressWithCredentials() + "/" + repoPath + + authOpts := &git.AuthOptions{ + Transport: git.HTTP, + Username: username, + Password: password, + } + + // Create test tags in the repo. + now := time.Now() + tags := []struct { + tag string + annotated bool + commitTime time.Time + tagTime time.Time + }{ + { + tag: "v0.0.1", + annotated: false, + commitTime: now, + }, + { + tag: "v0.1.0+build-1", + annotated: true, + commitTime: now.Add(10 * time.Minute), + tagTime: now.Add(2 * time.Hour), // This should be ignored during TS comparisons + }, + { + tag: "v0.1.0+build-2", + annotated: false, + commitTime: now.Add(30 * time.Minute), + }, + { + tag: "v0.1.0+build-3", + annotated: true, + commitTime: now.Add(1 * time.Hour), + tagTime: now.Add(1 * time.Hour), // This should be ignored during TS comparisons + }, + { + tag: "0.2.0", + annotated: true, + commitTime: now, + tagTime: now, + }, + } + + // Clone the repo locally. + cloneDir, err := os.MkdirTemp("", "test-clone") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(cloneDir) + repo, err := extgogit.PlainClone(cloneDir, false, &extgogit.CloneOptions{ + URL: repoURL, + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Create commits and tags. + // Keep a record of all the tags and commit refs. + refs := make(map[string]string, len(tags)) + for _, tt := range tags { + ref, err := commitFile(repo, "tag", tt.tag, tt.commitTime) + g.Expect(err).ToNot(HaveOccurred()) + _, err = tag(repo, ref, tt.annotated, tt.tag, tt.tagTime) + g.Expect(err).ToNot(HaveOccurred()) + refs[tt.tag] = ref.String() + } + + // Push everything. + err = repo.Push(&extgogit.PushOptions{ + RefSpecs: []config.RefSpec{"refs/*:refs/*"}, + }) + g.Expect(err).ToNot(HaveOccurred()) + + // Test cases. + type testCase struct { + name string + constraint string + expectErr error + expectTag string + } + tests := []testCase{ + { + name: "Orders by SemVer", + constraint: ">0.1.0", + expectTag: "0.2.0", + }, + { + name: "Orders by SemVer and timestamp", + constraint: "<0.2.0", + expectTag: "v0.1.0+build-3", + }, + { + name: "Errors without match", + constraint: ">=1.0.0", + expectErr: errors.New("no match found for semver: >=1.0.0"), + }, + } + testFunc := func(tt testCase, impl git.Implementation) func(t *testing.T) { + return func(t *testing.T) { + g := NewWithT(t) + + // Get the checkout strategy. + checkoutOpts := git.CheckoutOptions{ + SemVer: tt.constraint, + } + checkoutStrategy, err := CheckoutStrategyForImplementation(context.TODO(), impl, checkoutOpts) + g.Expect(err).ToNot(HaveOccurred()) + + // Checkout and verify. + tmpDir, err := os.MkdirTemp("", "test-checkout") + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(tmpDir) + + cc, err := checkoutStrategy.Checkout(context.TODO(), tmpDir, repoURL, authOpts) + if tt.expectErr != nil { + g.Expect(err).To(Equal(tt.expectErr)) + g.Expect(cc).To(BeNil()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + refs[tt.expectTag])) + g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile()) + g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.expectTag)) + } + } + + // Run the test cases against the git implementations. + for _, gitImpl := range gitImpls { + for _, tt := range tests { + t.Run(string(gitImpl)+"_"+tt.name, testFunc(tt, gitImpl)) + } + } +} + +func commitFile(repo *extgogit.Repository, path, content string, time time.Time) (plumbing.Hash, error) { + wt, err := repo.Worktree() + if err != nil { + return plumbing.Hash{}, err + } + f, err := wt.Filesystem.Create(path) + if err != nil { + return plumbing.Hash{}, err + } + if _, err := f.Write([]byte(content)); err != nil { + if ferr := f.Close(); ferr != nil { + return plumbing.Hash{}, ferr + } + return plumbing.Hash{}, err + } + if err := f.Close(); err != nil { + return plumbing.Hash{}, err + } + if _, err := wt.Add(path); err != nil { + return plumbing.Hash{}, err + } + return wt.Commit("Adding: "+path, &extgogit.CommitOptions{ + Author: mockSignature(time), + Committer: mockSignature(time), + }) +} + +func tag(repo *extgogit.Repository, commit plumbing.Hash, annotated bool, tag string, time time.Time) (*plumbing.Reference, error) { + var opts *extgogit.CreateTagOptions + if annotated { + opts = &extgogit.CreateTagOptions{ + Tagger: mockSignature(time), + Message: "Annotated tag for: " + tag, + } + } + return repo.CreateTag(tag, commit, opts) +} + +func mockSignature(time time.Time) *object.Signature { + return &object.Signature{ + Name: "Jane Doe", + Email: "jane@example.com", + When: time, + } +}