489 lines
14 KiB
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,
|
|
}
|
|
}
|