source-controller/pkg/git/strategy/strategy_test.go

489 lines
14 KiB
Go

/*
Copyright 2021 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package strategy
import (
"context"
"errors"
"fmt"
"net/http"
"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"
"github.com/fluxcd/source-controller/pkg/git/gogit"
"github.com/fluxcd/source-controller/pkg/git/libgit2"
)
func TestCheckoutStrategyForImplementation_Auth(t *testing.T) {
gitImpls := []git.Implementation{gogit.Implementation, libgit2.Implementation}
type testCase struct {
name string
transport git.TransportType
repoURLFunc func(g *WithT, srv *gittestserver.GitServer, repoPath string) string
authOptsFunc func(g *WithT, u *url.URL, user string, pswd string, ca []byte) *git.AuthOptions
wantFunc func(g *WithT, cs git.CheckoutStrategy, dir string, repoURL string, authOpts *git.AuthOptions)
}
cases := []testCase{
{
name: "HTTP clone",
transport: git.HTTP,
repoURLFunc: func(g *WithT, srv *gittestserver.GitServer, repoPath string) string {
return srv.HTTPAddressWithCredentials() + "/" + repoPath
},
authOptsFunc: func(g *WithT, u *url.URL, user string, pswd string, ca []byte) *git.AuthOptions {
return &git.AuthOptions{
Transport: git.HTTP,
Username: user,
Password: pswd,
}
},
wantFunc: func(g *WithT, cs git.CheckoutStrategy, dir string, repoURL string, authOpts *git.AuthOptions) {
_, err := cs.Checkout(context.TODO(), dir, repoURL, authOpts)
g.Expect(err).ToNot(HaveOccurred())
},
},
{
name: "HTTPS clone",
transport: git.HTTPS,
repoURLFunc: func(g *WithT, srv *gittestserver.GitServer, repoPath string) string {
return srv.HTTPAddress() + "/" + repoPath
},
authOptsFunc: func(g *WithT, u *url.URL, user, pswd string, ca []byte) *git.AuthOptions {
return &git.AuthOptions{
Transport: git.HTTPS,
Username: user,
Password: pswd,
CAFile: ca,
}
},
wantFunc: func(g *WithT, cs git.CheckoutStrategy, dir, repoURL string, authOpts *git.AuthOptions) {
_, err := cs.Checkout(context.TODO(), dir, repoURL, authOpts)
g.Expect(err).ToNot(HaveOccurred())
},
},
{
name: "SSH clone",
transport: git.SSH,
repoURLFunc: func(g *WithT, srv *gittestserver.GitServer, repoPath string) string {
return getSSHRepoURL(srv.SSHAddress(), repoPath)
},
authOptsFunc: func(g *WithT, u *url.URL, user, pswd string, ca []byte) *git.AuthOptions {
knownhosts, err := ssh.ScanHostKey(u.Host, 5*time.Second, git.HostKeyAlgos)
g.Expect(err).ToNot(HaveOccurred())
keygen := ssh.NewRSAGenerator(2048)
pair, err := keygen.Generate()
g.Expect(err).ToNot(HaveOccurred())
return &git.AuthOptions{
Host: u.Host, // Without this libgit2 returns error "user cancelled hostkey check".
Transport: git.SSH,
Username: "git", // Without this libgit2 returns error "username does not match previous request".
Identity: pair.PrivateKey,
KnownHosts: knownhosts,
}
},
wantFunc: func(g *WithT, cs git.CheckoutStrategy, dir, repoURL string, authOpts *git.AuthOptions) {
_, err := cs.Checkout(context.TODO(), dir, repoURL, authOpts)
g.Expect(err).ToNot(HaveOccurred())
},
},
}
testFunc := func(tt testCase, impl git.Implementation) func(t *testing.T) {
return func(t *testing.T) {
g := NewWithT(t)
var examplePublicKey, examplePrivateKey, exampleCA []byte
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())
// Start the HTTP/HTTPS server.
if tt.transport == git.HTTPS {
var err error
examplePublicKey, err = os.ReadFile("testdata/certs/server.pem")
g.Expect(err).ToNot(HaveOccurred())
examplePrivateKey, err = os.ReadFile("testdata/certs/server-key.pem")
g.Expect(err).ToNot(HaveOccurred())
exampleCA, err = os.ReadFile("testdata/certs/ca.pem")
g.Expect(err).ToNot(HaveOccurred())
err = gitServer.StartHTTPS(examplePublicKey, examplePrivateKey, exampleCA, "example.com")
g.Expect(err).ToNot(HaveOccurred())
} else {
g.Expect(gitServer.StartHTTP()).ToNot(HaveOccurred())
}
defer gitServer.StopHTTP()
// Start the SSH server.
if tt.transport == git.SSH {
g.Expect(gitServer.ListenSSH()).ToNot(HaveOccurred())
go func() {
gitServer.StartSSH()
}()
defer func() {
g.Expect(gitServer.StopSSH()).To(Succeed())
}()
}
// Initialize a git repo.
branch := "main"
repoPath := "bar/test-reponame"
err = gitServer.InitRepo("testdata/repo1", branch, repoPath)
g.Expect(err).ToNot(HaveOccurred())
repoURL := tt.repoURLFunc(g, gitServer, repoPath)
u, err := url.Parse(repoURL)
g.Expect(err).ToNot(HaveOccurred())
authOpts := tt.authOptsFunc(g, u, username, password, exampleCA)
// Get the checkout strategy.
checkoutOpts := git.CheckoutOptions{
Branch: branch,
}
checkoutStrategy, err := CheckoutStrategyForImplementation(context.TODO(), impl, checkoutOpts)
g.Expect(err).ToNot(HaveOccurred())
tmpDir := t.TempDir()
tt.wantFunc(g, checkoutStrategy, tmpDir, repoURL, authOpts)
}
}
// Run the test cases against the git implementations.
for _, gitImpl := range gitImpls {
for _, tt := range cases {
t.Run(fmt.Sprintf("%s_%s", gitImpl, tt.name), testFunc(tt, gitImpl))
}
}
}
func getSSHRepoURL(sshAddress, repoPath string) string {
// This is expected to use 127.0.0.1, but host key
// checking usually wants a hostname, so use
// "localhost".
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", "main", 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 := t.TempDir()
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 := t.TempDir()
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(fmt.Sprintf("%s_%s", gitImpl, tt.name), testFunc(tt, gitImpl))
}
}
}
func TestCheckoutStrategyForImplementation_WithCtxTimeout(t *testing.T) {
gitImpls := []git.Implementation{gogit.Implementation, libgit2.Implementation}
type testCase struct {
name string
timeout time.Duration
wantErr bool
}
cases := []testCase{
{
name: "fails with short timeout",
timeout: 100 * time.Millisecond,
wantErr: true,
},
{
name: "succeeds with sufficient timeout",
timeout: 5 * time.Second,
wantErr: false,
},
}
// Keeping it low to keep the test run time low.
serverDelay := 500 * time.Millisecond
testFunc := func(tt testCase, impl git.Implementation) func(t *testing.T) {
return func(*testing.T) {
g := NewWithT(t)
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())
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(serverDelay)
next.ServeHTTP(w, r)
})
}
gitServer.AddHTTPMiddlewares(middleware)
g.Expect(gitServer.StartHTTP()).ToNot(HaveOccurred())
defer gitServer.StopHTTP()
branch := "main"
repoPath := "bar/test-reponame"
err = gitServer.InitRepo("testdata/repo1", branch, repoPath)
g.Expect(err).ToNot(HaveOccurred())
repoURL := gitServer.HTTPAddressWithCredentials() + "/" + repoPath
authOpts := &git.AuthOptions{
Transport: git.HTTP,
Username: username,
Password: password,
}
checkoutOpts := git.CheckoutOptions{
Branch: branch,
}
checkoutStrategy, err := CheckoutStrategyForImplementation(context.TODO(), impl, checkoutOpts)
g.Expect(err).ToNot(HaveOccurred())
tmpDir := t.TempDir()
checkoutCtx, cancel := context.WithTimeout(context.TODO(), tt.timeout)
defer cancel()
_, gotErr := checkoutStrategy.Checkout(checkoutCtx, tmpDir, repoURL, authOpts)
if tt.wantErr {
g.Expect(gotErr).To(HaveOccurred())
} else {
g.Expect(gotErr).ToNot(HaveOccurred())
}
}
}
// Run the test cases against the git implementations.
for _, gitImpl := range gitImpls {
for _, tt := range cases {
t.Run(fmt.Sprintf("%s_%s", 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,
}
}