Skip to content

Commit 027370a

Browse files
authored
Merge pull request #55 from fluxcd/suspect-and-force
Suspend and force mechanisms
2 parents 0b4914a + 7d816f3 commit 027370a

File tree

6 files changed

+168
-40
lines changed

6 files changed

+168
-40
lines changed

api/v1alpha1/imageupdateautomation_types.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ type ImageUpdateAutomationSpec struct {
4040
// Commit specifies how to commit to the git repo
4141
// +required
4242
Commit CommitSpec `json:"commit"`
43+
44+
// Suspend tells the controller to not run this automation, until
45+
// it is unset (or set to false). Defaults to false.
46+
// +optional
47+
Suspend bool `json:"suspend,omitempty"`
4348
}
4449

4550
type GitCheckoutSpec struct {
@@ -97,7 +102,8 @@ type ImageUpdateAutomationStatus struct {
97102
// +optional
98103
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
99104
// +optional
100-
Conditions []metav1.Condition `json:"conditions,omitempty"`
105+
Conditions []metav1.Condition `json:"conditions,omitempty"`
106+
meta.ReconcileRequestStatus `json:",inline"`
101107
}
102108

103109
const (

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ spec:
8686
description: RunInterval gives a lower bound for how often the automation
8787
run should be attempted. Otherwise it will default.
8888
type: string
89+
suspend:
90+
description: Suspend tells the controller to not run this automation,
91+
until it is unset (or set to false). Defaults to false.
92+
type: boolean
8993
update:
9094
description: Update gives the specification for how to update the
9195
files in the repository
@@ -187,6 +191,10 @@ spec:
187191
made).
188192
format: date-time
189193
type: string
194+
lastHandledReconcileAt:
195+
description: LastHandledReconcileAt holds the value of the most recent
196+
reconcile request value, so a change can be detected.
197+
type: string
190198
observedGeneration:
191199
format: int64
192200
type: integer

controllers/imageupdateautomation_controller.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(req ctrl.Request) (ctrl.Resu
8787
return ctrl.Result{}, client.IgnoreNotFound(err)
8888
}
8989

90+
if auto.Spec.Suspend {
91+
log.Info("ImageUpdateAutomation is suspended, skipping automation run")
92+
return ctrl.Result{}, nil
93+
}
94+
95+
// whatever else happens, we've now "seen" the reconcile
96+
// annotation if it's there
97+
if token, ok := meta.ReconcileAnnotationValue(auto.GetAnnotations()); ok {
98+
auto.Status.SetLastHandledReconcileRequest(token)
99+
if err := r.Status().Update(ctx, &auto); err != nil {
100+
return ctrl.Result{Requeue: true}, err
101+
}
102+
}
103+
90104
// failWithError is a helper for bailing on the reconciliation.
91105
failWithError := func(err error) (ctrl.Result, error) {
92106
r.event(auto, events.EventSeverityError, err.Error())

controllers/suite_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
var cfg *rest.Config
4545
var k8sClient client.Client
4646
var k8sManager ctrl.Manager
47+
var imageAutoReconciler *ImageUpdateAutomationReconciler
4748
var testEnv *envtest.Environment
4849

4950
func TestAPIs(t *testing.T) {
@@ -81,19 +82,22 @@ var _ = BeforeSuite(func(done Done) {
8182
})
8283
Expect(err).ToNot(HaveOccurred())
8384

84-
err = (&ImageUpdateAutomationReconciler{
85+
imageAutoReconciler = &ImageUpdateAutomationReconciler{
8586
Client: k8sManager.GetClient(),
8687
Log: ctrl.Log.WithName("controllers").WithName("ImageUpdateAutomation"),
8788
Scheme: scheme.Scheme,
88-
}).SetupWithManager(k8sManager)
89-
Expect(err).ToNot(HaveOccurred())
89+
}
90+
Expect(imageAutoReconciler.SetupWithManager(k8sManager)).To(Succeed())
9091

9192
go func() {
9293
err = k8sManager.Start(ctrl.SetupSignalHandler())
9394
Expect(err).ToNot(HaveOccurred())
9495
}()
9596

96-
k8sClient = k8sManager.GetClient()
97+
// Specifically an uncached client. Use <reconciler>.Get if you
98+
// want to see what the reconcilers see.
99+
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
100+
Expect(err).ToNot(HaveOccurred())
97101
Expect(k8sClient).ToNot(BeNil())
98102

99103
close(done)

controllers/update_test.go

Lines changed: 130 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ import (
3636
. "github.com/onsi/gomega"
3737
"github.com/otiai10/copy"
3838
corev1 "k8s.io/api/core/v1"
39+
// apimeta "k8s.io/apimachinery/pkg/api/meta"
3940
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
4041
"k8s.io/apimachinery/pkg/types"
42+
ctrl "sigs.k8s.io/controller-runtime"
43+
"sigs.k8s.io/controller-runtime/pkg/client"
4144

4245
imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1alpha1"
46+
"github.com/fluxcd/pkg/apis/meta"
4347
"github.com/fluxcd/pkg/gittestserver"
4448
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
4549

@@ -185,29 +189,9 @@ var _ = Describe("ImageUpdateAutomation", func() {
185189
BeforeEach(func() {
186190
// Insert a setter reference into the deployment file,
187191
// before creating the automation object itself.
188-
tmp, err := ioutil.TempDir("", "gotest-imageauto-setters")
189-
Expect(err).ToNot(HaveOccurred())
190-
defer os.RemoveAll(tmp)
191-
repo, err := git.PlainClone(tmp, false, &git.CloneOptions{
192-
URL: repoURL,
193-
ReferenceName: plumbing.NewBranchReferenceName(defaultBranch),
192+
commitInRepo(repoURL, "Install setter marker", func(tmp string) {
193+
replaceMarker(tmp, policyKey)
194194
})
195-
Expect(err).ToNot(HaveOccurred())
196-
197-
replaceMarker(tmp, policyKey)
198-
worktree, err := repo.Worktree()
199-
Expect(err).ToNot(HaveOccurred())
200-
_, err = worktree.Add("deploy.yaml")
201-
Expect(err).ToNot(HaveOccurred())
202-
_, err = worktree.Commit("Install setter marker", &git.CommitOptions{
203-
Author: &object.Signature{
204-
Name: "Testbot",
205-
206-
When: time.Now(),
207-
},
208-
})
209-
Expect(err).ToNot(HaveOccurred())
210-
Expect(repo.Push(&git.PushOptions{RemoteName: "origin"})).To(Succeed())
211195

212196
// pull the head commit we just pushed, so it's not
213197
// considered a new commit when checking for a commit
@@ -226,6 +210,7 @@ var _ = Describe("ImageUpdateAutomation", func() {
226210
Namespace: updateKey.Namespace,
227211
},
228212
Spec: imagev1.ImageUpdateAutomationSpec{
213+
RunInterval: &metav1.Duration{Duration: 2 * time.Hour}, // this is to ensure any subsequent run should be outside the scope of the testing
229214
Checkout: imagev1.GitCheckoutSpec{
230215
GitRepositoryRef: corev1.LocalObjectReference{
231216
Name: gitRepoKey.Name,
@@ -256,22 +241,87 @@ var _ = Describe("ImageUpdateAutomation", func() {
256241
Expect(err).ToNot(HaveOccurred())
257242
Expect(commit.Message).To(Equal(commitMessage))
258243

259-
tmp, err := ioutil.TempDir("", "gotest-imageauto")
260-
Expect(err).ToNot(HaveOccurred())
261-
defer os.RemoveAll(tmp)
244+
compareRepoWithExpected(repoURL, "testdata/appconfig-setters-expected", func(tmp string) {
245+
replaceMarker(tmp, policyKey)
246+
})
247+
})
262248

263-
expected, err := ioutil.TempDir("", "gotest-imageauto-expected")
264-
Expect(err).ToNot(HaveOccurred())
265-
defer os.RemoveAll(expected)
266-
copy.Copy("testdata/appconfig-setters-expected", expected)
267-
replaceMarker(expected, policyKey)
249+
It("stops updating when suspended", func() {
250+
// suspend it, and check that it is marked as unready.
251+
var updatePatch imagev1.ImageUpdateAutomation
252+
updatePatch.Name = updateKey.Name
253+
updatePatch.Namespace = updateKey.Namespace
254+
updatePatch.Spec.Suspend = true
255+
Expect(k8sClient.Patch(context.Background(), &updatePatch, client.Merge)).To(Succeed())
256+
// wait for the suspension to reach the cache
257+
var newUpdate imagev1.ImageUpdateAutomation
258+
Eventually(func() bool {
259+
if err := imageAutoReconciler.Get(context.Background(), updateKey, &newUpdate); err != nil {
260+
return false
261+
}
262+
return newUpdate.Spec.Suspend
263+
}, timeout, time.Second).Should(BeTrue())
264+
// run the reconciliation explicitly, and make sure it
265+
// doesn't do anything
266+
result, err := imageAutoReconciler.Reconcile(ctrl.Request{
267+
NamespacedName: updateKey,
268+
})
269+
Expect(err).To(BeNil())
270+
Expect(result).To(Equal(ctrl.Result{})) // this ought to fail, since it should be rescheduled; but if not, additional checks lie below
271+
272+
var checkUpdate imagev1.ImageUpdateAutomation
273+
Expect(k8sClient.Get(context.Background(), updateKey, &checkUpdate)).To(Succeed())
274+
Expect(checkUpdate.Status.ObservedGeneration).NotTo(Equal(checkUpdate.ObjectMeta.Generation))
275+
})
268276

269-
_, err = git.PlainClone(tmp, false, &git.CloneOptions{
270-
URL: repoURL,
271-
ReferenceName: plumbing.NewBranchReferenceName(defaultBranch),
277+
It("runs when the reconcile request annotation is added", func() {
278+
println("[DEBUG]", updateKey.String())
279+
// the automation has run, and is not expected to run
280+
// again for 2 hours. Make a commit to the git repo
281+
// which needs to be undone by automation, then add
282+
// the annotation and make sure it runs again.
283+
Expect(k8sClient.Get(context.Background(), updateKey, updateBySetters)).To(Succeed())
284+
Expect(updateBySetters.Status.LastAutomationRunTime).ToNot(BeNil())
285+
lastRunTime := updateBySetters.Status.LastAutomationRunTime.Time
286+
287+
commitInRepo(repoURL, "Revert image update", func(tmp string) {
288+
// revert the change made by copying the old version
289+
// of the file back over then restoring the setter
290+
// marker
291+
copy.Copy("testdata/appconfig/deploy.yaml", filepath.Join(tmp, "deploy.yaml"))
292+
replaceMarker(tmp, policyKey)
293+
})
294+
// check that it was reverted correctly
295+
compareRepoWithExpected(repoURL, "testdata/appconfig", func(tmp string) {
296+
replaceMarker(tmp, policyKey)
297+
})
298+
299+
ts := time.Now().String()
300+
var updatePatch imagev1.ImageUpdateAutomation
301+
updatePatch.Name = updateKey.Name
302+
updatePatch.Namespace = updateKey.Namespace
303+
updatePatch.ObjectMeta.Annotations = map[string]string{
304+
meta.ReconcileRequestAnnotation: ts,
305+
}
306+
Expect(k8sClient.Patch(context.Background(), &updatePatch, client.Merge)).To(Succeed())
307+
308+
// ... this is where the reconciler is supposed to do its work ...
309+
310+
var newUpdate imagev1.ImageUpdateAutomation
311+
Eventually(func() bool {
312+
if err := k8sClient.Get(context.Background(), updateKey, &newUpdate); err != nil {
313+
return false
314+
}
315+
newLastRun := newUpdate.Status.LastAutomationRunTime
316+
return newLastRun != nil && newLastRun.Time.After(lastRunTime)
317+
}, timeout, time.Second).Should(BeTrue())
318+
// check that the annotation was recorded as seen
319+
Expect(newUpdate.Status.LastHandledReconcileAt).To(Equal(ts))
320+
321+
// check that a new commit was made
322+
compareRepoWithExpected(repoURL, "testdata/appconfig-setters-expected", func(tmp string) {
323+
replaceMarker(tmp, policyKey)
272324
})
273-
Expect(err).ToNot(HaveOccurred())
274-
test.ExpectMatchingDirectories(tmp, expected)
275325
})
276326
})
277327
})
@@ -307,6 +357,51 @@ func waitForNewHead(repo *git.Repository) {
307357
}, timeout, time.Second).Should(BeTrue())
308358
}
309359

360+
func compareRepoWithExpected(repoURL, fixture string, changeFixture func(tmp string)) {
361+
expected, err := ioutil.TempDir("", "gotest-imageauto-expected")
362+
Expect(err).ToNot(HaveOccurred())
363+
defer os.RemoveAll(expected)
364+
copy.Copy(fixture, expected)
365+
changeFixture(expected)
366+
367+
tmp, err := ioutil.TempDir("", "gotest-imageauto")
368+
Expect(err).ToNot(HaveOccurred())
369+
defer os.RemoveAll(tmp)
370+
_, err = git.PlainClone(tmp, false, &git.CloneOptions{
371+
URL: repoURL,
372+
ReferenceName: plumbing.NewBranchReferenceName(defaultBranch),
373+
})
374+
Expect(err).ToNot(HaveOccurred())
375+
test.ExpectMatchingDirectories(tmp, expected)
376+
}
377+
378+
func commitInRepo(repoURL, msg string, changeFiles func(path string)) {
379+
tmp, err := ioutil.TempDir("", "gotest-imageauto")
380+
Expect(err).ToNot(HaveOccurred())
381+
defer os.RemoveAll(tmp)
382+
repo, err := git.PlainClone(tmp, false, &git.CloneOptions{
383+
URL: repoURL,
384+
ReferenceName: plumbing.NewBranchReferenceName(defaultBranch),
385+
})
386+
Expect(err).ToNot(HaveOccurred())
387+
388+
changeFiles(tmp)
389+
390+
worktree, err := repo.Worktree()
391+
Expect(err).ToNot(HaveOccurred())
392+
_, err = worktree.Add(".")
393+
Expect(err).ToNot(HaveOccurred())
394+
_, err = worktree.Commit(msg, &git.CommitOptions{
395+
Author: &object.Signature{
396+
Name: "Testbot",
397+
398+
When: time.Now(),
399+
},
400+
})
401+
Expect(err).ToNot(HaveOccurred())
402+
Expect(repo.Push(&git.PushOptions{RemoteName: "origin"})).To(Succeed())
403+
}
404+
310405
// Initialise a git server with a repo including the files in dir.
311406
func initGitRepo(gitServer *gittestserver.GitServer, fixture, repositoryPath string) error {
312407
fs := memfs.New()

0 commit comments

Comments
 (0)