source-controller/pkg/git/gogit/checkout_test.go

806 lines
21 KiB
Go

/*
Copyright 2020 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 gogit
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fluxcd/gitkit"
"github.com/fluxcd/pkg/gittestserver"
"github.com/fluxcd/pkg/ssh"
"github.com/fluxcd/source-controller/pkg/git"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-billy/v5/osfs"
extgogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/filesystem"
. "github.com/onsi/gomega"
cryptossh "golang.org/x/crypto/ssh"
corev1 "k8s.io/api/core/v1"
)
const testRepositoryPath = "../testdata/git/repo"
func TestCheckoutBranch_Checkout(t *testing.T) {
repo, path, err := initRepo(t)
if err != nil {
t.Fatal(err)
}
firstCommit, err := commitFile(repo, "branch", "init", time.Now())
if err != nil {
t.Fatal(err)
}
if err = createBranch(repo, "test"); err != nil {
t.Fatal(err)
}
secondCommit, err := commitFile(repo, "branch", "second", time.Now())
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
branch string
filesCreated map[string]string
expectedCommit string
expectedErr string
lastRevision string
}{
{
name: "Default branch",
branch: "master",
filesCreated: map[string]string{"branch": "init"},
expectedCommit: firstCommit.String(),
},
{
name: "skip clone if LastRevision hasn't changed",
branch: "master",
filesCreated: map[string]string{"branch": "init"},
expectedErr: fmt.Sprintf("no changes since last reconcilation: observed revision 'master/%s'", firstCommit.String()),
lastRevision: fmt.Sprintf("master/%s", firstCommit.String()),
},
{
name: "Other branch - revision has changed",
branch: "test",
filesCreated: map[string]string{"branch": "second"},
expectedCommit: secondCommit.String(),
lastRevision: fmt.Sprintf("master/%s", firstCommit.String()),
},
{
name: "Non existing branch",
branch: "invalid",
expectedErr: "couldn't find remote ref \"refs/heads/invalid\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
branch := CheckoutBranch{
Branch: tt.branch,
LastRevision: tt.lastRevision,
}
tmpDir := t.TempDir()
cc, err := branch.Checkout(context.TODO(), tmpDir, path, nil)
if tt.expectedErr != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr))
g.Expect(cc).To(BeNil())
return
}
g.Expect(err).ToNot(HaveOccurred())
g.Expect(cc.String()).To(Equal(tt.branch + "/" + tt.expectedCommit))
for k, v := range tt.filesCreated {
g.Expect(filepath.Join(tmpDir, k)).To(BeARegularFile())
g.Expect(os.ReadFile(filepath.Join(tmpDir, k))).To(BeEquivalentTo(v))
}
})
}
}
func TestCheckoutTag_Checkout(t *testing.T) {
tests := []struct {
name string
tag string
annotated bool
checkoutTag string
expectTag string
expectErr string
}{
{
name: "Tag",
tag: "tag-1",
checkoutTag: "tag-1",
expectTag: "tag-1",
},
{
name: "Annotated",
tag: "annotated",
annotated: true,
checkoutTag: "annotated",
expectTag: "annotated",
},
{
name: "Non existing tag",
tag: "tag-1",
checkoutTag: "invalid",
expectErr: "couldn't find remote ref \"refs/tags/invalid\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
repo, path, err := initRepo(t)
if err != nil {
t.Fatal(err)
}
var h plumbing.Hash
if tt.tag != "" {
h, err = commitFile(repo, "tag", tt.tag, time.Now())
if err != nil {
t.Fatal(err)
}
_, err = tag(repo, h, !tt.annotated, tt.tag, time.Now())
if err != nil {
t.Fatal(err)
}
}
tag := CheckoutTag{
Tag: tt.checkoutTag,
}
tmpDir := t.TempDir()
cc, err := tag.Checkout(context.TODO(), tmpDir, path, nil)
if tt.expectErr != "" {
g.Expect(err.Error()).To(ContainSubstring(tt.expectErr))
g.Expect(cc).To(BeNil())
return
}
g.Expect(err).ToNot(HaveOccurred())
g.Expect(cc.String()).To(Equal(tt.expectTag + "/" + h.String()))
g.Expect(filepath.Join(tmpDir, "tag")).To(BeARegularFile())
g.Expect(os.ReadFile(filepath.Join(tmpDir, "tag"))).To(BeEquivalentTo(tt.tag))
})
}
}
func TestCheckoutCommit_Checkout(t *testing.T) {
repo, path, err := initRepo(t)
if err != nil {
t.Fatal(err)
}
firstCommit, err := commitFile(repo, "commit", "init", time.Now())
if err != nil {
t.Fatal(err)
}
if err = createBranch(repo, "other-branch"); err != nil {
t.Fatal(err)
}
secondCommit, err := commitFile(repo, "commit", "second", time.Now())
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
commit string
branch string
expectCommit string
expectFile string
expectError string
}{
{
name: "Commit",
commit: firstCommit.String(),
expectCommit: "HEAD/" + firstCommit.String(),
expectFile: "init",
},
{
name: "Commit in specific branch",
commit: secondCommit.String(),
branch: "other-branch",
expectCommit: "other-branch/" + secondCommit.String(),
expectFile: "second",
},
{
name: "Non existing commit",
commit: "a-random-invalid-commit",
expectError: "failed to resolve commit object for 'a-random-invalid-commit': object not found",
},
{
name: "Non existing commit in specific branch",
commit: secondCommit.String(),
branch: "master",
expectError: "object not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
commit := CheckoutCommit{
Commit: tt.commit,
Branch: tt.branch,
}
tmpDir := t.TempDir()
cc, err := commit.Checkout(context.TODO(), tmpDir, path, nil)
if tt.expectError != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.expectError))
g.Expect(cc).To(BeNil())
return
}
g.Expect(err).ToNot(HaveOccurred())
g.Expect(cc).ToNot(BeNil())
g.Expect(cc.String()).To(Equal(tt.expectCommit))
g.Expect(filepath.Join(tmpDir, "commit")).To(BeARegularFile())
g.Expect(os.ReadFile(filepath.Join(tmpDir, "commit"))).To(BeEquivalentTo(tt.expectFile))
})
}
}
func TestCheckoutTagSemVer_Checkout(t *testing.T) {
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,
},
}
tests := []struct {
name string
constraint string
expectErr error
expectTag string
}{
{
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"),
},
}
repo, path, err := initRepo(t)
if err != nil {
t.Fatal(err)
}
refs := make(map[string]string, len(tags))
for _, tt := range tags {
ref, err := commitFile(repo, "tag", tt.tag, tt.commitTime)
if err != nil {
t.Fatal(err)
}
_, err = tag(repo, ref, tt.annotated, tt.tag, tt.tagTime)
if err != nil {
t.Fatal(err)
}
refs[tt.tag] = ref.String()
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
semVer := CheckoutSemVer{
SemVer: tt.constraint,
}
tmpDir := t.TempDir()
cc, err := semVer.Checkout(context.TODO(), tmpDir, path, nil)
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))
})
}
}
// Test_KeyTypes assures support for the different types of keys
// for SSH Authentication supported by Flux.
func Test_KeyTypes(t *testing.T) {
tests := []struct {
name string
keyType ssh.KeyPairType
authorized bool
wantErr string
}{
{name: "RSA 4096", keyType: ssh.RSA_4096, authorized: true},
{name: "ECDSA P256", keyType: ssh.ECDSA_P256, authorized: true},
{name: "ECDSA P384", keyType: ssh.ECDSA_P384, authorized: true},
{name: "ECDSA P521", keyType: ssh.ECDSA_P521, authorized: true},
{name: "ED25519", keyType: ssh.ED25519, authorized: true},
{name: "unauthorized key", keyType: ssh.RSA_4096, wantErr: "unable to authenticate, attempted methods [none publickey], no supported methods remain"},
}
serverRootDir := t.TempDir()
server := gittestserver.NewGitServer(serverRootDir)
// Auth needs to be called, for authentication to be enabled.
server.Auth("", "")
var authorizedPublicKey string
server.PublicKeyLookupFunc(func(content string) (*gitkit.PublicKey, error) {
authedKey := strings.TrimSuffix(string(authorizedPublicKey), "\n")
if authedKey == content {
return &gitkit.PublicKey{Content: content}, nil
}
return nil, fmt.Errorf("pubkey provided '%s' does not match %s", content, authedKey)
})
g := NewWithT(t)
timeout := 5 * time.Second
server.KeyDir(filepath.Join(server.Root(), "keys"))
g.Expect(server.ListenSSH()).To(Succeed())
go func() {
server.StartSSH()
}()
defer server.StopSSH()
repoPath := "test.git"
err := server.InitRepo(testRepositoryPath, git.DefaultBranch, repoPath)
g.Expect(err).NotTo(HaveOccurred())
sshURL := server.SSHAddress()
repoURL := sshURL + "/" + repoPath
// Fetch host key.
u, err := url.Parse(sshURL)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(u.Host).ToNot(BeEmpty())
knownHosts, err := ssh.ScanHostKey(u.Host, timeout, git.HostKeyAlgos)
g.Expect(err).ToNot(HaveOccurred())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
// Generate ssh keys based on key type.
kp, err := ssh.GenerateKeyPair(tt.keyType)
g.Expect(err).ToNot(HaveOccurred())
// Update authorized key to ensure only the new key is valid on the server.
if tt.authorized {
authorizedPublicKey = string(kp.PublicKey)
}
secret := corev1.Secret{
Data: map[string][]byte{
"identity": kp.PrivateKey,
"known_hosts": knownHosts,
},
}
authOpts, err := git.AuthOptionsFromSecret(repoURL, &secret)
g.Expect(err).ToNot(HaveOccurred())
// Prepare for checkout.
branchCheckoutStrat := &CheckoutBranch{Branch: git.DefaultBranch}
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
defer cancel()
// Checkout the repo.
commit, err := branchCheckoutStrat.Checkout(ctx, tmpDir, repoURL, authOpts)
if tt.wantErr == "" {
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit).ToNot(BeNil())
// Confirm checkout actually happened.
d, err := os.ReadDir(tmpDir)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(d).To(HaveLen(2)) // .git and foo.txt
} else {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).Should(ContainSubstring(tt.wantErr))
}
})
}
}
// Test_KeyExchangeAlgos assures support for the different
// types of SSH key exchange algorithms supported by Flux.
func Test_KeyExchangeAlgos(t *testing.T) {
tests := []struct {
name string
ClientKex []string
ServerKex []string
wantErr string
}{
{
name: "support for kex: diffie-hellman-group14-sha1",
ClientKex: []string{"diffie-hellman-group14-sha1"},
ServerKex: []string{"diffie-hellman-group14-sha1"},
},
{
name: "support for kex: diffie-hellman-group14-sha256",
ClientKex: []string{"diffie-hellman-group14-sha256"},
ServerKex: []string{"diffie-hellman-group14-sha256"},
},
{
name: "support for kex: curve25519-sha256",
ClientKex: []string{"curve25519-sha256"},
ServerKex: []string{"curve25519-sha256"},
},
{
name: "support for kex: ecdh-sha2-nistp256",
ClientKex: []string{"ecdh-sha2-nistp256"},
ServerKex: []string{"ecdh-sha2-nistp256"},
},
{
name: "support for kex: ecdh-sha2-nistp384",
ClientKex: []string{"ecdh-sha2-nistp384"},
ServerKex: []string{"ecdh-sha2-nistp384"},
},
{
name: "support for kex: ecdh-sha2-nistp521",
ClientKex: []string{"ecdh-sha2-nistp521"},
ServerKex: []string{"ecdh-sha2-nistp521"},
},
{
name: "support for kex: curve25519-sha256@libssh.org",
ClientKex: []string{"curve25519-sha256@libssh.org"},
ServerKex: []string{"curve25519-sha256@libssh.org"},
},
{
name: "non-matching kex",
ClientKex: []string{"ecdh-sha2-nistp521"},
ServerKex: []string{"curve25519-sha256@libssh.org"},
wantErr: "ssh: no common algorithm for key exchange; client offered: [ecdh-sha2-nistp521 ext-info-c], server offered: [curve25519-sha256@libssh.org]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
timeout := 5 * time.Second
serverRootDir := t.TempDir()
server := gittestserver.NewGitServer(serverRootDir).WithSSHConfig(&cryptossh.ServerConfig{
Config: cryptossh.Config{
KeyExchanges: tt.ServerKex,
},
})
// Set what Client Key Exchange Algos to send
git.KexAlgos = tt.ClientKex
server.KeyDir(filepath.Join(server.Root(), "keys"))
g.Expect(server.ListenSSH()).To(Succeed())
go func() {
server.StartSSH()
}()
defer server.StopSSH()
repoPath := "test.git"
err := server.InitRepo(testRepositoryPath, git.DefaultBranch, repoPath)
g.Expect(err).NotTo(HaveOccurred())
sshURL := server.SSHAddress()
repoURL := sshURL + "/" + repoPath
// Fetch host key.
u, err := url.Parse(sshURL)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(u.Host).ToNot(BeEmpty())
knownHosts, err := ssh.ScanHostKey(u.Host, timeout, git.HostKeyAlgos)
g.Expect(err).ToNot(HaveOccurred())
// No authentication is required for this test, but it is
// used here to make the Checkout logic happy.
kp, err := ssh.GenerateKeyPair(ssh.ED25519)
g.Expect(err).ToNot(HaveOccurred())
secret := corev1.Secret{
Data: map[string][]byte{
"identity": kp.PrivateKey,
"known_hosts": knownHosts,
},
}
authOpts, err := git.AuthOptionsFromSecret(repoURL, &secret)
g.Expect(err).ToNot(HaveOccurred())
// Prepare for checkout.
branchCheckoutStrat := &CheckoutBranch{Branch: git.DefaultBranch}
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
defer cancel()
// Checkout the repo.
_, err = branchCheckoutStrat.Checkout(ctx, tmpDir, repoURL, authOpts)
if tt.wantErr != "" {
g.Expect(err).Error().Should(HaveOccurred())
g.Expect(err.Error()).Should(ContainSubstring(tt.wantErr))
} else {
g.Expect(err).Error().ShouldNot(HaveOccurred())
}
})
}
}
// TestHostKeyAlgos assures support for the different
// types of SSH Host Key algorithms supported by Flux.
func TestHostKeyAlgos(t *testing.T) {
tests := []struct {
name string
keyType ssh.KeyPairType
ClientHostKeyAlgos []string
}{
{
name: "support for hostkey: ssh-rsa",
keyType: ssh.RSA_4096,
ClientHostKeyAlgos: []string{"ssh-rsa"},
},
{
name: "support for hostkey: rsa-sha2-256",
keyType: ssh.RSA_4096,
ClientHostKeyAlgos: []string{"rsa-sha2-256"},
},
{
name: "support for hostkey: rsa-sha2-512",
keyType: ssh.RSA_4096,
ClientHostKeyAlgos: []string{"rsa-sha2-512"},
},
{
name: "support for hostkey: ecdsa-sha2-nistp256",
keyType: ssh.ECDSA_P256,
ClientHostKeyAlgos: []string{"ecdsa-sha2-nistp256"},
},
{
name: "support for hostkey: ecdsa-sha2-nistp384",
keyType: ssh.ECDSA_P384,
ClientHostKeyAlgos: []string{"ecdsa-sha2-nistp384"},
},
{
name: "support for hostkey: ecdsa-sha2-nistp521",
keyType: ssh.ECDSA_P521,
ClientHostKeyAlgos: []string{"ecdsa-sha2-nistp521"},
},
{
name: "support for hostkey: ssh-ed25519",
keyType: ssh.ED25519,
ClientHostKeyAlgos: []string{"ssh-ed25519"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
timeout := 5 * time.Second
sshConfig := &cryptossh.ServerConfig{}
// Generate new keypair for the server to use for HostKeys.
hkp, err := ssh.GenerateKeyPair(tt.keyType)
g.Expect(err).NotTo(HaveOccurred())
p, err := cryptossh.ParseRawPrivateKey(hkp.PrivateKey)
g.Expect(err).NotTo(HaveOccurred())
// Add key to server.
signer, err := cryptossh.NewSignerFromKey(p)
g.Expect(err).NotTo(HaveOccurred())
sshConfig.AddHostKey(signer)
serverRootDir := t.TempDir()
server := gittestserver.NewGitServer(serverRootDir).WithSSHConfig(sshConfig)
// Set what HostKey Algos will be accepted from a client perspective.
git.HostKeyAlgos = tt.ClientHostKeyAlgos
keyDir := filepath.Join(server.Root(), "keys")
server.KeyDir(keyDir)
g.Expect(server.ListenSSH()).To(Succeed())
go func() {
server.StartSSH()
}()
defer server.StopSSH()
repoPath := "test.git"
err = server.InitRepo(testRepositoryPath, git.DefaultBranch, repoPath)
g.Expect(err).NotTo(HaveOccurred())
sshURL := server.SSHAddress()
repoURL := sshURL + "/" + repoPath
// Fetch host key.
u, err := url.Parse(sshURL)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(u.Host).ToNot(BeEmpty())
knownHosts, err := ssh.ScanHostKey(u.Host, timeout, git.HostKeyAlgos)
g.Expect(err).ToNot(HaveOccurred())
// No authentication is required for this test, but it is
// used here to make the Checkout logic happy.
kp, err := ssh.GenerateKeyPair(ssh.ED25519)
g.Expect(err).ToNot(HaveOccurred())
secret := corev1.Secret{
Data: map[string][]byte{
"identity": kp.PrivateKey,
"known_hosts": knownHosts,
},
}
authOpts, err := git.AuthOptionsFromSecret(repoURL, &secret)
g.Expect(err).ToNot(HaveOccurred())
// Prepare for checkout.
branchCheckoutStrat := &CheckoutBranch{Branch: git.DefaultBranch}
tmpDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
defer cancel()
// Checkout the repo.
_, err = branchCheckoutStrat.Checkout(ctx, tmpDir, repoURL, authOpts)
g.Expect(err).Error().ShouldNot(HaveOccurred())
})
}
}
func initRepo(t *testing.T) (*extgogit.Repository, string, error) {
tmpDir := t.TempDir()
sto := filesystem.NewStorage(osfs.New(tmpDir), cache.NewObjectLRUDefault())
repo, err := extgogit.Init(sto, memfs.New())
if err != nil {
return nil, "", err
}
return repo, tmpDir, err
}
func createBranch(repo *extgogit.Repository, branch string) error {
wt, err := repo.Worktree()
if err != nil {
return err
}
h, err := repo.Head()
if err != nil {
return err
}
return wt.Checkout(&extgogit.CheckoutOptions{
Hash: h.Hash(),
Branch: plumbing.ReferenceName("refs/heads/" + branch),
Create: true,
})
}
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 {
f.Close()
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,
}
}