/* 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 sparseCheckoutDirectory string 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 sparse checkout", autoGitSpec: &imagev1.GitSpec{ Push: &imagev1.PushSpec{Branch: "main"}, Checkout: &imagev1.GitCheckoutSpec{ Reference: sourcev1.GitRepositoryRef{Branch: "main"}, }, }, sparseCheckoutDirectory: "testdata/appconfig/deploy.yaml", wantErr: false, wantRef: "main", }, { name: "with sparse checkout for current directory", autoGitSpec: &imagev1.GitSpec{ Push: &imagev1.PushSpec{Branch: "main"}, Checkout: &imagev1.GitCheckoutSpec{ Reference: sourcev1.GitRepositoryRef{Branch: "main"}, }, }, sparseCheckoutDirectory: "./", wantErr: false, wantRef: "main", }, { name: "with sparse checkout for different push branch", autoGitSpec: &imagev1.GitSpec{ Push: &imagev1.PushSpec{Branch: "foo"}, Checkout: &imagev1.GitCheckoutSpec{ Reference: sourcev1.GitRepositoryRef{Branch: "main"}, }, }, sparseCheckoutDirectory: "testdata/appconfig/deploy.yaml", wantErr: false, wantRef: "foo", }, { 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.sparseCheckoutDirectory != "" { opts = append(opts, WithCheckoutOptionSparseCheckoutDirectories(tt.sparseCheckoutDirectory)) } 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{ LatestRef: testutil.ImageToRef(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{ LatestRef: testutil.ImageToRef(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.LatestRef = testutil.ImageToRef(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.LatestRef = testutil.ImageToRef(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 }