image-automation-controller/internal/source/source_test.go

1172 lines
34 KiB
Go

/*
Copyright 2024 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 source
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
fuzz "github.com/AdaLogics/go-fuzz-headers"
"github.com/ProtonMail/go-crypto/openpgp"
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-logr/logr"
. "github.com/onsi/gomega"
"github.com/otiai10/copy"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/rand"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/log"
imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta2"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/pkg/gittestserver"
"github.com/fluxcd/pkg/ssh"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
"github.com/fluxcd/image-automation-controller/internal/policy"
"github.com/fluxcd/image-automation-controller/internal/testutil"
)
const (
originRemote = "origin"
testCommitTemplate = `Commit summary
Automation: {{ .AutomationObject }}
Files:
{{ range $filename, $_ := .Updated.Files -}}
- {{ $filename }}
{{ end -}}
Objects:
{{ range $resource, $_ := .Updated.Objects -}}
{{ if eq $resource.Kind "Deployment" -}}
- {{ $resource.Kind | lower }} {{ $resource.Name | lower }}
{{ else -}}
- {{ $resource.Kind }} {{ $resource.Name }}
{{ end -}}
{{ end -}}
Images:
{{ range .Updated.Images -}}
- {{.}} ({{.Policy.Name}})
{{ end -}}
`
testCommitTemplateResultV2 = `Commit summary with ResultV2
Automation: {{ .AutomationObject }}
{{ range $filename, $objchange := .Changed.FileChanges -}}
- File: {{ $filename }}
{{- range $obj, $changes := $objchange }}
- Object: {{ $obj.Kind }}/{{ $obj.Namespace }}/{{ $obj.Name }}
Changes:
{{- range $_ , $change := $changes }}
- {{ $change.OldValue }} -> {{ $change.NewValue }}
{{ end -}}
{{ end -}}
{{ end -}}
`
testCommitTemplateWithValues = `Commit summary
Automation: {{ .AutomationObject }}
Cluster: {{ index .Values "cluster" }}
Testing: {{ .Values.testing }}
`
)
func init() {
utilruntime.Must(imagev1_reflect.AddToScheme(scheme.Scheme))
utilruntime.Must(sourcev1.AddToScheme(scheme.Scheme))
utilruntime.Must(imagev1.AddToScheme(scheme.Scheme))
log.SetLogger(logr.New(log.NullLogSink{}))
}
func Fuzz_templateMsg(f *testing.F) {
f.Add("template", []byte{})
f.Add("", []byte{})
f.Fuzz(func(t *testing.T, template string, seed []byte) {
var values TemplateData
fuzz.NewConsumer(seed).GenerateStruct(&values)
_, _ = templateMsg(template, &values)
})
}
func TestNewSourceManager(t *testing.T) {
namespace := "test-ns"
gitRepoName := "foo"
tests := []struct {
name string
objSpec imagev1.ImageUpdateAutomationSpec
opts []SourceOption
sourceNamespace string
wantErr bool
}{
{
name: "unsupported source ref kind",
objSpec: imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: "HelmChart",
},
},
wantErr: true,
},
{
name: "empty gitSpec",
objSpec: imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: sourcev1.GitRepositoryKind,
},
GitSpec: nil,
},
wantErr: true,
},
{
name: "refer cross namespace source",
objSpec: imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: sourcev1.GitRepositoryKind,
Name: gitRepoName,
Namespace: "foo-ns",
},
GitSpec: &imagev1.GitSpec{},
},
sourceNamespace: "foo-ns",
},
{
name: "refer cross namespace source with crossnamespace disabled",
objSpec: imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: sourcev1.GitRepositoryKind,
Name: gitRepoName,
Namespace: "foo-ns",
},
GitSpec: &imagev1.GitSpec{},
},
sourceNamespace: "foo-ns",
opts: []SourceOption{WithSourceOptionNoCrossNamespaceRef()},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
gitRepo := &sourcev1.GitRepository{}
gitRepo.Name = gitRepoName
gitRepo.Namespace = tt.sourceNamespace
gitRepo.Spec = sourcev1.GitRepositorySpec{
URL: "https://example.com",
Reference: &sourcev1.GitRepositoryRef{Branch: "main"},
}
clientBuilder := fakeclient.NewClientBuilder().
WithScheme(scheme.Scheme).
WithObjects(gitRepo)
c := clientBuilder.Build()
obj := &imagev1.ImageUpdateAutomation{}
obj.Name = "test-update"
obj.Namespace = namespace
obj.Spec = tt.objSpec
sm, err := NewSourceManager(context.TODO(), c, obj, tt.opts...)
if (err != nil) != tt.wantErr {
g.Fail(fmt.Sprintf("unexpected error: %v", err))
return
}
if err == nil {
g.Expect(os.RemoveAll(sm.WorkDirectory()))
}
})
}
}
func TestSourceManager_CheckoutSource(t *testing.T) {
test_sourceManager_CheckoutSource(t, "http")
test_sourceManager_CheckoutSource(t, "ssh")
}
func test_sourceManager_CheckoutSource(t *testing.T, proto string) {
tests := []struct {
name string
autoGitSpec *imagev1.GitSpec
gitRepoRef *sourcev1.GitRepositoryRef
shallowClone bool
lastObserved bool
wantErr bool
wantRef string
}{
{
name: "checkout for single branch",
autoGitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{Branch: "main"},
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{Branch: "main"},
},
},
wantErr: false,
wantRef: "main",
},
{
name: "checkout for different push branch",
autoGitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{Branch: "foo"},
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{Branch: "main"},
},
},
wantErr: false,
wantRef: "foo",
},
{
name: "checkout from gitrepo ref",
autoGitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{Branch: "main"},
},
gitRepoRef: &sourcev1.GitRepositoryRef{
Branch: "main",
},
wantErr: false,
wantRef: "main",
},
{
name: "with shallow clone",
autoGitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{Branch: "main"},
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{Branch: "main"},
},
},
shallowClone: true,
wantErr: false,
wantRef: "main",
},
{
name: "with last observed commit",
autoGitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{Branch: "main"},
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{Branch: "main"},
},
},
lastObserved: true,
wantErr: false,
wantRef: "main",
},
{
name: "checkout non-existing branch",
autoGitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{Branch: "main"},
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{Branch: "non-existing"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s(%s)", tt.name, proto), func(t *testing.T) {
g := NewWithT(t)
ctx := context.TODO()
testObjects := []client.Object{}
testNS := "test-ns"
// Run git server.
gitServer := testutil.SetUpGitTestServer(g)
t.Cleanup(func() {
g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred())
gitServer.StopHTTP()
})
// Start the ssh server if needed.
if proto == "ssh" {
go func() {
gitServer.StartSSH()
}()
defer func() {
g.Expect(gitServer.StopSSH()).To(Succeed())
}()
}
// Create a git repo on the server.
fixture := "testdata/appconfig"
branch := rand.String(5)
repoPath := "/config-" + rand.String(5) + ".git"
initRepo := testutil.InitGitRepo(g, gitServer, fixture, branch, repoPath)
// Obtain the head revision reference.
initHead, err := initRepo.Head()
g.Expect(err).ToNot(HaveOccurred())
headRev := fmt.Sprintf("%s@sha1:%s", initHead.Name().Short(), initHead.Hash().String())
repoURL, err := getRepoURL(gitServer, repoPath, proto)
g.Expect(err).ToNot(HaveOccurred())
// Create GitRepository for the above git repository.
gitRepo := &sourcev1.GitRepository{}
gitRepo.Name = "test-repo"
gitRepo.Namespace = testNS
gitRepo.Spec = sourcev1.GitRepositorySpec{
URL: repoURL,
}
if tt.gitRepoRef != nil {
gitRepo.Spec.Reference = tt.gitRepoRef
}
// Create ssh Secret for the GitRepository.
if proto == "ssh" {
sshSecretName := "ssh-key-" + rand.String(5)
sshSecret, err := getSSHIdentitySecret(sshSecretName, testNS, repoURL)
g.Expect(err).ToNot(HaveOccurred())
testObjects = append(testObjects, sshSecret)
gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: sshSecretName}
}
testObjects = append(testObjects, gitRepo)
// Create an ImageUpdateAutomation to checkout the above git
// repository.
updateAuto := &imagev1.ImageUpdateAutomation{}
updateAuto.Name = "test-update"
updateAuto.Namespace = testNS
updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
GitSpec: tt.autoGitSpec,
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: sourcev1.GitRepositoryKind,
Name: gitRepo.Name,
},
}
testObjects = append(testObjects, updateAuto)
kClient := fakeclient.NewClientBuilder().
WithScheme(scheme.Scheme).
WithObjects(testObjects...).
Build()
sm, err := NewSourceManager(ctx, kClient, updateAuto, WithSourceOptionGitAllBranchReferences())
g.Expect(err).ToNot(HaveOccurred())
defer func() {
g.Expect(sm.Cleanup()).ToNot(HaveOccurred())
}()
opts := []CheckoutOption{}
if tt.shallowClone {
opts = append(opts, WithCheckoutOptionShallowClone())
}
if tt.lastObserved {
opts = append(opts, WithCheckoutOptionLastObserved(headRev))
}
commit, err := sm.CheckoutSource(ctx, opts...)
if (err != nil) != tt.wantErr {
g.Fail("unexpected error")
return
}
if err == nil {
if tt.lastObserved {
g.Expect(git.IsConcreteCommit(*commit)).To(BeFalse())
// Didn't download anything, can't check anything.
} else {
g.Expect(git.IsConcreteCommit(*commit)).To(BeTrue())
// Inspect the cloned repository.
r, err := extgogit.PlainOpen(sm.workingDir)
g.Expect(err).ToNot(HaveOccurred())
ref, err := r.Head()
g.Expect(err).ToNot(HaveOccurred())
g.Expect(ref.Name().Short()).To(Equal(tt.wantRef))
}
}
})
}
}
func TestSourceManager_CommitAndPush(t *testing.T) {
test_sourceManager_CommitAndPush(t, "http")
test_sourceManager_CommitAndPush(t, "ssh")
}
func test_sourceManager_CommitAndPush(t *testing.T, proto string) {
tests := []struct {
name string
gitSpec *imagev1.GitSpec
gitRepoReference *sourcev1.GitRepositoryRef
latestImage string
noChange bool
wantErr bool
wantCommitMsg string
checkRefSpecBranch string
}{
{
name: "push to cloned branch with custom template",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main",
},
Commit: imagev1.CommitSpec{
MessageTemplate: testCommitTemplate,
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: `Commit summary
Automation: test-ns/test-update
Files:
- deploy.yaml
Objects:
- deployment test
Images:
- helloworld:1.0.1 (policy1)
`,
},
{
name: "commit with update ResultV2 template",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main",
},
Commit: imagev1.CommitSpec{
MessageTemplate: testCommitTemplateResultV2,
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: `Commit summary with ResultV2
Automation: test-ns/test-update
- File: deploy.yaml
- Object: Deployment//test
Changes:
- helloworld:1.0.0 -> helloworld:1.0.1
`,
},
{
name: "push to cloned branch with template and values",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main",
},
Commit: imagev1.CommitSpec{
MessageTemplate: testCommitTemplateWithValues,
MessageTemplateValues: map[string]string{
"cluster": "prod",
"testing": "value",
},
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: `Commit summary
Automation: test-ns/test-update
Cluster: prod
Testing: value
`,
},
{
name: "push to different branch",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main2",
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: defaultMessageTemplate,
},
{
name: "push to cloned branch+refspec",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main",
Refspec: "refs/heads/main:refs/heads/smth/else",
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: defaultMessageTemplate,
checkRefSpecBranch: "smth/else",
},
{
name: "push to different branch+refspec",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "auto",
Refspec: "refs/heads/auto:refs/heads/smth/else",
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: defaultMessageTemplate,
checkRefSpecBranch: "smth/else",
},
{
name: "push to branch from tag",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main2",
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Tag: "v1.0.0",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: defaultMessageTemplate,
},
{
name: "push signed commit to branch",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main",
},
Commit: imagev1.CommitSpec{
Author: imagev1.CommitUser{
Name: "Flux B Ot",
Email: "fluxbot@example.com",
},
SigningKey: &imagev1.SigningKey{
SecretRef: meta.LocalObjectReference{
Name: "test-signing-key",
},
},
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.1",
wantErr: false,
wantCommitMsg: defaultMessageTemplate,
},
{
name: "no change to push",
gitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: "main",
},
},
gitRepoReference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
latestImage: "helloworld:1.0.0",
noChange: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s(%s)", tt.name, proto), func(t *testing.T) {
g := NewWithT(t)
ctx := context.TODO()
testObjects := []client.Object{}
// Run git server.
gitServer := testutil.SetUpGitTestServer(g)
t.Cleanup(func() {
g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred())
gitServer.StopHTTP()
})
// Start the ssh server if needed.
if proto == "ssh" {
go func() {
gitServer.StartSSH()
}()
defer func() {
g.Expect(gitServer.StopSSH()).To(Succeed())
}()
}
// Prepare test directory.
workDir := t.TempDir()
testNS := "test-ns"
imgPolicy := &imagev1_reflect.ImagePolicy{}
imgPolicy.Name = "policy1"
imgPolicy.Namespace = testNS
imgPolicy.Status = imagev1_reflect.ImagePolicyStatus{
LatestImage: tt.latestImage,
}
testObjects = append(testObjects, imgPolicy)
policyKey := client.ObjectKeyFromObject(imgPolicy)
fixture := "testdata/appconfig"
g.Expect(copy.Copy(fixture, workDir)).ToNot(HaveOccurred())
// Update the setters in the test data.
g.Expect(testutil.ReplaceMarker(filepath.Join(workDir, "deploy.yaml"), policyKey))
// Create a git repo with the test directory content.
branch := "main"
repoPath := "/config-" + rand.String(5) + ".git"
repo := testutil.InitGitRepo(g, gitServer, workDir, branch, repoPath)
// Create a tag.
if tt.gitRepoReference.Tag != "" {
h, err := repo.Head()
g.Expect(err).ToNot(HaveOccurred())
testutil.TagCommit(g, repo, h.Hash(), false, tt.gitRepoReference.Tag, time.Now())
}
cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repoPath
repoURL, err := getRepoURL(gitServer, repoPath, proto)
g.Expect(err).ToNot(HaveOccurred())
// Create GitRepository for the above git repository.
gitRepo := &sourcev1.GitRepository{}
gitRepo.Name = "test-repo"
gitRepo.Namespace = testNS
gitRepo.Spec = sourcev1.GitRepositorySpec{
URL: repoURL,
}
if tt.gitRepoReference != nil {
gitRepo.Spec.Reference = tt.gitRepoReference
}
// Create ssh Secret for the GitRepository.
if proto == "ssh" {
sshSecretName := "ssh-key-" + rand.String(5)
sshSecret, err := getSSHIdentitySecret(sshSecretName, testNS, repoURL)
g.Expect(err).ToNot(HaveOccurred())
testObjects = append(testObjects, sshSecret)
gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: sshSecretName}
}
testObjects = append(testObjects, gitRepo)
// Create an ImageUpdateAutomation to update the above git repository.
updateAuto := &imagev1.ImageUpdateAutomation{}
updateAuto.Name = "test-update"
updateAuto.Namespace = testNS
updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: sourcev1.GitRepositoryKind,
Name: gitRepo.Name,
},
Update: &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
},
}
testObjects = append(testObjects, updateAuto)
var pgpEntity *openpgp.Entity
var signingSecret *corev1.Secret
if tt.gitSpec != nil {
updateAuto.Spec.GitSpec = tt.gitSpec
if tt.gitSpec.Commit.SigningKey != nil {
signingSecret, pgpEntity = testutil.GetSigningKeyPairSecret(g, tt.gitSpec.Commit.SigningKey.SecretRef.Name, testNS)
testObjects = append(testObjects, signingSecret)
}
}
kClient := fakeclient.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(testObjects...).Build()
sm, err := NewSourceManager(ctx, kClient, updateAuto, WithSourceOptionGitAllBranchReferences())
g.Expect(err).ToNot(HaveOccurred())
defer func() {
g.Expect(sm.Cleanup()).ToNot(HaveOccurred())
}()
_, err = sm.CheckoutSource(ctx)
g.Expect(err).ToNot(HaveOccurred())
policies := []imagev1_reflect.ImagePolicy{*imgPolicy}
result, err := policy.ApplyPolicies(ctx, sm.workingDir, updateAuto, policies)
g.Expect(err).ToNot(HaveOccurred())
pushResult, err := sm.CommitAndPush(ctx, updateAuto, result)
g.Expect(err != nil).To(Equal(tt.wantErr))
if tt.noChange {
g.Expect(pushResult).To(BeNil())
return
}
// Inspect the pushed commit in the repository.
localRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, sm.srcCfg.pushBranch, originRemote)
g.Expect(err).ToNot(HaveOccurred())
defer func() { os.RemoveAll(cloneDir) }()
head, _ := localRepo.Head()
pushBranchHash := head.Hash()
commit, err := localRepo.CommitObject(pushBranchHash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Hash.String()).To(Equal(pushResult.Commit().Hash.String()))
g.Expect(commit.Message).To(Equal(tt.wantCommitMsg))
// Verify commit signature.
if pgpEntity != nil {
// Separate the commit signature and content, and verify with
// the known PGP Entity.
c := *commit
c.PGPSignature = ""
encoded := &plumbing.MemoryObject{}
g.Expect(c.Encode(encoded)).ToNot(HaveOccurred())
content, err := encoded.Reader()
g.Expect(err).ToNot(HaveOccurred())
kr := openpgp.EntityList([]*openpgp.Entity{pgpEntity})
signature := strings.NewReader(commit.PGPSignature)
_, err = openpgp.CheckArmoredDetachedSignature(kr, content, signature, nil)
g.Expect(err).ToNot(HaveOccurred())
}
// Clone the repo at refspec and verify its commit.
if tt.gitSpec.Push.Refspec != "" {
refLocalRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, tt.checkRefSpecBranch, originRemote)
g.Expect(err).ToNot(HaveOccurred())
defer func() { os.RemoveAll(cloneDir) }()
refName := plumbing.NewRemoteReferenceName(extgogit.DefaultRemoteName, tt.checkRefSpecBranch)
ref, err := refLocalRepo.Reference(refName, true)
g.Expect(err).ToNot(HaveOccurred())
refspecHash := ref.Hash()
g.Expect(pushBranchHash).To(Equal(refspecHash))
}
})
}
}
// Test_pushBranchUpdateScenarios tests the push operation for different states
// of the remote repository.
func Test_pushBranchUpdateScenarios(t *testing.T) {
// This test requires all branch references to be enabled.
sourceOpts := []SourceOption{WithSourceOptionGitAllBranchReferences()}
testcases := []struct {
name string
checkoutOpts []CheckoutOption
pushConfig []PushConfig
}{
{
name: "default checkout and push configs",
},
{
name: "shallow clone and force push",
checkoutOpts: []CheckoutOption{
WithCheckoutOptionShallowClone(),
},
pushConfig: []PushConfig{
WithPushConfigForce(),
},
},
}
for _, tt := range testcases {
for _, proto := range []string{"http", "ssh"} {
t.Run(fmt.Sprintf("%s(%s)", tt.name, proto), func(t *testing.T) {
test_pushBranchUpdateScenarios(t, proto, sourceOpts, tt.checkoutOpts, tt.pushConfig)
})
}
}
}
func test_pushBranchUpdateScenarios(t *testing.T, proto string, srcOpts []SourceOption, checkoutOpts []CheckoutOption, pushCfg []PushConfig) {
g := NewWithT(t)
ctx := context.TODO()
testObjects := []client.Object{}
// Run git server.
gitServer := testutil.SetUpGitTestServer(g)
t.Cleanup(func() {
g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred())
gitServer.StopHTTP()
})
// Start the ssh server if needed.
if proto == "ssh" {
go func() {
gitServer.StartSSH()
}()
defer func() {
g.Expect(gitServer.StopSSH()).To(Succeed())
}()
}
// Prepare test directory.
workDir := t.TempDir()
testNS := "test-ns"
fixture := "testdata/appconfig"
g.Expect(copy.Copy(fixture, workDir)).ToNot(HaveOccurred())
// Create a git repo with the test directory content.
branch := "main"
repoPath := "/config-" + rand.String(5) + ".git"
_ = testutil.InitGitRepo(g, gitServer, workDir, branch, repoPath)
pushBranch := "pr-" + rand.String(5)
cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repoPath
repoURL, err := getRepoURL(gitServer, repoPath, proto)
g.Expect(err).ToNot(HaveOccurred())
// Clone the repo locally.
localRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, branch, originRemote)
g.Expect(err).ToNot(HaveOccurred())
defer func() { os.RemoveAll(cloneDir) }()
// Create ImagePolicy, GitRepository and ImageUpdateAutomation objects.
latestImage := "helloworld:1.0.1"
imgPolicy := &imagev1_reflect.ImagePolicy{}
imgPolicy.Name = "policy1"
imgPolicy.Namespace = testNS
imgPolicy.Status = imagev1_reflect.ImagePolicyStatus{
LatestImage: latestImage,
}
testObjects = append(testObjects, imgPolicy)
// Take the policyKey to update the setter marker with.
policyKey := client.ObjectKeyFromObject(imgPolicy)
gitRepo := &sourcev1.GitRepository{}
gitRepo.Name = "test-repo"
gitRepo.Namespace = testNS
gitRepo.Spec = sourcev1.GitRepositorySpec{
URL: repoURL,
// Set a reference to main branch explicitly. If unspecified, it'll
// default to "master". The test repo above is set up against "main"
// branch.
Reference: &sourcev1.GitRepositoryRef{
Branch: "main",
},
}
// Create ssh Secret for the GitRepository.
if proto == "ssh" {
sshSecretName := "ssh-key-" + rand.String(5)
sshSecret, err := getSSHIdentitySecret(sshSecretName, testNS, repoURL)
g.Expect(err).ToNot(HaveOccurred())
testObjects = append(testObjects, sshSecret)
gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: sshSecretName}
}
testObjects = append(testObjects, gitRepo)
commitTemplate := "Commit a difference " + rand.String(5)
updateAuto := &imagev1.ImageUpdateAutomation{}
updateAuto.Name = "test-update"
updateAuto.Namespace = testNS
updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: sourcev1.GitRepositoryKind,
Name: gitRepo.Name,
},
Update: &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
},
GitSpec: &imagev1.GitSpec{
Push: &imagev1.PushSpec{
Branch: pushBranch,
},
Commit: imagev1.CommitSpec{
MessageTemplate: commitTemplate,
},
},
}
testObjects = append(testObjects, updateAuto)
kClient := fakeclient.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(testObjects...).Build()
// Commit in the repository, updating the source with setter markers.
preChangeCommitId := testutil.CommitIdFromBranch(localRepo, branch)
testutil.CommitInRepo(ctx, g, cloneLocalRepoURL, branch, originRemote, "Install setter marker", func(tmp string) {
g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
})
// Pull the pushed changes in the local repo.
testutil.WaitForNewHead(g, localRepo, branch, originRemote, preChangeCommitId)
// ======= Scenario 1 =======
// Push to a separate push branch.
checkoutBranchHead, err := testutil.HeadFromBranch(localRepo, branch)
g.Expect(err).ToNot(HaveOccurred())
policies := []imagev1_reflect.ImagePolicy{*imgPolicy}
checkoutAndUpdate(ctx, g, kClient, updateAuto, policies, srcOpts, checkoutOpts, pushCfg)
// Pull the new changes to the local repo.
preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
testutil.WaitForNewHead(g, localRepo, pushBranch, originRemote, preChangeCommitId)
// Check the commits in the branches.
pushBranchHead, err := testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
g.Expect(err).NotTo(HaveOccurred())
commit, err := localRepo.CommitObject(pushBranchHead)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Message).To(Equal(commitTemplate))
// previous commits should still exist in the tree.
// regression check to ensure previous commits were not squashed.
oldCommit, err := localRepo.CommitObject(checkoutBranchHead.Hash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(oldCommit).ToNot(BeNil())
// ======= Scenario 2 =======
// Push branch gets updated.
checkoutBranchHead, err = testutil.HeadFromBranch(localRepo, branch)
g.Expect(err).ToNot(HaveOccurred())
// Get the head of push branch before update.
pushBranchHead, err = testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
g.Expect(err).ToNot(HaveOccurred())
// Update latest image.
latestImage = "helloworld:v1.3.0"
imgPolicy.Status.LatestImage = latestImage
g.Expect(kClient.Update(ctx, imgPolicy)).To(Succeed())
policies = []imagev1_reflect.ImagePolicy{*imgPolicy}
checkoutAndUpdate(ctx, g, kClient, updateAuto, policies, srcOpts, checkoutOpts, pushCfg)
// Pull the new changes to the local repo.
preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
testutil.WaitForNewHead(g, localRepo, pushBranch, originRemote, preChangeCommitId)
newPushBranchHead, err := testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(newPushBranchHead.String()).NotTo(Equal(pushBranchHead))
// previous commits should still exist in the tree.
// regression check to ensure previous commits were not squashed.
oldCommit, err = localRepo.CommitObject(checkoutBranchHead.Hash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(oldCommit).ToNot(BeNil())
// ======= Scenario 3 =======
// Still pushes to push branch after it's merged.
checkoutBranchHead, err = testutil.HeadFromBranch(localRepo, branch)
g.Expect(err).ToNot(HaveOccurred())
// Get the head of push branch before update.
pushBranchHead, err = testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
g.Expect(err).ToNot(HaveOccurred())
// Merge the push branch into checkout branch, and push the merge commit
// upstream.
// WaitForNewHead() leaves the repo at the head of the branch given, i.e., the
// push branch, so we have to check out the "main" branch first.
w, err := localRepo.Worktree()
g.Expect(err).ToNot(HaveOccurred())
w.Pull(&extgogit.PullOptions{
RemoteName: originRemote,
ReferenceName: plumbing.ReferenceName(fmt.Sprintf("refs/remotes/origin/%s", pushBranch)),
})
err = localRepo.Push(&extgogit.PushOptions{
RemoteName: originRemote,
RefSpecs: []config.RefSpec{
config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/remotes/origin/%s", branch, pushBranch))},
})
g.Expect(err).ToNot(HaveOccurred())
// Update latest image.
latestImage = "helloworld:v1.3.1"
imgPolicy.Status.LatestImage = latestImage
g.Expect(kClient.Update(ctx, imgPolicy)).To(Succeed())
policies = []imagev1_reflect.ImagePolicy{*imgPolicy}
checkoutAndUpdate(ctx, g, kClient, updateAuto, policies, srcOpts, checkoutOpts, pushCfg)
// Pull the new changes to the local repo.
preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
testutil.WaitForNewHead(g, localRepo, pushBranch, originRemote, preChangeCommitId)
newPushBranchHead, err = testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(newPushBranchHead.String()).NotTo(Equal(pushBranchHead))
// previous commits should still exist in the tree.
// regression check to ensure previous commits were not squashed.
oldCommit, err = localRepo.CommitObject(checkoutBranchHead.Hash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(oldCommit).ToNot(BeNil())
}
func TestPushResult_Summary(t *testing.T) {
testRev := "a47b32f4814810acac804df5054ec37cbfdbfb53"
testRevShort := testRev[:7]
testBranch := "test-branch"
tests := []struct {
name string
rev string
commitMsg string
refspecs []string
wantSummary string
wantErr bool
}{
{
name: "only push branch",
rev: testRev,
commitMsg: defaultMessageTemplate,
wantSummary: fmt.Sprintf("pushed commit '%s' to branch '%s'\nUpdate from image update automation", testRevShort, testBranch),
},
{
name: "with custom template",
rev: testRev,
commitMsg: "test commit message",
wantSummary: fmt.Sprintf(`pushed commit '%s' to branch '%s'
test commit message`,
testRevShort, testBranch),
},
{
name: "no template",
rev: testRev,
wantSummary: fmt.Sprintf("pushed commit '%s' to branch '%s'", testRevShort, testBranch),
},
{
name: "with refspec",
rev: testRev,
commitMsg: defaultMessageTemplate,
refspecs: []string{"refs/heads/auto:refs/heads/smth/else", "refs/heads/auto:refs/heads/foo"},
wantSummary: fmt.Sprintf(`pushed commit '%s' to branch '%s' and refspecs 'refs/heads/auto:refs/heads/smth/else', 'refs/heads/auto:refs/heads/foo'
Update from image update automation`, testRevShort, testBranch),
},
{
name: "short rev",
rev: "foo",
commitMsg: defaultMessageTemplate,
wantSummary: fmt.Sprintf(`pushed commit '%s' to branch '%s'
Update from image update automation`, "foo", testBranch),
},
{
name: "empty rev",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
prOpts := []PushResultOption{WithPushResultRefspec(tt.refspecs)}
pr, err := NewPushResult(testBranch, tt.rev, tt.commitMsg, prOpts...)
if (err != nil) != tt.wantErr {
g.Fail("unexpected error")
return
}
if err == nil {
g.Expect(pr.Summary()).To(Equal(tt.wantSummary))
}
})
}
}
// checkoutAndUpdate performs source checkout, update and push for the given
// arguments.
func checkoutAndUpdate(ctx context.Context, g *WithT, kClient client.Client,
updateAuto *imagev1.ImageUpdateAutomation, policies []imagev1_reflect.ImagePolicy,
srcOpts []SourceOption, checkoutOpts []CheckoutOption, pushCfg []PushConfig) {
g.THelper()
sm, err := NewSourceManager(ctx, kClient, updateAuto, srcOpts...)
g.Expect(err).ToNot(HaveOccurred())
defer func() { g.Expect(sm.Cleanup()).ToNot(HaveOccurred()) }()
_, err = sm.CheckoutSource(ctx, checkoutOpts...)
g.Expect(err).ToNot(HaveOccurred())
result, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), updateAuto, policies)
g.Expect(err).ToNot(HaveOccurred())
_, err = sm.CommitAndPush(ctx, updateAuto, result, pushCfg...)
g.Expect(err).ToNot(HaveOccurred())
}
func getRepoURL(gitServer *gittestserver.GitServer, repoPath, proto string) (string, error) {
if proto == "http" {
return gitServer.HTTPAddressWithCredentials() + repoPath, nil
} else if proto == "ssh" {
// This is expected to use 127.0.0.1, but host key
// checking usually wants a hostname, so use
// "localhost".
sshURL := strings.Replace(gitServer.SSHAddress(), "127.0.0.1", "localhost", 1)
return sshURL + repoPath, nil
}
return "", fmt.Errorf("proto not set to http or ssh")
}
func getSSHIdentitySecret(name, namespace, repoURL string) (*corev1.Secret, error) {
url, err := url.Parse(repoURL)
if err != nil {
return nil, err
}
knownhosts, err := ssh.ScanHostKey(url.Host, 5*time.Second, []string{}, false)
if err != nil {
return nil, err
}
keygen := ssh.NewRSAGenerator(2048)
pair, err := keygen.Generate()
if err != nil {
return nil, err
}
sec := &corev1.Secret{
StringData: map[string]string{
"known_hosts": string(knownhosts),
"identity": string(pair.PrivateKey),
"identity.pub": string(pair.PublicKey),
},
// Without KAS, StringData and Data must be kept in sync manually.
Data: map[string][]byte{
"known_hosts": knownhosts,
"identity": pair.PrivateKey,
"identity.pub": pair.PublicKey,
},
}
sec.Name = name
sec.Namespace = namespace
return sec, nil
}