source-controller/controllers/gitrepository_controller_te...

1132 lines
33 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 controllers
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
helper "github.com/fluxcd/pkg/runtime/controller"
"github.com/go-git/go-billy/v5/memfs"
gogit "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/go-git/go-git/v5/storage/memory"
"github.com/go-logr/logr"
. "github.com/onsi/gomega"
sshtestdata "golang.org/x/crypto/ssh/testdata"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"k8s.io/utils/pointer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/gittestserver"
"github.com/fluxcd/pkg/runtime/conditions"
"github.com/fluxcd/pkg/ssh"
"github.com/fluxcd/pkg/testserver"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
"github.com/fluxcd/source-controller/pkg/git"
"github.com/fluxcd/source-controller/pkg/git/fake"
)
var (
testGitImplementations = []string{sourcev1.GoGitImplementation, sourcev1.LibGit2Implementation}
)
func TestGitRepositoryReconciler_Reconcile(t *testing.T) {
g := NewWithT(t)
server, err := gittestserver.NewTempGitServer()
g.Expect(err).NotTo(HaveOccurred())
defer os.RemoveAll(server.Root())
server.AutoCreate()
g.Expect(server.StartHTTP()).To(Succeed())
defer server.StopHTTP()
repoPath := "/test.git"
_, err = initGitRepo(server, "testdata/git/repository", git.DefaultBranch, repoPath)
g.Expect(err).NotTo(HaveOccurred())
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "gitrepository-reconcile-",
Namespace: "default",
},
Spec: sourcev1.GitRepositorySpec{
Interval: metav1.Duration{Duration: interval},
URL: server.HTTPAddress() + repoPath,
},
}
g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace}
// Wait for finalizer to be set
g.Eventually(func() bool {
if err := testEnv.Get(ctx, key, obj); err != nil {
return false
}
return len(obj.Finalizers) > 0
}, timeout).Should(BeTrue())
// Wait for GitRepository to be Ready
g.Eventually(func() bool {
if err := testEnv.Get(ctx, key, obj); err != nil {
return false
}
if !conditions.IsReady(obj) || obj.Status.Artifact == nil {
return false
}
readyCondition := conditions.Get(obj, meta.ReadyCondition)
return obj.Generation == readyCondition.ObservedGeneration &&
obj.Generation == obj.Status.ObservedGeneration
}, timeout).Should(BeTrue())
g.Expect(testEnv.Delete(ctx, obj)).To(Succeed())
// Wait for GitRepository to be deleted
g.Eventually(func() bool {
if err := testEnv.Get(ctx, key, obj); err != nil {
return apierrors.IsNotFound(err)
}
return false
}, timeout).Should(BeTrue())
}
func TestGitRepositoryReconciler_reconcileSource_authStrategy(t *testing.T) {
type options struct {
username string
password string
publicKey []byte
privateKey []byte
ca []byte
}
tests := []struct {
name string
skipForImplementation string
protocol string
server options
secret *corev1.Secret
beforeFunc func(obj *sourcev1.GitRepository)
want ctrl.Result
wantErr bool
assertConditions []metav1.Condition
}{
{
name: "HTTP without secretRef makes ArtifactOutdated=True",
protocol: "http",
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "New upstream revision 'master/<commit>'"),
},
},
{
name: "HTTP with Basic Auth secret makes ArtifactOutdated=True",
protocol: "http",
server: options{
username: "git",
password: "1234",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "basic-auth",
},
Data: map[string][]byte{
"username": []byte("git"),
"password": []byte("1234"),
},
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "basic-auth"}
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "New upstream revision 'master/<commit>'"),
},
},
{
name: "HTTPS with CAFile secret makes ArtifactOutdated=True",
protocol: "https",
server: options{
publicKey: tlsPublicKey,
privateKey: tlsPrivateKey,
ca: tlsCA,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "ca-file",
},
Data: map[string][]byte{
"caFile": tlsCA,
},
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "ca-file"}
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "New upstream revision 'master/<commit>'"),
},
},
{
name: "HTTPS with invalid CAFile secret makes CheckoutFailed=True and returns error",
skipForImplementation: sourcev1.LibGit2Implementation,
protocol: "https",
server: options{
publicKey: tlsPublicKey,
privateKey: tlsPrivateKey,
ca: tlsCA,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "invalid-ca",
},
Data: map[string][]byte{
"caFile": []byte("invalid"),
},
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "invalid-ca"}
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.CheckoutFailedCondition, sourcev1.GitOperationFailedReason, "x509: certificate signed by unknown authority"),
},
},
{
name: "HTTPS with invalid CAFile secret makes CheckoutFailed=True and returns error",
skipForImplementation: sourcev1.GoGitImplementation,
protocol: "https",
server: options{
publicKey: tlsPublicKey,
privateKey: tlsPrivateKey,
ca: tlsCA,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "invalid-ca",
},
Data: map[string][]byte{
"caFile": []byte("invalid"),
},
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "invalid-ca"}
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.CheckoutFailedCondition, sourcev1.GitOperationFailedReason, "Failed to checkout and determine revision: unable to clone '<url>', error: Certificate"),
},
},
{
name: "SSH with private key secret makes ArtifactOutdated=True",
protocol: "ssh",
server: options{
username: "git",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "private-key",
},
Data: map[string][]byte{
"username": []byte("git"),
"identity": sshtestdata.PEMBytes["rsa"],
},
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "private-key"}
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "New upstream revision 'master/<commit>'"),
},
},
{
name: "SSH with password protected private key secret makes ArtifactOutdated=True",
protocol: "ssh",
server: options{
username: "git",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "private-key",
},
Data: map[string][]byte{
"username": []byte("git"),
"identity": sshtestdata.PEMEncryptedKeys[2].PEMBytes,
"password": []byte("password"),
},
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "private-key"}
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "NewRevision", "New upstream revision 'master/<commit>'"),
},
},
{
name: "Include get failure makes CheckoutFailed=True and returns error",
protocol: "http",
server: options{
username: "git",
},
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "non-existing"}
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.CheckoutFailedCondition, "AuthenticationFailed", "Failed to get secret '/non-existing': secrets \"non-existing\" not found"),
},
},
}
for _, tt := range tests {
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "auth-strategy-",
},
Spec: sourcev1.GitRepositorySpec{
Interval: metav1.Duration{Duration: interval},
Timeout: &metav1.Duration{Duration: interval},
},
}
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
server, err := gittestserver.NewTempGitServer()
g.Expect(err).NotTo(HaveOccurred())
defer os.RemoveAll(server.Root())
server.AutoCreate()
repoPath := "/test.git"
localRepo, err := initGitRepo(server, "testdata/git/repository", git.DefaultBranch, repoPath)
g.Expect(err).NotTo(HaveOccurred())
if len(tt.server.username+tt.server.password) > 0 {
server.Auth(tt.server.username, tt.server.password)
}
secret := tt.secret.DeepCopy()
switch tt.protocol {
case "http":
g.Expect(server.StartHTTP()).To(Succeed())
defer server.StopHTTP()
obj.Spec.URL = server.HTTPAddress() + repoPath
case "https":
g.Expect(server.StartHTTPS(tt.server.publicKey, tt.server.privateKey, tt.server.ca, "example.com")).To(Succeed())
obj.Spec.URL = server.HTTPAddress() + repoPath
case "ssh":
server.KeyDir(filepath.Join(server.Root(), "keys"))
g.Expect(server.ListenSSH()).To(Succeed())
obj.Spec.URL = server.SSHAddress() + repoPath
go func() {
server.StartSSH()
}()
defer server.StopSSH()
if secret != nil && len(secret.Data["known_hosts"]) == 0 {
u, err := url.Parse(obj.Spec.URL)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(u.Host).ToNot(BeEmpty())
knownHosts, err := ssh.ScanHostKey(u.Host, timeout)
g.Expect(err).NotTo(HaveOccurred())
secret.Data["known_hosts"] = knownHosts
}
default:
t.Fatalf("unsupported protocol %q", tt.protocol)
}
if tt.beforeFunc != nil {
tt.beforeFunc(obj)
}
builder := fakeclient.NewClientBuilder().WithScheme(testEnv.GetScheme())
if secret != nil {
builder.WithObjects(secret.DeepCopy())
}
r := &GitRepositoryReconciler{
Client: builder.Build(),
Storage: testStorage,
}
for _, i := range testGitImplementations {
t.Run(i, func(t *testing.T) {
g := NewWithT(t)
if tt.skipForImplementation == i {
t.Skipf("Skipped for Git implementation %q", i)
}
tmpDir, err := os.MkdirTemp("", "auth-strategy-")
g.Expect(err).To(BeNil())
defer os.RemoveAll(tmpDir)
obj := obj.DeepCopy()
obj.Spec.GitImplementation = i
head, _ := localRepo.Head()
assertConditions := tt.assertConditions
for k := range assertConditions {
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<commit>", head.Hash().String())
assertConditions[k].Message = strings.ReplaceAll(assertConditions[k].Message, "<url>", obj.Spec.URL)
}
var artifact sourcev1.Artifact
got, err := r.reconcileSource(logr.NewContext(ctx, log.NullLogger{}), obj, &artifact, tmpDir)
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions))
g.Expect(err != nil).To(Equal(tt.wantErr))
g.Expect(got).To(Equal(tt.want))
g.Expect(artifact).ToNot(BeNil())
})
}
})
}
}
func TestGitRepositoryReconciler_reconcileSource_checkoutStrategy(t *testing.T) {
g := NewWithT(t)
branches := []string{"staging"}
tags := []string{"non-semver-tag", "v0.1.0", "0.2.0", "v0.2.1", "v1.0.0-alpha", "v1.1.0", "v2.0.0"}
tests := []struct {
name string
reference *sourcev1.GitRepositoryRef
want ctrl.Result
wantErr bool
wantRevision string
}{
{
name: "Nil reference (default branch)",
want: ctrl.Result{RequeueAfter: interval},
wantRevision: "master/<commit>",
},
{
name: "Branch",
reference: &sourcev1.GitRepositoryRef{
Branch: "staging",
},
want: ctrl.Result{RequeueAfter: interval},
wantRevision: "staging/<commit>",
},
{
name: "Tag",
reference: &sourcev1.GitRepositoryRef{
Tag: "v0.1.0",
},
want: ctrl.Result{RequeueAfter: interval},
wantRevision: "v0.1.0/<commit>",
},
{
name: "Branch commit",
reference: &sourcev1.GitRepositoryRef{
Branch: "staging",
Commit: "<commit>",
},
want: ctrl.Result{RequeueAfter: interval},
wantRevision: "staging/<commit>",
},
{
name: "SemVer",
reference: &sourcev1.GitRepositoryRef{
SemVer: "*",
},
want: ctrl.Result{RequeueAfter: interval},
wantRevision: "v2.0.0/<commit>",
},
{
name: "SemVer range",
reference: &sourcev1.GitRepositoryRef{
SemVer: "<v0.2.1",
},
want: ctrl.Result{RequeueAfter: interval},
wantRevision: "0.2.0/<commit>",
},
{
name: "SemVer prerelease",
reference: &sourcev1.GitRepositoryRef{
SemVer: ">=1.0.0-0 <1.1.0-0",
},
wantRevision: "v1.0.0-alpha/<commit>",
want: ctrl.Result{RequeueAfter: interval},
},
}
server, err := gittestserver.NewTempGitServer()
g.Expect(err).To(BeNil())
server.AutoCreate()
g.Expect(server.StartHTTP()).To(Succeed())
defer server.StopHTTP()
repoPath := "/test.git"
localRepo, err := initGitRepo(server, "testdata/git/repository", git.DefaultBranch, repoPath)
g.Expect(err).NotTo(HaveOccurred())
headRef, err := localRepo.Head()
g.Expect(err).NotTo(HaveOccurred())
for _, branch := range branches {
g.Expect(remoteBranchForHead(localRepo, headRef, branch)).To(Succeed())
}
for _, tag := range tags {
g.Expect(remoteTagForHead(localRepo, headRef, tag)).To(Succeed())
}
r := &GitRepositoryReconciler{
Client: fakeclient.NewClientBuilder().WithScheme(runtime.NewScheme()).Build(),
Storage: testStorage,
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "checkout-strategy-",
},
Spec: sourcev1.GitRepositorySpec{
Interval: metav1.Duration{Duration: interval},
Timeout: &metav1.Duration{Duration: interval},
URL: server.HTTPAddress() + repoPath,
Reference: tt.reference,
},
}
if obj.Spec.Reference != nil && obj.Spec.Reference.Commit == "<commit>" {
obj.Spec.Reference.Commit = headRef.Hash().String()
}
for _, i := range testGitImplementations {
t.Run(i, func(t *testing.T) {
g := NewWithT(t)
tmpDir, err := os.MkdirTemp("", "checkout-strategy-")
g.Expect(err).NotTo(HaveOccurred())
defer os.RemoveAll(tmpDir)
obj := obj.DeepCopy()
obj.Spec.GitImplementation = i
var artifact sourcev1.Artifact
got, err := r.reconcileSource(ctx, obj, &artifact, tmpDir)
if err != nil {
println(err.Error())
}
g.Expect(err != nil).To(Equal(tt.wantErr))
g.Expect(got).To(Equal(tt.want))
if tt.wantRevision != "" {
revision := strings.ReplaceAll(tt.wantRevision, "<commit>", headRef.Hash().String())
g.Expect(artifact.Revision).To(Equal(revision))
g.Expect(conditions.IsTrue(obj, sourcev1.ArtifactOutdatedCondition)).To(BeTrue())
}
})
}
})
}
}
func TestGitRepositoryReconciler_reconcileArtifact(t *testing.T) {
tests := []struct {
name string
dir string
beforeFunc func(obj *sourcev1.GitRepository)
afterFunc func(t *WithT, obj *sourcev1.GitRepository, artifact sourcev1.Artifact)
want ctrl.Result
wantErr bool
assertConditions []metav1.Condition
}{
{
name: "Archiving artifact to storage makes Ready=True",
dir: "testdata/git/repository",
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
},
afterFunc: func(t *WithT, obj *sourcev1.GitRepository, artifact sourcev1.Artifact) {
t.Expect(obj.GetArtifact()).ToNot(BeNil())
t.Expect(obj.GetArtifact().Checksum).To(Equal("f9955588f6aeed7be9b1ef15cd2ddac47bb53291"))
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Stored artifact for revision 'main/revision'"),
},
},
{
name: "Spec ignore overwrite is taken into account",
dir: "testdata/git/repository",
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
obj.Spec.Ignore = pointer.StringPtr("!**.txt\n")
},
afterFunc: func(t *WithT, obj *sourcev1.GitRepository, artifact sourcev1.Artifact) {
t.Expect(obj.GetArtifact()).ToNot(BeNil())
t.Expect(obj.GetArtifact().Checksum).To(Equal("542a8ad0171118a3249e8c531c598b898defd742"))
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Stored artifact for revision 'main/revision'"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
r := &GitRepositoryReconciler{
Storage: testStorage,
}
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "reconcile-artifact-",
Generation: 1,
},
Status: sourcev1.GitRepositoryStatus{},
}
if tt.beforeFunc != nil {
tt.beforeFunc(obj)
}
artifact := testStorage.NewArtifactFor(obj.Kind, obj, "main/revision", "checksum.tar.gz")
got, err := r.reconcileArtifact(ctx, obj, artifact, nil, tt.dir)
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions))
g.Expect(err != nil).To(Equal(tt.wantErr))
g.Expect(got).To(Equal(tt.want))
if tt.afterFunc != nil {
tt.afterFunc(g, obj, artifact)
}
})
}
}
func TestGitRepositoryReconciler_reconcileInclude(t *testing.T) {
g := NewWithT(t)
server, err := testserver.NewTempArtifactServer()
g.Expect(err).NotTo(HaveOccurred())
storage, err := newTestStorage(server.HTTPServer)
g.Expect(err).NotTo(HaveOccurred())
defer os.RemoveAll(testStorage.BasePath)
dependencyInterval := 5 * time.Second
type dependency struct {
name string
withArtifact bool
conditions []metav1.Condition
}
type include struct {
name string
fromPath string
toPath string
shouldExist bool
}
tests := []struct {
name string
dependencies []dependency
includes []include
beforeFunc func(obj *sourcev1.GitRepository)
want ctrl.Result
wantErr bool
assertConditions []metav1.Condition
}{
{
name: "New includes make ArtifactOutdated=True",
dependencies: []dependency{
{
name: "a",
withArtifact: true,
conditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, "Foo", "foo ready"),
},
},
{
name: "b",
withArtifact: true,
conditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, "Bar", "bar ready"),
},
},
},
includes: []include{
{name: "a", toPath: "a/"},
{name: "b", toPath: "b/"},
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.ArtifactOutdatedCondition, "IncludeChange", "Included artifacts differ from last observed includes"),
},
},
{
name: "Include get failure makes IncludeUnavailable=True and returns error",
includes: []include{
{name: "a", toPath: "a/"},
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.IncludeUnavailableCondition, "NotFound", "Could not get resource for include \"a\": gitrepositories.source.toolkit.fluxcd.io \"a\" not found"),
},
},
{
name: "Include without an artifact makes IncludeUnavailable=True",
dependencies: []dependency{
{
name: "a",
withArtifact: false,
conditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.IncludeUnavailableCondition, "Foo", "foo unavailable"),
},
},
},
includes: []include{
{name: "a", toPath: "a/"},
},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.IncludeUnavailableCondition, "NoArtifact", "No artifact available for include \"a\""),
},
},
{
name: "Invalid FromPath makes IncludeUnavailable=True and returns error",
dependencies: []dependency{
{
name: "a",
withArtifact: true,
},
},
includes: []include{
{name: "a", fromPath: "../../../path", shouldExist: false},
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.IncludeUnavailableCondition, "CopyFailure", "unpack/path: no such file or directory"),
},
},
{
name: "Outdated IncludeUnavailable is removed",
beforeFunc: func(obj *sourcev1.GitRepository) {
conditions.MarkTrue(obj, sourcev1.IncludeUnavailableCondition, "NoArtifact", "")
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
var depObjs []client.Object
for _, d := range tt.dependencies {
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
Name: d.name,
},
Status: sourcev1.GitRepositoryStatus{
Conditions: d.conditions,
},
}
if d.withArtifact {
obj.Status.Artifact = &sourcev1.Artifact{
Path: d.name + ".tar.gz",
Revision: d.name,
LastUpdateTime: metav1.Now(),
}
g.Expect(storage.Archive(obj.GetArtifact(), "testdata/git/repository", nil)).To(Succeed())
}
depObjs = append(depObjs, obj)
}
builder := fakeclient.NewClientBuilder().WithScheme(testEnv.GetScheme())
if len(tt.dependencies) > 0 {
builder.WithObjects(depObjs...)
}
r := &GitRepositoryReconciler{
Client: builder.Build(),
Events: helper.Events{
Scheme: testEnv.GetScheme(),
EventRecorder: record.NewFakeRecorder(32),
},
Storage: storage,
requeueDependency: dependencyInterval,
}
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
Name: "reconcile-include",
},
Spec: sourcev1.GitRepositorySpec{
Interval: metav1.Duration{Duration: interval},
},
}
for i, incl := range tt.includes {
incl := sourcev1.GitRepositoryInclude{
GitRepositoryRef: meta.LocalObjectReference{Name: incl.name},
FromPath: incl.fromPath,
ToPath: incl.toPath,
}
tt.includes[i].fromPath = incl.GetFromPath()
tt.includes[i].toPath = incl.GetToPath()
obj.Spec.Include = append(obj.Spec.Include, incl)
}
if tt.beforeFunc != nil {
tt.beforeFunc(obj)
}
tmpDir, err := os.MkdirTemp("", "include-")
g.Expect(err).NotTo(HaveOccurred())
defer os.RemoveAll(tmpDir)
var artifacts artifactSet
got, err := r.reconcileInclude(ctx, obj, artifacts, tmpDir)
g.Expect(obj.GetConditions()).To(conditions.MatchConditions(tt.assertConditions))
g.Expect(err != nil).To(Equal(tt.wantErr))
g.Expect(got).To(Equal(tt.want))
for _, i := range tt.includes {
if i.toPath != "" {
expect := g.Expect(filepath.Join(testStorage.BasePath, i.toPath))
if i.shouldExist {
expect.To(BeADirectory())
} else {
expect.NotTo(BeADirectory())
}
}
if i.shouldExist {
g.Expect(filepath.Join(testStorage.BasePath, i.toPath)).Should(BeADirectory())
} else {
g.Expect(filepath.Join(testStorage.BasePath, i.toPath)).ShouldNot(BeADirectory())
}
}
})
}
}
func TestGitRepositoryReconciler_reconcileDelete(t *testing.T) {
g := NewWithT(t)
r := &GitRepositoryReconciler{
Storage: testStorage,
}
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
Name: "reconcile-delete-",
DeletionTimestamp: &metav1.Time{Time: time.Now()},
Finalizers: []string{
sourcev1.SourceFinalizer,
},
},
Status: sourcev1.GitRepositoryStatus{},
}
artifact := testStorage.NewArtifactFor(sourcev1.GitRepositoryKind, obj.GetObjectMeta(), "revision", "foo.txt")
obj.Status.Artifact = &artifact
got, err := r.reconcileDelete(ctx, obj)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(got).To(Equal(ctrl.Result{}))
g.Expect(controllerutil.ContainsFinalizer(obj, sourcev1.SourceFinalizer)).To(BeFalse())
g.Expect(obj.Status.Artifact).To(BeNil())
}
func TestGitRepositoryReconciler_verifyCommitSignature(t *testing.T) {
tests := []struct {
name string
secret *corev1.Secret
commit git.Commit
beforeFunc func(obj *sourcev1.GitRepository)
want ctrl.Result
wantErr bool
assertConditions []metav1.Condition
}{
{
name: "Valid commit makes SourceVerifiedCondition=True",
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "existing",
},
},
commit: fake.NewCommit(true, "shasum"),
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
obj.Spec.Verification = &sourcev1.GitRepositoryVerification{
Mode: "head",
SecretRef: meta.LocalObjectReference{
Name: "existing",
},
}
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.SourceVerifiedCondition, meta.SucceededReason, "Verified signature of commit \"shasum\""),
},
},
{
name: "Invalid commit makes SourceVerifiedCondition=False and returns error",
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "existing",
},
},
commit: fake.NewCommit(false, "shasum"),
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
obj.Spec.Verification = &sourcev1.GitRepositoryVerification{
Mode: "head",
SecretRef: meta.LocalObjectReference{
Name: "existing",
},
}
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, meta.FailedReason, "Signature verification of commit \"shasum\" failed: invalid signature"),
},
},
{
name: "Secret get failure makes SourceVerified=False and returns error",
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
obj.Spec.Verification = &sourcev1.GitRepositoryVerification{
Mode: "head",
SecretRef: meta.LocalObjectReference{
Name: "none-existing",
},
}
},
wantErr: true,
assertConditions: []metav1.Condition{
*conditions.FalseCondition(sourcev1.SourceVerifiedCondition, meta.FailedReason, "PGP public keys secret error: secrets \"none-existing\" not found"),
},
},
{
name: "Nil verification in spec deletes SourceVerified condition",
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, "Foo", "")
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{},
},
{
name: "Empty verification mode in spec deletes SourceVerified condition",
beforeFunc: func(obj *sourcev1.GitRepository) {
obj.Spec.Interval = metav1.Duration{Duration: interval}
obj.Spec.Verification = &sourcev1.GitRepositoryVerification{}
conditions.MarkTrue(obj, sourcev1.SourceVerifiedCondition, "Foo", "")
},
want: ctrl.Result{RequeueAfter: interval},
assertConditions: []metav1.Condition{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
builder := fakeclient.NewClientBuilder().WithScheme(testEnv.GetScheme())
if tt.secret != nil {
builder.WithObjects(tt.secret)
}
r := &GitRepositoryReconciler{
Client: builder.Build(),
}
obj := &sourcev1.GitRepository{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "verify-commit-",
Generation: 1,
},
Status: sourcev1.GitRepositoryStatus{},
}
if tt.beforeFunc != nil {
tt.beforeFunc(obj)
}
got, err := r.verifyCommitSignature(logr.NewContext(ctx, log.NullLogger{}), obj, tt.commit)
g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(tt.assertConditions))
g.Expect(err != nil).To(Equal(tt.wantErr))
g.Expect(got).To(Equal(tt.want))
})
}
}
// helpers
func initGitRepo(server *gittestserver.GitServer, fixture, branch, repositoryPath string) (*gogit.Repository, error) {
fs := memfs.New()
repo, err := gogit.Init(memory.NewStorage(), fs)
if err != nil {
return nil, err
}
branchRef := plumbing.NewBranchReferenceName(branch)
if err = repo.CreateBranch(&config.Branch{
Name: branch,
Remote: gogit.DefaultRemoteName,
Merge: branchRef,
}); err != nil {
return nil, err
}
err = commitFromFixture(repo, fixture)
if err != nil {
return nil, err
}
if server.HTTPAddress() == "" {
if err = server.StartHTTP(); err != nil {
return nil, err
}
defer server.StopHTTP()
}
if _, err = repo.CreateRemote(&config.RemoteConfig{
Name: gogit.DefaultRemoteName,
URLs: []string{server.HTTPAddressWithCredentials() + repositoryPath},
}); err != nil {
return nil, err
}
if err = repo.Push(&gogit.PushOptions{
RefSpecs: []config.RefSpec{"refs/heads/*:refs/heads/*"},
}); err != nil {
return nil, err
}
return repo, nil
}
func Test_commitFromFixture(t *testing.T) {
g := NewWithT(t)
repo, err := gogit.Init(memory.NewStorage(), memfs.New())
g.Expect(err).ToNot(HaveOccurred())
err = commitFromFixture(repo, "testdata/git/repository")
g.Expect(err).ToNot(HaveOccurred())
}
func commitFromFixture(repo *gogit.Repository, fixture string) error {
working, err := repo.Worktree()
if err != nil {
return err
}
fs := working.Filesystem
if err = filepath.Walk(fixture, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return fs.MkdirAll(fs.Join(path[len(fixture):]), info.Mode())
}
fileBytes, err := os.ReadFile(path)
if err != nil {
return err
}
ff, err := fs.Create(path[len(fixture):])
if err != nil {
return err
}
defer ff.Close()
_, err = ff.Write(fileBytes)
return err
}); err != nil {
return err
}
_, err = working.Add(".")
if err != nil {
return err
}
if _, err = working.Commit("Fixtures from "+fixture, &gogit.CommitOptions{
Author: &object.Signature{
Name: "Jane Doe",
Email: "jane@example.com",
When: time.Now(),
},
}); err != nil {
return err
}
return nil
}
func remoteBranchForHead(repo *gogit.Repository, head *plumbing.Reference, branch string) error {
refSpec := fmt.Sprintf("%s:refs/heads/%s", head.Name(), branch)
return repo.Push(&gogit.PushOptions{
RemoteName: "origin",
RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
Force: true,
})
}
func remoteTagForHead(repo *gogit.Repository, head *plumbing.Reference, tag string) error {
if _, err := repo.CreateTag(tag, head.Hash(), &gogit.CreateTagOptions{
// Not setting this seems to make things flaky
// Expected success, but got an error:
// <*errors.errorString | 0xc0000f6350>: {
// s: "tagger field is required",
// }
// tagger field is required
Tagger: &object.Signature{
Name: "Jane Doe",
Email: "jane@example.com",
When: time.Now(),
},
Message: tag,
}); err != nil {
return err
}
refSpec := fmt.Sprintf("refs/tags/%[1]s:refs/tags/%[1]s", tag)
return repo.Push(&gogit.PushOptions{
RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
})
}