image-automation-controller/controllers/update_test.go

1655 lines
52 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 (
"bytes"
"context"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"testing"
"time"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-logr/logr"
. "github.com/onsi/gomega"
"github.com/otiai10/copy"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
corev1 "k8s.io/api/core/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
extgogit "github.com/fluxcd/go-git/v5"
"github.com/fluxcd/go-git/v5/config"
extgogitcfg "github.com/fluxcd/go-git/v5/config"
"github.com/fluxcd/go-git/v5/plumbing"
"github.com/fluxcd/go-git/v5/plumbing/cache"
"github.com/fluxcd/go-git/v5/plumbing/object"
"github.com/fluxcd/go-git/v5/storage/filesystem"
imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta1"
"github.com/fluxcd/pkg/apis/acl"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/git/gogit"
"github.com/fluxcd/pkg/git/gogit/fs"
"github.com/fluxcd/pkg/gittestserver"
"github.com/fluxcd/pkg/ssh"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
"github.com/fluxcd/image-automation-controller/internal/features"
"github.com/fluxcd/image-automation-controller/pkg/test"
"github.com/fluxcd/image-automation-controller/pkg/update"
)
const (
timeout = 10 * time.Second
testAuthorName = "Flux B Ot"
testAuthorEmail = "fluxbot@example.com"
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 -}}
`
testCommitMessageFmt = `Commit summary
Automation: %s/update-test
Files:
- deploy.yaml
Objects:
- deployment test
Images:
- helloworld:v1.0.0 (%s)
`
)
var (
// Copied from
// https://github.com/fluxcd/source-controller/blob/master/controllers/suite_test.go
letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890")
gitServer *gittestserver.GitServer
repositoryPath string
)
func randStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func TestImageAutomationReconciler_commitMessage(t *testing.T) {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
},
Policy: imagev1_reflect.ImagePolicyChoice{
SemVer: &imagev1_reflect.SemVerPolicy{
Range: "1.x",
},
},
}
fixture := "testdata/appconfig"
latest := "helloworld:v1.0.0"
t.Run(gogit.ClientName, func(t *testing.T) {
testWithRepoAndImagePolicy(
NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
commitMessage := fmt.Sprintf(testCommitMessageFmt, s.namespace, s.imagePolicyName)
// Update the setter marker in the repo.
policyKey := types.NamespacedName{
Name: s.imagePolicyName,
Namespace: s.namespace,
}
commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
})
// Pull the head commit we just pushed, so it's not
// considered a new commit when checking for a commit
// made by automation.
preChangeCommitId := commitIdFromBranch(localRepo, s.branch)
// Pull the head commit that was just pushed, so it's not considered a new
// commit when checking for a commit made by automation.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
// Create the automation object and let it make a commit itself.
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
}
err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
// Wait for a new commit to be made by the controller.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
head, _ := localRepo.Head()
commit, err := localRepo.CommitObject(head.Hash())
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Message).To(Equal(commitMessage))
signature := commit.Author
g.Expect(signature).NotTo(BeNil())
g.Expect(signature.Name).To(Equal(testAuthorName))
g.Expect(signature.Email).To(Equal(testAuthorEmail))
},
)
})
}
func TestImageAutomationReconciler_crossNamespaceRef(t *testing.T) {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
},
Policy: imagev1_reflect.ImagePolicyChoice{
SemVer: &imagev1_reflect.SemVerPolicy{
Range: "1.x",
},
},
}
fixture := "testdata/appconfig"
latest := "helloworld:v1.0.0"
// Test successful cross namespace reference when NoCrossNamespaceRef=false.
args := newRepoAndPolicyArgs()
args.gitRepoNamespace = "cross-ns-git-repo" + randStringRunes(5)
t.Run(gogit.ClientName, func(t *testing.T) {
testWithCustomRepoAndImagePolicy(
NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName, args,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
commitMessage := fmt.Sprintf(testCommitMessageFmt, s.namespace, s.imagePolicyName)
// Update the setter marker in the repo.
policyKey := types.NamespacedName{
Name: s.imagePolicyName,
Namespace: s.namespace,
}
commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
})
// Pull the head commit we just pushed, so it's not
// considered a new commit when checking for a commit
// made by automation.
preChangeCommitId := commitIdFromBranch(localRepo, s.branch)
// Pull the head commit that was just pushed, so it's not considered a new
// commit when checking for a commit made by automation.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
// Create the automation object and let it make a commit itself.
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
}
err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
// Wait for a new commit to be made by the controller.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
head, _ := localRepo.Head()
commit, err := localRepo.CommitObject(head.Hash())
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Message).To(Equal(commitMessage))
signature := commit.Author
g.Expect(signature).NotTo(BeNil())
g.Expect(signature.Name).To(Equal(testAuthorName))
g.Expect(signature.Email).To(Equal(testAuthorEmail))
},
)
// Test cross namespace reference failure when NoCrossNamespaceRef=true.
builder := fakeclient.NewClientBuilder().WithScheme(testEnv.Scheme())
r := &ImageUpdateAutomationReconciler{
Client: builder.Build(),
EventRecorder: testEnv.GetEventRecorderFor("image-automation-controller"),
NoCrossNamespaceRef: true,
}
args = newRepoAndPolicyArgs()
args.gitRepoNamespace = "cross-ns-git-repo" + randStringRunes(5)
testWithCustomRepoAndImagePolicy(
NewWithT(t), r.Client, fixture, policySpec, latest, gogit.ClientName, args,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
}
err := createImageUpdateAutomation(r.Client, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
imageUpdateKey := types.NamespacedName{
Name: "update-test",
Namespace: s.namespace,
}
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: imageUpdateKey})
g.Expect(err).To(BeNil())
var imageUpdate imagev1.ImageUpdateAutomation
_ = r.Client.Get(context.TODO(), imageUpdateKey, &imageUpdate)
ready := apimeta.FindStatusCondition(imageUpdate.Status.Conditions, meta.ReadyCondition)
g.Expect(ready.Reason).To(Equal(acl.AccessDeniedReason))
},
)
})
}
func TestImageAutomationReconciler_updatePath(t *testing.T) {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
},
Policy: imagev1_reflect.ImagePolicyChoice{
SemVer: &imagev1_reflect.SemVerPolicy{
Range: "1.x",
},
},
}
fixture := "testdata/pathconfig"
latest := "helloworld:v1.0.0"
t.Run(gogit.ClientName, func(t *testing.T) {
testWithRepoAndImagePolicy(
NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
// Update the setter marker in the repo.
policyKey := types.NamespacedName{
Name: s.imagePolicyName,
Namespace: s.namespace,
}
// pull the head commit we just pushed, so it's not
// considered a new commit when checking for a commit
// made by automation.
preChangeCommitId := commitIdFromBranch(localRepo, s.branch)
commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(path.Join(tmp, "yes"), policyKey)).To(Succeed())
})
commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(path.Join(tmp, "no"), policyKey)).To(Succeed())
})
// Pull the head commit that was just pushed, so it's not considered a new
// commit when checking for a commit made by automation.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
// Create the automation object and let it make a commit itself.
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
Path: "./yes",
}
err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
// Wait for a new commit to be made by the controller.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
head, _ := localRepo.Head()
commit, err := localRepo.CommitObject(head.Hash())
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Message).ToNot(ContainSubstring("update-no"))
g.Expect(commit.Message).To(ContainSubstring("update-yes"))
},
)
})
}
func TestImageAutomationReconciler_signedCommit(t *testing.T) {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
},
Policy: imagev1_reflect.ImagePolicyChoice{
SemVer: &imagev1_reflect.SemVerPolicy{
Range: "1.x",
},
},
}
fixture := "testdata/appconfig"
latest := "helloworld:v1.0.0"
t.Run(gogit.ClientName, func(t *testing.T) {
testWithRepoAndImagePolicy(
NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
signingKeySecretName := "signing-key-secret-" + randStringRunes(5)
// Update the setter marker in the repo.
policyKey := types.NamespacedName{
Name: s.imagePolicyName,
Namespace: s.namespace,
}
commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
})
preChangeCommitId := commitIdFromBranch(localRepo, s.branch)
// Pull the head commit that was just pushed, so it's not considered a new
// commit when checking for a commit made by automation.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
pgpEntity, err := createSigningKeyPair(testEnv, signingKeySecretName, s.namespace)
g.Expect(err).ToNot(HaveOccurred(), "failed to create signing key pair")
preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
// Create the automation object and let it make a commit itself.
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
}
err = createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, signingKeySecretName, updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
// Wait for a new commit to be made by the controller.
waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
head, _ := localRepo.Head()
g.Expect(err).ToNot(HaveOccurred())
commit, err := localRepo.CommitObject(head.Hash())
g.Expect(err).ToNot(HaveOccurred())
c2 := *commit
c2.PGPSignature = ""
encoded := &plumbing.MemoryObject{}
err = c2.Encode(encoded)
g.Expect(err).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)
g.Expect(err).ToNot(HaveOccurred())
},
)
})
}
func TestImageAutomationReconciler_e2e(t *testing.T) {
protos := []string{"http", "ssh"}
testFunc := func(t *testing.T, proto string, feats map[string]bool) {
g := NewWithT(t)
const latestImage = "helloworld:1.0.1"
namespace := "image-auto-test-" + randStringRunes(5)
branch := randStringRunes(8)
repositoryPath := "/config-" + randStringRunes(6) + ".git"
gitRepoName := "image-auto-" + randStringRunes(5)
gitSecretName := "git-secret-" + randStringRunes(5)
imagePolicyName := "policy-" + randStringRunes(5)
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
}
controllerName := "image-automation-controller"
// Create ImagePolicy and ImageUpdateAutomation resource for each of the
// test cases and cleanup at the end.
builder := fakeclient.NewClientBuilder().WithScheme(testEnv.Scheme())
r := &ImageUpdateAutomationReconciler{
Client: builder.Build(),
EventRecorder: testEnv.GetEventRecorderFor(controllerName),
features: feats,
}
// Create a test namespace.
nsCleanup, err := createNamespace(r.Client, namespace)
g.Expect(err).ToNot(HaveOccurred(), "failed to create test namespace")
defer func() {
g.Expect(nsCleanup()).To(Succeed())
}()
// Create git server.
gitServer, err := setupGitTestServer()
g.Expect(err).ToNot(HaveOccurred(), "failed to create test git server")
defer os.RemoveAll(gitServer.Root())
defer gitServer.StopHTTP()
cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repositoryPath
repoURL, err := getRepoURL(gitServer, repositoryPath, proto)
g.Expect(err).ToNot(HaveOccurred())
// Start the ssh server if needed.
if proto == "ssh" {
// NOTE: Check how this is done in source-controller.
go func() {
gitServer.StartSSH()
}()
defer func() {
g.Expect(gitServer.StopSSH()).To(Succeed())
}()
}
commitMessage := "Commit a difference " + randStringRunes(5)
// Initialize a git repo.
g.Expect(initGitRepo(gitServer, "testdata/appconfig", branch, repositoryPath)).To(Succeed())
// Create GitRepository resource for the above repo.
if proto == "ssh" {
// SSH requires an identity (private key) and known_hosts file
// in a secret.
err = createSSHIdentitySecret(r.Client, gitSecretName, namespace, repoURL)
g.Expect(err).ToNot(HaveOccurred())
err = createGitRepository(r.Client, gitRepoName, namespace, repoURL, gitSecretName)
g.Expect(err).ToNot(HaveOccurred())
} else {
err = createGitRepository(r.Client, gitRepoName, namespace, repoURL, "")
g.Expect(err).ToNot(HaveOccurred())
}
// Create an image policy.
policyKey := types.NamespacedName{
Name: imagePolicyName,
Namespace: namespace,
}
t.Run("PushSpec", func(t *testing.T) {
g := NewWithT(t)
// Clone the repo locally.
cloneCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
localRepo, err := clone(cloneCtx, cloneLocalRepoURL, branch)
g.Expect(err).ToNot(HaveOccurred(), "failed to clone git repo")
// NB not testing the image reflector controller; this
// will make a "fully formed" ImagePolicy object.
err = createImagePolicyWithLatestImage(r.Client, imagePolicyName, namespace, "not-expected-to-exist", "1.x", latestImage)
g.Expect(err).ToNot(HaveOccurred(), "failed to create ImagePolicy resource")
defer func() {
g.Expect(deleteImagePolicy(r.Client, imagePolicyName, namespace)).ToNot(HaveOccurred())
}()
imageUpdateAutomationName := "update-" + randStringRunes(5)
pushBranch := "pr-" + randStringRunes(5)
automationKey := types.NamespacedName{
Name: imageUpdateAutomationName,
Namespace: namespace,
}
t.Run("update with PushSpec", func(t *testing.T) {
g := NewWithT(t)
preChangeCommitId := commitIdFromBranch(localRepo, branch)
commitInRepo(g, cloneLocalRepoURL, branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
})
// Pull the head commit we just pushed, so it's not
// considered a new commit when checking for a commit
// made by automation.
waitForNewHead(g, localRepo, branch, preChangeCommitId)
// Now create the automation object, and let it (one
// hopes!) make a commit itself.
err = createImageUpdateAutomation(r.Client, imageUpdateAutomationName, namespace, gitRepoName, namespace, branch, pushBranch, commitMessage, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey})
g.Expect(err).To(BeNil())
initialHead, err := headFromBranch(localRepo, branch)
g.Expect(err).ToNot(HaveOccurred())
preChangeCommitId = commitIdFromBranch(localRepo, branch)
// Wait for a new commit to be made by the controller.
waitForNewHead(g, localRepo, pushBranch, preChangeCommitId)
head, err := getRemoteHead(localRepo, pushBranch)
g.Expect(err).NotTo(HaveOccurred())
commit, err := localRepo.CommitObject(head)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Message).To(Equal(commitMessage))
// previous commits should still exist in the tree.
// regression check to ensure previous commits were not squashed.
oldCommit, err := localRepo.CommitObject(initialHead.Hash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(oldCommit).ToNot(BeNil())
})
t.Run("push branch gets updated", func(t *testing.T) {
if !feats[features.GitAllBranchReferences] {
t.Skip("GitAllBranchReferences feature not enabled")
}
g := NewWithT(t)
initialHead, err := headFromBranch(localRepo, branch)
g.Expect(err).ToNot(HaveOccurred())
// Get the head hash before update.
head, err := getRemoteHead(localRepo, pushBranch)
g.Expect(err).NotTo(HaveOccurred())
headHash := head.String()
preChangeCommitId := commitIdFromBranch(localRepo, branch)
// Update the policy and expect another commit in the push
// branch.
err = updateImagePolicyWithLatestImage(r.Client, imagePolicyName, namespace, "helloworld:v1.3.0")
g.Expect(err).ToNot(HaveOccurred())
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey})
g.Expect(err).To(BeNil())
waitForNewHead(g, localRepo, pushBranch, preChangeCommitId)
head, err = getRemoteHead(localRepo, pushBranch)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(head.String()).NotTo(Equal(headHash))
// previous commits should still exist in the tree.
// regression check to ensure previous commits were not squashed.
oldCommit, err := localRepo.CommitObject(initialHead.Hash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(oldCommit).ToNot(BeNil())
})
t.Run("still pushes to the push branch after it's merged", func(t *testing.T) {
if !feats[features.GitAllBranchReferences] {
t.Skip("GitAllBranchReferences feature not enabled")
}
g := NewWithT(t)
initialHead, err := headFromBranch(localRepo, branch)
g.Expect(err).ToNot(HaveOccurred())
// Get the head hash before.
head, err := getRemoteHead(localRepo, pushBranch)
g.Expect(err).NotTo(HaveOccurred())
headHash := head.String()
// 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: []extgogitcfg.RefSpec{
extgogitcfg.RefSpec(fmt.Sprintf("refs/heads/%s:refs/remotes/origin/%s", branch, pushBranch))},
})
g.Expect(err).ToNot(HaveOccurred())
preChangeCommitId := commitIdFromBranch(localRepo, branch)
// Update the policy and expect another commit in the push
// branch.
err = updateImagePolicyWithLatestImage(r.Client, imagePolicyName, namespace, "helloworld:v1.3.1")
g.Expect(err).ToNot(HaveOccurred())
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey})
g.Expect(err).To(BeNil())
waitForNewHead(g, localRepo, pushBranch, preChangeCommitId)
head, err = getRemoteHead(localRepo, pushBranch)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(head.String()).NotTo(Equal(headHash))
// previous commits should still exist in the tree.
// regression check to ensure previous commits were not squashed.
oldCommit, err := localRepo.CommitObject(initialHead.Hash)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(oldCommit).ToNot(BeNil())
})
// Cleanup the image update automation used above.
g.Expect(deleteImageUpdateAutomation(r.Client, imageUpdateAutomationName, namespace)).To(Succeed())
})
t.Run("with update strategy setters", func(t *testing.T) {
g := NewWithT(t)
// Clone the repo locally.
// NOTE: A new localRepo is created here instead of reusing the one
// in the previous case due to a bug in some of the git operations
// test helper. When switching branches, the localRepo seems to get
// stuck in one particular branch. As a workaround, create a
// separate localRepo.
cloneCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
localRepo, err := clone(cloneCtx, cloneLocalRepoURL, branch)
g.Expect(err).ToNot(HaveOccurred(), "failed to clone git repo")
g.Expect(checkoutBranch(localRepo, branch)).ToNot(HaveOccurred())
err = createImagePolicyWithLatestImage(r.Client, imagePolicyName, namespace, "not-expected-to-exist", "1.x", latestImage)
g.Expect(err).ToNot(HaveOccurred(), "failed to create ImagePolicy resource")
defer func() {
g.Expect(deleteImagePolicy(r.Client, imagePolicyName, namespace)).ToNot(HaveOccurred())
}()
preChangeCommitId := commitIdFromBranch(localRepo, branch)
// Insert a setter reference into the deployment file,
// before creating the automation object itself.
commitInRepo(g, cloneLocalRepoURL, branch, "Install setter marker", func(tmp string) {
g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
})
// Pull the head commit we just pushed, so it's not
// considered a new commit when checking for a commit
// made by automation.
waitForNewHead(g, localRepo, branch, preChangeCommitId)
preChangeCommitId = commitIdFromBranch(localRepo, branch)
// Now create the automation object, and let it (one
// hopes!) make a commit itself.
updateKey := types.NamespacedName{
Namespace: namespace,
Name: "update-" + randStringRunes(5),
}
err = createImageUpdateAutomation(r.Client, updateKey.Name, namespace, gitRepoName, namespace, branch, "", commitMessage, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
defer func() {
g.Expect(deleteImageUpdateAutomation(r.Client, updateKey.Name, namespace)).To(Succeed())
}()
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: updateKey})
g.Expect(err).To(BeNil())
// Wait for a new commit to be made by the controller.
waitForNewHead(g, localRepo, branch, preChangeCommitId)
// Check if the repo head matches with the ImageUpdateAutomation
// last push commit status.
head, _ := localRepo.Head()
commit, err := localRepo.CommitObject(head.Hash())
g.Expect(err).ToNot(HaveOccurred())
g.Expect(commit.Message).To(Equal(commitMessage))
var newObj imagev1.ImageUpdateAutomation
g.Expect(r.Client.Get(context.Background(), updateKey, &newObj)).To(Succeed())
g.Expect(newObj.Status.LastPushCommit).To(Equal(commit.Hash.String()))
g.Expect(newObj.Status.LastPushTime).ToNot(BeNil())
compareRepoWithExpected(g, cloneLocalRepoURL, branch, "testdata/appconfig-setters-expected", func(tmp string) {
g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
})
})
t.Run("no reconciliation when object is suspended", func(t *testing.T) {
g := NewWithT(t)
nsCleanup, err := createNamespace(testEnv, namespace)
g.Expect(err).ToNot(HaveOccurred(), "failed to create test namespace")
defer func() {
g.Expect(nsCleanup()).To(Succeed())
}()
err = createImagePolicyWithLatestImage(testEnv, imagePolicyName, namespace, "not-expected-to-exist", "1.x", latestImage)
g.Expect(err).ToNot(HaveOccurred(), "failed to create ImagePolicy resource")
defer func() {
g.Expect(deleteImagePolicy(testEnv, imagePolicyName, namespace)).ToNot(HaveOccurred())
}()
// Create the automation object.
updateKey := types.NamespacedName{
Namespace: namespace,
Name: "update-" + randStringRunes(5),
}
err = createImageUpdateAutomation(testEnv, updateKey.Name, namespace, gitRepoName, namespace, branch, "", commitMessage, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
defer func() {
g.Expect(deleteImageUpdateAutomation(testEnv, updateKey.Name, namespace)).To(Succeed())
}()
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: updateKey})
g.Expect(err).To(BeNil())
// Wait for the object to be available in the cache before
// attempting update.
g.Eventually(func() bool {
obj := &imagev1.ImageUpdateAutomation{}
if err := testEnv.Get(context.Background(), updateKey, obj); err != nil {
return false
}
if len(obj.Finalizers) == 0 {
return false
}
return true
}, timeout, time.Second).Should(BeTrue())
// Suspend the automation object.
var updatePatch imagev1.ImageUpdateAutomation
g.Expect(testEnv.Get(context.TODO(), updateKey, &updatePatch)).To(Succeed())
updatePatch.Spec.Suspend = true
g.Expect(testEnv.Patch(context.Background(), &updatePatch, client.Merge)).To(Succeed())
// Create a new image automation reconciler and run it
// explicitly.
imageAutoReconciler := &ImageUpdateAutomationReconciler{
Client: testEnv,
}
// Wait for the suspension to reach the cache
var newUpdate imagev1.ImageUpdateAutomation
g.Eventually(func() bool {
if err := imageAutoReconciler.Get(context.Background(), updateKey, &newUpdate); err != nil {
return false
}
return newUpdate.Spec.Suspend
}, timeout, time.Second).Should(BeTrue())
// Run the reconciliation explicitly, and make sure it
// doesn't do anything
result, err := imageAutoReconciler.Reconcile(logr.NewContext(context.TODO(), ctrl.Log), ctrl.Request{
NamespacedName: updateKey,
})
g.Expect(err).To(BeNil())
// This ought to fail if suspend is not working, since the item would be requeued;
// but if not, additional checks lie below.
g.Expect(result).To(Equal(ctrl.Result{}))
var checkUpdate imagev1.ImageUpdateAutomation
g.Expect(testEnv.Get(context.Background(), updateKey, &checkUpdate)).To(Succeed())
g.Expect(checkUpdate.Status.ObservedGeneration).NotTo(Equal(checkUpdate.ObjectMeta.Generation))
})
}
for _, enabled := range []bool{true, false} {
feats := features.FeatureGates()
for k := range feats {
feats[k] = enabled
}
for _, proto := range protos {
t.Run(fmt.Sprintf("%s/features=%t", proto, enabled), func(t *testing.T) {
testFunc(t, proto, feats)
})
}
}
}
func TestImageAutomationReconciler_defaulting(t *testing.T) {
g := NewWithT(t)
branch := randStringRunes(8)
namespace := &corev1.Namespace{}
namespace.Name = "image-auto-test-" + randStringRunes(5)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Create a test namespace.
g.Expect(testEnv.Create(ctx, namespace)).To(Succeed())
defer func() {
g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed())
}()
// Create an instance of ImageUpdateAutomation.
key := types.NamespacedName{
Name: "update-" + randStringRunes(5),
Namespace: namespace.Name,
}
auto := &imagev1.ImageUpdateAutomation{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
},
Spec: imagev1.ImageUpdateAutomationSpec{
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: "GitRepository",
Name: "garbage",
},
Interval: metav1.Duration{Duration: 2 * time.Hour}, // this is to ensure any subsequent run should be outside the scope of the testing
GitSpec: &imagev1.GitSpec{
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{
Branch: branch,
},
},
// leave Update field out
Commit: imagev1.CommitSpec{
Author: imagev1.CommitUser{
Email: testAuthorEmail,
},
MessageTemplate: "nothing",
},
},
},
}
g.Expect(testEnv.Create(ctx, auto)).To(Succeed())
defer func() {
g.Expect(testEnv.Delete(ctx, auto)).To(Succeed())
}()
// Should default .spec.update to {strategy: Setters}.
var fetchedAuto imagev1.ImageUpdateAutomation
g.Eventually(func() bool {
err := testEnv.Get(ctx, key, &fetchedAuto)
return err == nil
}, timeout, time.Second).Should(BeTrue())
g.Expect(fetchedAuto.Spec.Update).
To(Equal(&imagev1.UpdateStrategy{Strategy: imagev1.UpdateStrategySetters}))
}
func checkoutBranch(repo *extgogit.Repository, branch string) error {
wt, err := repo.Worktree()
if err != nil {
return err
}
status, err := wt.Status()
if err != nil {
return err
}
for _, s := range status {
fmt.Println(s)
}
return wt.Checkout(&extgogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branch),
})
}
func replaceMarker(path string, policyKey types.NamespacedName) error {
// NB this requires knowledge of what's in the git repo, so a little brittle
deployment := filepath.Join(path, "deploy.yaml")
filebytes, err := os.ReadFile(deployment)
if err != nil {
return err
}
newfilebytes := bytes.ReplaceAll(filebytes, []byte("SETTER_SITE"), []byte(setterRef(policyKey)))
if err = os.WriteFile(deployment, newfilebytes, os.FileMode(0666)); err != nil {
return err
}
return nil
}
func setterRef(name types.NamespacedName) string {
return fmt.Sprintf(`{"%s": "%s:%s"}`, update.SetterShortHand, name.Namespace, name.Name)
}
func compareRepoWithExpected(g *WithT, repoURL, branch, fixture string, changeFixture func(tmp string)) {
expected, err := os.MkdirTemp("", "gotest-imageauto-expected")
g.Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(expected)
copy.Copy(fixture, expected)
changeFixture(expected)
cloneCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
repo, err := clone(cloneCtx, repoURL, branch)
g.Expect(err).ToNot(HaveOccurred())
// NOTE: The workdir contains a trailing /. Clean it to not confuse the
// DiffDirectories().
wt, err := repo.Worktree()
g.Expect(err).ToNot(HaveOccurred())
defer wt.Filesystem.Remove(".")
g.Expect(err).ToNot(HaveOccurred())
test.ExpectMatchingDirectories(g, wt.Filesystem.Root(), expected)
}
func commitInRepo(g *WithT, repoURL, branch, msg string, changeFiles func(path string)) plumbing.Hash {
cloneCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
repo, err := clone(cloneCtx, repoURL, branch)
g.Expect(err).ToNot(HaveOccurred())
wt, err := repo.Worktree()
g.Expect(err).ToNot(HaveOccurred())
changeFiles(wt.Filesystem.Root())
id, err := commitWorkDir(repo, branch, msg)
g.Expect(err).ToNot(HaveOccurred())
origin, err := repo.Remote(originRemote)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(origin.Push(&extgogit.PushOptions{
RemoteName: originRemote,
RefSpecs: []extgogitcfg.RefSpec{extgogitcfg.RefSpec(branchRefName(branch))},
})).To(Succeed())
return id
}
// Initialise a git server with a repo including the files in dir.
func initGitRepo(gitServer *gittestserver.GitServer, fixture, branch, repositoryPath string) error {
workDir, err := securejoin.SecureJoin(gitServer.Root(), repositoryPath)
if err != nil {
return err
}
repo, err := initGitRepoPlain(fixture, workDir)
if err != nil {
return err
}
headRef, err := repo.Head()
if err != nil {
return err
}
ref := plumbing.NewHashReference(
plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)),
headRef.Hash())
return repo.Storer.SetReference(ref)
}
func initGitRepoPlain(fixture, repositoryPath string) (*extgogit.Repository, error) {
wt := fs.New(repositoryPath)
dot := fs.New(filepath.Join(repositoryPath, extgogit.GitDirName))
storer := filesystem.NewStorage(dot, cache.NewObjectLRUDefault())
repo, err := extgogit.Init(storer, wt)
if err != nil {
return nil, err
}
err = copyDir(fixture, repositoryPath)
if err != nil {
return nil, err
}
_, err = commitWorkDir(repo, "main", "Initial commit")
if err != nil {
return nil, err
}
return repo, nil
}
func headFromBranch(repo *extgogit.Repository, branchName string) (*object.Commit, error) {
ref, err := repo.Storer.Reference(plumbing.ReferenceName("refs/heads/" + branchName))
if err != nil {
return nil, err
}
return repo.CommitObject(ref.Hash())
}
func commitWorkDir(repo *extgogit.Repository, branchName, message string) (plumbing.Hash, error) {
wt, err := repo.Worktree()
if err != nil {
return plumbing.ZeroHash, err
}
// Checkout to an existing branch. If this is the first commit,
// this is a no-op.
_ = wt.Checkout(&extgogit.CheckoutOptions{
Branch: plumbing.ReferenceName("refs/heads/" + branchName),
})
status, err := wt.Status()
if err != nil {
return plumbing.ZeroHash, err
}
for file := range status {
wt.Add(file)
}
sig := mockSignature(time.Now())
c, err := wt.Commit(message, &extgogit.CommitOptions{
All: true,
Author: sig,
Committer: sig,
})
if err != nil {
return plumbing.ZeroHash, err
}
_, err = repo.Branch(branchName)
if err == extgogit.ErrBranchNotFound {
ref := plumbing.NewHashReference(
plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branchName)), c)
err = repo.Storer.SetReference(ref)
}
if err != nil {
return plumbing.ZeroHash, err
}
// Now the target branch exists, we can checkout to it.
err = wt.Checkout(&extgogit.CheckoutOptions{
Branch: plumbing.ReferenceName("refs/heads/" + branchName),
})
if err != nil {
return plumbing.ZeroHash, err
}
return c, nil
}
func copyDir(src string, dest string) error {
file, err := os.Stat(src)
if err != nil {
return err
}
if !file.IsDir() {
return fmt.Errorf("source %q must be a directory", file.Name())
}
if err = os.MkdirAll(dest, 0o755); err != nil {
return err
}
files, err := ioutil.ReadDir(src)
if err != nil {
return err
}
for _, f := range files {
srcFile := filepath.Join(src, f.Name())
destFile := filepath.Join(dest, f.Name())
if f.IsDir() {
if err = copyDir(srcFile, destFile); err != nil {
return err
}
}
if !f.IsDir() {
// ignore symlinks
if f.Mode()&os.ModeSymlink == os.ModeSymlink {
continue
}
content, err := ioutil.ReadFile(srcFile)
if err != nil {
return err
}
if err = ioutil.WriteFile(destFile, content, 0o755); err != nil {
return err
}
}
}
return nil
}
func branchRefName(branch string) string {
return fmt.Sprintf("refs/heads/%s:refs/heads/%s", branch, branch)
}
func commitFile(repo *extgogit.Repository, path, content string, time time.Time) (plumbing.Hash, error) {
wt, err := repo.Worktree()
if err != nil {
return plumbing.ZeroHash, err
}
f, err := wt.Filesystem.Create(path)
if err != nil {
return plumbing.ZeroHash, err
}
if _, err := f.Write([]byte(content)); err != nil {
return plumbing.ZeroHash, err
}
wt.Add(path)
sig := mockSignature(time)
c, err := wt.Commit("Committing "+path, &extgogit.CommitOptions{
Author: sig,
Committer: sig,
})
if err != nil {
return plumbing.ZeroHash, err
}
return c, nil
}
func mockSignature(time time.Time) *object.Signature {
return &object.Signature{
Name: "Jane Doe",
Email: "author@example.com",
When: time,
}
}
func clone(ctx context.Context, repoURL, branchName string) (*extgogit.Repository, error) {
dir, err := os.MkdirTemp("", "iac-clone-*")
if err != nil {
return nil, err
}
opts := &extgogit.CloneOptions{
URL: repoURL,
RemoteName: originRemote,
ReferenceName: plumbing.NewBranchReferenceName(branchName),
}
wt := fs.New(dir)
dot := fs.New(filepath.Join(dir, extgogit.GitDirName))
storer := filesystem.NewStorage(dot, cache.NewObjectLRUDefault())
repo, err := extgogit.Clone(storer, wt, opts)
if err != nil {
return nil, err
}
w, err := repo.Worktree()
if err != nil {
return nil, err
}
err = w.Checkout(&extgogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branchName),
Create: false,
})
if err != nil {
return nil, err
}
return repo, nil
}
func waitForNewHead(g *WithT, repo *extgogit.Repository, branch, preChangeHash string) {
var commitToResetTo *object.Commit
origin, err := repo.Remote(originRemote)
g.Expect(err).ToNot(HaveOccurred())
// Now try to fetch new commits from that remote branch
g.Eventually(func() bool {
err := origin.Fetch(&extgogit.FetchOptions{
RemoteName: originRemote,
RefSpecs: []config.RefSpec{config.RefSpec(branchRefName(branch))},
})
if err != nil {
return false
}
wt, err := repo.Worktree()
if err != nil {
return false
}
err = wt.Checkout(&extgogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branch),
})
if err != nil {
return false
}
remoteHeadRef, err := repo.Head()
if err != nil {
return false
}
remoteHeadHash := remoteHeadRef.Hash()
if err != nil {
return false
}
if preChangeHash != remoteHeadHash.String() {
commitToResetTo, _ = repo.CommitObject(remoteHeadHash)
return true
}
return false
}, timeout, time.Second).Should(BeTrue())
if commitToResetTo != nil {
wt, err := repo.Worktree()
g.Expect(err).ToNot(HaveOccurred())
// New commits in the remote branch -- reset the working tree head
// to that. Note this does not create a local branch tracking the
// remote, so it is a detached head.
g.Expect(wt.Reset(&extgogit.ResetOptions{
Commit: commitToResetTo.Hash,
Mode: extgogit.HardReset,
})).To(Succeed())
}
}
func headCommit(repo *extgogit.Repository) (*object.Commit, error) {
head, err := repo.Head()
if err != nil {
return nil, err
}
c, err := repo.CommitObject(head.Hash())
if err != nil {
return nil, err
}
return c, nil
}
func commitIdFromBranch(repo *extgogit.Repository, branchName string) string {
commitId := ""
head, err := headFromBranch(repo, branchName)
if err == nil {
commitId = head.Hash.String()
}
return commitId
}
func getRemoteHead(repo *extgogit.Repository, branchName string) (plumbing.Hash, error) {
remote, err := repo.Remote(originRemote)
if err != nil {
return plumbing.ZeroHash, err
}
err = remote.Fetch(&extgogit.FetchOptions{
RemoteName: originRemote,
RefSpecs: []config.RefSpec{config.RefSpec(branchRefName(branchName))},
})
if err != nil && !errors.Is(err, extgogit.NoErrAlreadyUpToDate) {
return plumbing.ZeroHash, err
}
remoteHeadRef, err := headFromBranch(repo, branchName)
if err != nil {
return plumbing.ZeroHash, err
}
return remoteHeadRef.Hash, nil
}
type repoAndPolicyArgs struct {
namespace, imagePolicyName, gitRepoName, branch, gitRepoNamespace string
}
// newRepoAndPolicyArgs generates random namespace, git repo, branch and image
// policy names to be used in the test. The gitRepoNamespace is set the same
// as the overall namespace. For different git repo namespace, the caller may
// assign it as per the needs.
func newRepoAndPolicyArgs() repoAndPolicyArgs {
args := repoAndPolicyArgs{
namespace: "image-auto-test-" + randStringRunes(5),
gitRepoName: "image-auto-test-" + randStringRunes(5),
branch: randStringRunes(8),
imagePolicyName: "policy-" + randStringRunes(5),
}
args.gitRepoNamespace = args.namespace
return args
}
// testWithRepoAndImagePolicyTestFunc is the test closure function type passed
// to testWithRepoAndImagePolicy.
type testWithRepoAndImagePolicyTestFunc func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository)
// testWithRepoAndImagePolicy generates a repoAndPolicyArgs with all the
// resource in the same namespace and runs the given repo and image policy test.
func testWithRepoAndImagePolicy(
g *WithT,
kClient client.Client,
fixture string,
policySpec imagev1_reflect.ImagePolicySpec,
latest, gitImpl string,
testFunc testWithRepoAndImagePolicyTestFunc) {
// Generate unique repo and policy arguments.
args := newRepoAndPolicyArgs()
testWithCustomRepoAndImagePolicy(g, kClient, fixture, policySpec, latest, gitImpl, args, testFunc)
}
// testWithRepoAndImagePolicy sets up a git server, a repository in the git
// server, a GitRepository object for the created git repo, and an ImagePolicy
// with the given policy spec based on a repoAndPolicyArgs. It calls testFunc
// to run the test in the created environment.
func testWithCustomRepoAndImagePolicy(
g *WithT,
kClient client.Client,
fixture string,
policySpec imagev1_reflect.ImagePolicySpec,
latest, gitImpl string,
args repoAndPolicyArgs,
testFunc testWithRepoAndImagePolicyTestFunc) {
repositoryPath := "/config-" + randStringRunes(6) + ".git"
// Create test git server.
gitServer, err := setupGitTestServer()
g.Expect(err).ToNot(HaveOccurred(), "failed to create test git server")
defer os.RemoveAll(gitServer.Root())
defer gitServer.StopHTTP()
// Create test namespace.
nsCleanup, err := createNamespace(kClient, args.namespace)
g.Expect(err).ToNot(HaveOccurred(), "failed to create test namespace")
defer func() {
g.Expect(nsCleanup()).To(Succeed())
}()
// Create gitRepoNamespace if it's not the same as the overall test
// namespace.
if args.namespace != args.gitRepoNamespace {
gitNSCleanup, err := createNamespace(kClient, args.gitRepoNamespace)
g.Expect(err).ToNot(HaveOccurred(), "failed to create test git repo namespace")
defer func() {
g.Expect(gitNSCleanup()).To(Succeed())
}()
}
// Create a git repo.
g.Expect(initGitRepo(gitServer, fixture, args.branch, repositoryPath)).To(Succeed())
// Clone the repo.
repoURL := gitServer.HTTPAddressWithCredentials() + repositoryPath
cloneCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
localRepo, err := clone(cloneCtx, repoURL, args.branch)
g.Expect(err).ToNot(HaveOccurred(), "failed to clone git repo")
err = localRepo.DeleteRemote(originRemote)
g.Expect(err).ToNot(HaveOccurred(), "failed to delete existing remote origin")
localRepo.CreateRemote(&extgogitcfg.RemoteConfig{
Name: originRemote,
URLs: []string{repoURL},
})
g.Expect(err).ToNot(HaveOccurred(), "failed to create new remote origin")
// Create GitRepository resource for the above repo.
err = createGitRepository(kClient, args.gitRepoName, args.gitRepoNamespace, repoURL, "")
g.Expect(err).ToNot(HaveOccurred(), "failed to create GitRepository resource")
// Create ImagePolicy with populated latest image in the status.
err = createImagePolicyWithLatestImageForSpec(kClient, args.imagePolicyName, args.namespace, policySpec, latest)
g.Expect(err).ToNot(HaveOccurred(), "failed to create ImagePolicy resource")
testFunc(g, args, repoURL, localRepo)
}
// setupGitTestServer creates and returns a git test server. The caller must
// ensure it's stopped and cleaned up.
func setupGitTestServer() (*gittestserver.GitServer, error) {
gitServer, err := gittestserver.NewTempGitServer()
if err != nil {
return nil, err
}
username := randStringRunes(5)
password := randStringRunes(5)
// Using authentication makes using the server more fiddly in
// general, but is required for testing SSH.
gitServer.Auth(username, password)
gitServer.AutoCreate()
if err := gitServer.StartHTTP(); err != nil {
return nil, err
}
gitServer.KeyDir(filepath.Join(gitServer.Root(), "keys"))
if err := gitServer.ListenSSH(); err != nil {
return nil, err
}
return gitServer, nil
}
// cleanup is used to return closures for cleaning up.
type cleanup func() error
// createNamespace creates a namespace and returns a closure for deleting the
// namespace.
func createNamespace(kClient client.Client, name string) (cleanup, error) {
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: name},
}
if err := kClient.Create(context.Background(), namespace); err != nil {
return nil, err
}
cleanup := func() error {
return kClient.Delete(context.Background(), namespace)
}
return cleanup, nil
}
func createGitRepository(kClient client.Client, name, namespace, repoURL, secretRef string) error {
gitRepo := &sourcev1.GitRepository{
Spec: sourcev1.GitRepositorySpec{
URL: repoURL,
Interval: metav1.Duration{Duration: time.Minute},
Timeout: &metav1.Duration{Duration: time.Minute},
},
}
gitRepo.Name = name
gitRepo.Namespace = namespace
if secretRef != "" {
gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: secretRef}
}
return kClient.Create(context.Background(), gitRepo)
}
func createImagePolicyWithLatestImage(kClient client.Client, name, namespace, repoRef, semverRange, latest string) error {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: repoRef,
},
Policy: imagev1_reflect.ImagePolicyChoice{
SemVer: &imagev1_reflect.SemVerPolicy{
Range: semverRange,
},
},
}
return createImagePolicyWithLatestImageForSpec(kClient, name, namespace, policySpec, latest)
}
func createImagePolicyWithLatestImageForSpec(kClient client.Client, name, namespace string, policySpec imagev1_reflect.ImagePolicySpec, latest string) error {
policy := &imagev1_reflect.ImagePolicy{
Spec: policySpec,
}
policy.Name = name
policy.Namespace = namespace
err := kClient.Create(context.Background(), policy)
if err != nil {
return err
}
patch := client.MergeFrom(policy.DeepCopy())
policy.Status.LatestImage = latest
return kClient.Status().Patch(context.Background(), policy, patch)
}
func updateImagePolicyWithLatestImage(kClient client.Client, name, namespace, latest string) error {
policy := &imagev1_reflect.ImagePolicy{}
key := types.NamespacedName{
Name: name,
Namespace: namespace,
}
if err := kClient.Get(context.Background(), key, policy); err != nil {
return err
}
patch := client.MergeFrom(policy.DeepCopy())
policy.Status.LatestImage = latest
return kClient.Status().Patch(context.Background(), policy, patch)
}
func createImageUpdateAutomation(kClient client.Client, name, namespace,
gitRepo, gitRepoNamespace, checkoutBranch, pushBranch, commitTemplate, signingKeyRef string,
updateStrategy *imagev1.UpdateStrategy) error {
updateAutomation := &imagev1.ImageUpdateAutomation{
Spec: imagev1.ImageUpdateAutomationSpec{
Interval: metav1.Duration{Duration: 2 * time.Hour}, // This is to ensure any subsequent run should be outside the scope of the testing.
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: "GitRepository",
Name: gitRepo,
Namespace: gitRepoNamespace,
},
GitSpec: &imagev1.GitSpec{
Checkout: &imagev1.GitCheckoutSpec{
Reference: sourcev1.GitRepositoryRef{
Branch: checkoutBranch,
},
},
Commit: imagev1.CommitSpec{
MessageTemplate: commitTemplate,
Author: imagev1.CommitUser{
Name: testAuthorName,
Email: testAuthorEmail,
},
},
},
Update: updateStrategy,
},
}
updateAutomation.Name = name
updateAutomation.Namespace = namespace
if pushBranch != "" {
updateAutomation.Spec.GitSpec.Push = &imagev1.PushSpec{
Branch: pushBranch,
}
}
if signingKeyRef != "" {
updateAutomation.Spec.GitSpec.Commit.SigningKey = &imagev1.SigningKey{
SecretRef: meta.LocalObjectReference{Name: signingKeyRef},
}
}
return kClient.Create(context.Background(), updateAutomation)
}
func deleteImageUpdateAutomation(kClient client.Client, name, namespace string) error {
update := &imagev1.ImageUpdateAutomation{}
update.Name = name
update.Namespace = namespace
return kClient.Delete(context.Background(), update)
}
func deleteImagePolicy(kClient client.Client, name, namespace string) error {
imagePolicy := &imagev1_reflect.ImagePolicy{}
imagePolicy.Name = name
imagePolicy.Namespace = namespace
return kClient.Delete(context.Background(), imagePolicy)
}
func createSigningKeyPair(kClient client.Client, name, namespace string) (*openpgp.Entity, error) {
pgpEntity, err := openpgp.NewEntity("", "", "", nil)
if err != nil {
return nil, err
}
// Configure OpenPGP armor encoder.
b := bytes.NewBuffer(nil)
w, err := armor.Encode(b, openpgp.PrivateKeyType, nil)
if err != nil {
return nil, err
}
// Serialize private key.
if err := pgpEntity.SerializePrivate(w, nil); err != nil {
return nil, err
}
if err = w.Close(); err != nil {
return nil, err
}
// Create the secret containing signing key.
sec := &corev1.Secret{
Data: map[string][]byte{
"git.asc": b.Bytes(),
},
}
sec.Name = name
sec.Namespace = namespace
if err := kClient.Create(ctx, sec); err != nil {
return nil, err
}
return pgpEntity, nil
}
func createSSHIdentitySecret(kClient client.Client, name, namespace, repoURL string) error {
url, err := url.Parse(repoURL)
if err != nil {
return err
}
knownhosts, err := ssh.ScanHostKey(url.Host, 5*time.Second, []string{}, false)
if err != nil {
return err
}
keygen := ssh.NewRSAGenerator(2048)
pair, err := keygen.Generate()
if err != nil {
return 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 kClient.Create(ctx, sec)
}
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")
}