diff --git a/Makefile b/Makefile
index 831c7c7a..629f5eac 100644
--- a/Makefile
+++ b/Makefile
@@ -142,7 +142,7 @@ manifests: controller-gen ## Generate manifests e.g. CRD, RBAC etc.
cd api; $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role paths="./..." output:crd:artifacts:config="../config/crd/bases"
api-docs: gen-crd-api-reference-docs ## Generate API reference documentation
- $(GEN_CRD_API_REFERENCE_DOCS) -api-dir=./api/v1beta1 -config=./hack/api-docs/config.json -template-dir=./hack/api-docs/template -out-file=./docs/api/v1beta1/image-automation.md
+ $(GEN_CRD_API_REFERENCE_DOCS) -api-dir=./api/v1beta2 -config=./hack/api-docs/config.json -template-dir=./hack/api-docs/template -out-file=./docs/api/v1beta2/image-automation.md
tidy: ## Run go mod tidy
cd api; rm -f go.sum; go mod tidy -compat=1.22
diff --git a/PROJECT b/PROJECT
index 7c9d05bc..b5055d09 100644
--- a/PROJECT
+++ b/PROJECT
@@ -4,4 +4,7 @@ resources:
- group: image
kind: ImageUpdateAutomation
version: v1beta1
+- group: image
+ kind: ImageUpdateAutomation
+ version: v1beta2
version: "2"
diff --git a/api/v1beta1/imageupdateautomation_types.go b/api/v1beta1/imageupdateautomation_types.go
index d926aa90..c60df753 100644
--- a/api/v1beta1/imageupdateautomation_types.go
+++ b/api/v1beta1/imageupdateautomation_types.go
@@ -133,7 +133,6 @@ func SetImageUpdateAutomationReadiness(auto *ImageUpdateAutomation, status metav
apimeta.SetStatusCondition(auto.GetStatusConditions(), newCondition)
}
-//+kubebuilder:storageversion
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Last run",type=string,JSONPath=`.status.lastAutomationRunTime`
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 5ac505d8..69e18358 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -1,7 +1,7 @@
//go:build !ignore_autogenerated
/*
-Copyright 2020 The Flux authors
+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.
diff --git a/api/v1beta2/condition_types.go b/api/v1beta2/condition_types.go
new file mode 100644
index 00000000..910b706f
--- /dev/null
+++ b/api/v1beta2/condition_types.go
@@ -0,0 +1,39 @@
+/*
+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 v1beta2
+
+const (
+ // InvalidUpdateStrategyReason represents an invalid image update strategy
+ // configuration.
+ InvalidUpdateStrategyReason string = "InvalidUpdateStrategy"
+
+ // InvalidSourceConfigReason represents an invalid source configuration.
+ InvalidSourceConfigReason string = "InvalidSourceConfiguration"
+
+ // SourceManagerFailedReason represents a failure in the SourceManager which
+ // manages the source.
+ SourceManagerFailedReason string = "SourceManagerFailed"
+
+ // GitOperationFailedReason represents a failure in Git source operation.
+ GitOperationFailedReason string = "GitOperationFailed"
+
+ // UpdateFailedReason represents a failure during source update.
+ UpdateFailedReason string = "UpdateFailed"
+
+ // InvalidPolicySelectorReason represents an invalid policy selector.
+ InvalidPolicySelectorReason string = "InvalidPolicySelector"
+)
diff --git a/internal/controller/imageupdateautomation_controller_test.go b/api/v1beta2/doc.go
similarity index 57%
rename from internal/controller/imageupdateautomation_controller_test.go
rename to api/v1beta2/doc.go
index 2f9ac967..8063cad6 100644
--- a/internal/controller/imageupdateautomation_controller_test.go
+++ b/api/v1beta2/doc.go
@@ -1,5 +1,5 @@
/*
-Copyright 2022 The Flux authors
+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.
@@ -14,22 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package controller
-
-import (
- "testing"
-
- fuzz "github.com/AdaLogics/go-fuzz-headers"
-)
-
-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)
- })
-}
+// Package v1beta2 contains API types for the image API group, version
+// v1beta2. The types here are concerned with automated updates to
+// git, based on metadata from OCI image registries gathered by the
+// image-reflector-controller.
+//
+// +kubebuilder:object:generate=true
+// +groupName=image.toolkit.fluxcd.io
+package v1beta2
diff --git a/api/v1beta2/git.go b/api/v1beta2/git.go
new file mode 100644
index 00000000..060870a0
--- /dev/null
+++ b/api/v1beta2/git.go
@@ -0,0 +1,112 @@
+/*
+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 v1beta2
+
+import (
+ "github.com/fluxcd/pkg/apis/meta"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+)
+
+type GitSpec struct {
+ // Checkout gives the parameters for cloning the git repository,
+ // ready to make changes. If not present, the `spec.ref` field from the
+ // referenced `GitRepository` or its default will be used.
+ // +optional
+ Checkout *GitCheckoutSpec `json:"checkout,omitempty"`
+
+ // Commit specifies how to commit to the git repository.
+ // +required
+ Commit CommitSpec `json:"commit"`
+
+ // Push specifies how and where to push commits made by the
+ // automation. If missing, commits are pushed (back) to
+ // `.spec.checkout.branch` or its default.
+ // +optional
+ Push *PushSpec `json:"push,omitempty"`
+}
+
+// HasRefspec returns if the GitSpec has a Refspec.
+func (gs GitSpec) HasRefspec() bool {
+ if gs.Push == nil {
+ return false
+ }
+ return gs.Push.Refspec != ""
+}
+
+type GitCheckoutSpec struct {
+ // Reference gives a branch, tag or commit to clone from the Git
+ // repository.
+ // +required
+ Reference sourcev1.GitRepositoryRef `json:"ref"`
+}
+
+// CommitSpec specifies how to commit changes to the git repository
+type CommitSpec struct {
+ // Author gives the email and optionally the name to use as the
+ // author of commits.
+ // +required
+ Author CommitUser `json:"author"`
+ // SigningKey provides the option to sign commits with a GPG key
+ // +optional
+ SigningKey *SigningKey `json:"signingKey,omitempty"`
+ // MessageTemplate provides a template for the commit message,
+ // into which will be interpolated the details of the change made.
+ // +optional
+ MessageTemplate string `json:"messageTemplate,omitempty"`
+}
+
+type CommitUser struct {
+ // Name gives the name to provide when making a commit.
+ // +optional
+ Name string `json:"name,omitempty"`
+ // Email gives the email to provide when making a commit.
+ // +required
+ Email string `json:"email"`
+}
+
+// SigningKey references a Kubernetes secret that contains a GPG keypair
+type SigningKey struct {
+ // SecretRef holds the name to a secret that contains a 'git.asc' key
+ // corresponding to the ASCII Armored file containing the GPG signing
+ // keypair as the value. It must be in the same namespace as the
+ // ImageUpdateAutomation.
+ // +required
+ SecretRef meta.LocalObjectReference `json:"secretRef,omitempty"`
+}
+
+// PushSpec specifies how and where to push commits.
+type PushSpec struct {
+ // Branch specifies that commits should be pushed to the branch
+ // named. The branch is created using `.spec.checkout.branch` as the
+ // starting point, if it doesn't already exist.
+ // +optional
+ Branch string `json:"branch,omitempty"`
+
+ // Refspec specifies the Git Refspec to use for a push operation.
+ // If both Branch and Refspec are provided, then the commit is pushed
+ // to the branch and also using the specified refspec.
+ // For more details about Git Refspecs, see:
+ // https://git-scm.com/book/en/v2/Git-Internals-The-Refspec
+ // +optional
+ Refspec string `json:"refspec,omitempty"`
+
+ // Options specifies the push options that are sent to the Git
+ // server when performing a push operation. For details, see:
+ // https://git-scm.com/docs/git-push#Documentation/git-push.txt---push-optionltoptiongt
+ // +optional
+ Options map[string]string `json:"options,omitempty"`
+}
diff --git a/api/v1beta2/groupversion_info.go b/api/v1beta2/groupversion_info.go
new file mode 100644
index 00000000..65a281db
--- /dev/null
+++ b/api/v1beta2/groupversion_info.go
@@ -0,0 +1,36 @@
+/*
+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 v1beta2 contains API Schema definitions for the image v1beta2 API group
+// +kubebuilder:object:generate=true
+// +groupName=image.toolkit.fluxcd.io
+package v1beta2
+
+import (
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+ // GroupVersion is group version used to register these objects
+ GroupVersion = schema.GroupVersion{Group: "image.toolkit.fluxcd.io", Version: "v1beta2"}
+
+ // SchemeBuilder is used to add go types to the GroupVersionKind scheme
+ SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+ // AddToScheme adds the types in this group-version to the given scheme.
+ AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/api/v1beta2/imageupdateautomation_types.go b/api/v1beta2/imageupdateautomation_types.go
new file mode 100644
index 00000000..e841779d
--- /dev/null
+++ b/api/v1beta2/imageupdateautomation_types.go
@@ -0,0 +1,174 @@
+/*
+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 v1beta2
+
+import (
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/fluxcd/pkg/apis/meta"
+)
+
+const (
+ ImageUpdateAutomationKind = "ImageUpdateAutomation"
+ ImageUpdateAutomationFinalizer = "finalizers.fluxcd.io"
+)
+
+// ImageUpdateAutomationSpec defines the desired state of ImageUpdateAutomation
+type ImageUpdateAutomationSpec struct {
+ // SourceRef refers to the resource giving access details
+ // to a git repository.
+ // +required
+ SourceRef CrossNamespaceSourceReference `json:"sourceRef"`
+
+ // GitSpec contains all the git-specific definitions. This is
+ // technically optional, but in practice mandatory until there are
+ // other kinds of source allowed.
+ // +optional
+ GitSpec *GitSpec `json:"git,omitempty"`
+
+ // Interval gives an lower bound for how often the automation
+ // run should be attempted.
+ // +kubebuilder:validation:Type=string
+ // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$"
+ // +required
+ Interval metav1.Duration `json:"interval"`
+
+ // PolicySelector allows to filter applied policies based on labels.
+ // By default includes all policies in namespace.
+ // +optional
+ PolicySelector *metav1.LabelSelector `json:"policySelector,omitempty"`
+
+ // Update gives the specification for how to update the files in
+ // the repository. This can be left empty, to use the default
+ // value.
+ // +kubebuilder:default={"strategy":"Setters"}
+ Update *UpdateStrategy `json:"update,omitempty"`
+
+ // Suspend tells the controller to not run this automation, until
+ // it is unset (or set to false). Defaults to false.
+ // +optional
+ Suspend bool `json:"suspend,omitempty"`
+}
+
+// UpdateStrategyName is the type for names that go in
+// .update.strategy. NB the value in the const immediately below.
+// +kubebuilder:validation:Enum=Setters
+type UpdateStrategyName string
+
+const (
+ // UpdateStrategySetters is the name of the update strategy that
+ // uses kyaml setters. NB the value in the enum annotation for the
+ // type, above.
+ UpdateStrategySetters UpdateStrategyName = "Setters"
+)
+
+// UpdateStrategy is a union of the various strategies for updating
+// the Git repository. Parameters for each strategy (if any) can be
+// inlined here.
+type UpdateStrategy struct {
+ // Strategy names the strategy to be used.
+ // +required
+ // +kubebuilder:default=Setters
+ Strategy UpdateStrategyName `json:"strategy"`
+
+ // Path to the directory containing the manifests to be updated.
+ // Defaults to 'None', which translates to the root path
+ // of the GitRepositoryRef.
+ // +optional
+ Path string `json:"path,omitempty"`
+}
+
+// ImageUpdateAutomationStatus defines the observed state of ImageUpdateAutomation
+type ImageUpdateAutomationStatus struct {
+ // LastAutomationRunTime records the last time the controller ran
+ // this automation through to completion (even if no updates were
+ // made).
+ // +optional
+ LastAutomationRunTime *metav1.Time `json:"lastAutomationRunTime,omitempty"`
+ // LastPushCommit records the SHA1 of the last commit made by the
+ // controller, for this automation object
+ // +optional
+ LastPushCommit string `json:"lastPushCommit,omitempty"`
+ // LastPushTime records the time of the last pushed change.
+ // +optional
+ LastPushTime *metav1.Time `json:"lastPushTime,omitempty"`
+ // +optional
+ ObservedGeneration int64 `json:"observedGeneration,omitempty"`
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+ // ObservedPolicies is the list of observed ImagePolicies that were
+ // considered by the ImageUpdateAutomation update process.
+ // +optional
+ ObservedPolicies ObservedPolicies `json:"observedPolicies,omitempty"`
+ // ObservedPolicies []ObservedPolicy `json:"observedPolicies,omitempty"`
+ // ObservedSourceRevision is the last observed source revision. This can be
+ // used to determine if the source has been updated since last observation.
+ // +optional
+ ObservedSourceRevision string `json:"observedSourceRevision,omitempty"`
+
+ meta.ReconcileRequestStatus `json:",inline"`
+}
+
+// ObservedPolicies is a map of policy name and ImageRef of their latest
+// ImageRef.
+type ObservedPolicies map[string]ImageRef
+
+//+kubebuilder:storageversion
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+//+kubebuilder:printcolumn:name="Last run",type=string,JSONPath=`.status.lastAutomationRunTime`
+
+// ImageUpdateAutomation is the Schema for the imageupdateautomations API
+type ImageUpdateAutomation struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec ImageUpdateAutomationSpec `json:"spec,omitempty"`
+ // +kubebuilder:default={"observedGeneration":-1}
+ Status ImageUpdateAutomationStatus `json:"status,omitempty"`
+}
+
+// GetRequeueAfter returns the duration after which the ImageUpdateAutomation
+// must be reconciled again.
+func (auto ImageUpdateAutomation) GetRequeueAfter() time.Duration {
+ return auto.Spec.Interval.Duration
+}
+
+// GetConditions returns the status conditions of the object.
+func (auto ImageUpdateAutomation) GetConditions() []metav1.Condition {
+ return auto.Status.Conditions
+}
+
+// SetConditions sets the status conditions on the object.
+func (auto *ImageUpdateAutomation) SetConditions(conditions []metav1.Condition) {
+ auto.Status.Conditions = conditions
+}
+
+//+kubebuilder:object:root=true
+
+// ImageUpdateAutomationList contains a list of ImageUpdateAutomation
+type ImageUpdateAutomationList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []ImageUpdateAutomation `json:"items"`
+}
+
+func init() {
+ SchemeBuilder.Register(&ImageUpdateAutomation{}, &ImageUpdateAutomationList{})
+}
diff --git a/api/v1beta2/reference.go b/api/v1beta2/reference.go
new file mode 100644
index 00000000..917189ee
--- /dev/null
+++ b/api/v1beta2/reference.go
@@ -0,0 +1,64 @@
+/*
+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 v1beta2
+
+import "fmt"
+
+// CrossNamespaceSourceReference contains enough information to let you locate the
+// typed Kubernetes resource object at cluster level.
+type CrossNamespaceSourceReference struct {
+ // API version of the referent.
+ // +optional
+ APIVersion string `json:"apiVersion,omitempty"`
+
+ // Kind of the referent.
+ // +kubebuilder:validation:Enum=GitRepository
+ // +kubebuilder:default=GitRepository
+ // +required
+ Kind string `json:"kind"`
+
+ // Name of the referent.
+ // +required
+ Name string `json:"name"`
+
+ // Namespace of the referent, defaults to the namespace of the Kubernetes resource object that contains the reference.
+ // +optional
+ Namespace string `json:"namespace,omitempty"`
+}
+
+func (s *CrossNamespaceSourceReference) String() string {
+ if s.Namespace != "" {
+ return fmt.Sprintf("%s/%s/%s", s.Kind, s.Namespace, s.Name)
+ }
+ return fmt.Sprintf("%s/%s", s.Kind, s.Name)
+}
+
+// ImageRef represents an image reference.
+type ImageRef struct {
+ // Name is the bare image's name.
+ // +required
+ Name string `json:"name"`
+ // Tag is the image's tag.
+ // +required
+ Tag string `json:"tag"`
+}
+
+// String combines the components of ImageRef to construct a string
+// representation of the image reference.
+func (r ImageRef) String() string {
+ return r.Name + ":" + r.Tag
+}
diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go
new file mode 100644
index 00000000..2400f0a6
--- /dev/null
+++ b/api/v1beta2/zz_generated.deepcopy.go
@@ -0,0 +1,337 @@
+//go:build !ignore_autogenerated
+
+/*
+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.
+*/
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1beta2
+
+import (
+ "k8s.io/apimachinery/pkg/apis/meta/v1"
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CommitSpec) DeepCopyInto(out *CommitSpec) {
+ *out = *in
+ out.Author = in.Author
+ if in.SigningKey != nil {
+ in, out := &in.SigningKey, &out.SigningKey
+ *out = new(SigningKey)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommitSpec.
+func (in *CommitSpec) DeepCopy() *CommitSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(CommitSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CommitUser) DeepCopyInto(out *CommitUser) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommitUser.
+func (in *CommitUser) DeepCopy() *CommitUser {
+ if in == nil {
+ return nil
+ }
+ out := new(CommitUser)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CrossNamespaceSourceReference) DeepCopyInto(out *CrossNamespaceSourceReference) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossNamespaceSourceReference.
+func (in *CrossNamespaceSourceReference) DeepCopy() *CrossNamespaceSourceReference {
+ if in == nil {
+ return nil
+ }
+ out := new(CrossNamespaceSourceReference)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GitCheckoutSpec) DeepCopyInto(out *GitCheckoutSpec) {
+ *out = *in
+ out.Reference = in.Reference
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitCheckoutSpec.
+func (in *GitCheckoutSpec) DeepCopy() *GitCheckoutSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(GitCheckoutSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GitSpec) DeepCopyInto(out *GitSpec) {
+ *out = *in
+ if in.Checkout != nil {
+ in, out := &in.Checkout, &out.Checkout
+ *out = new(GitCheckoutSpec)
+ **out = **in
+ }
+ in.Commit.DeepCopyInto(&out.Commit)
+ if in.Push != nil {
+ in, out := &in.Push, &out.Push
+ *out = new(PushSpec)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitSpec.
+func (in *GitSpec) DeepCopy() *GitSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(GitSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ImageRef) DeepCopyInto(out *ImageRef) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRef.
+func (in *ImageRef) DeepCopy() *ImageRef {
+ if in == nil {
+ return nil
+ }
+ out := new(ImageRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ImageUpdateAutomation) DeepCopyInto(out *ImageUpdateAutomation) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageUpdateAutomation.
+func (in *ImageUpdateAutomation) DeepCopy() *ImageUpdateAutomation {
+ if in == nil {
+ return nil
+ }
+ out := new(ImageUpdateAutomation)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ImageUpdateAutomation) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ImageUpdateAutomationList) DeepCopyInto(out *ImageUpdateAutomationList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]ImageUpdateAutomation, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageUpdateAutomationList.
+func (in *ImageUpdateAutomationList) DeepCopy() *ImageUpdateAutomationList {
+ if in == nil {
+ return nil
+ }
+ out := new(ImageUpdateAutomationList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *ImageUpdateAutomationList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ImageUpdateAutomationSpec) DeepCopyInto(out *ImageUpdateAutomationSpec) {
+ *out = *in
+ out.SourceRef = in.SourceRef
+ if in.GitSpec != nil {
+ in, out := &in.GitSpec, &out.GitSpec
+ *out = new(GitSpec)
+ (*in).DeepCopyInto(*out)
+ }
+ out.Interval = in.Interval
+ if in.PolicySelector != nil {
+ in, out := &in.PolicySelector, &out.PolicySelector
+ *out = new(v1.LabelSelector)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.Update != nil {
+ in, out := &in.Update, &out.Update
+ *out = new(UpdateStrategy)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageUpdateAutomationSpec.
+func (in *ImageUpdateAutomationSpec) DeepCopy() *ImageUpdateAutomationSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(ImageUpdateAutomationSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ImageUpdateAutomationStatus) DeepCopyInto(out *ImageUpdateAutomationStatus) {
+ *out = *in
+ if in.LastAutomationRunTime != nil {
+ in, out := &in.LastAutomationRunTime, &out.LastAutomationRunTime
+ *out = (*in).DeepCopy()
+ }
+ if in.LastPushTime != nil {
+ in, out := &in.LastPushTime, &out.LastPushTime
+ *out = (*in).DeepCopy()
+ }
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]v1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.ObservedPolicies != nil {
+ in, out := &in.ObservedPolicies, &out.ObservedPolicies
+ *out = make(ObservedPolicies, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+ out.ReconcileRequestStatus = in.ReconcileRequestStatus
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageUpdateAutomationStatus.
+func (in *ImageUpdateAutomationStatus) DeepCopy() *ImageUpdateAutomationStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(ImageUpdateAutomationStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in ObservedPolicies) DeepCopyInto(out *ObservedPolicies) {
+ {
+ in := &in
+ *out = make(ObservedPolicies, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedPolicies.
+func (in ObservedPolicies) DeepCopy() ObservedPolicies {
+ if in == nil {
+ return nil
+ }
+ out := new(ObservedPolicies)
+ in.DeepCopyInto(out)
+ return *out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PushSpec) DeepCopyInto(out *PushSpec) {
+ *out = *in
+ if in.Options != nil {
+ in, out := &in.Options, &out.Options
+ *out = make(map[string]string, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PushSpec.
+func (in *PushSpec) DeepCopy() *PushSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(PushSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SigningKey) DeepCopyInto(out *SigningKey) {
+ *out = *in
+ out.SecretRef = in.SecretRef
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SigningKey.
+func (in *SigningKey) DeepCopy() *SigningKey {
+ if in == nil {
+ return nil
+ }
+ out := new(SigningKey)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *UpdateStrategy) DeepCopyInto(out *UpdateStrategy) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpdateStrategy.
+func (in *UpdateStrategy) DeepCopy() *UpdateStrategy {
+ if in == nil {
+ return nil
+ }
+ out := new(UpdateStrategy)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml b/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml
index ae76917d..d6e7c8ad 100644
--- a/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml
+++ b/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml
@@ -335,6 +335,400 @@ spec:
type: object
type: object
served: true
+ storage: false
+ subresources:
+ status: {}
+ - additionalPrinterColumns:
+ - jsonPath: .status.lastAutomationRunTime
+ name: Last run
+ type: string
+ name: v1beta2
+ schema:
+ openAPIV3Schema:
+ description: ImageUpdateAutomation is the Schema for the imageupdateautomations
+ API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: ImageUpdateAutomationSpec defines the desired state of ImageUpdateAutomation
+ properties:
+ git:
+ description: |-
+ GitSpec contains all the git-specific definitions. This is
+ technically optional, but in practice mandatory until there are
+ other kinds of source allowed.
+ properties:
+ checkout:
+ description: |-
+ Checkout gives the parameters for cloning the git repository,
+ ready to make changes. If not present, the `spec.ref` field from the
+ referenced `GitRepository` or its default will be used.
+ properties:
+ ref:
+ description: |-
+ Reference gives a branch, tag or commit to clone from the Git
+ repository.
+ properties:
+ branch:
+ description: Branch to check out, defaults to 'master'
+ if no other field is defined.
+ type: string
+ commit:
+ description: |-
+ Commit SHA to check out, takes precedence over all reference fields.
+
+
+ This can be combined with Branch to shallow clone the branch, in which
+ the commit is expected to exist.
+ type: string
+ name:
+ description: |-
+ Name of the reference to check out; takes precedence over Branch, Tag and SemVer.
+
+
+ It must be a valid Git reference: https://git-scm.com/docs/git-check-ref-format#_description
+ Examples: "refs/heads/main", "refs/tags/v0.1.0", "refs/pull/420/head", "refs/merge-requests/1/head"
+ type: string
+ semver:
+ description: SemVer tag expression to check out, takes
+ precedence over Tag.
+ type: string
+ tag:
+ description: Tag to check out, takes precedence over Branch.
+ type: string
+ type: object
+ required:
+ - ref
+ type: object
+ commit:
+ description: Commit specifies how to commit to the git repository.
+ properties:
+ author:
+ description: |-
+ Author gives the email and optionally the name to use as the
+ author of commits.
+ properties:
+ email:
+ description: Email gives the email to provide when making
+ a commit.
+ type: string
+ name:
+ description: Name gives the name to provide when making
+ a commit.
+ type: string
+ required:
+ - email
+ type: object
+ messageTemplate:
+ description: |-
+ MessageTemplate provides a template for the commit message,
+ into which will be interpolated the details of the change made.
+ type: string
+ signingKey:
+ description: SigningKey provides the option to sign commits
+ with a GPG key
+ properties:
+ secretRef:
+ description: |-
+ SecretRef holds the name to a secret that contains a 'git.asc' key
+ corresponding to the ASCII Armored file containing the GPG signing
+ keypair as the value. It must be in the same namespace as the
+ ImageUpdateAutomation.
+ properties:
+ name:
+ description: Name of the referent.
+ type: string
+ required:
+ - name
+ type: object
+ type: object
+ required:
+ - author
+ type: object
+ push:
+ description: |-
+ Push specifies how and where to push commits made by the
+ automation. If missing, commits are pushed (back) to
+ `.spec.checkout.branch` or its default.
+ properties:
+ branch:
+ description: |-
+ Branch specifies that commits should be pushed to the branch
+ named. The branch is created using `.spec.checkout.branch` as the
+ starting point, if it doesn't already exist.
+ type: string
+ options:
+ additionalProperties:
+ type: string
+ description: |-
+ Options specifies the push options that are sent to the Git
+ server when performing a push operation. For details, see:
+ https://git-scm.com/docs/git-push#Documentation/git-push.txt---push-optionltoptiongt
+ type: object
+ refspec:
+ description: |-
+ Refspec specifies the Git Refspec to use for a push operation.
+ If both Branch and Refspec are provided, then the commit is pushed
+ to the branch and also using the specified refspec.
+ For more details about Git Refspecs, see:
+ https://git-scm.com/book/en/v2/Git-Internals-The-Refspec
+ type: string
+ type: object
+ required:
+ - commit
+ type: object
+ interval:
+ description: |-
+ Interval gives an lower bound for how often the automation
+ run should be attempted.
+ pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$
+ type: string
+ policySelector:
+ description: |-
+ PolicySelector allows to filter applied policies based on labels.
+ By default includes all policies in namespace.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements.
+ The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies
+ to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ sourceRef:
+ description: |-
+ SourceRef refers to the resource giving access details
+ to a git repository.
+ properties:
+ apiVersion:
+ description: API version of the referent.
+ type: string
+ kind:
+ default: GitRepository
+ description: Kind of the referent.
+ enum:
+ - GitRepository
+ type: string
+ name:
+ description: Name of the referent.
+ type: string
+ namespace:
+ description: Namespace of the referent, defaults to the namespace
+ of the Kubernetes resource object that contains the reference.
+ type: string
+ required:
+ - kind
+ - name
+ type: object
+ suspend:
+ description: |-
+ Suspend tells the controller to not run this automation, until
+ it is unset (or set to false). Defaults to false.
+ type: boolean
+ update:
+ default:
+ strategy: Setters
+ description: |-
+ Update gives the specification for how to update the files in
+ the repository. This can be left empty, to use the default
+ value.
+ properties:
+ path:
+ description: |-
+ Path to the directory containing the manifests to be updated.
+ Defaults to 'None', which translates to the root path
+ of the GitRepositoryRef.
+ type: string
+ strategy:
+ default: Setters
+ description: Strategy names the strategy to be used.
+ enum:
+ - Setters
+ type: string
+ required:
+ - strategy
+ type: object
+ required:
+ - interval
+ - sourceRef
+ type: object
+ status:
+ default:
+ observedGeneration: -1
+ description: ImageUpdateAutomationStatus defines the observed state of
+ ImageUpdateAutomation
+ properties:
+ conditions:
+ items:
+ description: "Condition contains details for one aspect of the current
+ state of this API Resource.\n---\nThis struct is intended for
+ direct use as an array at the field path .status.conditions. For
+ example,\n\n\n\ttype FooStatus struct{\n\t // Represents the
+ observations of a foo's current state.\n\t // Known .status.conditions.type
+ are: \"Available\", \"Progressing\", and \"Degraded\"\n\t //
+ +patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t
+ \ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\"
+ patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t
+ \ // other fields\n\t}"
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: |-
+ type of condition in CamelCase or in foo.example.com/CamelCase.
+ ---
+ Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be
+ useful (see .node.status.conditions), the ability to deconflict is important.
+ The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ lastAutomationRunTime:
+ description: |-
+ LastAutomationRunTime records the last time the controller ran
+ this automation through to completion (even if no updates were
+ made).
+ format: date-time
+ type: string
+ lastHandledReconcileAt:
+ description: |-
+ LastHandledReconcileAt holds the value of the most recent
+ reconcile request value, so a change of the annotation value
+ can be detected.
+ type: string
+ lastPushCommit:
+ description: |-
+ LastPushCommit records the SHA1 of the last commit made by the
+ controller, for this automation object
+ type: string
+ lastPushTime:
+ description: LastPushTime records the time of the last pushed change.
+ format: date-time
+ type: string
+ observedGeneration:
+ format: int64
+ type: integer
+ observedPolicies:
+ additionalProperties:
+ description: ImageRef represents an image reference.
+ properties:
+ name:
+ description: Name is the bare image's name.
+ type: string
+ tag:
+ description: Tag is the image's tag.
+ type: string
+ required:
+ - name
+ - tag
+ type: object
+ description: |-
+ ObservedPolicies is the list of observed ImagePolicies that were
+ considered by the ImageUpdateAutomation update process.
+ type: object
+ observedSourceRevision:
+ description: |-
+ ObservedPolicies []ObservedPolicy `json:"observedPolicies,omitempty"`
+ ObservedSourceRevision is the last observed source revision. This can be
+ used to determine if the source has been updated since last observation.
+ type: string
+ type: object
+ type: object
+ served: true
storage: true
subresources:
status: {}
diff --git a/config/samples/image_v1beta2_imageupdateautomation.yaml b/config/samples/image_v1beta2_imageupdateautomation.yaml
new file mode 100644
index 00000000..d99bb4f3
--- /dev/null
+++ b/config/samples/image_v1beta2_imageupdateautomation.yaml
@@ -0,0 +1,29 @@
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name: imageupdateautomation-sample
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository # the only valid value, but good practice to be explicit here
+ name: sample-repo
+ git:
+ checkout:
+ ref:
+ branch: main
+ commit:
+ author:
+ name: fluxbot
+ email: fluxbot@example.com
+ messageTemplate: |
+ An automated update from FluxBot
+
+ [ci skip]
+ signingKey:
+ secretRef:
+ name: git-pgp
+ push:
+ branch: auto
+ update:
+ strategy: Setters
+ path: ./cluster/sample
diff --git a/docs/api/v1beta2/image-automation.md b/docs/api/v1beta2/image-automation.md
new file mode 100644
index 00000000..107ebb96
--- /dev/null
+++ b/docs/api/v1beta2/image-automation.md
@@ -0,0 +1,882 @@
+
Image update automation API reference v1beta2
+Packages:
+
+
+Package v1beta2 contains API types for the image API group, version
+v1beta2. The types here are concerned with automated updates to
+git, based on metadata from OCI image registries gathered by the
+image-reflector-controller.
+Resource Types:
+
+
+
+(Appears on:
+GitSpec)
+
+CommitSpec specifies how to commit changes to the git repository
+
+
+
+(Appears on:
+CommitSpec)
+
+
+
+
+(Appears on:
+ImageUpdateAutomationSpec)
+
+CrossNamespaceSourceReference contains enough information to let you locate the
+typed Kubernetes resource object at cluster level.
+
+
+
+(Appears on:
+GitSpec)
+
+
+
+
+(Appears on:
+ImageUpdateAutomationSpec)
+
+
+
+ImageRef represents an image reference.
+
+
+ImageUpdateAutomation is the Schema for the imageupdateautomations API
+
+
+
+(Appears on:
+ImageUpdateAutomation)
+
+ImageUpdateAutomationSpec defines the desired state of ImageUpdateAutomation
+
+
+
+(Appears on:
+ImageUpdateAutomation)
+
+ImageUpdateAutomationStatus defines the observed state of ImageUpdateAutomation
+
+
+
+(Appears on:
+ImageUpdateAutomationStatus)
+
+ObservedPolicies is a map of policy name and ImageRef of their latest
+ImageRef.
+
+
+(Appears on:
+GitSpec)
+
+PushSpec specifies how and where to push commits.
+
+
+
+(Appears on:
+CommitSpec)
+
+SigningKey references a Kubernetes secret that contains a GPG keypair
+
+
+
+(Appears on:
+ImageUpdateAutomationSpec)
+
+UpdateStrategy is a union of the various strategies for updating
+the Git repository. Parameters for each strategy (if any) can be
+inlined here.
+
+
+
+(Appears on:
+UpdateStrategy)
+
+UpdateStrategyName is the type for names that go in
+.update.strategy. NB the value in the const immediately below.
+
+
This page was automatically generated with gen-crd-api-reference-docs
+
diff --git a/docs/spec/v1beta2/imageupdateautomations.md b/docs/spec/v1beta2/imageupdateautomations.md
new file mode 100644
index 00000000..4ae62fa2
--- /dev/null
+++ b/docs/spec/v1beta2/imageupdateautomations.md
@@ -0,0 +1,983 @@
+# Image Update Automations
+
+The `ImageUpdateAutomation` API defines an automation process that will update a
+Git repository, based on `ImagePolicy` objects in the same namespace.
+
+The updates are governed by marking fields to be updated in each YAML file. For
+each field marked, the automation process checks the image policy named, and
+updates the field value if there is a new image selected by the policy. The
+marker format is shown in the [image automation guide][image-auto-guide].
+
+## Example
+
+The following is an example of keeping the images in a Git repository
+up-to-date:
+
+```yaml
+---
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: GitRepository
+metadata:
+ name: podinfo
+ namespace: default
+spec:
+ interval: 5m0s
+ url: https://github.com/fluxcd/example
+ ref:
+ branch: main
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageRepository
+metadata:
+ name: podinfo
+ namespace: default
+spec:
+ image: ghcr.io/stefanprodan/podinfo
+ interval: 5h
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImagePolicy
+metadata:
+ name: podinfo-policy
+ namespace: default
+spec:
+ imageRepositoryRef:
+ name: podinfo
+ policy:
+ semver:
+ range: 5.0.x
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name: podinfo-update
+ namespace: default
+spec:
+ interval: 30m
+ sourceRef:
+ kind: GitRepository
+ name: podinfo
+ git:
+ commit:
+ author:
+ email: fluxcdbot@users.noreply.github.com
+ name: fluxcdbot
+ push:
+ branch: main
+ update:
+ path: ./
+```
+
+In the above example:
+
+- A GitRepository named `podinfo` is created, indicated by the
+ `GitRepository.metadata.name` field. The Git repository at
+ `https://github.com/fluxcd/example` is assumed to contain YAML files with
+ image policy markers, as described in [image automation
+ guide][image-auto-guide], to update them.
+- An ImageRepository named `podinfo` is created, indicated by the
+ `ImageRepository.metadata.name` field. This scans all the tags for an image repository.
+- An ImagePolicy named `podinfo-policy` is created, indicated by the
+ `ImagePolicy.metadata.name` field.
+- An ImageUpdateAutomation named `podinfo-update` is created, indicated by the
+ `ImageUpdateAutomation.metadata.name` field.
+- The ImagePolicy refers to the `podinfo` ImageRepository to query for all the
+ tags related to an image, indicated by `ImagePolicy.spec.imageRepositoryRef`.
+ These tags are then evaluated to select the latest image with tag based on the
+ policy rules, indicated by `ImagePolicy.spec.policy`.
+- The ImageUpdateAutomation refers to `podinfo` GitRepository as the source that
+ should be kept up-to-date, indicated by
+ `ImageUpdateAutomation.spec.sourceRef`.
+- The image-automation-controller lists all the ImagePolicies in the
+ ImageUpdateAutomation's namespace. It then checks out the Git repository
+ `main` branch, as configured in `GitRepository.spec.ref.branch`. It then goes
+ through the YAML manifests from the root of the Git repository, as configured
+ in `ImageUpdateAutomation.spec.update.path` and applies updates based on the
+ latest images from the image policies. The source changes are saved as a Git
+ commit with the commit author defined in
+ `ImageUpdateAutomation.spec.git.commit.author`. The commit is then push to the
+ remote Git repository's `main` branch, indicated by
+ `ImageUpdateAutomation.spec.git.push.branch`.
+- The push commit hash is reported in the
+ `ImageUpdateAutomation.status.lastPushCommit` field and the push time is
+ reported in `.status.lastPushTime` field.
+
+This example can be run by saving the manifest into
+`imageupdateautomation.yaml`.
+
+1. Apply the resource on the cluster:
+
+```sh
+kubectl apply -f imageupdateautomation.yaml
+```
+
+2. Run `kubectl get imageupdateautomation` to see the ImageUpdateAutomation:
+
+```console
+NAME LAST RUN
+podinfo-update 2024-03-17T22:22:34Z
+```
+
+3. Run `kubectl describe imageupdateautomation podinfo-update` to see the [Last
+ Push Commit](#) and [Conditions](#conditions) in the ImageUpdateAutomation's
+ Status:
+
+```console
+Status:
+ Conditions:
+ Last Transition Time: 2024-03-17T22:22:33Z
+ Message: repository up-to-date
+ Observed Generation: 1
+ Reason: Succeeded
+ Status: True
+ Type: Ready
+ Last Automation Run Time: 2024-03-17T22:22:34Z
+ Last Push Commit: 3ebb95cc56d2db59bc6ffbe0d9dd0ea445edeb77
+ Last Push Time: 2024-03-17T22:22:34Z
+ Observed Generation: 1
+ Observed Policies:
+ Podinfo - Policy:
+ Name: ghcr.io/stefanprodan/podinfo
+ Tag: 5.0.3
+ Observed Source Revision: main@sha1:3ebb95cc56d2db59bc6ffbe0d9dd0ea445edeb77
+Events:
+ Type Reason Age From Message
+ ---- ------ ---- ---- -------
+ Normal Succeeded 5s (x2 over 6s) image-automation-controller repository up-to-date
+ Normal Succeeded 5s image-automation-controller pushed commit '3ebb95c' to branch 'main'
+Update from image update automation
+```
+
+## Writing an ImageUpdateAutomation spec
+
+As with all other Kubernetes config, an ImageUpdateAutomation needs
+`apiVersion`, `kind`, and `metadata` fields. The name of an
+ImageUpdateAutomation object must be a valid [DNS subdomain
+name](https://kubernetes.io/docs/concepts/overview/working-with-objects/names#dns-subdomain-names).
+
+An ImageUpdateAutomation also needs a [`.spec`
+section](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status).
+
+### Source reference
+
+`.spec.sourceRef` is a required field to specify a reference to a source object
+in the same namespace as the ImageUpdateAutomation or in another namespace. The
+only supported source kind at the moment is `GitRepository`, which is used by
+default if the `.spec.sourceRef.kind` is not specified. The source reference
+name is a required field, `.spec.sourceRef.name`. The source reference namespace
+is optional, `.spec.sourceRef.namespace`. If not specified, the source is
+assumed to be in the same namespace as the ImageUpdateAutomation. The
+GitRepository must contain the authentication configuration required to check
+out the source, if any.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ sourceRef:
+ name:
+ namespace:
+```
+
+By default, GitRepository in a different namespace can be referenced. This can
+be disabled by setting the controller flag `--no-cross-namespace-refs`.
+
+The timeouts used in the Git operations for an ImageUpdateAutomation is derived
+from the referenced GitRepository source. `GitRepository.spec.timeout` can be
+tuned to adjust the Git operation timeout.
+
+The proxy configurations are also derived from the referenced GitRepository
+source. `GitRepository.spec.proxySecretRef` can be used to configure proxy use.
+
+### Git specification
+
+`.spec.git` is a required field to specify Git configurations related to source
+`checkout`, `commit` and `push` operations.
+
+#### Checkout
+
+`.spec.git.checkout` is an optional field to specify the Git reference to check
+out. The `.spec.git.checkout.ref` field is the same as the
+`GitRepository.spec.ref` field. It can be used to override the checkout
+configuration in the referenced GitRepository. Not specifying this reference
+defaults to the checkout reference of the associated GitRepository.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ git:
+ checkout:
+ ref:
+ branch:
+```
+
+If `.spec.git.push` is unspecified, `.spec.git.checkout` will be used as the
+push branch for any updates.
+
+By default the controller will only do shallow clones, but this can be disabled
+by starting the controller with flag `--feature-gates=GitShallowClone=false`.
+
+#### Commit
+
+`.spec.git.commit` is a required field to specify the details about the commit
+made by the automation.
+
+##### Author
+
+`.spec.git.commit.author` is a required field to specify the commit author. The
+author `.email` is required. The author `.name` is optional. The name and email
+are used as the author of the commits made by the automation.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ git:
+ commit:
+ author:
+ email:
+ name:
+```
+
+##### Signing Key
+
+`.spec.git.commit.signingKey` is an optional field to specify the signing PGP
+key to sign the commits with. `.secretRef.name` refers to a Secret in the same
+namespace as the ImageUpdateAutomation, containing an ASCII-armored PGP key, in
+a field named `git.asc`. If the private key is protected by a passphrase, the
+passphrase can be specified in the same Secret in a field named `passphrase`.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ git:
+ commit:
+ signingKey:
+ secretRef:
+ name: signing-key
+...
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: signing-key
+stringData:
+ git.asc: |
+
+ passphrase:
+```
+
+##### Message Template
+
+`.spec.git.commit.messageTemplate` is an optional field to specify the commit
+message template. If unspecified, a default commit message is used.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ git:
+ commit:
+ messageTemplate: |-
+ Automated image update by Flux
+```
+
+**Deprecation Note:** The `Updated` template data available in v1beta1 API is
+deprecated. `Changed` template data is recommended for template data, as it
+accommodates for all the updates, including partial updates to just the image
+name or the tag, not just full image with name and tag update. The old templates
+will continue to work in v1beta2 API as `Updated` has not been removed yet. In
+the next API version, `Updated` may be removed.
+
+The message template also has access to the data related to the changes made by
+the automation. The template is a [Go text template][go-text-template]. The data
+available to the template have the following structure (not reproduced
+verbatim):
+
+```go
+// TemplateData is the type of the value given to the commit message
+// template.
+type TemplateData struct {
+ AutomationObject struct {
+ Name, Namespace string
+ }
+ Changed update.ResultV2
+}
+
+// ResultV2 contains the file changes made during the update. It contains
+// details about the exact changes made to the files and the objects in them. It
+// has a nested structure file->objects->changes.
+type ResultV2 struct {
+ FileChanges map[string]ObjectChanges
+}
+
+// ObjectChanges contains all the changes made to objects.
+type ObjectChanges map[ObjectIdentifier][]Change
+
+// ObjectIdentifier holds the identifying data for a particular
+// object. This won't always have a name (e.g., a kustomization.yaml).
+type ObjectIdentifier struct {
+ Name, Namespace, APIVersion, Kind string
+}
+
+// Change contains the setter that resulted in a Change, the old and the new
+// value after the Change.
+type Change struct {
+ OldValue string
+ NewValue string
+ Setter string
+}
+```
+
+The `Changed` template data field also has a few helper methods to easily range
+over the changed objects and changes:
+
+```go
+// Changes returns all the changes that were made in at least one update.
+func (r ResultV2) Changes() []Change
+
+// Objects returns ObjectChanges, regardless of which file they appear in.
+func (r ResultV2) Objects() ObjectChanges
+```
+
+Example of using the methods in a template:
+
+```yaml
+spec:
+ commit:
+ messageTemplate: |
+ Automated image update
+
+ Automation name: {{ .AutomationObject }}
+
+ Files:
+ {{ range $filename, $_ := .Changed.FileChanges -}}
+ - {{ $filename }}
+ {{ end -}}
+
+ Objects:
+ {{ range $resource, $changes := .Changed.Objects -}}
+ - {{ $resource.Kind }} {{ $resource.Name }}
+ Changes:
+ {{- range $_, $change := $changes }}
+ - {{ $change.OldValue }} -> {{ $change.NewValue }}
+ {{ end -}}
+ {{ end -}}
+```
+
+With template functions, it is possible to manipulate and transform the supplied
+data in order to generate more complex commit messages.
+
+```yaml
+spec:
+ commit:
+ messageTemplate: |
+ Automated image update
+
+ Automation name: {{ .AutomationObject }}
+
+ Files:
+ {{ range $filename, $_ := .Changed.FileChanges -}}
+ - {{ $filename }}
+ {{ end -}}
+
+ Objects:
+ {{ range $resource, $changes := .Changed.Objects -}}
+ - {{ $resource.Kind | lower }} {{ $resource.Name | lower }}
+ Changes:
+ {{- range $_, $change := $changes }}
+ {{ if contains "5.0.3" $change.NewValue -}}
+ - {{ $change.OldValue }} -> {{ $change.NewValue }}
+ {{ else -}}
+ [skip ci] wrong image
+ {{ end -}}
+ {{ end -}}
+ {{ end -}}
+```
+
+There are over 70 available functions. Some of them are defined by the [Go
+template language](https://pkg.go.dev/text/template) itself. Most of the others
+are part of the [Sprig template library](http://masterminds.github.io/sprig/).
+
+#### Push
+
+`.spec.git.push` is an optional field that specifies how the commits are pushed
+to the remote source repository.
+
+##### Branch
+
+`.spec.git.push.branch` field specifies the remote branch to push to. If
+unspecified, the commits are pushed to the branch specified in
+`.spec.git.checkout.branch`. If `.spec.git.checkout` is also unspecified, it
+will fall back to the branch specified in the associated GitRepository's
+`.spec.sourceRef`. If none of these yield a push branch name, the automation
+will fail.
+
+The push branch will be created locally if it does not already exist, starting
+from the checkout branch. If the push branch already exists, it will be
+overwritten with the cloned version plus the changes made by the controller.
+Alternatively, force push can be disabled by starting the controller with flag
+`--feature-gates=GitForcePushBranch=false`, in which case the updates will be
+calculated on top of any commits already on the push branch. Note that without
+force push in push branches, if the target branch is stale, the controller may
+not be able to conclude the operation and will consistently fail until the
+branch is either deleted or refreshed.
+
+In the following snippet, updates will be pushed as commits to the branch
+`auto`, and when that branch does not exist at the origin, it will be created
+locally starting from the branch `main`, and pushed:
+
+```yaml
+spec:
+ git:
+ checkout:
+ ref:
+ branch: main
+ push:
+ branch: auto
+```
+
+##### Refspec
+
+`.spec.git.push.refspec` field specifies the refspec to push to any arbitrary
+destination reference. An example of a valid refspec is
+`refs/heads/branch:refs/heads/branch`.
+
+If both `.push.refspec` and `.push.branch` are specified, then the reconciler
+will push to both the destinations. This is particularly useful for working with
+Gerrit servers. For more information about this, please refer to the
+[Gerrit](#gerrit) section.
+
+**Note:** If both `.push.refspec` and `.push.branch` are essentially equal to
+each other (for e.g.: `.push.refspec: refs/heads/main:refs/heads/main` and
+`.push.branch: main`), then the reconciler might fail with an `already
+up-to-date` error.
+
+In the following snippet, updates and commits will be made on the `main` branch locally.
+The commits will be then pushed using the `refs/heads/main:refs/heads/auto` refspec:
+
+```yaml
+spec:
+ git:
+ checkout:
+ ref:
+ branch: main
+ push:
+ refspec: refs/heads/main:refs/heads/auto
+```
+
+##### Push options
+
+To specify the [push options](https://git-scm.com/docs/git-push#Documentation/git-push.txt---push-optionltoptiongt)
+to be sent to the upstream Git server, use `.push.options`. These options can be
+used to perform operations as a result of the push. For example, using the below
+push options will open a GitLab Merge Request to the `release` branch
+automatically with the commit the controller pushed to the `dev` branch:
+
+```yaml
+spec:
+ git:
+ push:
+ branch: dev
+ options:
+ merge_request.create: ""
+ merge_request.target: release
+```
+
+### Interval
+
+`.spec.interval` is a required field that specifies the interval at which the
+Image update is attempted.
+
+After successfully reconciling the object, the image-automation-controller
+requeues it for inspection after the specified interval. The value must be in a
+[Go recognized duration string format](https://pkg.go.dev/time#ParseDuration),
+e.g. `10m0s` to reconcile the object every 10 minutes.
+
+If the `.metadata.generation` of a resource changes (due to e.g. a change to
+the spec), this is handled instantly outside the interval window.
+
+### Update
+
+`.spec.update` is an optional field that specifies how to carry out the updates
+on a source. The only supported update strategy at the moment is `Setters`,
+which is used by default for `.spec.update.strategy` field. The
+`.spec.update.path` is an optional field to specify the directory containing the
+manifests to be updated. If not specified, it defaults to the root of the source
+repository.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ update:
+ path:
+```
+
+### Suspend
+
+`.spec.suspend` is an optional field to suspend the reconciliation of an
+ImageUpdateAutomation. When set to `true`, the controller will stop reconciling
+the ImageUpdateAutomation, and changes to the resource or image policies or Git
+repository will not result in any update. When the field is set to `false` or
+removed, it will resume.
+
+### PolicySelector
+
+`.spec.policySelector` is an optional field to limit policies that an
+ImageUpdateAutomation takes into account. It supports the same selectors as
+`Deployment.spec.selector` (`matchLabels` and `matchExpressions` fields). If
+not specified, it defaults to `matchLabels: {}` which means all policies in
+namespace.
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ policySelector:
+ matchLabels:
+ app.kubernetes.io/instance: my-app
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ policySelector:
+ matchExpressions:
+ - key: app.kubernetes.io/component
+ operator: In
+ values:
+ - my-component
+ - my-other-component
+```
+
+## Working with ImageUpdateAutomation
+
+### Triggering a reconciliation
+
+To manually tell the image-automation-controller to reconcile an
+ImageUpdateAutomation outside of the [specified interval window](#interval), an
+ImageUpdateAutomation can be annotated with
+`reconcile.fluxcd.io/requestedAt: `. Annotating the resource
+queues the ImageUpdateAutomation for reconciliation if the ``
+differs from the last value the controller acted on, as reported in
+[`.status.lastHandledReconcileAt`](#last-handled-reconcile-at).
+
+Using `kubectl`:
+
+```sh
+kubectl annotate --field-manager=flux-client-side-apply --overwrite imageupdateautomation/ reconcile.fluxcd.io/requestedAt="$(date +%s)"
+```
+
+Using `flux`:
+
+```sh
+flux reconcile image update
+```
+
+### Waiting for `Ready`
+
+When a change is applied, it is possible to wait for the ImageUpdateAutomation
+to reach a [ready state](#ready-imageupdateautomation) using `kubectl`:
+
+```sh
+kubectl wait imageupdateautomation/ --for=condition=ready --timeout=1m
+```
+
+### Suspending and resuming
+
+When you find yourself in a situation where you temporarily want to pause the
+reconciliation of a ImageUpdateAutomation, you can suspend it using the
+[`.spec.suspend` field](#suspend).
+
+#### Suspend an ImageUpdateAutomation
+
+In your YAML declaration:
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ suspend: true
+```
+
+Using `kubectl`:
+
+```sh
+kubectl patch imageupdateautomation --field-manager=flux-client-side-apply -p '{\"spec\": {\"suspend\" : true }}'
+```
+
+Using `flux`:
+
+```sh
+flux suspend image update
+```
+
+#### Resume an ImageUpdateAutomation
+
+In your YAML declaration, comment out (or remove) the `.spec.suspend` field:
+
+```yaml
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name:
+spec:
+ # suspend: true
+```
+
+**Note:** Setting the field value to `false` has the same effect as removing
+it, but does not allow for "hot patching" using e.g. `kubectl` while practicing
+GitOps; as the manually applied patch would be overwritten by the declared
+state in Git.
+
+Using `kubectl`:
+
+```sh
+kubectl patch imageupdateautomation --field-manager=flux-client-side-apply -p '{\"spec\" : {\"suspend\" : false }}'
+```
+
+Using `flux`:
+
+```sh
+flux resume image update
+```
+
+### Debugging an ImageUpdateAutomation
+
+There are several ways to gather information about an ImageUpdateAutomation for
+debugging purposes.
+
+#### Describe the ImageUpdateAutomation
+
+Describing an ImageUpdateAutomation using
+`kubectl describe imageupdateautomation ` displays the latest
+recorded information for the resource in the `Status` and
+`Events` sections:
+
+```console
+...
+Status:
+ Conditions:
+ Last Transition Time: 2024-03-18T20:00:56Z
+ Message: processing object: new generation 6 -> 7
+ Observed Generation: 7
+ Reason: ProgressingWithRetry
+ Status: True
+ Type: Reconciling
+ Last Transition Time: 2024-03-18T20:00:54Z
+ Message: failed to checkout source: unable to clone 'https://github.com/fluxcd/example': couldn't find remote ref "refs/heads/non-existing-branch"
+ Observed Generation: 7
+ Reason: GitOperationFailed
+ Status: False
+ Type: Ready
+ Last Automation Run Time: 2024-03-18T20:00:56Z
+ Last Handled Reconcile At: 1710791381
+ Last Push Commit: 8084f1bb180ac259c6698cd027064b7dce86a72a
+ Last Push Time: 2024-03-18T18:53:04Z
+ Observed Generation: 6
+ Observed Policies:
+ Podinfo - Policy:
+ Name: ghcr.io/stefanprodan/podinfo
+ Tag: 4.0.6
+ Observed Source Revision: main@sha1:8084f1bb180ac259c6698cd027064b7dce86a72a
+Events:
+ Type Reason Age From Message
+ ---- ------ ---- ---- -------
+ Normal Succeeded 11m (x11 over 170m) image-automation-controller no change since last reconciliation
+ Warning GitOperationFailed 2s (x3 over 4s) image-automation-controller failed to checkout source: unable to clone 'https://github.com/fluxcd/example': couldn't find remote ref "refs/heads/non-existing-branch"
+```
+
+#### Trace emitted Events
+
+To view events for specific ImageUpdateAutomation(s), `kubectl events` can be
+used in combination with `--for` to list the Events for specific objects. For
+example, running
+
+```sh
+kubectl events --for ImageUpdateAutomation/
+```
+
+lists
+
+```console
+LAST SEEN TYPE REASON OBJECT MESSAGE
+3m29s (x7 over 4m17s) Warning GitOperationFailed ImageUpdateAutomation/ failed to checkout source: unable to clone 'https://github.com/fluxcd/example': couldn't find remote ref "refs/heads/non-existing-branch"
+3m14s (x4 over 3h24m) Normal Succeeded ImageUpdateAutomation/ repository up-to-date
+2m41s (x12 over 174m) Normal Succeeded ImageUpdateAutomation/ no change since last reconciliation
+```
+
+Besides being reported in Events, the reconciliation errors are also logged by
+the controller. The Flux CLI offer commands for filtering the logs for a
+specific ImageUpdateAutomation, e.g.
+`flux logs --level=error --kind=ImageUpdateAutomation --name=`.
+
+#### Gerrit
+
+[Gerrit](https://www.gerritcodereview.com/) operates differently from a
+standard Git server. Rather than sending individual commits to a branch,
+all changes are bundled into a single commit. This commit requires a distinct
+identifier separate from the commit SHA. Additionally, instead of initiating
+a Pull Request between branches, the commit is pushed using a refspec:
+`HEAD:refs/for/main`.
+
+As the image-automation-controller is primarily designed to work with
+standard Git servers, these special characteristics necessitate a few
+workarounds. The following is an example configuration that works
+well with Gerrit:
+
+```yaml
+spec:
+ git:
+ checkout:
+ ref:
+ branch: main
+ commit:
+ author:
+ email: flux@localdomain
+ name: flux
+ messageTemplate: |
+ Perform automatic image update
+
+ Automation name: {{ .AutomationObject }}
+
+ {{- $ChangeId := .AutomationObject -}}
+ {{- $ChangeId = printf "%s%s" $ChangeId ( .Changed.FileChanges | toString ) -}}
+ {{- $ChangeId = printf "%s%s" $ChangeId ( .Changed.Objects | toString ) -}}
+ {{- $ChangeId = printf "%s%s" $ChangeId ( .Changed.Changes | toString ) }}
+ Change-Id: {{ printf "I%s" ( sha256sum $ChangeId | trunc 40 ) }}
+ push:
+ branch: auto
+ refspec: refs/heads/auto:refs/heads/main
+```
+
+This instructs the image-automation-controller to clone the repository using the
+`main` branch but execute its update logic and commit with the provided message
+template on the `auto` branch. Commits are then pushed to the `auto` branch,
+followed by pushing the `HEAD` of the `auto` branch to the `HEAD` of the remote
+`main` branch. The message template ensures the inclusion of a [Change-Id](https://gerrit-review.googlesource.com/Documentation/concept-changes.html#change-id)
+at the bottom of the commit message.
+
+The initial branch push aims to prevent multiple
+[Patch Sets](https://gerrit-review.googlesource.com/Documentation/concept-patch-sets.html).
+If we exclude `.push.branch` and only specify
+`.push.refspec: refs/heads/main:refs/heads/main`, the desired [Change](https://gerrit-review.googlesource.com/Documentation/concept-changes.html)
+can be created as intended. However, when the controller freshly clones the
+`main` branch while a Change is open, it executes its update logic on `main`,
+leading to new commits being pushed with the same changes to the existing open
+Change. Specifying `.push.branch` circumvents this by instructing the controller
+to apply the update logic to the `auto` branch, already containing the desired
+commit. This approach is also recommended in the
+[Gerrit documentation](https://gerrit-review.googlesource.com/Documentation/intro-gerrit-walkthrough-github.html#create-change).
+
+Another thing to note is the syntax of `.push.refspec`. Instead of it being
+`HEAD:refs/for/main`, commonly used by Gerrit users, we specify the full
+refname `refs/heads/auto` in the source part of the refpsec.
+
+**Note:** A known limitation of using the image-automation-controller with
+Gerrit involves handling multiple concurrent Changes. This is due to the
+calculation of the Change-Id, relying on factors like file names and image
+tags. If the controller introduces a new file or modifies a previously updated
+image tag to a different one, it leads to a distinct Change-Id for the commit.
+Consequently, this action will trigger the creation of an additional Change,
+even when an existing Change containing outdated modifications remains open.
+
+## ImageUpdateAutomation Status
+
+### Observed Policies
+
+The ImageUpdateAutomation reports the observed image policies that were
+considered during the image update in the `.status.observedPolicies` field. It
+is a map of the policy name and its latest image name and tag.
+
+Example:
+```yaml
+status:
+ ...
+ observedPolicies:
+ podinfo-policy:
+ name: ghcr.io/stefanprodan/podinfo
+ tag: 4.0.6
+ myapp1:
+ name: ghcr.io/fluxcd/myapp1
+ tag: 4.0.0
+ myapp2:
+ name: ghcr.io/fluxcd/myapp2
+ tag: 2.0.0
+ ...
+```
+
+The observed policies keep track of the policies considered in the last
+reconciliation and is used to determine if the reconciliation can skip full
+execution due to no change in image policies or remote source.
+
+### Observed Source Revision
+
+The ImageUpdateAutomation reports the observed source revision that was checked
+out during the image update in the `.status.observedSourceRevision` field. For a
+GitRepository, the observed source revision would contain the branch name and
+the commit hash; e.g., `main@sha1:8084f1bb180ac259c6698cd027064b7dce86a72a`.
+If the checkout and push branchs are the same, the commit hash of the observed
+source revision is equal to the [last push commit](#last-push-commit).
+
+The observed source revision keeps track of the source revision seen in the last
+reconciliation and is used to determine if the reconciliation can skip full
+execution due to no change in image policies or remote source.
+
+### Last Automation Run Time
+
+The ImageUpdateAutomation reports the last automation run time in the
+`.status.lastAutomationRunTime` field. It is a timestamp of when the
+reconciliation ran the last time, regardless of any effective resulting update.
+
+### Last Push Commit
+
+The ImageUpdateAutomation reports the last pushed commit for image update in the
+`.status.lastPushCommit` field. It is the commit hash of the last pushed commit.
+The commit has may not be the same that's present in the observed source
+revision if the puch branch is different from the checkout branch or the remote
+repository has new commits which didn't result in an image update.
+
+### Last Push Time
+
+The ImageUpdateAutomation reports the last pushed commit time for image update
+in the `.status.lastPushTime` field. It is a timestamp of when the last image
+update resulted in a pushing of new commit to the source.
+
+### Conditions
+
+An ImageUpdateAutomation enters various states during its lifecycle, reflected
+as [Kubernetes Conditions][typical-status-properties].
+It can be [reconciling](#reconciling-imageupdateautomation) while checking out
+and updating images in source, it can be [ready](#ready-imageupdateautomation),
+or it can [fail during reconciliation](#failed-imageupdateautomation).
+
+The ImageUpdateAutomation API is compatible with the [kstatus specification][kstatus-spec],
+and reports `Reconciling` and `Stalled` conditions where applicable to provide
+better (timeout) support to solutions polling the ImageUpdateAutomation to
+become `Ready`.
+
+#### Reconciling ImageUpdateAutomation
+
+The image-automation-controller marks an ImageUpdateAutomation as _reconciling_
+when one of the following is true:
+
+- The generation of the ImageUpdateAutomation is newer than the [Observed
+Generation](#observed-generation).
+- The ImageUpdateAutomation has observed new ImagePolicies or changes in the
+ ImagePolicies' latest images, or change in the remote source.
+
+When the ImageUpdateAutomation is "reconciling", the `Ready` Condition status
+becomes `Unknown`, and the controller adds a Condition with the following
+attributes to the ImageUpdateAutomation's `.status.conditions`:
+
+- `type: Reconciling`
+- `status: "True"`
+- `reason: Progressing`
+
+It has a ["negative polarity"][typical-status-properties], and is only present
+on the ImageUpdateAutomation while its status value is `"True"`.
+
+#### Ready ImageUpdateAutomation
+
+The image-automation-controller marks an ImageUpdateAutomation as _ready_ when
+it has the following characteristics:
+
+- The controller was able to check out the remote source repository using the
+ specified GitRepository configurations.
+- The ImageUpdateAutomation could not find any update to the source, already
+ up-to-date.
+- The ImageUpdateAutomation pushes image updates to the source, making it
+ up-to-date.
+
+When the ImageUpdateAutomation is "ready", the controller sets a Condition with the
+following attributes in the ImageUpdateAutomation's `.status.conditions`:
+
+- `type: Ready`
+- `status: "True"`
+- `reason: Succeeded`
+
+This `Ready` Condition will retain a status value of `"True"` until a
+[failure](#failed-imageupdateautomation) occurs due to any reason.
+
+#### Failed ImageUpdateAutomation
+
+The image-automation-controller may get stuck trying to update a source without
+completing. This can occur due to some of the following factors:
+
+- The remote source is temporarily unavailable.
+- The referenced source is in a different namespace and cross-namespace
+ reference is disabled.
+- The referenced source does not exist.
+- The credentials associated with the source are invalid.
+- The source configuration is invalid for the current state of the source, for
+ example, the specified branch does not exists in the remote source repository.
+- The remote source repository prevents push or creation of new push branch.
+- The policy selector is invalid, for example, label is too long.
+
+When this happens, the controller sets the `Ready` Condition status to `False`
+with the following reasons:
+
+- `reason: AccessDenied` | `reason: InvalidSourceConfiguration` | `reason: GitOperationFailed` | `reason: UpdateFailed` | `reason: InvalidPolicySelector`
+
+While the ImageUpdateAutomation is in failing state, the controller will
+continue to attempt to update the source with an exponential backoff, until it
+succeeds and the ImageUpdateAutomation is marked as
+[ready](#ready-imageupdateautomation).
+
+Note that an ImageUpdateAutomation can be [reconciling](#reconciling-imageupdateautomation)
+while failing at the same time, for example due to a newly introduced
+configuration issue in the ImageUpdateAutomation spec.
+
+### Observed Generation
+
+The image-automation-controller reports an
+[observed generation][typical-status-properties] in the ImageUpdateAutomation's
+`.status.observedGeneration`. The observed generation is the latest
+`.metadata.generation` which resulted in either a
+[ready state](#ready-imageupdateautomation), or stalled due to error it can not
+recover from without human intervention.
+
+### Last Handled Reconcile At
+
+The image-automation-controller reports the last
+`reconcile.fluxcd.io/requestedAt` annotation value it acted on in the
+`.status.lastHandledReconcileAt` field.
+
+For practical information about this field, see [triggering a
+reconcile](#triggering-a-reconcile).
+
+
+[image-auto-guide]: https://fluxcd.io/flux/guides/image-update/#configure-image-update-for-custom-resources
+[go-text-template]: https://golang.org/pkg/text/template/
+[typical-status-properties]: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
+[kstatus-spec]:
+ https://github.com/kubernetes-sigs/cli-utils/tree/master/pkg/kstatus
diff --git a/go.mod b/go.mod
index 4411301c..1ceba8f1 100644
--- a/go.mod
+++ b/go.mod
@@ -92,6 +92,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
diff --git a/go.sum b/go.sum
index 003b7826..e2cad4b4 100644
--- a/go.sum
+++ b/go.sum
@@ -176,6 +176,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt
index 681f7759..ab427acb 100644
--- a/hack/boilerplate.go.txt
+++ b/hack/boilerplate.go.txt
@@ -1,5 +1,5 @@
/*
-Copyright 2020 The Flux authors
+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.
diff --git a/internal/controller/controllers_fuzzer_test.go b/internal/controller/controllers_fuzzer_test.go
index 32083cd5..ba85a9d5 100644
--- a/internal/controller/controllers_fuzzer_test.go
+++ b/internal/controller/controllers_fuzzer_test.go
@@ -57,7 +57,7 @@ import (
"github.com/fluxcd/pkg/runtime/testenv"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
- image_automationv1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
+ image_automationv1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
"github.com/fluxcd/image-automation-controller/pkg/update"
)
diff --git a/internal/controller/imageupdateautomation_controller.go b/internal/controller/imageupdateautomation_controller.go
index f963ae56..d7ccdcea 100644
--- a/internal/controller/imageupdateautomation_controller.go
+++ b/internal/controller/imageupdateautomation_controller.go
@@ -1,5 +1,5 @@
/*
-Copyright 2020 The Flux authors
+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.
@@ -17,25 +17,18 @@ limitations under the License.
package controller
import (
- "bytes"
"context"
"errors"
"fmt"
- "net/url"
- "os"
"strings"
- "text/template"
"time"
- "github.com/Masterminds/sprig/v3"
- "github.com/ProtonMail/go-crypto/openpgp"
- securejoin "github.com/cyphar/filepath-securejoin"
- extgogit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing/transport"
- "github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/runtime"
+ kerrors "k8s.io/apimachinery/pkg/util/errors"
kuberecorder "k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
@@ -48,420 +41,86 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta2"
- apiacl "github.com/fluxcd/pkg/apis/acl"
+ aclapi "github.com/fluxcd/pkg/apis/acl"
eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/git"
- "github.com/fluxcd/pkg/git/gogit"
- "github.com/fluxcd/pkg/git/repository"
"github.com/fluxcd/pkg/runtime/acl"
+ "github.com/fluxcd/pkg/runtime/conditions"
helper "github.com/fluxcd/pkg/runtime/controller"
- "github.com/fluxcd/pkg/runtime/logger"
+ "github.com/fluxcd/pkg/runtime/patch"
"github.com/fluxcd/pkg/runtime/predicates"
+ runtimereconcile "github.com/fluxcd/pkg/runtime/reconcile"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
- imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
"github.com/fluxcd/image-automation-controller/internal/features"
- "github.com/fluxcd/image-automation-controller/pkg/update"
+ "github.com/fluxcd/image-automation-controller/internal/policy"
+ "github.com/fluxcd/image-automation-controller/internal/source"
)
-const (
- originRemote = "origin"
- defaultMessageTemplate = `Update from image update automation`
- repoRefKey = ".spec.gitRepository"
- signingSecretKey = "git.asc"
- signingPassphraseKey = "passphrase"
-)
+const repoRefKey = ".spec.gitRepository"
-// TemplateData is the type of the value given to the commit message
-// template.
-type TemplateData struct {
- AutomationObject types.NamespacedName
- Updated update.Result
-}
+const readyMessage = "repository up-to-date"
-// ImageUpdateAutomationReconciler reconciles a ImageUpdateAutomation object
-type ImageUpdateAutomationReconciler struct {
- client.Client
- EventRecorder kuberecorder.EventRecorder
- helper.Metrics
-
- NoCrossNamespaceRef bool
+// imageUpdateAutomationOwnedConditions is a list of conditions owned by the
+// ImageUpdateAutomationReconciler.
+var imageUpdateAutomationOwnedConditions = []string{
+ meta.ReadyCondition,
+ meta.ReconcilingCondition,
+ meta.StalledCondition,
+}
- features map[string]bool
+// imageUpdateAutomationNegativeConditions is a list of negative polarity
+// conditions owned by ImageUpdateAutomationReconciler. It is used in tests for
+// compliance with kstatus.
+var imageUpdateAutomationNegativeConditions = []string{
+ meta.StalledCondition,
+ meta.ReconcilingCondition,
}
-type ImageUpdateAutomationReconcilerOptions struct {
- RateLimiter ratelimiter.RateLimiter
+var errParsePolicySelector = errors.New("failed to parse policy selector")
+
+// getPatchOptions composes patch options based on the given parameters.
+// It is used as the options used when patching an object.
+func getPatchOptions(ownedConditions []string, controllerName string) []patch.Option {
+ return []patch.Option{
+ patch.WithOwnedConditions{Conditions: ownedConditions},
+ patch.WithFieldOwner(controllerName),
+ }
}
// +kubebuilder:rbac:groups=image.toolkit.fluxcd.io,resources=imageupdateautomations,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=image.toolkit.fluxcd.io,resources=imageupdateautomations/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=gitrepositories,verbs=get;list;watch
-func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- log := ctrl.LoggerFrom(ctx)
- debuglog := log.V(logger.DebugLevel)
- tracelog := log.V(logger.TraceLevel)
- start := time.Now()
- var templateValues TemplateData
-
- var auto imagev1.ImageUpdateAutomation
- if err := r.Get(ctx, req.NamespacedName, &auto); err != nil {
- return ctrl.Result{}, client.IgnoreNotFound(err)
- }
-
- defer func() {
- // Always record suspend, readiness and duration metrics.
- r.Metrics.RecordSuspend(ctx, &auto, auto.Spec.Suspend)
- r.Metrics.RecordReadiness(ctx, &auto)
- r.Metrics.RecordDuration(ctx, &auto, start)
- }()
-
- // If the object is under deletion, record the readiness, and remove our finalizer.
- if !auto.ObjectMeta.DeletionTimestamp.IsZero() {
- controllerutil.RemoveFinalizer(&auto, imagev1.ImageUpdateAutomationFinalizer)
- if err := r.Update(ctx, &auto); err != nil {
- return ctrl.Result{}, err
- }
- return ctrl.Result{}, nil
- }
-
- // Add our finalizer if it does not exist.
- // Note: Finalizers in general can only be added when the deletionTimestamp
- // is not set.
- if !controllerutil.ContainsFinalizer(&auto, imagev1.ImageUpdateAutomationFinalizer) {
- patch := client.MergeFrom(auto.DeepCopy())
- controllerutil.AddFinalizer(&auto, imagev1.ImageUpdateAutomationFinalizer)
- if err := r.Patch(ctx, &auto, patch); err != nil {
- log.Error(err, "unable to register finalizer")
- return ctrl.Result{}, err
- }
- }
-
- if auto.Spec.Suspend {
- log.Info("ImageUpdateAutomation is suspended, skipping automation run")
- return ctrl.Result{}, nil
- }
-
- templateValues.AutomationObject = req.NamespacedName
-
- // whatever else happens, we've now "seen" the reconcile
- // annotation if it's there
- if token, ok := meta.ReconcileAnnotationValue(auto.GetAnnotations()); ok {
- auto.Status.SetLastHandledReconcileRequest(token)
-
- if err := r.patchStatus(ctx, req, auto.Status); err != nil {
- return ctrl.Result{Requeue: true}, err
- }
- }
-
- // failWithError is a helper for bailing on the reconciliation.
- failWithError := func(err error) (ctrl.Result, error) {
- r.event(ctx, auto, eventv1.EventSeverityError, err.Error())
- imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, imagev1.ReconciliationFailedReason, err.Error())
- if err := r.patchStatus(ctx, req, auto.Status); err != nil {
- log.Error(err, "failed to reconcile")
- }
- return ctrl.Result{Requeue: true}, err
- }
-
- // get the git repository object so it can be checked out
-
- // only GitRepository objects are supported for now
- if kind := auto.Spec.SourceRef.Kind; kind != sourcev1.GitRepositoryKind {
- return failWithError(fmt.Errorf("source kind '%s' not supported", kind))
- }
-
- gitSpec := auto.Spec.GitSpec
- if gitSpec == nil {
- return failWithError(fmt.Errorf("source kind %s neccessitates field .spec.git", sourcev1.GitRepositoryKind))
- }
-
- var origin sourcev1.GitRepository
- gitRepoNamespace := req.Namespace
- if auto.Spec.SourceRef.Namespace != "" {
- gitRepoNamespace = auto.Spec.SourceRef.Namespace
- }
- originName := types.NamespacedName{
- Name: auto.Spec.SourceRef.Name,
- Namespace: gitRepoNamespace,
- }
- debuglog.Info("fetching git repository", "gitrepository", originName)
-
- if r.NoCrossNamespaceRef && gitRepoNamespace != auto.GetNamespace() {
- err := acl.AccessDeniedError(fmt.Sprintf("can't access '%s/%s', cross-namespace references have been blocked",
- auto.Spec.SourceRef.Kind, originName))
- log.Error(err, "access denied to cross-namespaced resource")
- imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, apiacl.AccessDeniedReason,
- err.Error())
- if err := r.patchStatus(ctx, req, auto.Status); err != nil {
- return ctrl.Result{Requeue: true}, err
- }
- r.event(ctx, auto, eventv1.EventSeverityError, err.Error())
- return ctrl.Result{}, nil
- }
-
- if err := r.Get(ctx, originName, &origin); err != nil {
- if client.IgnoreNotFound(err) == nil {
- imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, imagev1.GitNotAvailableReason, "referenced git repository is missing")
- log.Error(err, fmt.Sprintf("referenced git repository %s does not exist.", originName.String()))
- if err := r.patchStatus(ctx, req, auto.Status); err != nil {
- return ctrl.Result{Requeue: true}, err
- }
- return ctrl.Result{}, nil // and assume we'll hear about it when it arrives
- }
- return ctrl.Result{}, err
- }
-
- // validate the git spec and default any values needed later, before proceeding
- var checkoutRef *sourcev1.GitRepositoryRef
- if gitSpec.Checkout != nil {
- checkoutRef = &gitSpec.Checkout.Reference
- tracelog.Info("using git repository ref from .spec.git.checkout", "ref", checkoutRef)
- } else if r := origin.Spec.Reference; r != nil {
- checkoutRef = r
- tracelog.Info("using git repository ref from GitRepository spec", "ref", checkoutRef)
- } // else remain as `nil` and git.DefaultBranch will be used.
-
- tmp, err := os.MkdirTemp("", fmt.Sprintf("%s-%s", originName.Namespace, originName.Name))
- if err != nil {
- return failWithError(err)
- }
- defer func() {
- if err := os.RemoveAll(tmp); err != nil {
- log.Error(err, "failed to remove working directory", "path", tmp)
- }
- }()
-
- // pushBranch contains the branch name the commit needs to be pushed to.
- // It takes the value of the push branch if one is specified or, if the push
- // config is nil, then it takes the value of the checkout branch if possible.
- var pushBranch string
- var switchBranch bool
- if gitSpec.Push != nil && gitSpec.Push.Branch != "" {
- pushBranch = gitSpec.Push.Branch
- tracelog.Info("using push branch from .spec.push.branch", "branch", pushBranch)
- // We only need to switch branches when a branch has been specified in
- // the push spec and it is different than the one in the checkout ref.
- if gitSpec.Push.Branch != checkoutRef.Branch {
- switchBranch = true
- }
- } else {
- // Here's where it gets constrained. If there's no push branch
- // given, then the checkout ref must include a branch, and
- // that can be used.
- if checkoutRef == nil || checkoutRef.Branch == "" {
- return failWithError(
- fmt.Errorf("Push spec not provided, and cannot be inferred from .spec.git.checkout.ref or GitRepository .spec.ref"),
- )
- }
- pushBranch = checkoutRef.Branch
- tracelog.Info("using push branch from $ref.branch", "branch", pushBranch)
- }
-
- authOpts, err := r.getAuthOpts(ctx, &origin)
- if err != nil {
- return failWithError(err)
- }
- var proxyOpts *transport.ProxyOptions
- if origin.Spec.ProxySecretRef != nil {
- proxyOpts, err = r.getProxyOpts(ctx, origin.Spec.ProxySecretRef.Name, origin.GetNamespace())
- if err != nil {
- return failWithError(err)
- }
- }
-
- clientOpts := r.getGitClientOpts(authOpts.Transport, proxyOpts, switchBranch)
- gitClient, err := gogit.NewClient(tmp, authOpts, clientOpts...)
- if err != nil {
- return failWithError(err)
- }
- defer gitClient.Close()
-
- opts := repository.CloneConfig{}
- if checkoutRef != nil {
- opts.Tag = checkoutRef.Tag
- opts.SemVer = checkoutRef.SemVer
- opts.Commit = checkoutRef.Commit
- opts.Branch = checkoutRef.Branch
- }
-
- if enabled, _ := r.features[features.GitShallowClone]; enabled {
- opts.ShallowClone = true
- }
-
- // Use the git operations timeout for the repo.
- cloneCtx, cancel := context.WithTimeout(ctx, origin.Spec.Timeout.Duration)
- defer cancel()
- debuglog.Info("attempting to clone git repository", "gitrepository", originName, "ref", checkoutRef, "working", tmp)
- if _, err := gitClient.Clone(cloneCtx, origin.Spec.URL, opts); err != nil {
- return failWithError(err)
- }
-
- // When there's a push branch specified, the pushed-to branch is where commits
- // shall be made
- if switchBranch {
- // Use the git operations timeout for the repo.
- fetchCtx, cancel := context.WithTimeout(ctx, origin.Spec.Timeout.Duration)
- defer cancel()
- if err := gitClient.SwitchBranch(fetchCtx, pushBranch); err != nil {
- return failWithError(err)
- }
- }
-
- switch {
- case auto.Spec.Update != nil && auto.Spec.Update.Strategy == imagev1.UpdateStrategySetters:
- // For setters we first want to compile a list of _all_ the
- // policies in the same namespace (maybe in the future this
- // could be filtered by the automation object).
- var policies imagev1_reflect.ImagePolicyList
- if err := r.List(ctx, &policies, &client.ListOptions{Namespace: req.NamespacedName.Namespace}); err != nil {
- return failWithError(err)
- }
-
- manifestsPath := tmp
- if auto.Spec.Update.Path != "" {
- tracelog.Info("adjusting update path according to .spec.update.path", "base", tmp, "spec-path", auto.Spec.Update.Path)
- p, err := securejoin.SecureJoin(tmp, auto.Spec.Update.Path)
- if err != nil {
- return failWithError(err)
- }
- manifestsPath = p
- }
-
- debuglog.Info("updating with setters according to image policies", "count", len(policies.Items), "manifests-path", manifestsPath)
- if tracelog.Enabled() {
- for _, item := range policies.Items {
- tracelog.Info("found policy", "namespace", item.Namespace, "name", item.Name, "latest-image", item.Status.LatestImage)
- }
- }
-
- result, err := updateAccordingToSetters(ctx, tracelog, manifestsPath, manifestsPath, policies.Items)
- if err != nil {
- return failWithError(err)
- }
-
- templateValues.Updated = result
-
- default:
- log.Info("no update strategy given in the spec")
- // no sense rescheduling until this resource changes
- r.event(ctx, auto, eventv1.EventSeverityInfo, "no known update strategy in spec, failing trivially")
- imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, imagev1.NoStrategyReason, "no known update strategy is given for object")
- return ctrl.Result{}, r.patchStatus(ctx, req, auto.Status)
- }
-
- debuglog.Info("ran updates to working dir", "working", tmp)
-
- var signingEntity *openpgp.Entity
- if gitSpec.Commit.SigningKey != nil {
- if signingEntity, err = r.getSigningEntity(ctx, auto); err != nil {
- return failWithError(err)
- }
- }
-
- // construct the commit message from template and values
- message, err := templateMsg(gitSpec.Commit.MessageTemplate, &templateValues)
- if err != nil {
- return failWithError(err)
- }
-
- var rev string
- if len(templateValues.Updated.Files) > 0 {
- // The status message depends on what happens next. Since there's
- // more than one way to succeed, there's some if..else below, and
- // early returns only on failure.
- rev, err = gitClient.Commit(
- git.Commit{
- Author: git.Signature{
- Name: gitSpec.Commit.Author.Name,
- Email: gitSpec.Commit.Author.Email,
- When: time.Now(),
- },
- Message: message,
- },
- repository.WithSigner(signingEntity),
- )
- } else {
- err = extgogit.ErrEmptyCommit
- }
-
- var statusMessage strings.Builder
- if err != nil {
- if !errors.Is(err, git.ErrNoStagedFiles) && !errors.Is(err, extgogit.ErrEmptyCommit) {
- return failWithError(err)
- }
-
- log.Info("no changes made in working directory; no commit")
- statusMessage.WriteString("no updates made")
-
- if auto.Status.LastPushTime != nil && len(auto.Status.LastPushCommit) >= 7 {
- statusMessage.WriteString(fmt.Sprintf("; last commit %s at %s",
- auto.Status.LastPushCommit[:7], auto.Status.LastPushTime.Format(time.RFC3339)))
- }
- } else {
- // Use the git operations timeout for the repo.
- pushCtx, cancel := context.WithTimeout(ctx, origin.Spec.Timeout.Duration)
- defer cancel()
-
- var pushConfig repository.PushConfig
- if gitSpec.Push != nil {
- pushConfig.Options = gitSpec.Push.Options
- }
- if pushBranch != "" {
- // If the force push feature flag is true and we are pushing to a
- // different branch than the one we checked out to, then force push
- // these changes.
- forcePush := r.features[features.GitForcePushBranch]
- if forcePush && switchBranch {
- pushConfig.Force = true
- }
+// ImageUpdateAutomationReconciler reconciles a ImageUpdateAutomation object
+type ImageUpdateAutomationReconciler struct {
+ client.Client
+ kuberecorder.EventRecorder
+ helper.Metrics
- if err := gitClient.Push(pushCtx, pushConfig); err != nil {
- return failWithError(err)
- }
- log.Info("pushed commit to origin", "revision", rev, "branch", pushBranch)
- statusMessage.WriteString(fmt.Sprintf("committed and pushed commit '%s' to branch '%s'", rev, pushBranch))
- }
+ ControllerName string
+ NoCrossNamespaceRef bool
- if gitSpec.Push != nil && gitSpec.Push.Refspec != "" {
- pushConfig.Refspecs = []string{gitSpec.Push.Refspec}
- if err := gitClient.Push(pushCtx, pushConfig); err != nil {
- return failWithError(err)
- }
- log.Info("pushed commit to origin", "revision", rev, "refspec", gitSpec.Push.Refspec)
+ features map[string]bool
- if statusMessage.Len() > 0 {
- statusMessage.WriteString(fmt.Sprintf(" and using refspec '%s'", gitSpec.Push.Refspec))
- } else {
- statusMessage.WriteString(fmt.Sprintf("committed and pushed commit '%s' using refspec '%s'", rev, gitSpec.Push.Refspec))
- }
- }
+ patchOptions []patch.Option
+}
- r.event(ctx, auto, eventv1.EventSeverityInfo, fmt.Sprintf("%s\n%s", statusMessage.String(), message))
+type ImageUpdateAutomationReconcilerOptions struct {
+ MaxConcurrentReconciles int
+ RateLimiter ratelimiter.RateLimiter
+ RecoverPanic bool
+}
- auto.Status.LastPushCommit = rev
- auto.Status.LastPushTime = &metav1.Time{Time: start}
- }
+func (r *ImageUpdateAutomationReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts ImageUpdateAutomationReconcilerOptions) error {
+ r.patchOptions = getPatchOptions(imageUpdateAutomationOwnedConditions, r.ControllerName)
- // Getting to here is a successful run.
- auto.Status.LastAutomationRunTime = &metav1.Time{Time: start}
- imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionTrue, imagev1.ReconciliationSucceededReason, statusMessage.String())
- if err := r.patchStatus(ctx, req, auto.Status); err != nil {
- return ctrl.Result{Requeue: true}, err
+ if r.features == nil {
+ r.features = features.FeatureGates()
}
- // We're either in this method because something changed, or this
- // object got requeued. Either way, once successful, we don't need
- // to see the object again until Interval has passed, or something
- // changes again.
-
- interval := intervalOrDefault(&auto)
- return ctrl.Result{RequeueAfter: interval}, nil
-}
-
-func (r *ImageUpdateAutomationReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, opts ImageUpdateAutomationReconcilerOptions) error {
// Index the git repository object that each I-U-A refers to
if err := mgr.GetFieldIndexer().IndexField(ctx, &imagev1.ImageUpdateAutomation{}, repoRefKey, func(obj client.Object) []string {
updater := obj.(*imagev1.ImageUpdateAutomation)
@@ -471,10 +130,6 @@ func (r *ImageUpdateAutomationReconciler) SetupWithManager(ctx context.Context,
return err
}
- if r.features == nil {
- r.features = features.FeatureGates()
- }
-
return ctrl.NewControllerManagedBy(mgr).
For(&imagev1.ImageUpdateAutomation{}, builder.WithPredicates(
predicate.Or(predicate.GenerationChangedPredicate{}, predicates.ReconcileRequestedPredicate{}))).
@@ -494,54 +149,6 @@ func (r *ImageUpdateAutomationReconciler) SetupWithManager(ctx context.Context,
Complete(r)
}
-func (r *ImageUpdateAutomationReconciler) patchStatus(ctx context.Context,
- req ctrl.Request,
- newStatus imagev1.ImageUpdateAutomationStatus) error {
-
- var auto imagev1.ImageUpdateAutomation
- if err := r.Get(ctx, req.NamespacedName, &auto); err != nil {
- return err
- }
-
- patch := client.MergeFrom(auto.DeepCopy())
- auto.Status = newStatus
-
- return r.Status().Patch(ctx, &auto, patch)
-}
-
-// intervalOrDefault gives the interval specified, or if missing, the default
-func intervalOrDefault(auto *imagev1.ImageUpdateAutomation) time.Duration {
- if auto.Spec.Interval.Duration < time.Second {
- return time.Second
- }
- return auto.Spec.Interval.Duration
-}
-
-func (r *ImageUpdateAutomationReconciler) getGitClientOpts(gitTransport git.TransportType, proxyOpts *transport.ProxyOptions,
- diffPushBranch bool) []gogit.ClientOption {
- clientOpts := []gogit.ClientOption{gogit.WithDiskStorage()}
- if gitTransport == git.HTTP {
- clientOpts = append(clientOpts, gogit.WithInsecureCredentialsOverHTTP())
- }
-
- if proxyOpts != nil {
- clientOpts = append(clientOpts, gogit.WithProxy(*proxyOpts))
- }
-
- // If the push branch is different from the checkout ref, we need to
- // have all the references downloaded at clone time, to ensure that
- // SwitchBranch will have access to the target branch state. fluxcd/flux2#3384
- //
- // To always overwrite the push branch, the feature gate
- // GitAllBranchReferences can be set to false, which will cause
- // the SwitchBranch operation to ignore the remote branch state.
- allReferences := r.features[features.GitAllBranchReferences]
- if diffPushBranch {
- clientOpts = append(clientOpts, gogit.WithSingleBranch(!allReferences))
- }
- return clientOpts
-}
-
// automationsForGitRepo fetches all the automations that refer to a
// particular source.GitRepository object.
func (r *ImageUpdateAutomationReconciler) automationsForGitRepo(ctx context.Context, obj client.Object) []reconcile.Request {
@@ -551,7 +158,7 @@ func (r *ImageUpdateAutomationReconciler) automationsForGitRepo(ctx context.Cont
ctrl.LoggerFrom(ctx).Error(err, "failed to list ImageUpdateAutomations for GitRepository change")
return nil
}
- reqs := make([]reconcile.Request, len(autoList.Items), len(autoList.Items))
+ reqs := make([]reconcile.Request, len(autoList.Items))
for i := range autoList.Items {
reqs[i].NamespacedName.Name = autoList.Items[i].GetName()
reqs[i].NamespacedName.Namespace = autoList.Items[i].GetNamespace()
@@ -569,7 +176,7 @@ func (r *ImageUpdateAutomationReconciler) automationsForImagePolicy(ctx context.
ctrl.LoggerFrom(ctx).Error(err, "failed to list ImageUpdateAutomations for ImagePolicy change")
return nil
}
- reqs := make([]reconcile.Request, len(autoList.Items), len(autoList.Items))
+ reqs := make([]reconcile.Request, len(autoList.Items))
for i := range autoList.Items {
reqs[i].NamespacedName.Name = autoList.Items[i].GetName()
reqs[i].NamespacedName.Namespace = autoList.Items[i].GetNamespace()
@@ -577,142 +184,452 @@ func (r *ImageUpdateAutomationReconciler) automationsForImagePolicy(ctx context.
return reqs
}
-// getAuthOpts fetches the secret containing the auth options (if specified),
-// constructs a git.AuthOptions object using those options along with the provided
-// repository's URL and returns it.
-func (r *ImageUpdateAutomationReconciler) getAuthOpts(ctx context.Context, repository *sourcev1.GitRepository) (*git.AuthOptions, error) {
- var data map[string][]byte
- var err error
- if repository.Spec.SecretRef != nil {
- data, err = r.getSecretData(ctx, repository.Spec.SecretRef.Name, repository.GetNamespace())
- if err != nil {
- return nil, fmt.Errorf("failed to get auth secret '%s/%s': %w", repository.GetNamespace(), repository.Spec.SecretRef.Name, err)
+func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
+ start := time.Now()
+ log := ctrl.LoggerFrom(ctx)
+
+ // Fetch the ImageUpdateAutomation.
+ obj := &imagev1.ImageUpdateAutomation{}
+ if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // Initialize the patch helper with the current version of the object.
+ serialPatcher := patch.NewSerialPatcher(obj, r.Client)
+
+ // Always attempt to patch the object after each reconciliation.
+ defer func() {
+ // Create patch options for the final patch of the object.
+ patchOpts := runtimereconcile.AddPatchOptions(obj, r.patchOptions, imageUpdateAutomationOwnedConditions, r.ControllerName)
+ if err := serialPatcher.Patch(ctx, obj, patchOpts...); err != nil {
+ // Ignore patch error "not found" when the object is being deleted.
+ if !obj.GetDeletionTimestamp().IsZero() {
+ err = kerrors.FilterOut(err, func(e error) bool { return apierrors.IsNotFound(e) })
+ }
+ retErr = kerrors.NewAggregate([]error{retErr, err})
+ }
+
+ // When the reconciliation ends with an error, ensure that the Result is
+ // empty. This is to suppress the runtime warning when returning a
+ // non-zero Result and an error.
+ if retErr != nil {
+ result = ctrl.Result{}
}
+
+ // Always record suspend, readiness and duration metrics.
+ r.Metrics.RecordDuration(ctx, obj, start)
+ }()
+
+ // Examine if the object is under deletion.
+ if !obj.ObjectMeta.DeletionTimestamp.IsZero() {
+ return r.reconcileDelete(obj)
+ }
+
+ // Add finalizer first if it doesn't exist to avoid the race condition
+ // between init and delete.
+ // Note: Finalizers in general can only be added when the deletionTimestamp
+ // is not set.
+ if !controllerutil.ContainsFinalizer(obj, imagev1.ImageUpdateAutomationFinalizer) {
+ controllerutil.AddFinalizer(obj, imagev1.ImageUpdateAutomationFinalizer)
+ return ctrl.Result{Requeue: true}, nil
+ }
+
+ // Return if the object is suspended.
+ if obj.Spec.Suspend {
+ log.Info("reconciliation is suspended for this object")
+ return ctrl.Result{}, nil
}
- u, err := url.Parse(repository.Spec.URL)
+ result, retErr = r.reconcile(ctx, serialPatcher, obj, start)
+ return
+}
+
+func (r *ImageUpdateAutomationReconciler) reconcile(ctx context.Context, sp *patch.SerialPatcher,
+ obj *imagev1.ImageUpdateAutomation, startTime time.Time) (result ctrl.Result, retErr error) {
+ oldObj := obj.DeepCopy()
+
+ var pushResult *source.PushResult
+
+ // syncNeeded decides if full reconciliation with image update is needed.
+ syncNeeded := false
+
+ defer func() {
+ // Define the meaning of success based on the requeue interval.
+ isSuccess := func(res ctrl.Result, err error) bool {
+ if err != nil || res.RequeueAfter != obj.GetRequeueAfter() || res.Requeue {
+ return false
+ }
+ return true
+ }
+
+ rs := runtimereconcile.NewResultFinalizer(isSuccess, readyMessage)
+ retErr = rs.Finalize(obj, result, retErr)
+
+ // Presence of reconciling means that the reconciliation didn't succeed.
+ // Set the Reconciling reason to ProgressingWithRetry to indicate a
+ // failure retry.
+ if conditions.IsReconciling(obj) {
+ reconciling := conditions.Get(obj, meta.ReconcilingCondition)
+ reconciling.Reason = meta.ProgressingWithRetryReason
+ conditions.Set(obj, reconciling)
+ }
+
+ r.notify(ctx, oldObj, obj, pushResult, syncNeeded)
+ }()
+
+ // TODO: Maybe move this to Reconcile()'s defer and avoid passing startTime
+ // to reconcile()?
+ obj.Status.LastAutomationRunTime = &metav1.Time{Time: startTime}
+
+ // Set reconciling condition.
+ runtimereconcile.ProgressiveStatus(false, obj, meta.ProgressingReason, "reconciliation in progress")
+
+ var reconcileAtVal string
+ if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok {
+ reconcileAtVal = v
+ }
+
+ // Persist reconciling if generation differs or reconciliation is requested.
+ switch {
+ case obj.Generation != obj.Status.ObservedGeneration:
+ runtimereconcile.ProgressiveStatus(false, obj, meta.ProgressingReason,
+ "processing object: new generation %d -> %d", obj.Status.ObservedGeneration, obj.Generation)
+ if err := sp.Patch(ctx, obj, r.patchOptions...); err != nil {
+ result, retErr = ctrl.Result{}, err
+ return
+ }
+ case reconcileAtVal != obj.Status.GetLastHandledReconcileRequest():
+ if err := sp.Patch(ctx, obj, r.patchOptions...); err != nil {
+ result, retErr = ctrl.Result{}, err
+ return
+ }
+ }
+
+ // List the policies and construct observed policies.
+ policies, err := getPolicies(ctx, r.Client, obj.Namespace, obj.Spec.PolicySelector)
if err != nil {
- return nil, fmt.Errorf("failed to parse URL '%s': %w", repository.Spec.URL, err)
+ if errors.Is(err, errParsePolicySelector) {
+ conditions.MarkStalled(obj, imagev1.InvalidPolicySelectorReason, err.Error())
+ result, retErr = ctrl.Result{}, nil
+ return
+ }
+ result, retErr = ctrl.Result{}, err
+ return
+ }
+ // Update any stale Ready=False condition from policies config failure.
+ if conditions.HasAnyReason(obj, meta.ReadyCondition, imagev1.InvalidPolicySelectorReason) {
+ conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress")
}
- opts, err := git.NewAuthOptions(*u, data)
+ observedPolicies, err := observedPolicies(policies)
if err != nil {
- return nil, fmt.Errorf("failed to configure authentication options: %w", err)
+ result, retErr = ctrl.Result{}, err
+ return
}
- return opts, nil
-}
+ // If the policies have changed, require a full sync.
+ if observedPoliciesChanged(obj.Status.ObservedPolicies, observedPolicies) {
+ syncNeeded = true
+ }
-// getProxyOpts fetches the secret containing the proxy settings, constructs a
-// transport.ProxyOptions object using those settings and then returns it.
-func (r *ImageUpdateAutomationReconciler) getProxyOpts(ctx context.Context, proxySecretName,
- proxySecretNamespace string) (*transport.ProxyOptions, error) {
- proxyData, err := r.getSecretData(ctx, proxySecretName, proxySecretNamespace)
+ // Create source manager with options.
+ smOpts := []source.SourceOption{}
+ if r.NoCrossNamespaceRef {
+ smOpts = append(smOpts, source.WithSourceOptionNoCrossNamespaceRef())
+ }
+ if r.features[features.GitAllBranchReferences] {
+ smOpts = append(smOpts, source.WithSourceOptionGitAllBranchReferences())
+ }
+ sm, err := source.NewSourceManager(ctx, r.Client, obj, smOpts...)
if err != nil {
- return nil, fmt.Errorf("failed to get proxy secret '%s/%s': %w", proxySecretNamespace, proxySecretName, err)
+ if acl.IsAccessDenied(err) {
+ conditions.MarkStalled(obj, aclapi.AccessDeniedReason, err.Error())
+ result, retErr = ctrl.Result{}, nil
+ return
+ }
+ if errors.Is(err, source.ErrInvalidSourceConfiguration) {
+ conditions.MarkStalled(obj, imagev1.InvalidSourceConfigReason, err.Error())
+ result, retErr = ctrl.Result{}, nil
+ return
+ }
+ e := fmt.Errorf("failed configuring source manager: %w", err)
+ conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.SourceManagerFailedReason, e.Error())
+ result, retErr = ctrl.Result{}, e
+ return
}
- address, ok := proxyData["address"]
- if !ok {
- return nil, fmt.Errorf("invalid proxy secret '%s/%s': key 'address' is missing", proxySecretNamespace, proxySecretName)
+ defer func() {
+ if err := sm.Cleanup(); err != nil {
+ retErr = err
+ }
+ }()
+ // Update any stale Ready=False condition from SourceManager failure.
+ if conditions.HasAnyReason(obj, meta.ReadyCondition, aclapi.AccessDeniedCondition, imagev1.InvalidSourceConfigReason, imagev1.SourceManagerFailedReason) {
+ conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress")
}
- proxyOpts := &transport.ProxyOptions{
- URL: string(address),
- Username: string(proxyData["username"]),
- Password: string(proxyData["password"]),
+ // When the checkout and push branches are different or a refspec is
+ // defined, always perform a full sync.
+ // This can be worked around in the future by also querying the HEAD of push
+ // branch to detech if it has drifted.
+ if sm.SwitchBranch() || obj.Spec.GitSpec.HasRefspec() {
+ syncNeeded = true
}
- return proxyOpts, nil
-}
-func (r *ImageUpdateAutomationReconciler) getSecretData(ctx context.Context, name, namespace string) (map[string][]byte, error) {
- key := types.NamespacedName{
- Namespace: namespace,
- Name: name,
+ // Build checkout options.
+ checkoutOpts := []source.CheckoutOption{}
+ if r.features[features.GitShallowClone] {
+ checkoutOpts = append(checkoutOpts, source.WithCheckoutOptionShallowClone())
}
- var secret corev1.Secret
- if err := r.Client.Get(ctx, key, &secret); err != nil {
- return nil, err
+ // If full sync is still not needed, configure last observed commit to
+ // perform optimized clone and obtain a non-concrete commit if the remote
+ // has not changed.
+ if !syncNeeded && obj.Status.ObservedSourceRevision != "" {
+ checkoutOpts = append(checkoutOpts, source.WithCheckoutOptionLastObserved(obj.Status.ObservedSourceRevision))
}
- return secret.Data, nil
-}
-// getSigningEntity retrieves an OpenPGP entity referenced by the
-// provided imagev1.ImageUpdateAutomation for git commit signing
-func (r *ImageUpdateAutomationReconciler) getSigningEntity(ctx context.Context, auto imagev1.ImageUpdateAutomation) (*openpgp.Entity, error) {
- // get kubernetes secret
- secretName := types.NamespacedName{
- Namespace: auto.GetNamespace(),
- Name: auto.Spec.GitSpec.Commit.SigningKey.SecretRef.Name,
+ commit, err := sm.CheckoutSource(ctx, checkoutOpts...)
+ if err != nil {
+ e := fmt.Errorf("failed to checkout source: %w", err)
+ conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.GitOperationFailedReason, e.Error())
+ result, retErr = ctrl.Result{}, e
+ return
+ }
+ // Update any stale Ready=False condition from checkout failure.
+ if conditions.HasAnyReason(obj, meta.ReadyCondition, imagev1.GitOperationFailedReason) {
+ conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress")
+ }
+
+ // If it's a partial commit, the reconciliation can be skipped. The last
+ // observed commit is only configured above when full sync is not needed.
+ // No change in the policies and remote git repository. Skip reconciliation.
+ if !git.IsConcreteCommit(*commit) {
+ // Remove any stale Ready condition, most likely False, set above. Its value
+ // is derived from the overall result of the reconciliation in the deferred
+ // block at the very end.
+ conditions.Delete(obj, meta.ReadyCondition)
+ result, retErr = ctrl.Result{RequeueAfter: obj.GetRequeueAfter()}, nil
+ return
+ } else {
+ // Concrete commit indicates full sync is needed due to new remote
+ // revision.
+ syncNeeded = true
+ }
+ // Continue with full sync with a concrete commit.
+
+ // Apply the policies and check if there's anything to update.
+ policyResult, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), obj, policies)
+ if err != nil {
+ if errors.Is(err, policy.ErrNoUpdateStrategy) || errors.Is(err, policy.ErrUnsupportedUpdateStrategy) {
+ conditions.MarkStalled(obj, imagev1.InvalidUpdateStrategyReason, err.Error())
+ result, retErr = ctrl.Result{}, nil
+ return
+ }
+ e := fmt.Errorf("failed to apply policies: %w", err)
+ conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.UpdateFailedReason, e.Error())
+ result, retErr = ctrl.Result{}, e
+ return
}
- var secret corev1.Secret
- if err := r.Get(ctx, secretName, &secret); err != nil {
- return nil, fmt.Errorf("could not find signing key secret '%s': %w", secretName, err)
+ // Update any stale Ready=False condition from apply policies failure.
+ if conditions.HasAnyReason(obj, meta.ReadyCondition, imagev1.InvalidUpdateStrategyReason, imagev1.UpdateFailedReason) {
+ conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress")
}
- // get data from secret
- data, ok := secret.Data[signingSecretKey]
- if !ok {
- return nil, fmt.Errorf("signing key secret '%s' does not contain a 'git.asc' key", secretName)
+ if len(policyResult.FileChanges) == 0 {
+ // Remove any stale Ready condition, most likely False, set above. Its
+ // value is derived from the overall result of the reconciliation in the
+ // deferred block at the very end.
+ conditions.Delete(obj, meta.ReadyCondition)
+
+ // Persist observations.
+ obj.Status.ObservedSourceRevision = commit.String()
+ obj.Status.ObservedPolicies = observedPolicies
+
+ result, retErr = ctrl.Result{RequeueAfter: obj.GetRequeueAfter()}, nil
+ return
+ }
+
+ // Build push config.
+ pushCfg := []source.PushConfig{}
+ // Enable force only when branch is changed for push.
+ if r.features[features.GitForcePushBranch] && sm.SwitchBranch() {
+ pushCfg = append(pushCfg, source.WithPushConfigForce())
+ }
+ // Include any push options.
+ if obj.Spec.GitSpec.Push != nil && obj.Spec.GitSpec.Push.Options != nil {
+ pushCfg = append(pushCfg, source.WithPushConfigOptions(obj.Spec.GitSpec.Push.Options))
}
- // read entity from secret value
- entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
+ pushResult, err = sm.CommitAndPush(ctx, obj, policyResult, pushCfg...)
if err != nil {
- return nil, fmt.Errorf("could not read signing key from secret '%s': %w", secretName, err)
+ e := fmt.Errorf("failed to update source: %w", err)
+ conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.GitOperationFailedReason, e.Error())
+ result, retErr = ctrl.Result{}, e
+ return
+ }
+ // Update any stale Ready=False condition from commit and push failure.
+ if conditions.HasAnyReason(obj, meta.ReadyCondition, imagev1.GitOperationFailedReason) {
+ conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress")
+ }
+
+ if pushResult == nil {
+ // NOTE: This should not happen. This exists as a legacy behavior from
+ // the old implementation where no commit is made due to no stagged
+ // files. If nothing is pushed, the repository is up-to-date. Persist
+ // observations and return with successful result.
+ conditions.Delete(obj, meta.ReadyCondition)
+ obj.Status.ObservedSourceRevision = commit.String()
+ obj.Status.ObservedPolicies = observedPolicies
+ result, retErr = ctrl.Result{RequeueAfter: obj.GetRequeueAfter()}, nil
+ return
+ }
+
+ // Persist observations.
+ obj.Status.ObservedSourceRevision = pushResult.Commit().String()
+ // If the push branch is different, store the checkout branch commit as the
+ // observed source revision.
+ if pushResult.SwitchBranch() {
+ obj.Status.ObservedSourceRevision = commit.String()
+ }
+ obj.Status.ObservedPolicies = observedPolicies
+ obj.Status.LastPushCommit = pushResult.Commit().Hash.String()
+ obj.Status.LastPushTime = pushResult.Time()
+
+ // Remove any stale Ready condition, most likely False, set above. Its value
+ // is derived from the overall result of the reconciliation in the deferred
+ // block at the very end.
+ conditions.Delete(obj, meta.ReadyCondition)
+ result, retErr = ctrl.Result{RequeueAfter: obj.GetRequeueAfter()}, nil
+ return
+}
+
+// reconcileDelete handles the deletion of the object.
+func (r *ImageUpdateAutomationReconciler) reconcileDelete(obj *imagev1.ImageUpdateAutomation) (ctrl.Result, error) {
+ // Remove our finalizer from the list.
+ controllerutil.RemoveFinalizer(obj, imagev1.ImageUpdateAutomationFinalizer)
+
+ // Stop reconciliation as the object is being deleted.
+ return ctrl.Result{}, nil
+}
+
+// getPolicies returns list of policies in the given namespace that have latest
+// image.
+func getPolicies(ctx context.Context, kclient client.Client, namespace string, selector *metav1.LabelSelector) ([]imagev1_reflect.ImagePolicy, error) {
+ policySelector := labels.Everything()
+ var err error
+ if selector != nil {
+ if policySelector, err = metav1.LabelSelectorAsSelector(selector); err != nil {
+ return nil, fmt.Errorf("%w: %w", errParsePolicySelector, err)
+ }
+ }
+
+ var policies imagev1_reflect.ImagePolicyList
+ if err := kclient.List(ctx, &policies, &client.ListOptions{Namespace: namespace, LabelSelector: policySelector}); err != nil {
+ return nil, fmt.Errorf("failed to list policies: %w", err)
+ }
+
+ readyPolicies := []imagev1_reflect.ImagePolicy{}
+ for _, policy := range policies.Items {
+ // Ignore the policies that don't have a latest image.
+ if policy.Status.LatestImage == "" {
+ continue
+ }
+ readyPolicies = append(readyPolicies, policy)
}
- if len(entities) > 1 {
- return nil, fmt.Errorf("multiple entities read from secret '%s', could not determine which signing key to use", secretName)
+
+ return readyPolicies, nil
+}
+
+// observedPolicies takes a list of ImagePolicies and returns an
+// ObservedPolicies with all the policies in it.
+func observedPolicies(policies []imagev1_reflect.ImagePolicy) (imagev1.ObservedPolicies, error) {
+ observedPolicies := imagev1.ObservedPolicies{}
+ for _, policy := range policies {
+ parts := strings.SplitN(policy.Status.LatestImage, ":", 2)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("failed parsing image: %s", policy.Status.LatestImage)
+ }
+ observedPolicies[policy.Name] = imagev1.ImageRef{
+ Name: parts[0],
+ Tag: parts[1],
+ }
}
+ return observedPolicies, nil
+}
- entity := entities[0]
- if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
- passphrase, ok := secret.Data[signingPassphraseKey]
+// observedPoliciesChanged returns if the previous and current observedPolicies
+// have changed.
+func observedPoliciesChanged(previous, current imagev1.ObservedPolicies) bool {
+ if len(previous) != len(current) {
+ return true
+ }
+ for name, imageRef := range current {
+ oldImageRef, ok := previous[name]
if !ok {
- return nil, fmt.Errorf("can not use passphrase protected signing key without '%s' field present in secret %s",
- signingPassphraseKey, secretName)
+ // Changed if an entry is not found.
+ return true
}
- if err = entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
- return nil, fmt.Errorf("could not decrypt private key of the signing key present in secret %s: %w", secretName, err)
+ if oldImageRef != imageRef {
+ return true
}
}
- return entity, nil
+ return false
}
-// --- events, metrics
+// notify emits notifications and events based on the state of the object and
+// the given PushResult. It tries to always send the PushResult commit message
+// if there has been any update. Otherwise, a generic up-to-date message. In
+// case of any failure, the failure message is read from the Ready condition and
+// included in the event.
+func (r *ImageUpdateAutomationReconciler) notify(ctx context.Context, oldObj, newObj conditions.Setter, result *source.PushResult, syncNeeded bool) {
+ // Use the Ready message as the notification message by default.
+ ready := conditions.Get(newObj, meta.ReadyCondition)
+ msg := ready.Message
-func (r *ImageUpdateAutomationReconciler) event(ctx context.Context, auto imagev1.ImageUpdateAutomation, severity, msg string) {
- eventtype := "Normal"
- if severity == eventv1.EventSeverityError {
- eventtype = "Warning"
+ // If there's a PushResult, use the summary as the notification message.
+ if result != nil {
+ msg = result.Summary()
}
- r.EventRecorder.Eventf(&auto, eventtype, severity, msg)
-}
-// --- updates
+ // Was ready before and is ready now, with new push result,
+ if conditions.IsReady(oldObj) && conditions.IsReady(newObj) && result != nil {
+ eventLogf(ctx, r.EventRecorder, newObj, corev1.EventTypeNormal, ready.Reason, msg)
+ return
+ }
-// updateAccordingToSetters updates files under the root by treating
-// the given image policies as kyaml setters.
-func updateAccordingToSetters(ctx context.Context, tracelog logr.Logger, inpath, outpath string, policies []imagev1_reflect.ImagePolicy) (update.Result, error) {
- return update.UpdateWithSetters(tracelog, inpath, outpath, policies)
-}
+ // Emit events when reconciliation fails or recovers from failure.
-// templateMsg renders a msg template, returning the message or an error.
-func templateMsg(messageTemplate string, templateValues *TemplateData) (string, error) {
- if messageTemplate == "" {
- messageTemplate = defaultMessageTemplate
+ // Became ready from not ready.
+ if !conditions.IsReady(oldObj) && conditions.IsReady(newObj) {
+ eventLogf(ctx, r.EventRecorder, newObj, corev1.EventTypeNormal, ready.Reason, msg)
+ return
+ }
+ // Not ready, failed. Use the failure message from ready condition.
+ if !conditions.IsReady(newObj) {
+ eventLogf(ctx, r.EventRecorder, newObj, corev1.EventTypeWarning, ready.Reason, ready.Message)
+ return
}
- // Includes only functions that are guaranteed to always evaluate to the same result for given input.
- // This removes the possibility of accidentally relying on where or when the template runs.
- // https://github.com/Masterminds/sprig/blob/3ac42c7bc5e4be6aa534e036fb19dde4a996da2e/functions.go#L70
- t, err := template.New("commit message").Funcs(sprig.HermeticTxtFuncMap()).Parse(messageTemplate)
- if err != nil {
- return "", fmt.Errorf("unable to create commit message template from spec: %w", err)
+ // No change.
+
+ if !syncNeeded {
+ // Full reconciliation skipped.
+ msg = "no change since last reconciliation"
}
+ eventLogf(ctx, r.EventRecorder, newObj, eventv1.EventTypeTrace, meta.SucceededReason, msg)
+}
- b := &strings.Builder{}
- if err := t.Execute(b, *templateValues); err != nil {
- return "", fmt.Errorf("failed to run template from spec: %w", err)
+// eventLogf records events, and logs at the same time.
+//
+// This log is different from the debug log in the EventRecorder, in the sense
+// that this is a simple log. While the debug log contains complete details
+// about the event.
+func eventLogf(ctx context.Context, r kuberecorder.EventRecorder, obj runtime.Object, eventType string, reason string, messageFmt string, args ...interface{}) {
+ msg := fmt.Sprintf(messageFmt, args...)
+ // Log and emit event.
+ if eventType == corev1.EventTypeWarning {
+ ctrl.LoggerFrom(ctx).Error(errors.New(reason), msg)
+ } else {
+ ctrl.LoggerFrom(ctx).Info(msg)
}
- return b.String(), nil
+ r.Eventf(obj, eventType, reason, msg)
}
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index 3146169c..8a2b7b76 100644
--- a/internal/controller/suite_test.go
+++ b/internal/controller/suite_test.go
@@ -26,6 +26,7 @@ import (
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -34,7 +35,7 @@ import (
"github.com/fluxcd/pkg/runtime/testenv"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
- imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
// +kubebuilder:scaffold:imports
)
@@ -85,9 +86,10 @@ func runTestsWithFeatures(m *testing.M, feats map[string]bool) int {
controllerName := "image-automation-controller"
if err := (&ImageUpdateAutomationReconciler{
- Client: testEnv,
- EventRecorder: testEnv.GetEventRecorderFor(controllerName),
- features: feats,
+ Client: testEnv,
+ EventRecorder: record.NewFakeRecorder(32),
+ features: feats,
+ ControllerName: controllerName,
}).SetupWithManager(ctx, testEnv, ImageUpdateAutomationReconcilerOptions{
RateLimiter: controller.GetDefaultRateLimiter(),
}); err != nil {
diff --git a/internal/controller/testdata/brokenlink/bar.yaml b/internal/controller/testdata/brokenlink/bar.yaml
deleted file mode 120000
index ab6a9707..00000000
--- a/internal/controller/testdata/brokenlink/bar.yaml
+++ /dev/null
@@ -1 +0,0 @@
-/surely/does/not/exist
\ No newline at end of file
diff --git a/internal/controller/update_test.go b/internal/controller/update_test.go
index 25518fb8..d1e370f1 100644
--- a/internal/controller/update_test.go
+++ b/internal/controller/update_test.go
@@ -17,60 +17,52 @@ limitations under the License.
package controller
import (
- "bytes"
"context"
- "errors"
"fmt"
- "io/ioutil"
- "math/rand"
"net/url"
"os"
- "path"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
- "github.com/ProtonMail/go-crypto/openpgp/armor"
- securejoin "github.com/cyphar/filepath-securejoin"
- "github.com/go-git/go-billy/v5/osfs"
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-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/object"
- "github.com/go-git/go-git/v5/plumbing/transport"
- "github.com/go-git/go-git/v5/storage/filesystem"
- "github.com/go-logr/logr"
. "github.com/onsi/gomega"
"github.com/otiai10/copy"
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"
+ "k8s.io/apimachinery/pkg/util/rand"
+ "k8s.io/apimachinery/pkg/util/validation"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta2"
- "github.com/fluxcd/pkg/apis/acl"
+ aclapi "github.com/fluxcd/pkg/apis/acl"
"github.com/fluxcd/pkg/apis/meta"
- "github.com/fluxcd/pkg/git"
- "github.com/fluxcd/pkg/git/gogit"
"github.com/fluxcd/pkg/gittestserver"
"github.com/fluxcd/pkg/runtime/conditions"
+ conditionscheck "github.com/fluxcd/pkg/runtime/conditions/check"
+ "github.com/fluxcd/pkg/runtime/patch"
"github.com/fluxcd/pkg/ssh"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
- imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
- "github.com/fluxcd/image-automation-controller/internal/features"
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
+ "github.com/fluxcd/image-automation-controller/internal/source"
+ "github.com/fluxcd/image-automation-controller/internal/testutil"
"github.com/fluxcd/image-automation-controller/pkg/test"
- "github.com/fluxcd/image-automation-controller/pkg/update"
)
const (
+ originRemote = "origin"
timeout = 10 * time.Second
testAuthorName = "Flux B Ot"
testAuthorEmail = "fluxbot@example.com"
@@ -110,41 +102,18 @@ Images:
`
)
-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 TestImageUpdateAutomationReconciler_deleteBeforeFinalizer(t *testing.T) {
g := NewWithT(t)
- namespaceName := "imageupdate-" + randStringRunes(5)
- namespace := &corev1.Namespace{
- ObjectMeta: metav1.ObjectMeta{Name: namespaceName},
- }
- g.Expect(k8sClient.Create(ctx, namespace)).ToNot(HaveOccurred())
- t.Cleanup(func() {
- g.Expect(k8sClient.Delete(ctx, namespace)).NotTo(HaveOccurred())
- })
+ namespace, err := testEnv.CreateNamespace(ctx, "imageupdate")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
imageUpdate := &imagev1.ImageUpdateAutomation{}
imageUpdate.Name = "test-imageupdate"
- imageUpdate.Namespace = namespaceName
+ imageUpdate.Namespace = namespace.Name
imageUpdate.Spec = imagev1.ImageUpdateAutomationSpec{
- Interval: metav1.Duration{Duration: time.Second},
+ Interval: metav1.Duration{Duration: time.Hour},
SourceRef: imagev1.CrossNamespaceSourceReference{
Kind: "GitRepository",
Name: "foo",
@@ -167,7 +136,9 @@ func TestImageUpdateAutomationReconciler_deleteBeforeFinalizer(t *testing.T) {
}, timeout).Should(Succeed())
}
-func TestImageAutomationReconciler_watchSourceAndLatestImage(t *testing.T) {
+func TestImageUpdateAutomationReconciler_watchSourceAndLatestImage(t *testing.T) {
+ g := NewWithT(t)
+
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
@@ -181,22 +152,29 @@ func TestImageAutomationReconciler_watchSourceAndLatestImage(t *testing.T) {
fixture := "testdata/appconfig"
latest := "helloworld:v1.0.0"
- testWithRepoAndImagePolicy(NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName, func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ namespace, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ testWithRepoAndImagePolicy(ctx, g, testEnv, namespace.Name, fixture, policySpec, latest, 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,
}
- commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
- g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
})
// Create the automation object.
updateStrategy := &imagev1.UpdateStrategy{
Strategy: imagev1.UpdateStrategySetters,
}
- err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", testCommitTemplate, "", updateStrategy)
+ err := createImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", testCommitTemplate, "", updateStrategy)
g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace)).To(Succeed())
+ }()
var imageUpdate imagev1.ImageUpdateAutomation
imageUpdateKey := types.NamespacedName{
@@ -211,12 +189,12 @@ func TestImageAutomationReconciler_watchSourceAndLatestImage(t *testing.T) {
}
return conditions.IsReady(&imageUpdate)
}, timeout).Should(BeTrue())
- readyMsg := conditions.Get(&imageUpdate, meta.ReadyCondition).Message
+ lastPushedCommit := imageUpdate.Status.LastPushCommit
// Update ImagePolicy with new latest and wait for image update to
// trigger.
latest = "helloworld:v1.1.0"
- err = updateImagePolicyWithLatestImage(testEnv, s.imagePolicyName, s.namespace, latest)
+ err = updateImagePolicyWithLatestImage(ctx, testEnv, s.imagePolicyName, s.namespace, latest)
g.Expect(err).ToNot(HaveOccurred())
g.Eventually(func() bool {
@@ -224,7 +202,7 @@ func TestImageAutomationReconciler_watchSourceAndLatestImage(t *testing.T) {
return false
}
ready := conditions.Get(&imageUpdate, meta.ReadyCondition)
- return ready.Status == metav1.ConditionTrue && ready.Message != readyMsg
+ return ready.Status == metav1.ConditionTrue && imageUpdate.Status.LastPushCommit != lastPushedCommit
}, timeout).Should(BeTrue())
// Update GitRepo with bad config and wait for image update to fail.
@@ -247,7 +225,46 @@ func TestImageAutomationReconciler_watchSourceAndLatestImage(t *testing.T) {
})
}
-func TestImageAutomationReconciler_commitMessage(t *testing.T) {
+func TestImageUpdateAutomationReconciler_suspended(t *testing.T) {
+ g := NewWithT(t)
+
+ updateKey := types.NamespacedName{
+ Name: "test-update",
+ Namespace: "default",
+ }
+ update := &imagev1.ImageUpdateAutomation{
+ Spec: imagev1.ImageUpdateAutomationSpec{
+ Interval: metav1.Duration{Duration: time.Hour},
+ Suspend: true,
+ },
+ }
+ update.Name = updateKey.Name
+ update.Namespace = updateKey.Namespace
+
+ // Add finalizer so that reconciliation reaches suspend check.
+ controllerutil.AddFinalizer(update, imagev1.ImageUpdateAutomationFinalizer)
+
+ builder := fakeclient.NewClientBuilder().WithScheme(testEnv.GetScheme())
+ builder.WithObjects(update)
+
+ r := ImageUpdateAutomationReconciler{
+ Client: builder.Build(),
+ }
+
+ res, err := r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: updateKey})
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(res.Requeue).ToNot(BeTrue())
+
+ // Make sure no status was written.
+ g.Expect(r.Get(context.TODO(), updateKey, update)).To(Succeed())
+ g.Expect(update.Status.Conditions).To(HaveLen(0))
+ g.Expect(update.Status.LastAutomationRunTime).To(BeNil())
+
+ // Cleanup.
+ g.Expect(r.Delete(ctx, update)).To(Succeed())
+}
+
+func TestImageUpdateAutomationReconciler_Reconcile(t *testing.T) {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
@@ -260,233 +277,434 @@ func TestImageAutomationReconciler_commitMessage(t *testing.T) {
}
fixture := "testdata/appconfig"
latest := "helloworld:v1.0.0"
+ updateName := "test-update"
- 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)
+ t.Run("no gitspec results in stalled", func(t *testing.T) {
+ g := NewWithT(t)
- // 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())
- })
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).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)
+ obj := &imagev1.ImageUpdateAutomation{}
+ obj.Name = updateName
+ obj.Namespace = namespace.Name
+ obj.Spec = imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Name: "non-existing",
+ Kind: sourcev1.GitRepositoryKind,
+ },
+ }
+ g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, obj.Name, obj.Namespace)).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)
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.StalledCondition, imagev1.InvalidSourceConfigReason, "invalid source configuration"),
+ *conditions.FalseCondition(meta.ReadyCondition, imagev1.InvalidSourceConfigReason, "invalid source configuration"),
+ }
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }).Should(Succeed())
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, obj)
+ })
- preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
+ t.Run("invalid policy selector results in stalled", func(t *testing.T) {
+ g := NewWithT(t)
- // 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, s.branch, "", testCommitTemplate, "", updateStrategy)
- g.Expect(err).ToNot(HaveOccurred())
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
- // Wait for a new commit to be made by the controller.
- waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
+ obj := &imagev1.ImageUpdateAutomation{}
+ obj.Name = updateName
+ obj.Namespace = namespace.Name
+ obj.Spec = imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: "GitRepository",
+ Name: "foo",
+ },
+ PolicySelector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "label-too-long-" + strings.Repeat("0", validation.LabelValueMaxLength): "",
+ },
+ },
+ }
+ g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, obj.Name, obj.Namespace)).To(Succeed())
+ }()
- head, _ := localRepo.Head()
- commit, err := localRepo.CommitObject(head.Hash())
- g.Expect(err).ToNot(HaveOccurred())
- g.Expect(commit.Message).To(Equal(commitMessage))
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.StalledCondition, imagev1.InvalidPolicySelectorReason, "failed to parse policy selector"),
+ *conditions.FalseCondition(meta.ReadyCondition, imagev1.InvalidPolicySelectorReason, "failed to parse policy selector"),
+ }
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }).Should(Succeed())
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, obj)
+ })
- signature := commit.Author
- g.Expect(signature).NotTo(BeNil())
- g.Expect(signature.Name).To(Equal(testAuthorName))
- g.Expect(signature.Email).To(Equal(testAuthorEmail))
+ t.Run("non-existing gitrepo results in failure", func(t *testing.T) {
+ g := NewWithT(t)
- // Regression check to ensure the status message contains the branch name
- // if checkout branch is the same as push branch.
- imageUpdateKey := types.NamespacedName{
- Namespace: s.namespace,
- Name: "update-test",
- }
- var imageUpdate imagev1.ImageUpdateAutomation
- _ = testEnv.Get(context.TODO(), imageUpdateKey, &imageUpdate)
- ready := apimeta.FindStatusCondition(imageUpdate.Status.Conditions, meta.ReadyCondition)
- g.Expect(ready.Message).To(Equal(fmt.Sprintf("committed and pushed commit '%s' to branch '%s'", head.Hash().String(), s.branch)))
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ obj := &imagev1.ImageUpdateAutomation{}
+ obj.Name = updateName
+ obj.Namespace = namespace.Name
+ obj.Spec = imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Name: "non-existing",
+ Kind: sourcev1.GitRepositoryKind,
+ },
+ GitSpec: &imagev1.GitSpec{
+ Commit: imagev1.CommitSpec{
+ Author: imagev1.CommitUser{
+ Email: "aaa",
+ },
+ },
},
- )
+ }
+ g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, obj.Name, obj.Namespace)).To(Succeed())
+ }()
+
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingWithRetryReason, "processing"),
+ *conditions.FalseCondition(meta.ReadyCondition, imagev1.SourceManagerFailedReason, "not found"),
+ }
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }).Should(Succeed())
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, obj)
})
-}
-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"
+ t.Run("source checkout fails", func(t *testing.T) {
+ g := NewWithT(t)
- // 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,
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ testWithRepoAndImagePolicy(ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
- commitMessage := fmt.Sprintf(testCommitMessageFmt, s.namespace, s.imagePolicyName)
+ err := createImageUpdateAutomation(ctx, testEnv, updateName, s.namespace, s.gitRepoName, s.gitRepoNamespace, "bad-branch", s.branch, "", testCommitTemplate, "", nil)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, updateName, s.namespace)).To(Succeed())
+ }()
- // Update the setter marker in the repo.
- policyKey := types.NamespacedName{
- Name: s.imagePolicyName,
+ objKey := types.NamespacedName{
Namespace: s.namespace,
+ Name: updateName,
}
- commitInRepo(g, repoURL, s.branch, "Install setter marker", func(tmp string) {
- g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
- })
+ var obj imagev1.ImageUpdateAutomation
- // 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)
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingWithRetryReason, "processing"),
+ *conditions.FalseCondition(meta.ReadyCondition, imagev1.GitOperationFailedReason, "reference not found"),
+ }
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }).Should(Succeed())
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, &obj)
+ })
+ })
- // 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)
+ t.Run("no marker no update", func(t *testing.T) {
+ g := NewWithT(t)
- preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
- // 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)
+ testWithRepoAndImagePolicy(ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
+ func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ err := createImageUpdateAutomation(ctx, testEnv, updateName, s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", testCommitTemplate, "", nil)
g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, updateName, s.namespace)).To(Succeed())
+ }()
- // Wait for a new commit to be made by the controller.
- waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
+ objKey := types.NamespacedName{
+ Namespace: s.namespace,
+ Name: updateName,
+ }
+ var obj imagev1.ImageUpdateAutomation
- head, _ := localRepo.Head()
- commit, err := localRepo.CommitObject(head.Hash())
- g.Expect(err).ToNot(HaveOccurred())
- g.Expect(commit.Message).To(Equal(commitMessage))
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, readyMessage),
+ }
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(obj.Status.ObservedGeneration).To(Equal(obj.GetGeneration()))
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }).Should(Succeed())
+
+ g.Expect(obj.Status.LastPushCommit).To(BeEmpty())
+ g.Expect(obj.Status.LastPushTime).To(BeNil())
+ g.Expect(obj.Status.LastAutomationRunTime).ToNot(BeNil())
+ g.Expect(obj.Status.ObservedSourceRevision).ToNot(BeEmpty())
+ g.Expect(obj.Status.ObservedPolicies).To(HaveLen(1))
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, &obj)
+ })
+ })
- 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.
- r := &ImageUpdateAutomationReconciler{
- Client: fakeclient.NewClientBuilder().
- WithScheme(testEnv.Scheme()).
- WithStatusSubresource(&imagev1.ImageUpdateAutomation{}, &imagev1_reflect.ImagePolicy{}).
- 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,
+ t.Run("push update", func(t *testing.T) {
+ g := NewWithT(t)
+
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ testWithRepoAndImagePolicy(ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
- updateStrategy := &imagev1.UpdateStrategy{
- Strategy: imagev1.UpdateStrategySetters,
+ policyKey := types.NamespacedName{
+ Name: s.imagePolicyName,
+ Namespace: s.namespace,
}
- err := createImageUpdateAutomation(r.Client, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy)
+
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
+ })
+
+ err := createImageUpdateAutomation(ctx, testEnv, updateName, s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", testCommitTemplate, "", nil)
g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, updateName, s.namespace)).To(Succeed())
+ }()
- imageUpdateKey := types.NamespacedName{
- Name: "update-test",
+ objKey := types.NamespacedName{
Namespace: s.namespace,
+ Name: updateName,
}
- _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: imageUpdateKey})
- g.Expect(err).To(BeNil())
+ var obj imagev1.ImageUpdateAutomation
- 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))
- },
- )
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, readyMessage),
+ }
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(obj.Status.ObservedGeneration).To(Equal(obj.GetGeneration()))
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }).Should(Succeed())
+ g.Expect(obj.Status.LastPushCommit).ToNot(BeEmpty())
+ g.Expect(obj.Status.LastPushTime).ToNot(BeNil())
+ g.Expect(obj.Status.LastAutomationRunTime).ToNot(BeNil())
+ g.Expect(obj.Status.ObservedSourceRevision).To(ContainSubstring("%s@sha1", s.branch))
+ g.Expect(obj.Status.ObservedPolicies).To(HaveLen(1))
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, &obj)
+ })
})
-}
-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("source moves forward & policy updates separately, new observations", func(t *testing.T) {
+ g := NewWithT(t)
+
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
- t.Run(gogit.ClientName, func(t *testing.T) {
- testWithRepoAndImagePolicy(
- NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName,
+ testWithRepoAndImagePolicy(ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
- // Update the setter marker in the repo.
- policyKey := types.NamespacedName{
+ policyKey1 := 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)
+ policyKey2 := types.NamespacedName{
+ Name: "non-existing-policy",
+ Namespace: s.namespace,
+ }
- 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())
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey1)).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)
+ err := createImageUpdateAutomation(ctx, testEnv, updateName, s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", testCommitTemplate, "", nil)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, updateName, s.namespace)).To(Succeed())
+ }()
- preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
+ objKey := types.NamespacedName{
+ Namespace: s.namespace,
+ Name: updateName,
+ }
+ var obj imagev1.ImageUpdateAutomation
- // Create the automation object and let it make a commit itself.
- updateStrategy := &imagev1.UpdateStrategy{
- Strategy: imagev1.UpdateStrategySetters,
- Path: "./yes",
+ expectedConditions := []metav1.Condition{
+ *conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, readyMessage),
}
- err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy)
- g.Expect(err).ToNot(HaveOccurred())
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(obj.Status.ObservedGeneration).To(Equal(obj.GetGeneration()))
+ g.Expect(obj.Status.Conditions).To(conditions.MatchConditions(expectedConditions))
+ }, timeout).Should(Succeed())
+ g.Expect(obj.Status.LastAutomationRunTime).ToNot(BeNil())
+ g.Expect(obj.Status.LastPushCommit).ToNot(BeEmpty())
+ g.Expect(obj.Status.LastPushTime).ToNot(BeNil())
+ g.Expect(obj.Status.LastAutomationRunTime).ToNot(BeNil())
+ g.Expect(obj.Status.ObservedSourceRevision).To(ContainSubstring("%s@sha1", s.branch))
+ g.Expect(obj.Status.ObservedPolicies).To(HaveLen(1))
+
+ // Record the previous values and check after a reconciliation.
+ //
+ // NOTE: Ignoring LastAutomationRunTime as the recorded time is
+ // only up to seconds. Because the test runs really quick, the
+ // run time may be at the same second. Introducing a sleep for a
+ // second shows that the time gets updated. Avoiding to
+ // introduce a sleep to test this for now.
+ srcRevBefore := obj.Status.ObservedSourceRevision
+ pushCommitBefore := obj.Status.LastPushCommit
+ pushTimeBefore := obj.Status.LastPushTime
+
+ // Annotate the object and trigger a no-op reconciliation.
+ patch := client.MergeFrom(obj.DeepCopy())
+ obj.SetAnnotations(map[string]string{meta.ReconcileRequestAnnotation: "now"})
+ g.Expect(testEnv.Patch(ctx, &obj, patch)).To(Succeed())
+
+ // Look for the LastHandledReconcileAt to update.
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(conditions.IsReady(&obj)).To(BeTrue())
+ g.Expect(obj.Status.LastHandledReconcileAt).To(Equal("now"))
+ }, timeout).Should(Succeed())
+ // Nothing else should change.
+ g.Expect(obj.Status.ObservedSourceRevision).To(Equal(srcRevBefore))
+ g.Expect(obj.Status.LastPushCommit).To(Equal(pushCommitBefore))
+ g.Expect(obj.Status.LastPushTime).To(Equal(pushTimeBefore))
+
+ // Push a new commit such that there's no new update and
+ // reconcile again.
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Update setter marker", func(tmp string) {
+ marker := fmt.Sprintf(`{"$imagepolicy": "%s:%s"}`, policyKey1.Namespace, policyKey1.Name)
+ g.Expect(testutil.ReplaceMarkerWithMarker(filepath.Join(tmp, "deploy.yaml"), policyKey2, marker))
+ })
+ patch = client.MergeFrom(obj.DeepCopy())
+ obj.SetAnnotations(map[string]string{meta.ReconcileRequestAnnotation: "nownow"})
+ g.Expect(testEnv.Patch(ctx, &obj, patch)).To(Succeed())
+
+ // Look for the ObservedSourceRevision to update.
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(conditions.IsReady(&obj)).To(BeTrue())
+ g.Expect(obj.Status.ObservedSourceRevision).ToNot(Equal(srcRevBefore))
+ }, timeout).Should(Succeed())
+ observedPoliciesBefore := obj.Status.ObservedPolicies
+ srcRevBefore = obj.Status.ObservedSourceRevision
+
+ // Update the policy, there will be no new update due to the
+ // setter set above, reconcile again.
+ latest = "helloworld:v2.0.0"
+ g.Expect(updateImagePolicyWithLatestImage(ctx, testEnv, s.imagePolicyName, s.namespace, latest)).To(Succeed())
+
+ // Look for the ObservedPolicies to update.
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(conditions.IsReady(&obj)).To(BeTrue())
+ g.Expect(obj.Status.ObservedPolicies).ToNot(Equal(observedPoliciesBefore))
+ }, timeout).Should(Succeed())
+ g.Expect(obj.Status.ObservedSourceRevision).To(Equal(srcRevBefore))
+ })
+ })
+
+ t.Run("error recovery with early return", func(t *testing.T) {
+ g := NewWithT(t)
- // Wait for a new commit to be made by the controller.
- waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
+ namespace, err := testEnv.CreateNamespace(ctx, "test-update")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed())
+ }()
- head, _ := localRepo.Head()
- commit, err := localRepo.CommitObject(head.Hash())
+ testWithRepoAndImagePolicy(ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
+ func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ err := createImageUpdateAutomation(ctx, testEnv, updateName, s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", testCommitTemplate, "", nil)
g.Expect(err).ToNot(HaveOccurred())
- g.Expect(commit.Message).ToNot(ContainSubstring("update-no"))
- g.Expect(commit.Message).To(ContainSubstring("update-yes"))
- },
- )
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, updateName, s.namespace)).To(Succeed())
+ }()
+
+ objKey := types.NamespacedName{
+ Namespace: s.namespace,
+ Name: updateName,
+ }
+ var obj imagev1.ImageUpdateAutomation
+
+ // Ensure the image update is ready.
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(obj.Status.ObservedGeneration).To(Equal(obj.GetGeneration()))
+ g.Expect(conditions.IsReady(&obj))
+ }, timeout).Should(Succeed())
+
+ g.Expect(obj.Status.ObservedSourceRevision).ToNot(BeEmpty())
+
+ // Update the GitRepository to add a non-existing secret ref.
+ gitRepoKey := types.NamespacedName{
+ Namespace: s.gitRepoNamespace,
+ Name: s.gitRepoName,
+ }
+ var gitRepo sourcev1.GitRepository
+ g.Expect(testEnv.Get(ctx, gitRepoKey, &gitRepo)).To(Succeed())
+ patch := client.MergeFrom(gitRepo.DeepCopy())
+ gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: "non-existing-git-sec"}
+ g.Expect(testEnv.Patch(ctx, &gitRepo, patch)).To(Succeed())
+
+ // Wait for image update to fail.
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(conditions.IsReady(&obj)).To(BeFalse())
+ }, timeout).Should(Succeed())
+
+ // Patch the GitRepository to remove the secret ref.
+ patch = client.MergeFrom(gitRepo.DeepCopy())
+ gitRepo.Spec.SecretRef = nil
+ g.Expect(testEnv.Patch(ctx, &gitRepo, patch)).To(Succeed())
+
+ // Wait for image update to recover from failure.
+ g.Eventually(func(g Gomega) {
+ g.Expect(testEnv.Get(ctx, objKey, &obj)).To(Succeed())
+ g.Expect(conditions.IsReady(&obj)).To(BeTrue())
+ }, timeout).Should(Succeed())
+ })
})
}
-func TestImageAutomationReconciler_signedCommit(t *testing.T) {
+func TestImageUpdateAutomationReconciler_commitMessage(t *testing.T) {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
@@ -500,66 +718,131 @@ func TestImageAutomationReconciler_signedCommit(t *testing.T) {
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)
+ tests := []struct {
+ name string
+ template string
+ wantCommitMsg func(policyName, policyNS string) string
+ }{
+ {
+ name: "template with update Result",
+ template: testCommitTemplate,
+ wantCommitMsg: func(policyName, policyNS string) string {
+ return fmt.Sprintf(testCommitMessageFmt, policyNS, policyName)
+ },
+ },
+ {
+ name: "template with update ResultV2",
+ template: `Commit summary with ResultV2
- // 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())
+Automation: {{ .AutomationObject }}
- // Wait for a new commit to be made by the controller.
- waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
+{{ 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 }} ({{ $change.Setter }})
+{{ end -}}
+{{ end -}}
+{{ end -}}
+`,
+ wantCommitMsg: func(policyName, policyNS string) string {
+ return fmt.Sprintf(`Commit summary with ResultV2
- head, _ := localRepo.Head()
- g.Expect(err).ToNot(HaveOccurred())
- commit, err := localRepo.CommitObject(head.Hash())
- g.Expect(err).ToNot(HaveOccurred())
+Automation: %s/update-test
- c2 := *commit
- c2.PGPSignature = ""
+- File: deploy.yaml
+ - Object: Deployment//test
+ Changes:
+ - helloworld:1.0.0 -> helloworld:v1.0.0 (%s:%s)
+`, policyNS, policyNS, policyName)
+ },
+ },
+ }
- encoded := &plumbing.MemoryObject{}
- err = c2.Encode(encoded)
- g.Expect(err).ToNot(HaveOccurred())
- content, err := encoded.Reader()
- g.Expect(err).ToNot(HaveOccurred())
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
- kr := openpgp.EntityList([]*openpgp.Entity{pgpEntity})
- signature := strings.NewReader(commit.PGPSignature)
+ // Create test namespace.
+ namespace, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ testWithRepoAndImagePolicy(
+ ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
+ func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ commitMessage := tt.wantCommitMsg(s.imagePolicyName, s.namespace)
+
+ // Update the setter marker in the repo.
+ policyKey := types.NamespacedName{
+ Name: s.imagePolicyName,
+ Namespace: s.namespace,
+ }
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), 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 := testutil.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 = testutil.CommitIdFromBranch(localRepo, s.branch)
+
+ // Create the automation object and let it make a commit itself.
+ updateStrategy := &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ }
+ err := createImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, s.branch, "", tt.template, "", updateStrategy)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace)).To(Succeed())
+ }()
+
+ // 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))
+
+ // Regression check to ensure the status message contains the branch name
+ // if checkout branch is the same as push branch.
+ imageUpdateKey := types.NamespacedName{
+ Namespace: s.namespace,
+ Name: "update-test",
+ }
+ var imageUpdate imagev1.ImageUpdateAutomation
+ _ = testEnv.Get(context.TODO(), imageUpdateKey, &imageUpdate)
+ ready := apimeta.FindStatusCondition(imageUpdate.Status.Conditions, meta.ReadyCondition)
+ g.Expect(ready.Message).To(Equal(readyMessage))
+ g.Expect(imageUpdate.Status.LastPushCommit).To(Equal(head.Hash().String()))
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, &imageUpdate)
+ },
+ )
+ })
- _, err = openpgp.CheckArmoredDetachedSignature(kr, content, signature, nil)
- g.Expect(err).ToNot(HaveOccurred())
- },
- )
- })
+ }
}
-func TestImageAutomationReconciler_push_refspec(t *testing.T) {
+func TestImageUpdateAutomationReconciler_crossNamespaceRef(t *testing.T) {
+ g := NewWithT(t)
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: "not-expected-to-exist",
@@ -573,485 +856,444 @@ func TestImageAutomationReconciler_push_refspec(t *testing.T) {
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) {
- // 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)
- preChangeCommitId = commitIdFromBranch(localRepo, s.branch)
+ // Test successful cross namespace reference when NoCrossNamespaceRef=false.
- // Create the automation object and let it make a commit itself.
- updateStrategy := &imagev1.UpdateStrategy{
- Strategy: imagev1.UpdateStrategySetters,
- }
- pushBranch := "auto"
- refspec := fmt.Sprintf("refs/heads/%s:refs/heads/smth/else", pushBranch)
- err := createImageUpdateAutomation(testEnv, "push-refspec", s.namespace,
- s.gitRepoName, s.gitRepoNamespace, s.branch, pushBranch, refspec,
- testCommitTemplate, "", updateStrategy)
- g.Expect(err).ToNot(HaveOccurred())
+ // Create test namespace.
+ namespace1, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace1)).To(Succeed()) }()
- // Wait for a new commit to be made by the controller to the destination
- // ref specified in refspec (the stuff after the colon) and the push branch.
- pushBranchHash := getRemoteRef(g, repoURL, pushBranch)
- refspecHash := getRemoteRef(g, repoURL, "smth/else")
- g.Expect(pushBranchHash.String()).ToNot(Equal(preChangeCommitId))
- g.Expect(pushBranchHash.String()).To(Equal(refspecHash.String()))
+ args := newRepoAndPolicyArgs(namespace1.Name)
- imageUpdateKey := types.NamespacedName{
- Namespace: s.namespace,
- Name: "push-refspec",
- }
- var imageUpdate imagev1.ImageUpdateAutomation
- _ = testEnv.Get(context.TODO(), imageUpdateKey, &imageUpdate)
- ready := apimeta.FindStatusCondition(imageUpdate.Status.Conditions, meta.ReadyCondition)
- g.Expect(ready.Message).To(Equal(
- fmt.Sprintf("committed and pushed commit '%s' to branch '%s' and using refspec '%s'",
- pushBranchHash.String(), pushBranch, refspec)))
- },
- )
- })
-}
+ // Create another test namespace.
+ namespace2, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace2)).To(Succeed()) }()
-func TestImageAutomationReconciler_e2e(t *testing.T) {
- protos := []string{"http", "ssh"}
+ args.gitRepoNamespace = namespace2.Name
- testFunc := func(t *testing.T, proto string, feats map[string]bool) {
- g := NewWithT(t)
+ testWithCustomRepoAndImagePolicy(
+ ctx, g, testEnv, fixture, policySpec, latest, args,
+ func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ commitMessage := fmt.Sprintf(testCommitMessageFmt, s.namespace, s.imagePolicyName)
- const latestImage = "helloworld:1.0.1"
+ // Update the setter marker in the repo.
+ policyKey := types.NamespacedName{
+ Name: s.imagePolicyName,
+ Namespace: s.namespace,
+ }
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
+ })
- 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,
- }
+ // Pull the head commit we just pushed, so it's not
+ // considered a new commit when checking for a commit
+ // made by automation.
+ preChangeCommitId := testutil.CommitIdFromBranch(localRepo, s.branch)
- controllerName := "image-automation-controller"
- // Create ImagePolicy and ImageUpdateAutomation resource for each of the
- // test cases and cleanup at the end.
- r := &ImageUpdateAutomationReconciler{
- Client: fakeclient.NewClientBuilder().
- WithScheme(testEnv.Scheme()).
- WithStatusSubresource(&imagev1.ImageUpdateAutomation{}, &imagev1_reflect.ImagePolicy{}).
- Build(),
- EventRecorder: testEnv.GetEventRecorderFor(controllerName),
- features: feats,
- }
+ // 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)
- // 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())
- }()
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, s.branch)
- // 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()
+ // Create the automation object and let it make a commit itself.
+ updateStrategy := &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ }
+ err := createImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace)).To(Succeed())
+ }()
- cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repositoryPath
- repoURL, err := getRepoURL(gitServer, repositoryPath, proto)
- g.Expect(err).ToNot(HaveOccurred())
+ // Wait for a new commit to be made by the controller.
+ waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
- // 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, "")
+ head, _ := localRepo.Head()
+ commit, err := localRepo.CommitObject(head.Hash())
g.Expect(err).ToNot(HaveOccurred())
- }
+ g.Expect(commit.Message).To(Equal(commitMessage))
- // Create an image policy.
- policyKey := types.NamespacedName{
- Name: imagePolicyName,
- Namespace: namespace,
- }
+ signature := commit.Author
+ g.Expect(signature).NotTo(BeNil())
+ g.Expect(signature.Name).To(Equal(testAuthorName))
+ g.Expect(signature.Email).To(Equal(testAuthorEmail))
+ },
+ )
- t.Run("PushSpec", func(t *testing.T) {
- g := NewWithT(t)
+ // Test cross namespace reference failure when NoCrossNamespaceRef=true.
+ r := &ImageUpdateAutomationReconciler{
+ Client: fakeclient.NewClientBuilder().
+ WithScheme(testEnv.Scheme()).
+ WithStatusSubresource(&imagev1.ImageUpdateAutomation{}, &imagev1_reflect.ImagePolicy{}).
+ Build(),
+ EventRecorder: testEnv.GetEventRecorderFor("image-automation-controller"),
+ NoCrossNamespaceRef: true,
+ }
- // 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")
+ // Create test namespace.
+ namespace3, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace3)).To(Succeed()) }()
- // 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")
+ // Test successful cross namespace reference when NoCrossNamespaceRef=false.
+ args = newRepoAndPolicyArgs(namespace3.Name)
- defer func() {
- g.Expect(deleteImagePolicy(r.Client, imagePolicyName, namespace)).ToNot(HaveOccurred())
- }()
+ // Create another test namespace.
+ namespace4, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace4)).To(Succeed()) }()
- imageUpdateAutomationName := "update-" + randStringRunes(5)
- pushBranch := "pr-" + randStringRunes(5)
+ args.gitRepoNamespace = namespace4.Name
- automationKey := types.NamespacedName{
- Name: imageUpdateAutomationName,
- Namespace: namespace,
+ testWithCustomRepoAndImagePolicy(
+ ctx, g, r.Client, fixture, policySpec, latest, args,
+ func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ updateStrategy := &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
}
+ err := createImageUpdateAutomation(ctx, r.Client, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy)
+ g.Expect(err).ToNot(HaveOccurred())
- t.Run("update with PushSpec", func(t *testing.T) {
- g := NewWithT(t)
+ imageUpdateKey := types.NamespacedName{
+ Name: "update-test",
+ Namespace: s.namespace,
+ }
+ var imageUpdate imagev1.ImageUpdateAutomation
+ _ = r.Client.Get(context.TODO(), imageUpdateKey, &imageUpdate)
- 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())
+ sp := patch.NewSerialPatcher(&imageUpdate, r.Client)
- _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey})
- g.Expect(err).To(BeNil())
+ _, err = r.reconcile(context.TODO(), sp, &imageUpdate, time.Now())
+ g.Expect(err).To(BeNil())
- initialHead, err := headFromBranch(localRepo, branch)
- g.Expect(err).ToNot(HaveOccurred())
+ ready := apimeta.FindStatusCondition(imageUpdate.Status.Conditions, meta.ReadyCondition)
+ g.Expect(ready.Reason).To(Equal(aclapi.AccessDeniedReason))
+ },
+ )
+}
- preChangeCommitId = commitIdFromBranch(localRepo, branch)
- // Wait for a new commit to be made by the controller.
- waitForNewHead(g, localRepo, pushBranch, preChangeCommitId)
+func TestImageUpdateAutomationReconciler_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"
- 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))
+ g := NewWithT(t)
- // 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())
- })
+ // Create test namespace.
+ namespace, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ testWithRepoAndImagePolicy(
+ ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
+ 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,
+ }
- t.Run("push branch gets updated", func(t *testing.T) {
- if !feats[features.GitAllBranchReferences] {
- t.Skip("GitAllBranchReferences feature not enabled")
- }
+ // pull the head commit we just pushed, so it's not
+ // considered a new commit when checking for a commit
+ // made by automation.
+ preChangeCommitId := testutil.CommitIdFromBranch(localRepo, s.branch)
- g := NewWithT(t)
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "yes", "deploy.yaml"), policyKey)).To(Succeed())
+ })
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "no", "deploy.yaml"), policyKey)).To(Succeed())
+ })
- initialHead, err := headFromBranch(localRepo, branch)
- g.Expect(err).ToNot(HaveOccurred())
+ // 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)
- // Get the head hash before update.
- head, err := getRemoteHead(localRepo, pushBranch)
- g.Expect(err).NotTo(HaveOccurred())
- headHash := head.String()
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, s.branch)
- preChangeCommitId := commitIdFromBranch(localRepo, branch)
+ // Create the automation object and let it make a commit itself.
+ updateStrategy := &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ Path: "./yes",
+ }
+ err := createImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace)).To(Succeed())
+ }()
- // 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())
+ // 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"))
- _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey})
- g.Expect(err).To(BeNil())
+ var update imagev1.ImageUpdateAutomation
+ updateKey := types.NamespacedName{
+ Namespace: s.namespace,
+ Name: "update-test",
+ }
+ g.Expect(testEnv.Get(ctx, updateKey, &update)).To(Succeed())
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, &update)
+ },
+ )
+}
- waitForNewHead(g, localRepo, pushBranch, preChangeCommitId)
+func TestImageUpdateAutomationReconciler_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"
- head, err = getRemoteHead(localRepo, pushBranch)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(head.String()).NotTo(Equal(headHash))
+ g := NewWithT(t)
- // 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())
+ // Create test namespace.
+ namespace, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
+
+ testWithRepoAndImagePolicy(
+ ctx, g, testEnv, namespace.Name, fixture, policySpec, latest,
+ func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) {
+ signingKeySecretName := "signing-key-secret-" + rand.String(5)
+ // Update the setter marker in the repo.
+ policyKey := types.NamespacedName{
+ Name: s.imagePolicyName,
+ Namespace: s.namespace,
+ }
+ _ = testutil.CommitInRepo(ctx, g, repoURL, s.branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
})
- 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")
- }
+ preChangeCommitId := testutil.CommitIdFromBranch(localRepo, s.branch)
- g := NewWithT(t)
+ // 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)
- initialHead, err := headFromBranch(localRepo, branch)
- g.Expect(err).ToNot(HaveOccurred())
+ pgpEntity := createSigningKeyPairSecret(ctx, g, testEnv, signingKeySecretName, s.namespace)
- // Get the head hash before.
- head, err := getRemoteHead(localRepo, pushBranch)
- g.Expect(err).NotTo(HaveOccurred())
- headHash := head.String()
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, s.branch)
- // 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())
+ // Create the automation object and let it make a commit itself.
+ updateStrategy := &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ }
+ err := createImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, signingKeySecretName, updateStrategy)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, "update-test", s.namespace)).To(Succeed())
+ }()
- preChangeCommitId := commitIdFromBranch(localRepo, branch)
+ // Wait for a new commit to be made by the controller.
+ waitForNewHead(g, localRepo, s.branch, preChangeCommitId)
- // 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())
+ head, _ := localRepo.Head()
+ g.Expect(err).ToNot(HaveOccurred())
+ commit, err := localRepo.CommitObject(head.Hash())
+ g.Expect(err).ToNot(HaveOccurred())
- _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey})
- g.Expect(err).To(BeNil())
+ c2 := *commit
+ c2.PGPSignature = ""
- waitForNewHead(g, localRepo, pushBranch, preChangeCommitId)
+ encoded := &plumbing.MemoryObject{}
+ err = c2.Encode(encoded)
+ g.Expect(err).ToNot(HaveOccurred())
+ content, err := encoded.Reader()
+ g.Expect(err).ToNot(HaveOccurred())
- head, err = getRemoteHead(localRepo, pushBranch)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(head.String()).NotTo(Equal(headHash))
+ kr := openpgp.EntityList([]*openpgp.Entity{pgpEntity})
+ signature := strings.NewReader(commit.PGPSignature)
- // 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())
- })
+ _, err = openpgp.CheckArmoredDetachedSignature(kr, content, signature, nil)
+ g.Expect(err).ToNot(HaveOccurred())
+ },
+ )
+}
- // Cleanup the image update automation used above.
- g.Expect(deleteImageUpdateAutomation(r.Client, imageUpdateAutomationName, namespace)).To(Succeed())
- })
+func TestImageUpdateAutomationReconciler_e2e(t *testing.T) {
+ protos := []string{"http", "ssh"}
- t.Run("with update strategy setters", func(t *testing.T) {
- g := NewWithT(t)
+ testFunc := func(t *testing.T, proto string) {
+ 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")
+ const latestImage = "helloworld:1.0.1"
- defer func() {
- g.Expect(deleteImagePolicy(r.Client, imagePolicyName, namespace)).ToNot(HaveOccurred())
- }()
+ // Create a test namespace.
+ namespace, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
- 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())
- })
+ branch := rand.String(8)
+ repositoryPath := "/config-" + rand.String(6) + ".git"
+ gitRepoName := "image-auto-" + rand.String(5)
+ gitSecretName := "git-secret-" + rand.String(5)
+ imagePolicyName := "policy-" + rand.String(5)
+ updateStrategy := &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ }
- // 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)
+ // Create git server.
+ gitServer := testutil.SetUpGitTestServer(g)
+ defer os.RemoveAll(gitServer.Root())
+ defer gitServer.StopHTTP()
- preChangeCommitId = commitIdFromBranch(localRepo, branch)
+ cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repositoryPath
+ repoURL, err := getRepoURL(gitServer, repositoryPath, proto)
+ g.Expect(err).ToNot(HaveOccurred())
- // 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())
+ // Start the ssh server if needed.
+ if proto == "ssh" {
+ go func() {
+ gitServer.StartSSH()
+ }()
defer func() {
- g.Expect(deleteImageUpdateAutomation(r.Client, updateKey.Name, namespace)).To(Succeed())
+ g.Expect(gitServer.StopSSH()).To(Succeed())
}()
+ }
- _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: updateKey})
- g.Expect(err).To(BeNil())
+ commitMessage := "Commit a difference " + rand.String(5)
- // Wait for a new commit to be made by the controller.
- waitForNewHead(g, localRepo, branch, preChangeCommitId)
+ // Initialize a git repo.
+ _ = testutil.InitGitRepo(g, gitServer, "testdata/appconfig", branch, repositoryPath)
- // Check if the repo head matches with the ImageUpdateAutomation
- // last push commit status.
- head, _ := localRepo.Head()
- commit, err := localRepo.CommitObject(head.Hash())
+ // 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(testEnv, gitSecretName, namespace.Name, repoURL)
g.Expect(err).ToNot(HaveOccurred())
- g.Expect(commit.Message).To(Equal(commitMessage))
+ err = createGitRepository(ctx, testEnv, gitRepoName, namespace.Name, repoURL, gitSecretName)
+ g.Expect(err).ToNot(HaveOccurred())
+ } else {
+ err = createGitRepository(ctx, testEnv, gitRepoName, namespace.Name, repoURL, "")
+ g.Expect(err).ToNot(HaveOccurred())
+ }
- 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())
+ // Create an image policy.
+ policyKey := types.NamespacedName{
+ Name: imagePolicyName,
+ Namespace: namespace.Name,
+ }
- compareRepoWithExpected(g, cloneLocalRepoURL, branch, "testdata/appconfig-setters-expected", func(tmp string) {
- g.Expect(replaceMarker(tmp, policyKey)).To(Succeed())
- })
- })
+ // Clone the repo locally.
+ cloneCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ localRepo, cloneDir, err := testutil.Clone(cloneCtx, cloneLocalRepoURL, branch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred(), "failed to clone")
+ defer func() { os.RemoveAll(cloneDir) }()
- t.Run("no reconciliation when object is suspended", func(t *testing.T) {
- g := NewWithT(t)
+ testutil.CheckoutBranch(g, localRepo, branch)
+ err = createImagePolicyWithLatestImage(ctx, testEnv, imagePolicyName, namespace.Name, "not-expected-to-exist", "1.x", latestImage)
+ g.Expect(err).ToNot(HaveOccurred(), "failed to create ImagePolicy resource")
- nsCleanup, err := createNamespace(testEnv, namespace)
- g.Expect(err).ToNot(HaveOccurred(), "failed to create test namespace")
- defer func() {
- g.Expect(nsCleanup()).To(Succeed())
- }()
+ defer func() {
+ g.Expect(deleteImagePolicy(ctx, testEnv, imagePolicyName, namespace.Name)).ToNot(HaveOccurred())
+ }()
- err = createImagePolicyWithLatestImage(testEnv, imagePolicyName, namespace, "not-expected-to-exist", "1.x", latestImage)
- g.Expect(err).ToNot(HaveOccurred(), "failed to create ImagePolicy resource")
+ preChangeCommitId := testutil.CommitIdFromBranch(localRepo, branch)
+ // Insert a setter reference into the deployment file,
+ // before creating the automation object itself.
+ _ = 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())
+ })
- defer func() {
- g.Expect(deleteImagePolicy(testEnv, imagePolicyName, namespace)).ToNot(HaveOccurred())
- }()
+ // 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)
- // 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())
- }()
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
- _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: updateKey})
- g.Expect(err).To(BeNil())
+ // Now create the automation object, and let it make a commit itself.
+ updateKey := types.NamespacedName{
+ Namespace: namespace.Name,
+ Name: "update-" + rand.String(5),
+ }
+ err = createImageUpdateAutomation(ctx, testEnv, updateKey.Name, namespace.Name, gitRepoName, namespace.Name, branch, "", "", commitMessage, "", updateStrategy)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(deleteImageUpdateAutomation(ctx, testEnv, updateKey.Name, namespace.Name)).To(Succeed())
+ }()
- // 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,
+ var imageUpdate imagev1.ImageUpdateAutomation
+ g.Eventually(func() bool {
+ if err := testEnv.Get(ctx, updateKey, &imageUpdate); err != nil {
+ return false
}
+ return conditions.IsReady(&imageUpdate) && imageUpdate.Status.LastPushCommit != ""
+ }, timeout).Should(BeTrue())
- // 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{}))
+ // 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))
+ g.Expect(commit.Hash.String()).To(Equal(imageUpdate.Status.LastPushCommit))
- var checkUpdate imagev1.ImageUpdateAutomation
- g.Expect(testEnv.Get(context.Background(), updateKey, &checkUpdate)).To(Succeed())
- g.Expect(checkUpdate.Status.ObservedGeneration).NotTo(Equal(checkUpdate.ObjectMeta.Generation))
+ checkCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ compareRepoWithExpected(checkCtx, g, cloneLocalRepoURL, branch, "testdata/appconfig-setters-expected", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
})
+
+ // Check if the object status is valid.
+ condns := &conditionscheck.Conditions{NegativePolarity: imageUpdateAutomationNegativeConditions}
+ checker := conditionscheck.NewChecker(testEnv.Client, condns)
+ checker.WithT(g).CheckErr(ctx, &imageUpdate)
}
- 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)
- })
- }
+ for _, proto := range protos {
+ t.Run(proto, func(t *testing.T) {
+ testFunc(t, proto)
+ })
}
}
-func TestImageAutomationReconciler_defaulting(t *testing.T) {
+func TestImageUpdateAutomationReconciler_defaulting(t *testing.T) {
g := NewWithT(t)
- branch := randStringRunes(8)
- namespace := &corev1.Namespace{}
- namespace.Name = "image-auto-test-" + randStringRunes(5)
-
+ branch := rand.String(8)
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())
- }()
+ namespace, err := testEnv.CreateNamespace(ctx, "image-auto-test")
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(testEnv.Delete(ctx, namespace)).To(Succeed()) }()
// Create an instance of ImageUpdateAutomation.
key := types.NamespacedName{
- Name: "update-" + randStringRunes(5),
+ Name: "update-" + rand.String(5),
Namespace: namespace.Name,
}
auto := &imagev1.ImageUpdateAutomation{
@@ -1096,173 +1338,370 @@ func TestImageAutomationReconciler_defaulting(t *testing.T) {
To(Equal(&imagev1.UpdateStrategy{Strategy: imagev1.UpdateStrategySetters}))
}
-func TestImageUpdateAutomationReconciler_getProxyOpts(t *testing.T) {
- invalidProxy := &corev1.Secret{
- ObjectMeta: metav1.ObjectMeta{
- Name: "invalid-proxy",
- Namespace: "default",
+func TestImageUpdateAutomationReconciler_notify(t *testing.T) {
+ g := NewWithT(t)
+ testPushResult, err := source.NewPushResult("branch", "rev", "test commit message")
+ g.Expect(err).ToNot(HaveOccurred())
+
+ tests := []struct {
+ name string
+ pushResult *source.PushResult
+ syncNeeded bool
+ oldObjBeforeFunc func(obj conditions.Setter)
+ newObjBeforeFunc func(obj conditions.Setter)
+ wantEvent string
+ }{
+ {
+ name: "first time reconciliation, no update",
+ pushResult: nil,
+ syncNeeded: true,
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ wantEvent: "Normal Succeeded repository up-to-date",
},
- Data: map[string][]byte{
- "url": []byte("https://example.com"),
+ {
+ name: "second reconciliation, syncNeeded=false, no update",
+ pushResult: nil,
+ syncNeeded: false,
+ oldObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ wantEvent: "Trace Succeeded no change since last reconciliation",
},
- }
- validProxy := &corev1.Secret{
- ObjectMeta: metav1.ObjectMeta{
- Name: "valid-proxy",
- Namespace: "default",
+ {
+ name: "second reconciliation, syncNeeded=true, no update",
+ pushResult: nil,
+ syncNeeded: true,
+ oldObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ wantEvent: "Trace Succeeded repository up-to-date",
},
- Data: map[string][]byte{
- "address": []byte("https://example.com"),
- "username": []byte("user"),
- "password": []byte("pass"),
+ {
+ name: "was ready, new update, is ready",
+ pushResult: testPushResult,
+ syncNeeded: true,
+ oldObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ wantEvent: "Normal Succeeded pushed commit 'rev' to branch 'branch'\ntest commit message",
+ },
+ {
+ name: "failure recovery, no update",
+ pushResult: nil,
+ syncNeeded: true,
+ oldObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, "failed to checkout source")
+ },
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ wantEvent: "Normal Succeeded repository up-to-date",
+ },
+ {
+ name: "failure recovery, with new update",
+ pushResult: testPushResult,
+ syncNeeded: true,
+ oldObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, "failed to checkout source")
+ },
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ wantEvent: "Normal Succeeded pushed commit 'rev' to branch 'branch'\ntest commit message",
+ },
+ {
+ name: "failed",
+ pushResult: nil,
+ syncNeeded: true,
+ oldObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, readyMessage)
+ },
+ newObjBeforeFunc: func(obj conditions.Setter) {
+ conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.GitOperationFailedReason, "failed to checkout source")
+ },
+ wantEvent: "Warning GitOperationFailed failed to checkout source",
},
}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+ recorder := record.NewFakeRecorder(32)
- clientBuilder := fakeclient.NewClientBuilder().
- WithScheme(testEnv.GetScheme()).
- WithObjects(invalidProxy, validProxy)
+ oldObj := &imagev1.ImageUpdateAutomation{}
+ newObj := oldObj.DeepCopy()
- r := &ImageUpdateAutomationReconciler{
- Client: clientBuilder.Build(),
+ if tt.oldObjBeforeFunc != nil {
+ tt.oldObjBeforeFunc(oldObj)
+ }
+ if tt.newObjBeforeFunc != nil {
+ tt.newObjBeforeFunc(newObj)
+ }
+
+ reconciler := &ImageUpdateAutomationReconciler{
+ EventRecorder: recorder,
+ }
+ reconciler.notify(ctx, oldObj, newObj, tt.pushResult, tt.syncNeeded)
+
+ select {
+ case x, ok := <-recorder.Events:
+ g.Expect(ok).To(Equal(tt.wantEvent != ""), "unexpected event received")
+ if tt.wantEvent != "" {
+ g.Expect(x).To(ContainSubstring(tt.wantEvent))
+ }
+ default:
+ if tt.wantEvent != "" {
+ g.Fail("expected some event to be emitted")
+ }
+ }
+ })
+ }
+}
+
+func Test_getPolicies(t *testing.T) {
+ testNS1 := "foo"
+ testNS2 := "bar"
+
+ type policyArgs struct {
+ name string
+ namespace string
+ latestImage string
+ labels map[string]string
}
tests := []struct {
- name string
- secret string
- err string
- proxyOpts *transport.ProxyOptions
+ name string
+ listNamespace string
+ selector *metav1.LabelSelector
+ policies []policyArgs
+ wantPolicies []string
}{
{
- name: "non-existent secret",
- secret: "non-existent",
- err: "failed to get proxy secret 'default/non-existent': ",
+ name: "lists policies with image and in same namespace",
+ listNamespace: testNS1,
+ policies: []policyArgs{
+ {name: "p1", namespace: testNS1, latestImage: "aaa:bbb"},
+ {name: "p2", namespace: testNS1, latestImage: "ccc:ddd"},
+ {name: "p3", namespace: testNS2, latestImage: "eee:fff"},
+ {name: "p4", namespace: testNS1, latestImage: ""},
+ },
+ wantPolicies: []string{"p1", "p2"},
},
{
- name: "invalid proxy secret",
- secret: "invalid-proxy",
- err: "invalid proxy secret 'default/invalid-proxy': key 'address' is missing",
+ name: "lists policies with label selector in same namespace",
+ listNamespace: testNS1,
+ selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "label": "one",
+ },
+ },
+ policies: []policyArgs{
+ {name: "p1", namespace: testNS1, latestImage: "aaa:bbb", labels: map[string]string{"label": "one"}},
+ {name: "p2", namespace: testNS1, latestImage: "ccc:ddd", labels: map[string]string{"label": "false"}},
+ {name: "p3", namespace: testNS2, latestImage: "eee:fff", labels: map[string]string{"label": "one"}},
+ },
+ wantPolicies: []string{"p1"},
},
{
- name: "valid proxy secret",
- secret: "valid-proxy",
- proxyOpts: &transport.ProxyOptions{
- URL: "https://example.com",
- Username: "user",
- Password: "pass",
+ name: "no policies in empty namespace",
+ listNamespace: testNS2,
+ policies: []policyArgs{
+ {name: "p1", namespace: testNS1, latestImage: "aaa:bbb"},
},
+ wantPolicies: []string{},
},
}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ // Create all the test policies.
+ testObjects := []client.Object{}
+ for _, p := range tt.policies {
+ aPolicy := &imagev1_reflect.ImagePolicy{}
+ aPolicy.Name = p.name
+ aPolicy.Namespace = p.namespace
+ aPolicy.Status = imagev1_reflect.ImagePolicyStatus{
+ LatestImage: p.latestImage,
+ }
+ aPolicy.Labels = p.labels
+ testObjects = append(testObjects, aPolicy)
+ }
+ kClient := fakeclient.NewClientBuilder().
+ WithScheme(testEnv.GetScheme()).
+ WithObjects(testObjects...).Build()
+ result, err := getPolicies(context.TODO(), kClient, tt.listNamespace, tt.selector)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Extract policy name from the result and compare with the expected
+ // result.
+ resultPolicyNames := []string{}
+ for _, r := range result {
+ resultPolicyNames = append(resultPolicyNames, r.Name)
+ }
+ g.Expect(resultPolicyNames).To(ContainElements(tt.wantPolicies))
+ })
+ }
+}
+
+func Test_observedPolicies(t *testing.T) {
+ tests := []struct {
+ name string
+ policyWithImage map[string]string
+ want imagev1.ObservedPolicies
+ wantErr bool
+ }{
+ {
+ name: "good policies",
+ policyWithImage: map[string]string{
+ "p1": "aaa:bbb",
+ "p2": "ccc:ddd",
+ "p3": "eee:latest",
+ "p4": "fff:ggg:hhh",
+ },
+ want: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ "p3": imagev1.ImageRef{Name: "eee", Tag: "latest"},
+ "p4": imagev1.ImageRef{Name: "fff", Tag: "ggg:hhh"},
+ },
+ },
+ {
+ name: "bad policy image with no tag",
+ policyWithImage: map[string]string{
+ "p1": "aaa",
+ },
+ wantErr: true,
+ },
+ {
+ name: "no policy",
+ want: imagev1.ObservedPolicies{},
+ },
+ }
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
- opts, err := r.getProxyOpts(context.TODO(), tt.secret, "default")
- if opts != nil {
- g.Expect(err).ToNot(HaveOccurred())
- g.Expect(opts).To(Equal(tt.proxyOpts))
- } else {
- g.Expect(err).To(HaveOccurred())
- g.Expect(err.Error()).To(ContainSubstring(tt.err))
+
+ policies := []imagev1_reflect.ImagePolicy{}
+ for name, image := range tt.policyWithImage {
+ aPolicy := imagev1_reflect.ImagePolicy{}
+ aPolicy.Name = name
+ aPolicy.Status = imagev1_reflect.ImagePolicyStatus{
+ LatestImage: image,
+ }
+ policies = append(policies, aPolicy)
+ }
+
+ result, err := observedPolicies(policies)
+ if (err != nil) != tt.wantErr {
+ g.Fail(fmt.Sprintf("unexpected error: %v", err))
+ }
+ if err == nil {
+ g.Expect(result).To(Equal(tt.want))
}
})
}
}
-func TestImageAutomationReconciler_getGitClientOpts(t *testing.T) {
+func Test_observedPoliciesChanged(t *testing.T) {
tests := []struct {
- name string
- gitTransport git.TransportType
- proxyOpts *transport.ProxyOptions
- diffPushBranch bool
- clientOptsN int
+ name string
+ previous imagev1.ObservedPolicies
+ current imagev1.ObservedPolicies
+ want bool
}{
{
- name: "default client opts",
- gitTransport: git.HTTPS,
- clientOptsN: 1,
+ name: "no change",
+ previous: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ current: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ want: false,
+ },
+ {
+ name: "change due to new tag",
+ previous: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ current: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "zzz"},
+ },
+ want: true,
},
{
- name: "http transport adds insecure credentials client opt",
- gitTransport: git.HTTP,
- clientOptsN: 2,
+ name: "change due to different policies, same count",
+ previous: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ current: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p3": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ want: true,
},
{
- name: "http transport and providing proxy options adds insecure crednetials and proxy client opt",
- gitTransport: git.HTTP,
- proxyOpts: &transport.ProxyOptions{},
- clientOptsN: 3,
+ name: "change due to new policy, different count",
+ previous: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ },
+ current: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ want: true,
},
{
- name: "push branch different from checkout branch adds single branch client opt",
- gitTransport: git.HTTPS,
- diffPushBranch: true,
- clientOptsN: 2,
+ name: "change due to deleted policy",
+ previous: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ "p2": imagev1.ImageRef{Name: "ccc", Tag: "ddd"},
+ },
+ current: imagev1.ObservedPolicies{
+ "p1": imagev1.ImageRef{Name: "aaa", Tag: "bbb"},
+ },
+ want: true,
},
}
-
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
- r := &ImageUpdateAutomationReconciler{
- features: map[string]bool{
- features.GitAllBranchReferences: true,
- },
- }
- clientOpts := r.getGitClientOpts(tt.gitTransport, tt.proxyOpts, tt.diffPushBranch)
- g.Expect(len(clientOpts)).To(Equal(tt.clientOptsN))
- })
- }
-}
-
-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
+ result := observedPoliciesChanged(tt.previous, tt.current)
+ g.Expect(result).To(Equal(tt.want))
+ })
}
- return nil
}
-func setterRef(name types.NamespacedName) string {
- return fmt.Sprintf(`{"%s": "%s:%s"}`, update.SetterShortHand, name.Namespace, name.Name)
-}
+func compareRepoWithExpected(ctx context.Context, g *WithT, repoURL, branch, fixture string, changeFixture func(tmp string)) {
+ g.THelper()
-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())
+ repo, cloneDir, err := testutil.Clone(ctx, repoURL, branch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred(), "failed to clone")
+ defer func() { os.RemoveAll(cloneDir) }()
// NOTE: The workdir contains a trailing /. Clean it to not confuse the
// DiffDirectories().
@@ -1275,285 +1714,9 @@ func compareRepoWithExpected(g *WithT, repoURL, branch, fixture string, changeFi
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: []config.RefSpec{config.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 := osfs.New(repositoryPath)
- dot := osfs.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 := osfs.New(dir, osfs.WithBoundOS())
- dot := osfs.New(filepath.Join(dir, extgogit.GitDirName), osfs.WithBoundOS())
- 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 getRemoteRef(g *WithT, repoURL, ref string) plumbing.Hash {
- var hash plumbing.Hash
- g.Eventually(func() bool {
- cloneCtx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
- repo, err := clone(cloneCtx, repoURL, ref)
- if err != nil {
- return false
- }
-
- remRefName := plumbing.NewRemoteReferenceName(extgogit.DefaultRemoteName, ref)
- remRef, err := repo.Reference(remRefName, true)
- if err != nil {
- return false
- }
- hash = remRef.Hash()
- return true
- }, timeout, time.Second).Should(BeTrue())
- return hash
-}
-
func waitForNewHead(g *WithT, repo *extgogit.Repository, branch, preChangeHash string) {
+ g.THelper()
+
var commitToResetTo *object.Commit
origin, err := repo.Remote(originRemote)
@@ -1563,7 +1726,7 @@ func waitForNewHead(g *WithT, repo *extgogit.Repository, branch, preChangeHash s
g.Eventually(func() bool {
err := origin.Fetch(&extgogit.FetchOptions{
RemoteName: originRemote,
- RefSpecs: []config.RefSpec{config.RefSpec(branchRefName(branch))},
+ RefSpecs: []config.RefSpec{config.RefSpec(testutil.BranchRefName(branch))},
})
if err != nil {
return false
@@ -1587,9 +1750,6 @@ func waitForNewHead(g *WithT, repo *extgogit.Repository, branch, preChangeHash s
}
remoteHeadHash := remoteHeadRef.Hash()
- if err != nil {
- return false
- }
if preChangeHash != remoteHeadHash.String() {
commitToResetTo, _ = repo.CommitObject(remoteHeadHash)
@@ -1612,68 +1772,22 @@ func waitForNewHead(g *WithT, repo *extgogit.Repository, branch, preChangeHash s
}
}
-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
+// newRepoAndPolicyArgs generates random 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 {
+// as the overall given namespace. For different git repo namespace, the caller
+// may assign it as per the needs.
+func newRepoAndPolicyArgs(namespace string) repoAndPolicyArgs {
args := repoAndPolicyArgs{
- namespace: "image-auto-test-" + randStringRunes(5),
- gitRepoName: "image-auto-test-" + randStringRunes(5),
- branch: randStringRunes(8),
- imagePolicyName: "policy-" + randStringRunes(5),
+ namespace: namespace,
+ gitRepoName: "image-auto-test-" + rand.String(5),
+ gitRepoNamespace: namespace,
+ branch: rand.String(8),
+ imagePolicyName: "policy-" + rand.String(5),
}
- args.gitRepoNamespace = args.namespace
return args
}
@@ -1682,65 +1796,51 @@ func newRepoAndPolicyArgs() repoAndPolicyArgs {
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.
+// resource in the given namespace and runs the given repo and image policy test.
func testWithRepoAndImagePolicy(
+ ctx context.Context,
g *WithT,
kClient client.Client,
+ namespace string,
fixture string,
policySpec imagev1_reflect.ImagePolicySpec,
- latest, gitImpl string,
+ latest string,
testFunc testWithRepoAndImagePolicyTestFunc) {
// Generate unique repo and policy arguments.
- args := newRepoAndPolicyArgs()
- testWithCustomRepoAndImagePolicy(g, kClient, fixture, policySpec, latest, gitImpl, args, testFunc)
+ args := newRepoAndPolicyArgs(namespace)
+ testWithCustomRepoAndImagePolicy(ctx, g, kClient, fixture, policySpec, latest, args, testFunc)
}
-// testWithRepoAndImagePolicy sets up a git server, a repository in the git
+// testWithCustomRepoAndImagePolicy 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(
+ ctx context.Context,
g *WithT,
kClient client.Client,
fixture string,
policySpec imagev1_reflect.ImagePolicySpec,
- latest, gitImpl string,
+ latest string,
args repoAndPolicyArgs,
testFunc testWithRepoAndImagePolicyTestFunc) {
- repositoryPath := "/config-" + randStringRunes(6) + ".git"
+ repositoryPath := "/config-" + rand.String(6) + ".git"
// Create test git server.
- gitServer, err := setupGitTestServer()
- g.Expect(err).ToNot(HaveOccurred(), "failed to create test git server")
+ gitServer := testutil.SetUpGitTestServer(g)
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())
+ _ = testutil.InitGitRepo(g, gitServer, fixture, args.branch, repositoryPath)
// 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")
+ localRepo, cloneDir, err := testutil.Clone(cloneCtx, repoURL, args.branch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred(), "failed to clone")
+ defer func() { os.RemoveAll(cloneDir) }()
err = localRepo.DeleteRemote(originRemote)
g.Expect(err).ToNot(HaveOccurred(), "failed to delete existing remote origin")
@@ -1751,62 +1851,21 @@ func testWithCustomRepoAndImagePolicy(
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, "")
+ err = createGitRepository(ctx, 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)
+ err = createImagePolicyWithLatestImageForSpec(ctx, 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 {
+func createGitRepository(ctx context.Context, kClient client.Client, name, namespace, repoURL, secretRef string) error {
gitRepo := &sourcev1.GitRepository{
Spec: sourcev1.GitRepositorySpec{
URL: repoURL,
- Interval: metav1.Duration{Duration: time.Minute},
+ Interval: metav1.Duration{Duration: time.Hour},
Timeout: &metav1.Duration{Duration: time.Minute},
},
}
@@ -1815,10 +1874,10 @@ func createGitRepository(kClient client.Client, name, namespace, repoURL, secret
if secretRef != "" {
gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: secretRef}
}
- return kClient.Create(context.Background(), gitRepo)
+ return kClient.Create(ctx, gitRepo)
}
-func createImagePolicyWithLatestImage(kClient client.Client, name, namespace, repoRef, semverRange, latest string) error {
+func createImagePolicyWithLatestImage(ctx context.Context, kClient client.Client, name, namespace, repoRef, semverRange, latest string) error {
policySpec := imagev1_reflect.ImagePolicySpec{
ImageRepositoryRef: meta.NamespacedObjectReference{
Name: repoRef,
@@ -1829,39 +1888,39 @@ func createImagePolicyWithLatestImage(kClient client.Client, name, namespace, re
},
},
}
- return createImagePolicyWithLatestImageForSpec(kClient, name, namespace, policySpec, latest)
+ return createImagePolicyWithLatestImageForSpec(ctx, kClient, name, namespace, policySpec, latest)
}
-func createImagePolicyWithLatestImageForSpec(kClient client.Client, name, namespace string, policySpec imagev1_reflect.ImagePolicySpec, latest string) error {
+func createImagePolicyWithLatestImageForSpec(ctx context.Context, 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)
+ err := kClient.Create(ctx, policy)
if err != nil {
return err
}
patch := client.MergeFrom(policy.DeepCopy())
policy.Status.LatestImage = latest
- return kClient.Status().Patch(context.Background(), policy, patch)
+ return kClient.Status().Patch(ctx, policy, patch)
}
-func updateImagePolicyWithLatestImage(kClient client.Client, name, namespace, latest string) error {
+func updateImagePolicyWithLatestImage(ctx context.Context, 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 {
+ if err := kClient.Get(ctx, key, policy); err != nil {
return err
}
patch := client.MergeFrom(policy.DeepCopy())
policy.Status.LatestImage = latest
- return kClient.Status().Patch(context.Background(), policy, patch)
+ return kClient.Status().Patch(ctx, policy, patch)
}
-func createImageUpdateAutomation(kClient client.Client, name, namespace,
+func createImageUpdateAutomation(ctx context.Context, kClient client.Client, name, namespace,
gitRepo, gitRepoNamespace, checkoutBranch, pushBranch, pushRefspec, commitTemplate, signingKeyRef string,
updateStrategy *imagev1.UpdateStrategy) error {
updateAutomation := &imagev1.ImageUpdateAutomation{
@@ -1902,60 +1961,27 @@ func createImageUpdateAutomation(kClient client.Client, name, namespace,
SecretRef: meta.LocalObjectReference{Name: signingKeyRef},
}
}
- return kClient.Create(context.Background(), updateAutomation)
+ return kClient.Create(ctx, updateAutomation)
}
-func deleteImageUpdateAutomation(kClient client.Client, name, namespace string) error {
+func deleteImageUpdateAutomation(ctx context.Context, kClient client.Client, name, namespace string) error {
update := &imagev1.ImageUpdateAutomation{}
update.Name = name
update.Namespace = namespace
- return kClient.Delete(context.Background(), update)
+ return kClient.Delete(ctx, update)
}
-func deleteImagePolicy(kClient client.Client, name, namespace string) error {
+func deleteImagePolicy(ctx context.Context, kClient client.Client, name, namespace string) error {
imagePolicy := &imagev1_reflect.ImagePolicy{}
imagePolicy.Name = name
imagePolicy.Namespace = namespace
- return kClient.Delete(context.Background(), imagePolicy)
+ return kClient.Delete(ctx, 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
- }
-
- passphrase := "abcde12345"
- if err = pgpEntity.PrivateKey.Encrypt([]byte(passphrase)); err != nil {
- return nil, err
- }
- // Create the secret containing signing key.
- sec := &corev1.Secret{
- Data: map[string][]byte{
- signingSecretKey: b.Bytes(),
- signingPassphraseKey: []byte(passphrase),
- },
- }
- sec.Name = name
- sec.Namespace = namespace
- if err := kClient.Create(ctx, sec); err != nil {
- return nil, err
- }
- return pgpEntity, nil
+func createSigningKeyPairSecret(ctx context.Context, g *WithT, kClient client.Client, name, namespace string) *openpgp.Entity {
+ secret, pgpEntity := testutil.GetSigningKeyPairSecret(g, name, namespace)
+ g.Expect(kClient.Create(ctx, secret)).To(Succeed())
+ return pgpEntity
}
func createSSHIdentitySecret(kClient client.Client, name, namespace, repoURL string) error {
diff --git a/internal/policy/applier.go b/internal/policy/applier.go
new file mode 100644
index 00000000..2722746e
--- /dev/null
+++ b/internal/policy/applier.go
@@ -0,0 +1,66 @@
+/*
+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 policy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ securejoin "github.com/cyphar/filepath-securejoin"
+ "github.com/fluxcd/pkg/runtime/logger"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta2"
+
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
+ "github.com/fluxcd/image-automation-controller/pkg/update"
+)
+
+var (
+ // ErrNoUpdateStrategy is an update error when the update strategy is not
+ // specified.
+ ErrNoUpdateStrategy = errors.New("no update strategy")
+ // ErrUnsupportedUpdateStrategy is an update error when the provided update
+ // strategy is not supported.
+ ErrUnsupportedUpdateStrategy = errors.New("unsupported update strategy")
+)
+
+// ApplyPolicies applies the given set of policies on the source present in the
+// workDir based on the provided ImageUpdateAutomation configuration.
+func ApplyPolicies(ctx context.Context, workDir string, obj *imagev1.ImageUpdateAutomation, policies []imagev1_reflect.ImagePolicy) (update.ResultV2, error) {
+ var result update.ResultV2
+ if obj.Spec.Update == nil {
+ return result, ErrNoUpdateStrategy
+ }
+ if obj.Spec.Update.Strategy != imagev1.UpdateStrategySetters {
+ return result, fmt.Errorf("%w: %s", ErrUnsupportedUpdateStrategy, obj.Spec.Update.Strategy)
+ }
+
+ // Resolve the path to the manifests to apply policies on.
+ manifestPath := workDir
+ if obj.Spec.Update.Path != "" {
+ p, err := securejoin.SecureJoin(workDir, obj.Spec.Update.Path)
+ if err != nil {
+ return result, fmt.Errorf("failed to secure join manifest path: %w", err)
+ }
+ manifestPath = p
+ }
+
+ tracelog := log.FromContext(ctx).V(logger.TraceLevel)
+ return update.UpdateV2WithSetters(tracelog, manifestPath, manifestPath, policies)
+}
diff --git a/internal/policy/applier_test.go b/internal/policy/applier_test.go
new file mode 100644
index 00000000..e183c483
--- /dev/null
+++ b/internal/policy/applier_test.go
@@ -0,0 +1,173 @@
+/*
+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 policy
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ . "github.com/onsi/gomega"
+ "github.com/otiai10/copy"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+
+ imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1beta2"
+
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
+ "github.com/fluxcd/image-automation-controller/internal/testutil"
+ "github.com/fluxcd/image-automation-controller/pkg/test"
+ "github.com/fluxcd/image-automation-controller/pkg/update"
+)
+
+func testdataPath(path string) string {
+ return filepath.Join("testdata", path)
+}
+
+func Test_applyPolicies(t *testing.T) {
+ tests := []struct {
+ name string
+ updateStrategy *imagev1.UpdateStrategy
+ policyLatestImages map[string]string
+ targetPolicyName string
+ replaceMarkerFunc func(g *WithT, path string, policyKey types.NamespacedName)
+ inputPath string
+ expectedPath string
+ wantErr bool
+ wantResult update.Result
+ }{
+ {
+ name: "valid update strategy and one policy",
+ updateStrategy: &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ },
+ policyLatestImages: map[string]string{
+ "policy1": "helloworld:1.0.1",
+ },
+ targetPolicyName: "policy1",
+ inputPath: testdataPath("appconfig"),
+ expectedPath: testdataPath("appconfig-setters-expected"),
+ wantErr: false,
+ },
+ {
+ name: "no update strategy",
+ updateStrategy: nil,
+ wantErr: true,
+ },
+ {
+ name: "unknown update strategy",
+ updateStrategy: &imagev1.UpdateStrategy{
+ Strategy: "foo",
+ },
+ wantErr: true,
+ },
+ {
+ name: "valid update strategy and multiple policies",
+ updateStrategy: &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ },
+ policyLatestImages: map[string]string{
+ "policy1": "foo:1.1.1",
+ "policy2": "helloworld:1.0.1",
+ "policy3": "bar:2.2.2",
+ },
+ targetPolicyName: "policy2",
+ inputPath: testdataPath("appconfig"),
+ expectedPath: testdataPath("appconfig-setters-expected"),
+ wantErr: false,
+ },
+ {
+ name: "valid update strategy with update path",
+ updateStrategy: &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ Path: "./yes",
+ },
+ policyLatestImages: map[string]string{
+ "policy1": "helloworld:1.0.1",
+ },
+ targetPolicyName: "policy1",
+ replaceMarkerFunc: func(g *WithT, path string, policyKey types.NamespacedName) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(path, "yes", "deploy.yaml"), policyKey)).ToNot(HaveOccurred())
+ g.Expect(testutil.ReplaceMarker(filepath.Join(path, "no", "deploy.yaml"), policyKey)).ToNot(HaveOccurred())
+ },
+ inputPath: testdataPath("pathconfig"),
+ expectedPath: testdataPath("pathconfig-expected"),
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ testNS := "test-ns"
+ workDir := t.TempDir()
+
+ // Create all the policy objects.
+ policyList := []imagev1_reflect.ImagePolicy{}
+ for name, image := range tt.policyLatestImages {
+ policy := &imagev1_reflect.ImagePolicy{}
+ policy.Name = name
+ policy.Namespace = testNS
+ policy.Status = imagev1_reflect.ImagePolicyStatus{
+ LatestImage: image,
+ }
+ policyList = append(policyList, *policy)
+ }
+ targetPolicyKey := types.NamespacedName{
+ Name: tt.targetPolicyName, Namespace: testNS,
+ }
+
+ if tt.inputPath != "" {
+ g.Expect(copy.Copy(tt.inputPath, workDir)).ToNot(HaveOccurred())
+ // Update the test files with the target policy.
+ if tt.replaceMarkerFunc != nil {
+ tt.replaceMarkerFunc(g, workDir, targetPolicyKey)
+ } else {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(workDir, "deploy.yaml"), targetPolicyKey)).ToNot(HaveOccurred())
+ }
+ }
+
+ updateAuto := &imagev1.ImageUpdateAutomation{}
+ updateAuto.Name = "test-update"
+ updateAuto.Namespace = testNS
+ updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
+ Update: tt.updateStrategy,
+ }
+
+ scheme := runtime.NewScheme()
+ imagev1_reflect.AddToScheme(scheme)
+ imagev1.AddToScheme(scheme)
+
+ _, err := ApplyPolicies(context.TODO(), workDir, updateAuto, policyList)
+ g.Expect(err != nil).To(Equal(tt.wantErr))
+
+ // Check the results if there wasn't any error.
+ if !tt.wantErr {
+ expected := t.TempDir()
+ copy.Copy(tt.expectedPath, expected)
+ // Update the markers in the expected test data.
+ if tt.replaceMarkerFunc != nil {
+ tt.replaceMarkerFunc(g, expected, targetPolicyKey)
+ } else {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(expected, "deploy.yaml"), targetPolicyKey)).ToNot(HaveOccurred())
+ }
+ test.ExpectMatchingDirectories(g, workDir, expected)
+ }
+ })
+ }
+}
diff --git a/internal/controller/testdata/appconfig-expected/deploy.yaml b/internal/policy/testdata/appconfig-setters-expected/deploy.yaml
similarity index 100%
rename from internal/controller/testdata/appconfig-expected/deploy.yaml
rename to internal/policy/testdata/appconfig-setters-expected/deploy.yaml
diff --git a/internal/controller/testdata/appconfig-expected2/deploy.yaml b/internal/policy/testdata/appconfig/deploy.yaml
similarity index 73%
rename from internal/controller/testdata/appconfig-expected2/deploy.yaml
rename to internal/policy/testdata/appconfig/deploy.yaml
index 136c05a4..1ca5a035 100644
--- a/internal/controller/testdata/appconfig-expected2/deploy.yaml
+++ b/internal/policy/testdata/appconfig/deploy.yaml
@@ -7,4 +7,4 @@ spec:
spec:
containers:
- name: hello
- image: helloworld:1.2.0 # SETTER_SITE
+ image: helloworld:1.0.0 # SETTER_SITE
diff --git a/internal/policy/testdata/pathconfig-expected/no/deploy.yaml b/internal/policy/testdata/pathconfig-expected/no/deploy.yaml
new file mode 100644
index 00000000..a64a5f5b
--- /dev/null
+++ b/internal/policy/testdata/pathconfig-expected/no/deploy.yaml
@@ -0,0 +1,10 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: update-no
+spec:
+ template:
+ spec:
+ containers:
+ - name: hello
+ image: helloworld:1.0.0 # SETTER_SITE
diff --git a/internal/policy/testdata/pathconfig-expected/yes/deploy.yaml b/internal/policy/testdata/pathconfig-expected/yes/deploy.yaml
new file mode 100644
index 00000000..52b0f690
--- /dev/null
+++ b/internal/policy/testdata/pathconfig-expected/yes/deploy.yaml
@@ -0,0 +1,10 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: update-yes
+spec:
+ template:
+ spec:
+ containers:
+ - name: hello
+ image: helloworld:1.0.1 # SETTER_SITE
diff --git a/internal/policy/testdata/pathconfig/no/deploy.yaml b/internal/policy/testdata/pathconfig/no/deploy.yaml
new file mode 100644
index 00000000..a64a5f5b
--- /dev/null
+++ b/internal/policy/testdata/pathconfig/no/deploy.yaml
@@ -0,0 +1,10 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: update-no
+spec:
+ template:
+ spec:
+ containers:
+ - name: hello
+ image: helloworld:1.0.0 # SETTER_SITE
diff --git a/internal/policy/testdata/pathconfig/yes/deploy.yaml b/internal/policy/testdata/pathconfig/yes/deploy.yaml
new file mode 100644
index 00000000..bdf3b26e
--- /dev/null
+++ b/internal/policy/testdata/pathconfig/yes/deploy.yaml
@@ -0,0 +1,10 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: update-yes
+spec:
+ template:
+ spec:
+ containers:
+ - name: hello
+ image: helloworld:1.0.0 # SETTER_SITE
diff --git a/internal/source/git.go b/internal/source/git.go
new file mode 100644
index 00000000..cd8ed725
--- /dev/null
+++ b/internal/source/git.go
@@ -0,0 +1,254 @@
+/*
+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 (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/go-git/go-git/v5/plumbing/transport"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/fluxcd/pkg/git"
+ "github.com/fluxcd/pkg/git/gogit"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
+)
+
+const (
+ signingSecretKey = "git.asc"
+ signingPassphraseKey = "passphrase"
+)
+
+// gitSrcCfg contains all the Git configurations related to a source derived
+// from the given configurations and the environment.
+type gitSrcCfg struct {
+ srcKey types.NamespacedName
+ url string
+ pushBranch string
+ switchBranch bool
+ timeout *metav1.Duration
+ checkoutRef *sourcev1.GitRepositoryRef
+ authOpts *git.AuthOptions
+ clientOpts []gogit.ClientOption
+ signingEntity *openpgp.Entity
+}
+
+func buildGitConfig(ctx context.Context, c client.Client, originKey, srcKey types.NamespacedName, gitSpec *imagev1.GitSpec, opts SourceOptions) (*gitSrcCfg, error) {
+ cfg := &gitSrcCfg{
+ srcKey: srcKey,
+ }
+
+ // Get the repo.
+ repo := &sourcev1.GitRepository{}
+ if err := c.Get(ctx, srcKey, repo); err != nil {
+ if client.IgnoreNotFound(err) == nil {
+ return nil, fmt.Errorf("referenced git repository does not exist: %w", err)
+ }
+ }
+ cfg.url = repo.Spec.URL
+
+ // Configure Git operation timeout from the GitRepository configuration.
+ if repo.Spec.Timeout != nil {
+ cfg.timeout = repo.Spec.Timeout
+ } else {
+ cfg.timeout = &metav1.Duration{Duration: time.Minute}
+ }
+
+ // Get the checkout ref for the source, prioritizing the image automation
+ // object gitSpec checkout reference and falling back to the GitRepository
+ // reference if not provided.
+ // var checkoutRef *sourcev1.GitRepositoryRef
+ if gitSpec.Checkout != nil {
+ cfg.checkoutRef = &gitSpec.Checkout.Reference
+ } else if repo.Spec.Reference != nil {
+ cfg.checkoutRef = repo.Spec.Reference
+ } // else remain as `nil` and git.DefaultBranch will be used.
+
+ // Configure push first as the client options below depend on the push
+ // configuration.
+ if err := configurePush(cfg, gitSpec, cfg.checkoutRef); err != nil {
+ return nil, err
+ }
+
+ var err error
+ cfg.authOpts, err = getAuthOpts(ctx, c, repo)
+ if err != nil {
+ return nil, err
+ }
+ proxyOpts, err := getProxyOpts(ctx, c, repo)
+ if err != nil {
+ return nil, err
+ }
+ cfg.clientOpts = []gogit.ClientOption{gogit.WithDiskStorage()}
+ if cfg.authOpts.Transport == git.HTTP {
+ cfg.clientOpts = append(cfg.clientOpts, gogit.WithInsecureCredentialsOverHTTP())
+ }
+ if proxyOpts != nil {
+ cfg.clientOpts = append(cfg.clientOpts, gogit.WithProxy(*proxyOpts))
+ }
+ // If the push branch is different from the checkout ref, we need to
+ // have all the references downloaded at clone time, to ensure that
+ // SwitchBranch will have access to the target branch state. fluxcd/flux2#3384
+ //
+ // To always overwrite the push branch, the feature gate
+ // GitAllBranchReferences can be set to false, which will cause
+ // the SwitchBranch operation to ignore the remote branch state.
+ if cfg.switchBranch {
+ cfg.clientOpts = append(cfg.clientOpts, gogit.WithSingleBranch(!opts.gitAllBranchReferences))
+ }
+
+ if gitSpec.Commit.SigningKey != nil {
+ if cfg.signingEntity, err = getSigningEntity(ctx, c, originKey.Namespace, gitSpec); err != nil {
+ return nil, err
+ }
+ }
+
+ return cfg, nil
+}
+
+func configurePush(cfg *gitSrcCfg, gitSpec *imagev1.GitSpec, checkoutRef *sourcev1.GitRepositoryRef) error {
+ if gitSpec.Push != nil && gitSpec.Push.Branch != "" {
+ cfg.pushBranch = gitSpec.Push.Branch
+
+ if checkoutRef != nil {
+ if cfg.pushBranch != checkoutRef.Branch {
+ cfg.switchBranch = true
+ }
+ } else {
+ // Compare with the git default branch when no checkout ref is
+ // explicitly defined.
+ if cfg.pushBranch != git.DefaultBranch {
+ cfg.switchBranch = true
+ }
+ }
+ return nil
+ }
+
+ // If no push branch is configured above, use the branch from checkoutRef.
+
+ // Here's where it gets constrained. If there's no push branch
+ // given, then the checkout ref must include a branch, and
+ // that can be used.
+ if checkoutRef == nil || checkoutRef.Branch == "" {
+ return errors.New("push spec not provided, and cannot be inferred from .spec.git.checkout.ref or GitRepository .spec.ref")
+ }
+ cfg.pushBranch = checkoutRef.Branch
+ return nil
+}
+
+func getAuthOpts(ctx context.Context, c client.Client, repo *sourcev1.GitRepository) (*git.AuthOptions, error) {
+ var data map[string][]byte
+ var err error
+ if repo.Spec.SecretRef != nil {
+ data, err = getSecretData(ctx, c, repo.Spec.SecretRef.Name, repo.GetNamespace())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get auth secret '%s/%s': %w", repo.GetNamespace(), repo.Spec.SecretRef.Name, err)
+ }
+ }
+
+ u, err := url.Parse(repo.Spec.URL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse URL '%s': %w", repo.Spec.URL, err)
+ }
+
+ opts, err := git.NewAuthOptions(*u, data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to configure authentication options: %w", err)
+ }
+
+ return opts, nil
+}
+
+func getProxyOpts(ctx context.Context, c client.Client, repo *sourcev1.GitRepository) (*transport.ProxyOptions, error) {
+ if repo.Spec.ProxySecretRef == nil {
+ return nil, nil
+ }
+ name := repo.Spec.ProxySecretRef.Name
+ namespace := repo.GetNamespace()
+ proxyData, err := getSecretData(ctx, c, name, namespace)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get proxy secret '%s/%s': %w", namespace, name, err)
+ }
+ address, ok := proxyData["address"]
+ if !ok {
+ return nil, fmt.Errorf("invalid proxy secret '%s/%s': key 'address' is missing", namespace, name)
+ }
+
+ proxyOpts := &transport.ProxyOptions{
+ URL: string(address),
+ Username: string(proxyData["username"]),
+ Password: string(proxyData["password"]),
+ }
+ return proxyOpts, nil
+}
+
+func getSigningEntity(ctx context.Context, c client.Client, namespace string, gitSpec *imagev1.GitSpec) (*openpgp.Entity, error) {
+ secretName := gitSpec.Commit.SigningKey.SecretRef.Name
+ secretData, err := getSecretData(ctx, c, secretName, namespace)
+ if err != nil {
+ return nil, fmt.Errorf("could not find signing key secret '%s': %w", secretName, err)
+ }
+
+ data, ok := secretData[signingSecretKey]
+ if !ok {
+ return nil, fmt.Errorf("signing key secret '%s' does not contain a 'git.asc' key", secretName)
+ }
+
+ // Read entity from secret value
+ entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("could not read signing key from secret '%s': %w", secretName, err)
+ }
+ if len(entities) > 1 {
+ return nil, fmt.Errorf("multiple entities read from secret '%s', could not determine which signing key to use", secretName)
+ }
+
+ entity := entities[0]
+ if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
+ passphrase, ok := secretData[signingPassphraseKey]
+ if !ok {
+ return nil, fmt.Errorf("can not use passphrase protected signing key without '%s' field present in secret %s",
+ "passphrase", secretName)
+ }
+ if err = entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
+ return nil, fmt.Errorf("could not decrypt private key of the signing key present in secret %s: %w", secretName, err)
+ }
+ }
+ return entity, nil
+}
+
+func getSecretData(ctx context.Context, c client.Client, name, namespace string) (map[string][]byte, error) {
+ key := types.NamespacedName{
+ Namespace: namespace,
+ Name: name,
+ }
+ var secret corev1.Secret
+ if err := c.Get(ctx, key, &secret); err != nil {
+ return nil, err
+ }
+ return secret.Data, nil
+}
diff --git a/internal/source/git_test.go b/internal/source/git_test.go
new file mode 100644
index 00000000..7921d766
--- /dev/null
+++ b/internal/source/git_test.go
@@ -0,0 +1,548 @@
+/*
+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"
+ "testing"
+ "time"
+
+ "github.com/go-git/go-git/v5/plumbing/transport"
+ . "github.com/onsi/gomega"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
+ "github.com/fluxcd/image-automation-controller/internal/testutil"
+ "github.com/fluxcd/pkg/apis/meta"
+ "github.com/fluxcd/pkg/git"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+)
+
+func Test_getAuthOpts(t *testing.T) {
+ namespace := "default"
+
+ invalidAuthSecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "invalid-auth",
+ Namespace: namespace,
+ },
+ Data: map[string][]byte{
+ "password": []byte("pass"),
+ },
+ }
+
+ validAuthSecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "valid-auth",
+ Namespace: namespace,
+ },
+ Data: map[string][]byte{
+ "username": []byte("user"),
+ "password": []byte("pass"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ url string
+ secretName string
+ want *git.AuthOptions
+ wantErr bool
+ }{
+ {
+ name: "non-existing secret",
+ secretName: "non-existing",
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "invalid secret",
+ url: "https://example.com",
+ secretName: "invalid-auth",
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "valid secret",
+ url: "https://example.com",
+ secretName: "valid-auth",
+ want: &git.AuthOptions{
+ Transport: git.HTTPS,
+ Host: "example.com",
+ Username: "user",
+ Password: "pass",
+ },
+ wantErr: false,
+ },
+ {
+ name: "no secret",
+ url: "https://example.com",
+ want: &git.AuthOptions{
+ Transport: git.HTTPS,
+ Host: "example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid URL",
+ url: "://example.com",
+ want: nil,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ clientBuilder := fakeclient.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithObjects(invalidAuthSecret, validAuthSecret)
+ c := clientBuilder.Build()
+
+ gitRepo := &sourcev1.GitRepository{}
+ gitRepo.Namespace = namespace
+ gitRepo.Spec = sourcev1.GitRepositorySpec{
+ URL: tt.url,
+ }
+ if tt.secretName != "" {
+ gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: tt.secretName}
+ }
+
+ got, err := getAuthOpts(context.TODO(), c, gitRepo)
+ if (err != nil) != tt.wantErr {
+ g.Fail(fmt.Sprintf("unexpected error: %v", err))
+ return
+ }
+ g.Expect(got).To(Equal(tt.want))
+ })
+ }
+}
+
+func Test_getProxyOpts(t *testing.T) {
+ namespace := "default"
+ invalidProxy := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "invalid-proxy",
+ Namespace: namespace,
+ },
+ Data: map[string][]byte{
+ "url": []byte("https://example.com"),
+ },
+ }
+ validProxy := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "valid-proxy",
+ Namespace: namespace,
+ },
+ Data: map[string][]byte{
+ "address": []byte("https://example.com"),
+ "username": []byte("user"),
+ "password": []byte("pass"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ secretName string
+ want *transport.ProxyOptions
+ wantErr bool
+ }{
+ {
+ name: "non-existing secret",
+ secretName: "non-existing",
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "invalid proxy secret",
+ secretName: "invalid-proxy",
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "valid proxy secret",
+ secretName: "valid-proxy",
+ want: &transport.ProxyOptions{
+ URL: "https://example.com",
+ Username: "user",
+ Password: "pass",
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ clientBuilder := fakeclient.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithObjects(invalidProxy, validProxy)
+ c := clientBuilder.Build()
+
+ gitRepo := &sourcev1.GitRepository{}
+ gitRepo.Namespace = namespace
+ if tt.secretName != "" {
+ gitRepo.Spec = sourcev1.GitRepositorySpec{
+ ProxySecretRef: &meta.LocalObjectReference{Name: tt.secretName},
+ }
+ }
+
+ got, err := getProxyOpts(context.TODO(), c, gitRepo)
+ if (err != nil) != tt.wantErr {
+ g.Fail(fmt.Sprintf("unexpected error: %v", err))
+ return
+ }
+ g.Expect(got).To(Equal(tt.want))
+ })
+ }
+}
+
+func Test_getSigningEntity(t *testing.T) {
+ g := NewWithT(t)
+
+ namespace := "default"
+
+ passphrase := "abcde12345"
+ _, keyEncrypted := testutil.GetSigningKeyPair(g, passphrase)
+ encryptedKeySecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "encrypted-key",
+ Namespace: namespace,
+ },
+ Data: map[string][]byte{
+ signingSecretKey: keyEncrypted,
+ signingPassphraseKey: []byte(passphrase),
+ },
+ }
+
+ _, keyUnencrypted := testutil.GetSigningKeyPair(g, "")
+ unencryptedKeySecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "unencrypted-key",
+ Namespace: namespace,
+ },
+ Data: map[string][]byte{
+ signingSecretKey: keyUnencrypted,
+ },
+ }
+
+ tests := []struct {
+ name string
+ secretName string
+ wantErr bool
+ }{
+ {
+ name: "non-existing secret",
+ secretName: "non-existing",
+ wantErr: true,
+ },
+ {
+ name: "unencrypted key",
+ secretName: "unencrypted-key",
+ wantErr: false,
+ },
+ {
+ name: "encrypted key",
+ secretName: "encrypted-key",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ clientBuilder := fakeclient.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithObjects(encryptedKeySecret, unencryptedKeySecret)
+ c := clientBuilder.Build()
+
+ gitSpec := &imagev1.GitSpec{}
+ if tt.secretName != "" {
+ gitSpec.Commit = imagev1.CommitSpec{
+ SigningKey: &imagev1.SigningKey{
+ SecretRef: meta.LocalObjectReference{Name: tt.secretName},
+ },
+ }
+ }
+
+ _, err := getSigningEntity(context.TODO(), c, namespace, gitSpec)
+ if (err != nil) != tt.wantErr {
+ g.Fail(fmt.Sprintf("unexpected error: %v", err))
+ return
+ }
+ })
+ }
+}
+
+func Test_buildGitConfig(t *testing.T) {
+ testGitRepoName := "test-gitrepo"
+ namespace := "foo-ns"
+ testTimeout := &metav1.Duration{Duration: time.Minute}
+ testGitURL := "https://example.com"
+
+ tests := []struct {
+ name string
+ gitSpec *imagev1.GitSpec
+ gitRepoName string
+ gitRepoRef *sourcev1.GitRepositoryRef
+ gitRepoTimeout *metav1.Duration
+ gitRepoURL string
+ gitRepoProxyData map[string][]byte
+ srcOpts SourceOptions
+ wantErr bool
+ wantCheckoutRef *sourcev1.GitRepositoryRef
+ wantPushBranch string
+ wantSwitchBranch bool
+ wantTimeout *metav1.Duration
+ }{
+ {
+ name: "same branch, gitSpec checkoutRef",
+ gitSpec: &imagev1.GitSpec{
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "aaa"},
+ },
+ },
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "aaa",
+ },
+ wantPushBranch: "aaa",
+ wantSwitchBranch: false,
+ wantTimeout: testTimeout,
+ },
+ {
+ name: "different branch, gitSpec checkoutRef",
+ gitSpec: &imagev1.GitSpec{
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "aaa"},
+ },
+ Push: &imagev1.PushSpec{
+ Branch: "bbb",
+ },
+ },
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "aaa",
+ },
+ wantPushBranch: "bbb",
+ wantSwitchBranch: true,
+ wantTimeout: testTimeout,
+ },
+ {
+ name: "same branch, gitrepo checkoutRef",
+ gitSpec: &imagev1.GitSpec{},
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantPushBranch: "ccc",
+ wantSwitchBranch: false,
+ wantTimeout: testTimeout,
+ },
+ {
+ name: "different branch, gitrepo checkoutRef",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "ddd",
+ },
+ },
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantPushBranch: "ddd",
+ wantSwitchBranch: true,
+ wantTimeout: testTimeout,
+ },
+ {
+ name: "no checkoutRef defined",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "aaa",
+ },
+ },
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ wantErr: false,
+ wantCheckoutRef: nil, // Use the git default checkout branch.
+ wantPushBranch: "aaa",
+ wantSwitchBranch: true,
+ wantTimeout: testTimeout,
+ },
+ {
+ name: "gitSpec override gitRepo checkout config",
+ gitSpec: &imagev1.GitSpec{
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "aaa"},
+ },
+ Push: &imagev1.PushSpec{
+ Branch: "bbb",
+ },
+ },
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "aaa",
+ },
+ wantPushBranch: "bbb",
+ wantSwitchBranch: true,
+ wantTimeout: testTimeout,
+ },
+ {
+ name: "non-existing gitRepo",
+ gitSpec: &imagev1.GitSpec{},
+ wantErr: true,
+ },
+ {
+ name: "use gitrepo timeout",
+ gitSpec: &imagev1.GitSpec{},
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ gitRepoTimeout: &metav1.Duration{Duration: 30 * time.Second},
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantPushBranch: "ccc",
+ wantSwitchBranch: false,
+ wantTimeout: &metav1.Duration{Duration: 30 * time.Second},
+ },
+ {
+ name: "bad git URL",
+ gitSpec: &imagev1.GitSpec{},
+ gitRepoName: testGitRepoName,
+ gitRepoURL: "://example.com",
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantErr: true,
+ },
+ {
+ name: "proxy config",
+ gitSpec: &imagev1.GitSpec{},
+ gitRepoName: testGitRepoName,
+ gitRepoURL: testGitURL,
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ gitRepoProxyData: map[string][]byte{
+ "address": []byte("http://example.com"),
+ },
+ wantErr: false,
+ wantCheckoutRef: &sourcev1.GitRepositoryRef{
+ Branch: "ccc",
+ },
+ wantPushBranch: "ccc",
+ wantSwitchBranch: false,
+ wantTimeout: testTimeout,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ testObjects := []client.Object{}
+
+ var proxySecret *corev1.Secret
+ if tt.gitRepoProxyData != nil {
+ proxySecret = &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "valid-proxy",
+ Namespace: namespace,
+ },
+ Data: tt.gitRepoProxyData,
+ }
+ testObjects = append(testObjects, proxySecret)
+ }
+
+ var gitRepo *sourcev1.GitRepository
+ if tt.gitRepoName != "" {
+ gitRepo = &sourcev1.GitRepository{}
+ gitRepo.Name = testGitRepoName
+ gitRepo.Namespace = namespace
+ gitRepo.Spec = sourcev1.GitRepositorySpec{}
+ if tt.gitRepoURL != "" {
+ gitRepo.Spec.URL = tt.gitRepoURL
+ }
+ if tt.gitRepoRef != nil {
+ gitRepo.Spec.Reference = tt.gitRepoRef
+ }
+ if tt.gitRepoTimeout != nil {
+ gitRepo.Spec.Timeout = tt.gitRepoTimeout
+ }
+ if proxySecret != nil {
+ gitRepo.Spec.ProxySecretRef = &meta.LocalObjectReference{Name: proxySecret.Name}
+ }
+ testObjects = append(testObjects, gitRepo)
+ }
+
+ clientBuilder := fakeclient.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithObjects(testObjects...)
+ c := clientBuilder.Build()
+
+ gitRepoKey := types.NamespacedName{
+ Namespace: namespace,
+ Name: tt.gitRepoName,
+ }
+
+ updateAutoKey := types.NamespacedName{
+ Namespace: namespace,
+ Name: "test-update",
+ }
+
+ gitSrcCfg, err := buildGitConfig(context.TODO(), c, updateAutoKey, gitRepoKey, tt.gitSpec, tt.srcOpts)
+ if (err != nil) != tt.wantErr {
+ g.Fail(fmt.Sprintf("unexpected error: %v", err))
+ return
+ }
+ if err == nil {
+ g.Expect(gitSrcCfg.checkoutRef).To(Equal(tt.wantCheckoutRef), "unexpected checkoutRef")
+ g.Expect(gitSrcCfg.pushBranch).To(Equal(tt.wantPushBranch), "unexpected push branch")
+ g.Expect(gitSrcCfg.switchBranch).To(Equal(tt.wantSwitchBranch), "unexpected switch branch")
+ g.Expect(gitSrcCfg.timeout).To(Equal(tt.wantTimeout), "unexpected git operation timeout")
+ }
+ })
+ }
+}
diff --git a/internal/source/source.go b/internal/source/source.go
new file mode 100644
index 00000000..7e9f10d6
--- /dev/null
+++ b/internal/source/source.go
@@ -0,0 +1,404 @@
+/*
+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"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "text/template"
+ "time"
+
+ "github.com/Masterminds/sprig/v3"
+ "github.com/fluxcd/pkg/git"
+ "github.com/fluxcd/pkg/git/gogit"
+ "github.com/fluxcd/pkg/git/repository"
+ "github.com/fluxcd/pkg/runtime/acl"
+ "github.com/go-git/go-git/v5/plumbing"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/fluxcd/pkg/runtime/logger"
+ sourcev1 "github.com/fluxcd/source-controller/api/v1"
+
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
+ "github.com/fluxcd/image-automation-controller/pkg/update"
+)
+
+// ErrInvalidSourceConfiguration is an error for invalid source configuration.
+var ErrInvalidSourceConfiguration = errors.New("invalid source configuration")
+
+const defaultMessageTemplate = `Update from image update automation`
+
+// TemplateData is the type of the value given to the commit message
+// template.
+type TemplateData struct {
+ AutomationObject types.NamespacedName
+ Updated update.Result
+ Changed update.ResultV2
+}
+
+// SourceManager manages source.
+type SourceManager struct {
+ srcCfg *gitSrcCfg
+ automationObjKey types.NamespacedName
+ gitClient *gogit.Client
+ workingDir string
+}
+
+// SourceOptions contains the optional attributes of SourceManager.
+type SourceOptions struct {
+ noCrossNamespaceRef bool
+ gitAllBranchReferences bool
+}
+
+// SourceOption configures the SourceManager options.
+type SourceOption func(*SourceOptions)
+
+// WithSourceOptionNoCrossNamespaceRef configures the SourceManager to disable
+// cross namespace references.
+func WithSourceOptionNoCrossNamespaceRef() SourceOption {
+ return func(so *SourceOptions) {
+ so.noCrossNamespaceRef = true
+ }
+}
+
+// WithSourceOptionGitAllBranchReferences configures the SourceManager to fetch
+// all the Git branch references that are present in the remote repository.
+func WithSourceOptionGitAllBranchReferences() SourceOption {
+ return func(so *SourceOptions) {
+ so.gitAllBranchReferences = true
+ }
+}
+
+// NewSourceManager takes all the provided inputs, validates them and returns a
+// SourceManager which can be used to operate on the configured source.
+func NewSourceManager(ctx context.Context, c client.Client, obj *imagev1.ImageUpdateAutomation, options ...SourceOption) (*SourceManager, error) {
+ opts := &SourceOptions{}
+ for _, o := range options {
+ o(opts)
+ }
+
+ // Only GitRepository source is supported.
+ if obj.Spec.SourceRef.Kind != sourcev1.GitRepositoryKind {
+ return nil, fmt.Errorf("source kind '%s' not supported: %w", obj.Spec.SourceRef.Kind, ErrInvalidSourceConfiguration)
+ }
+
+ if obj.Spec.GitSpec == nil {
+ return nil, fmt.Errorf("source kind '%s' necessitates field .spec.git: %w", sourcev1.GitRepositoryKind, ErrInvalidSourceConfiguration)
+ }
+
+ // Build source reference configuration to fetch and validate it.
+ srcNamespace := obj.GetNamespace()
+ if obj.Spec.SourceRef.Namespace != "" {
+ srcNamespace = obj.Spec.SourceRef.Namespace
+ }
+
+ // srcKey is the GitRepository object key.
+ srcKey := types.NamespacedName{Name: obj.Spec.SourceRef.Name, Namespace: srcNamespace}
+ // originKey is the update automation object key.
+ originKey := client.ObjectKeyFromObject(obj)
+
+ // Check if the source is accessible.
+ if opts.noCrossNamespaceRef && srcKey.Namespace != obj.GetNamespace() {
+ return nil, acl.AccessDeniedError(fmt.Sprintf("can't access '%s/%s', cross-namespace references have been blocked", sourcev1.GitRepositoryKind, srcKey))
+ }
+
+ gitSrcCfg, err := buildGitConfig(ctx, c, originKey, srcKey, obj.Spec.GitSpec, *opts)
+ if err != nil {
+ return nil, err
+ }
+
+ workDir, err := os.MkdirTemp("", fmt.Sprintf("%s-%s", gitSrcCfg.srcKey.Namespace, gitSrcCfg.srcKey.Name))
+ if err != nil {
+ return nil, err
+ }
+
+ sm := &SourceManager{
+ srcCfg: gitSrcCfg,
+ automationObjKey: originKey,
+ workingDir: workDir,
+ }
+ return sm, nil
+}
+
+// CreateWorkingDirectory creates a working directory for the SourceManager.
+func (sm SourceManager) WorkDirectory() string {
+ return sm.workingDir
+}
+
+// Cleanup deletes the working directory of the SourceManager.
+func (sm SourceManager) Cleanup() error {
+ return os.RemoveAll(sm.workingDir)
+}
+
+// SwitchBranch returns if the checkout branch and push branch are different.
+func (sm SourceManager) SwitchBranch() bool {
+ return sm.srcCfg.switchBranch
+}
+
+// CheckoutOption allows configuring the checkout options.
+type CheckoutOption func(*repository.CloneConfig)
+
+// WithCheckoutOptionLastObserved is a CheckoutOption option to configure the
+// last observed commit.
+func WithCheckoutOptionLastObserved(commit string) CheckoutOption {
+ return func(cc *repository.CloneConfig) {
+ cc.LastObservedCommit = commit
+ }
+}
+
+// WithCheckoutOptionShallowClone is a CheckoutOption option to configure
+// shallow clone.
+func WithCheckoutOptionShallowClone() CheckoutOption {
+ return func(cc *repository.CloneConfig) {
+ cc.ShallowClone = true
+ }
+}
+
+// CheckoutSource clones and checks out the source. If a push branch is
+// configured that doesn't match with the checkout branch, a checkout to the
+// push branch is also performed. This ensures any change and push operation
+// following the checkout happens on the push branch.
+func (sm *SourceManager) CheckoutSource(ctx context.Context, options ...CheckoutOption) (*git.Commit, error) {
+ // Configuration clone options.
+ cloneCfg := repository.CloneConfig{}
+ if sm.srcCfg.checkoutRef != nil {
+ cloneCfg.Tag = sm.srcCfg.checkoutRef.Tag
+ cloneCfg.SemVer = sm.srcCfg.checkoutRef.SemVer
+ cloneCfg.Commit = sm.srcCfg.checkoutRef.Commit
+ cloneCfg.Branch = sm.srcCfg.checkoutRef.Branch
+ }
+ // Apply checkout configurations.
+ for _, o := range options {
+ o(&cloneCfg)
+ }
+
+ var err error
+ sm.gitClient, err = gogit.NewClient(sm.workingDir, sm.srcCfg.authOpts, sm.srcCfg.clientOpts...)
+ if err != nil {
+ return nil, err
+ }
+
+ gitOpCtx, cancel := context.WithTimeout(ctx, sm.srcCfg.timeout.Duration)
+ defer cancel()
+ commit, err := sm.gitClient.Clone(gitOpCtx, sm.srcCfg.url, cloneCfg)
+ if err != nil {
+ return nil, err
+ }
+ if sm.srcCfg.switchBranch {
+ if err := sm.gitClient.SwitchBranch(gitOpCtx, sm.srcCfg.pushBranch); err != nil {
+ return nil, err
+ }
+ }
+ return commit, nil
+}
+
+// PushConfig configures the options used in push operation.
+type PushConfig func(*repository.PushConfig)
+
+// WithPushConfigForce configures the PushConfig to use force.
+func WithPushConfigForce() PushConfig {
+ return func(pc *repository.PushConfig) {
+ pc.Force = true
+ }
+}
+
+// WithPushConfigOptions configures the PushConfig Options that are used in
+// push.
+func WithPushConfigOptions(opts map[string]string) PushConfig {
+ return func(pc *repository.PushConfig) {
+ pc.Options = opts
+ }
+}
+
+// CommitAndPush performs a commit in the source and pushes it to the remote
+// repository.
+func (sm SourceManager) CommitAndPush(ctx context.Context, obj *imagev1.ImageUpdateAutomation, policyResult update.ResultV2, pushOptions ...PushConfig) (*PushResult, error) {
+ tracelog := log.FromContext(ctx).V(logger.TraceLevel)
+
+ // Make sure there were file changes that need to be committed.
+ if len(policyResult.FileChanges) == 0 {
+ return nil, nil
+ }
+
+ // Perform a Git commit.
+ templateValues := &TemplateData{
+ AutomationObject: sm.automationObjKey,
+ Updated: policyResult.ImageResult,
+ Changed: policyResult,
+ }
+ commitMsg, err := templateMsg(obj.Spec.GitSpec.Commit.MessageTemplate, templateValues)
+ if err != nil {
+ return nil, err
+ }
+ signature := git.Signature{
+ Name: obj.Spec.GitSpec.Commit.Author.Name,
+ Email: obj.Spec.GitSpec.Commit.Author.Email,
+ When: time.Now(),
+ }
+
+ var rev string
+ var commitErr error
+ rev, commitErr = sm.gitClient.Commit(
+ git.Commit{
+ Author: signature,
+ Message: commitMsg,
+ },
+ repository.WithSigner(sm.srcCfg.signingEntity),
+ )
+
+ if commitErr != nil {
+ if !errors.Is(commitErr, git.ErrNoStagedFiles) {
+ return nil, commitErr
+ }
+ log.FromContext(ctx).Info("no changes made in the source; no commit")
+ return nil, nil
+ }
+
+ // Push the commit to push branch.
+ gitOpCtx, cancel := context.WithTimeout(ctx, sm.srcCfg.timeout.Duration)
+ defer cancel()
+ pushConfig := repository.PushConfig{}
+ for _, po := range pushOptions {
+ po(&pushConfig)
+ }
+ if err := sm.gitClient.Push(gitOpCtx, pushConfig); err != nil {
+ return nil, err
+ }
+ tracelog.Info("pushed commit to push branch", "revision", rev, "branch", sm.srcCfg.pushBranch)
+
+ // Push to any provided refspec.
+ if obj.Spec.GitSpec.HasRefspec() {
+ pushConfig.Refspecs = append(pushConfig.Refspecs, obj.Spec.GitSpec.Push.Refspec)
+ if err := sm.gitClient.Push(gitOpCtx, pushConfig); err != nil {
+ return nil, err
+ }
+ tracelog.Info("pushed commit to refspec", "revision", rev, "refspecs", pushConfig.Refspecs)
+ }
+
+ // Construct the result of the push operation and return.
+ prOpts := []PushResultOption{WithPushResultRefspec(pushConfig.Refspecs)}
+ if sm.srcCfg.switchBranch {
+ prOpts = append(prOpts, WithPushResultSwitchBranch())
+ }
+ return NewPushResult(sm.srcCfg.pushBranch, rev, commitMsg, prOpts...)
+}
+
+// templateMsg renders a msg template, returning the message or an error.
+func templateMsg(messageTemplate string, templateValues *TemplateData) (string, error) {
+ if messageTemplate == "" {
+ messageTemplate = defaultMessageTemplate
+ }
+
+ // Includes only functions that are guaranteed to always evaluate to the same result for given input.
+ // This removes the possibility of accidentally relying on where or when the template runs.
+ // https://github.com/Masterminds/sprig/blob/3ac42c7bc5e4be6aa534e036fb19dde4a996da2e/functions.go#L70
+ t, err := template.New("commit message").Funcs(sprig.HermeticTxtFuncMap()).Parse(messageTemplate)
+ if err != nil {
+ return "", fmt.Errorf("unable to create commit message template from spec: %w", err)
+ }
+
+ b := &strings.Builder{}
+ if err := t.Execute(b, *templateValues); err != nil {
+ return "", fmt.Errorf("failed to run template from spec: %w", err)
+ }
+ return b.String(), nil
+}
+
+// PushResultOption allows configuring the options of PushResult.
+type PushResultOption func(*PushResult)
+
+// WithPushResultSwitchBranch marks the PushResult with switchBranch.
+func WithPushResultSwitchBranch() func(*PushResult) {
+ return func(pr *PushResult) {
+ pr.switchBranch = true
+ }
+}
+
+// WithPushResultRefspec sets the refspecs in the PushResult.
+func WithPushResultRefspec(refspecs []string) func(*PushResult) {
+ return func(pr *PushResult) {
+ pr.refspecs = append(pr.refspecs, refspecs...)
+ }
+}
+
+// PushResult is the result of a push operation.
+type PushResult struct {
+ commit *git.Commit
+ switchBranch bool
+ branch string
+ refspecs []string
+ creationTime *metav1.Time
+}
+
+// NewPushResult returns a new PushResult.
+func NewPushResult(branch string, rev string, commitMsg string, opts ...PushResultOption) (*PushResult, error) {
+ if rev == "" {
+ return nil, errors.New("empty push commit revision")
+ }
+
+ pr := &PushResult{}
+ for _, o := range opts {
+ o(pr)
+ }
+ pr.commit = &git.Commit{
+ Hash: git.ExtractHashFromRevision(rev),
+ Reference: plumbing.NewBranchReferenceName(branch).String(),
+ Message: commitMsg,
+ }
+ pr.branch = branch
+ pr.creationTime = &metav1.Time{Time: time.Now()}
+
+ return pr, nil
+}
+
+// Commit returns the revision of the pushed commit.
+func (pr PushResult) Commit() *git.Commit {
+ return pr.commit
+}
+
+// Time returns the time at which the push was performed.
+func (pr PushResult) Time() *metav1.Time {
+ return pr.creationTime
+}
+
+// SwitchBranch returns if the source has different checkout and push branch.
+func (pr PushResult) SwitchBranch() bool {
+ return pr.switchBranch
+}
+
+// Summary returns a summary of the PushResult.
+func (pr PushResult) Summary() string {
+ var summary strings.Builder
+ shortCommitHash := pr.Commit().Hash.String()
+ if len(shortCommitHash) > 7 {
+ shortCommitHash = shortCommitHash[:7]
+ }
+ summary.WriteString(fmt.Sprintf("pushed commit '%s' to branch '%s'", shortCommitHash, pr.branch))
+ if len(pr.refspecs) > 0 {
+ summary.WriteString(fmt.Sprintf(" and refspecs '%s'", strings.Join(pr.refspecs, "', '")))
+ }
+ if pr.Commit().Message != "" {
+ summary.WriteString(fmt.Sprintf("\n%s", pr.Commit().Message))
+ }
+ return summary.String()
+}
diff --git a/internal/source/source_test.go b/internal/source/source_test.go
new file mode 100644
index 00000000..e99ff96c
--- /dev/null
+++ b/internal/source/source_test.go
@@ -0,0 +1,1135 @@
+/*
+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 -}}
+`
+)
+
+func init() {
+ utilruntime.Must(imagev1_reflect.AddToScheme(scheme.Scheme))
+ utilruntime.Must(sourcev1.AddToScheme(scheme.Scheme))
+ utilruntime.Must(imagev1.AddToScheme(scheme.Scheme))
+
+ log.SetLogger(logr.New(log.NullLogSink{}))
+}
+
+func Fuzz_templateMsg(f *testing.F) {
+ f.Add("template", []byte{})
+ f.Add("", []byte{})
+
+ f.Fuzz(func(t *testing.T, template string, seed []byte) {
+ var values TemplateData
+ fuzz.NewConsumer(seed).GenerateStruct(&values)
+
+ _, _ = templateMsg(template, &values)
+ })
+}
+
+func TestNewSourceManager(t *testing.T) {
+ namespace := "test-ns"
+ gitRepoName := "foo"
+
+ tests := []struct {
+ name string
+ objSpec imagev1.ImageUpdateAutomationSpec
+ opts []SourceOption
+ sourceNamespace string
+ wantErr bool
+ }{
+ {
+ name: "unsupported source ref kind",
+ objSpec: imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: "HelmChart",
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty gitSpec",
+ objSpec: imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ },
+ GitSpec: nil,
+ },
+ wantErr: true,
+ },
+ {
+ name: "refer cross namespace source",
+ objSpec: imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ Name: gitRepoName,
+ Namespace: "foo-ns",
+ },
+ GitSpec: &imagev1.GitSpec{},
+ },
+ sourceNamespace: "foo-ns",
+ },
+ {
+ name: "refer cross namespace source with crossnamespace disabled",
+ objSpec: imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ Name: gitRepoName,
+ Namespace: "foo-ns",
+ },
+ GitSpec: &imagev1.GitSpec{},
+ },
+ sourceNamespace: "foo-ns",
+ opts: []SourceOption{WithSourceOptionNoCrossNamespaceRef()},
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ gitRepo := &sourcev1.GitRepository{}
+ gitRepo.Name = gitRepoName
+ gitRepo.Namespace = tt.sourceNamespace
+ gitRepo.Spec = sourcev1.GitRepositorySpec{
+ URL: "https://example.com",
+ Reference: &sourcev1.GitRepositoryRef{Branch: "main"},
+ }
+
+ clientBuilder := fakeclient.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithObjects(gitRepo)
+ c := clientBuilder.Build()
+
+ obj := &imagev1.ImageUpdateAutomation{}
+ obj.Name = "test-update"
+ obj.Namespace = namespace
+ obj.Spec = tt.objSpec
+
+ sm, err := NewSourceManager(context.TODO(), c, obj, tt.opts...)
+ if (err != nil) != tt.wantErr {
+ g.Fail(fmt.Sprintf("unexpected error: %v", err))
+ return
+ }
+ if err == nil {
+ g.Expect(os.RemoveAll(sm.WorkDirectory()))
+ }
+ })
+ }
+}
+
+func TestSourceManager_CheckoutSource(t *testing.T) {
+ test_sourceManager_CheckoutSource(t, "http")
+ test_sourceManager_CheckoutSource(t, "ssh")
+}
+
+func test_sourceManager_CheckoutSource(t *testing.T, proto string) {
+ tests := []struct {
+ name string
+ autoGitSpec *imagev1.GitSpec
+ gitRepoRef *sourcev1.GitRepositoryRef
+ shallowClone bool
+ lastObserved bool
+ wantErr bool
+ wantRef string
+ }{
+ {
+ name: "checkout for single branch",
+ autoGitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{Branch: "main"},
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "main"},
+ },
+ },
+ wantErr: false,
+ wantRef: "main",
+ },
+ {
+ name: "checkout for different push branch",
+ autoGitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{Branch: "foo"},
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "main"},
+ },
+ },
+ wantErr: false,
+ wantRef: "foo",
+ },
+ {
+ name: "checkout from gitrepo ref",
+ autoGitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{Branch: "main"},
+ },
+ gitRepoRef: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ wantErr: false,
+ wantRef: "main",
+ },
+ {
+ name: "with shallow clone",
+ autoGitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{Branch: "main"},
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "main"},
+ },
+ },
+ shallowClone: true,
+ wantErr: false,
+ wantRef: "main",
+ },
+ {
+ name: "with last observed commit",
+ autoGitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{Branch: "main"},
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "main"},
+ },
+ },
+ lastObserved: true,
+ wantErr: false,
+ wantRef: "main",
+ },
+ {
+ name: "checkout non-existing branch",
+ autoGitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{Branch: "main"},
+ Checkout: &imagev1.GitCheckoutSpec{
+ Reference: sourcev1.GitRepositoryRef{Branch: "non-existing"},
+ },
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("%s(%s)", tt.name, proto), func(t *testing.T) {
+ g := NewWithT(t)
+ ctx := context.TODO()
+ testObjects := []client.Object{}
+ testNS := "test-ns"
+
+ // Run git server.
+ gitServer := testutil.SetUpGitTestServer(g)
+ t.Cleanup(func() {
+ g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred())
+ gitServer.StopHTTP()
+ })
+
+ // Start the ssh server if needed.
+ if proto == "ssh" {
+ go func() {
+ gitServer.StartSSH()
+ }()
+ defer func() {
+ g.Expect(gitServer.StopSSH()).To(Succeed())
+ }()
+ }
+
+ // Create a git repo on the server.
+ fixture := "testdata/appconfig"
+ branch := rand.String(5)
+ repoPath := "/config-" + rand.String(5) + ".git"
+ initRepo := testutil.InitGitRepo(g, gitServer, fixture, branch, repoPath)
+ // Obtain the head revision reference.
+ initHead, err := initRepo.Head()
+ g.Expect(err).ToNot(HaveOccurred())
+ headRev := fmt.Sprintf("%s@sha1:%s", initHead.Name().Short(), initHead.Hash().String())
+
+ repoURL, err := getRepoURL(gitServer, repoPath, proto)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Create GitRepository for the above git repository.
+ gitRepo := &sourcev1.GitRepository{}
+ gitRepo.Name = "test-repo"
+ gitRepo.Namespace = testNS
+ gitRepo.Spec = sourcev1.GitRepositorySpec{
+ URL: repoURL,
+ }
+ if tt.gitRepoRef != nil {
+ gitRepo.Spec.Reference = tt.gitRepoRef
+ }
+ // Create ssh Secret for the GitRepository.
+ if proto == "ssh" {
+ sshSecretName := "ssh-key-" + rand.String(5)
+ sshSecret, err := getSSHIdentitySecret(sshSecretName, testNS, repoURL)
+ g.Expect(err).ToNot(HaveOccurred())
+ testObjects = append(testObjects, sshSecret)
+
+ gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: sshSecretName}
+ }
+ testObjects = append(testObjects, gitRepo)
+
+ // Create an ImageUpdateAutomation to checkout the above git
+ // repository.
+ updateAuto := &imagev1.ImageUpdateAutomation{}
+ updateAuto.Name = "test-update"
+ updateAuto.Namespace = testNS
+ updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
+ GitSpec: tt.autoGitSpec,
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ Name: gitRepo.Name,
+ },
+ }
+ testObjects = append(testObjects, updateAuto)
+
+ kClient := fakeclient.NewClientBuilder().
+ WithScheme(scheme.Scheme).
+ WithObjects(testObjects...).
+ Build()
+
+ sm, err := NewSourceManager(ctx, kClient, updateAuto, WithSourceOptionGitAllBranchReferences())
+ g.Expect(err).ToNot(HaveOccurred())
+
+ defer func() {
+ g.Expect(sm.Cleanup()).ToNot(HaveOccurred())
+ }()
+
+ opts := []CheckoutOption{}
+ if tt.shallowClone {
+ opts = append(opts, WithCheckoutOptionShallowClone())
+ }
+ if tt.lastObserved {
+ opts = append(opts, WithCheckoutOptionLastObserved(headRev))
+ }
+ commit, err := sm.CheckoutSource(ctx, opts...)
+ if (err != nil) != tt.wantErr {
+ g.Fail("unexpected error")
+ return
+ }
+ if err == nil {
+ if tt.lastObserved {
+ g.Expect(git.IsConcreteCommit(*commit)).To(BeFalse())
+ // Didn't download anything, can't check anything.
+ } else {
+ g.Expect(git.IsConcreteCommit(*commit)).To(BeTrue())
+ // Inspect the cloned repository.
+ r, err := extgogit.PlainOpen(sm.workingDir)
+ g.Expect(err).ToNot(HaveOccurred())
+ ref, err := r.Head()
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(ref.Name().Short()).To(Equal(tt.wantRef))
+ }
+ }
+ })
+ }
+}
+
+func TestSourceManager_CommitAndPush(t *testing.T) {
+ test_sourceManager_CommitAndPush(t, "http")
+ test_sourceManager_CommitAndPush(t, "ssh")
+}
+
+func test_sourceManager_CommitAndPush(t *testing.T, proto string) {
+ tests := []struct {
+ name string
+ gitSpec *imagev1.GitSpec
+ gitRepoReference *sourcev1.GitRepositoryRef
+ latestImage string
+ noChange bool
+ wantErr bool
+ wantCommitMsg string
+ checkRefSpecBranch string
+ }{
+ {
+ name: "push to cloned branch with custom template",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main",
+ },
+ Commit: imagev1.CommitSpec{
+ MessageTemplate: testCommitTemplate,
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: `Commit summary
+
+Automation: test-ns/test-update
+
+Files:
+- deploy.yaml
+Objects:
+- deployment test
+Images:
+- helloworld:1.0.1 (policy1)
+`,
+ },
+ {
+ name: "commit with update ResultV2 template",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main",
+ },
+ Commit: imagev1.CommitSpec{
+ MessageTemplate: testCommitTemplateResultV2,
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: `Commit summary with ResultV2
+
+Automation: test-ns/test-update
+
+- File: deploy.yaml
+ - Object: Deployment//test
+ Changes:
+ - helloworld:1.0.0 -> helloworld:1.0.1
+`,
+ },
+ {
+ name: "push to different branch",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main2",
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: defaultMessageTemplate,
+ },
+ {
+ name: "push to cloned branch+refspec",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main",
+ Refspec: "refs/heads/main:refs/heads/smth/else",
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: defaultMessageTemplate,
+ checkRefSpecBranch: "smth/else",
+ },
+ {
+ name: "push to different branch+refspec",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "auto",
+ Refspec: "refs/heads/auto:refs/heads/smth/else",
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: defaultMessageTemplate,
+ checkRefSpecBranch: "smth/else",
+ },
+ {
+ name: "push to branch from tag",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main2",
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Tag: "v1.0.0",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: defaultMessageTemplate,
+ },
+ {
+ name: "push signed commit to branch",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main",
+ },
+ Commit: imagev1.CommitSpec{
+ Author: imagev1.CommitUser{
+ Name: "Flux B Ot",
+ Email: "fluxbot@example.com",
+ },
+ SigningKey: &imagev1.SigningKey{
+ SecretRef: meta.LocalObjectReference{
+ Name: "test-signing-key",
+ },
+ },
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.1",
+ wantErr: false,
+ wantCommitMsg: defaultMessageTemplate,
+ },
+ {
+ name: "no change to push",
+ gitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: "main",
+ },
+ },
+ gitRepoReference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ latestImage: "helloworld:1.0.0",
+ noChange: true,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("%s(%s)", tt.name, proto), func(t *testing.T) {
+ g := NewWithT(t)
+ ctx := context.TODO()
+ testObjects := []client.Object{}
+
+ // Run git server.
+ gitServer := testutil.SetUpGitTestServer(g)
+ t.Cleanup(func() {
+ g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred())
+ gitServer.StopHTTP()
+ })
+
+ // Start the ssh server if needed.
+ if proto == "ssh" {
+ go func() {
+ gitServer.StartSSH()
+ }()
+ defer func() {
+ g.Expect(gitServer.StopSSH()).To(Succeed())
+ }()
+ }
+
+ // Prepare test directory.
+ workDir := t.TempDir()
+ testNS := "test-ns"
+
+ imgPolicy := &imagev1_reflect.ImagePolicy{}
+ imgPolicy.Name = "policy1"
+ imgPolicy.Namespace = testNS
+ imgPolicy.Status = imagev1_reflect.ImagePolicyStatus{
+ LatestImage: tt.latestImage,
+ }
+ testObjects = append(testObjects, imgPolicy)
+ policyKey := client.ObjectKeyFromObject(imgPolicy)
+
+ fixture := "testdata/appconfig"
+ g.Expect(copy.Copy(fixture, workDir)).ToNot(HaveOccurred())
+ // Update the setters in the test data.
+ g.Expect(testutil.ReplaceMarker(filepath.Join(workDir, "deploy.yaml"), policyKey))
+
+ // Create a git repo with the test directory content.
+ branch := "main"
+ repoPath := "/config-" + rand.String(5) + ".git"
+ repo := testutil.InitGitRepo(g, gitServer, workDir, branch, repoPath)
+
+ // Create a tag.
+ if tt.gitRepoReference.Tag != "" {
+ h, err := repo.Head()
+ g.Expect(err).ToNot(HaveOccurred())
+ testutil.TagCommit(g, repo, h.Hash(), false, tt.gitRepoReference.Tag, time.Now())
+ }
+
+ cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repoPath
+
+ repoURL, err := getRepoURL(gitServer, repoPath, proto)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Create GitRepository for the above git repository.
+ gitRepo := &sourcev1.GitRepository{}
+ gitRepo.Name = "test-repo"
+ gitRepo.Namespace = testNS
+ gitRepo.Spec = sourcev1.GitRepositorySpec{
+ URL: repoURL,
+ }
+ if tt.gitRepoReference != nil {
+ gitRepo.Spec.Reference = tt.gitRepoReference
+ }
+ // Create ssh Secret for the GitRepository.
+ if proto == "ssh" {
+ sshSecretName := "ssh-key-" + rand.String(5)
+ sshSecret, err := getSSHIdentitySecret(sshSecretName, testNS, repoURL)
+ g.Expect(err).ToNot(HaveOccurred())
+ testObjects = append(testObjects, sshSecret)
+
+ gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: sshSecretName}
+ }
+ testObjects = append(testObjects, gitRepo)
+
+ // Create an ImageUpdateAutomation to update the above git repository.
+ updateAuto := &imagev1.ImageUpdateAutomation{}
+ updateAuto.Name = "test-update"
+ updateAuto.Namespace = testNS
+ updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ Name: gitRepo.Name,
+ },
+ Update: &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ },
+ }
+ testObjects = append(testObjects, updateAuto)
+
+ var pgpEntity *openpgp.Entity
+ var signingSecret *corev1.Secret
+ if tt.gitSpec != nil {
+ updateAuto.Spec.GitSpec = tt.gitSpec
+
+ if tt.gitSpec.Commit.SigningKey != nil {
+ signingSecret, pgpEntity = testutil.GetSigningKeyPairSecret(g, tt.gitSpec.Commit.SigningKey.SecretRef.Name, testNS)
+ testObjects = append(testObjects, signingSecret)
+ }
+ }
+
+ kClient := fakeclient.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(testObjects...).Build()
+
+ sm, err := NewSourceManager(ctx, kClient, updateAuto, WithSourceOptionGitAllBranchReferences())
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() {
+ g.Expect(sm.Cleanup()).ToNot(HaveOccurred())
+ }()
+
+ _, err = sm.CheckoutSource(ctx)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ policies := []imagev1_reflect.ImagePolicy{*imgPolicy}
+ result, err := policy.ApplyPolicies(ctx, sm.workingDir, updateAuto, policies)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ pushResult, err := sm.CommitAndPush(ctx, updateAuto, result)
+ g.Expect(err != nil).To(Equal(tt.wantErr))
+ if tt.noChange {
+ g.Expect(pushResult).To(BeNil())
+ return
+ }
+
+ // Inspect the pushed commit in the repository.
+ localRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, sm.srcCfg.pushBranch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { os.RemoveAll(cloneDir) }()
+
+ head, _ := localRepo.Head()
+ pushBranchHash := head.Hash()
+ commit, err := localRepo.CommitObject(pushBranchHash)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(commit.Hash.String()).To(Equal(pushResult.Commit().Hash.String()))
+ g.Expect(commit.Message).To(Equal(tt.wantCommitMsg))
+ // Verify commit signature.
+ if pgpEntity != nil {
+ // Separate the commit signature and content, and verify with
+ // the known PGP Entity.
+ c := *commit
+ c.PGPSignature = ""
+ encoded := &plumbing.MemoryObject{}
+ g.Expect(c.Encode(encoded)).ToNot(HaveOccurred())
+ content, err := encoded.Reader()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ kr := openpgp.EntityList([]*openpgp.Entity{pgpEntity})
+ signature := strings.NewReader(commit.PGPSignature)
+
+ _, err = openpgp.CheckArmoredDetachedSignature(kr, content, signature, nil)
+ g.Expect(err).ToNot(HaveOccurred())
+ }
+
+ // Clone the repo at refspec and verify its commit.
+ if tt.gitSpec.Push.Refspec != "" {
+ refLocalRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, tt.checkRefSpecBranch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { os.RemoveAll(cloneDir) }()
+ refName := plumbing.NewRemoteReferenceName(extgogit.DefaultRemoteName, tt.checkRefSpecBranch)
+ ref, err := refLocalRepo.Reference(refName, true)
+ g.Expect(err).ToNot(HaveOccurred())
+ refspecHash := ref.Hash()
+ g.Expect(pushBranchHash).To(Equal(refspecHash))
+ }
+ })
+ }
+}
+
+// Test_pushBranchUpdateScenarios tests the push operation for different states
+// of the remote repository.
+func Test_pushBranchUpdateScenarios(t *testing.T) {
+ // This test requires all branch references to be enabled.
+ sourceOpts := []SourceOption{WithSourceOptionGitAllBranchReferences()}
+
+ testcases := []struct {
+ name string
+ checkoutOpts []CheckoutOption
+ pushConfig []PushConfig
+ }{
+ {
+ name: "default checkout and push configs",
+ },
+ {
+ name: "shallow clone and force push",
+ checkoutOpts: []CheckoutOption{
+ WithCheckoutOptionShallowClone(),
+ },
+ pushConfig: []PushConfig{
+ WithPushConfigForce(),
+ },
+ },
+ }
+
+ for _, tt := range testcases {
+ for _, proto := range []string{"http", "ssh"} {
+ t.Run(fmt.Sprintf("%s(%s)", tt.name, proto), func(t *testing.T) {
+ test_pushBranchUpdateScenarios(t, proto, sourceOpts, tt.checkoutOpts, tt.pushConfig)
+ })
+ }
+ }
+}
+
+func test_pushBranchUpdateScenarios(t *testing.T, proto string, srcOpts []SourceOption, checkoutOpts []CheckoutOption, pushCfg []PushConfig) {
+ g := NewWithT(t)
+ ctx := context.TODO()
+ testObjects := []client.Object{}
+
+ // Run git server.
+ gitServer := testutil.SetUpGitTestServer(g)
+ t.Cleanup(func() {
+ g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred())
+ gitServer.StopHTTP()
+ })
+
+ // Start the ssh server if needed.
+ if proto == "ssh" {
+ go func() {
+ gitServer.StartSSH()
+ }()
+ defer func() {
+ g.Expect(gitServer.StopSSH()).To(Succeed())
+ }()
+ }
+
+ // Prepare test directory.
+ workDir := t.TempDir()
+ testNS := "test-ns"
+ fixture := "testdata/appconfig"
+ g.Expect(copy.Copy(fixture, workDir)).ToNot(HaveOccurred())
+
+ // Create a git repo with the test directory content.
+ branch := "main"
+ repoPath := "/config-" + rand.String(5) + ".git"
+ _ = testutil.InitGitRepo(g, gitServer, workDir, branch, repoPath)
+ pushBranch := "pr-" + rand.String(5)
+
+ cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repoPath
+
+ repoURL, err := getRepoURL(gitServer, repoPath, proto)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Clone the repo locally.
+ localRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, branch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { os.RemoveAll(cloneDir) }()
+
+ // Create ImagePolicy, GitRepository and ImageUpdateAutomation objects.
+ latestImage := "helloworld:1.0.1"
+
+ imgPolicy := &imagev1_reflect.ImagePolicy{}
+ imgPolicy.Name = "policy1"
+ imgPolicy.Namespace = testNS
+ imgPolicy.Status = imagev1_reflect.ImagePolicyStatus{
+ LatestImage: latestImage,
+ }
+ testObjects = append(testObjects, imgPolicy)
+ // Take the policyKey to update the setter marker with.
+ policyKey := client.ObjectKeyFromObject(imgPolicy)
+
+ gitRepo := &sourcev1.GitRepository{}
+ gitRepo.Name = "test-repo"
+ gitRepo.Namespace = testNS
+ gitRepo.Spec = sourcev1.GitRepositorySpec{
+ URL: repoURL,
+ // Set a reference to main branch explicitly. If unspecified, it'll
+ // default to "master". The test repo above is set up against "main"
+ // branch.
+ Reference: &sourcev1.GitRepositoryRef{
+ Branch: "main",
+ },
+ }
+ // Create ssh Secret for the GitRepository.
+ if proto == "ssh" {
+ sshSecretName := "ssh-key-" + rand.String(5)
+ sshSecret, err := getSSHIdentitySecret(sshSecretName, testNS, repoURL)
+ g.Expect(err).ToNot(HaveOccurred())
+ testObjects = append(testObjects, sshSecret)
+
+ gitRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: sshSecretName}
+ }
+ testObjects = append(testObjects, gitRepo)
+
+ commitTemplate := "Commit a difference " + rand.String(5)
+
+ updateAuto := &imagev1.ImageUpdateAutomation{}
+ updateAuto.Name = "test-update"
+ updateAuto.Namespace = testNS
+ updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{
+ SourceRef: imagev1.CrossNamespaceSourceReference{
+ Kind: sourcev1.GitRepositoryKind,
+ Name: gitRepo.Name,
+ },
+ Update: &imagev1.UpdateStrategy{
+ Strategy: imagev1.UpdateStrategySetters,
+ },
+ GitSpec: &imagev1.GitSpec{
+ Push: &imagev1.PushSpec{
+ Branch: pushBranch,
+ },
+ Commit: imagev1.CommitSpec{
+ MessageTemplate: commitTemplate,
+ },
+ },
+ }
+ testObjects = append(testObjects, updateAuto)
+
+ kClient := fakeclient.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(testObjects...).Build()
+
+ // Commit in the repository, updating the source with setter markers.
+ preChangeCommitId := testutil.CommitIdFromBranch(localRepo, branch)
+ testutil.CommitInRepo(ctx, g, cloneLocalRepoURL, branch, originRemote, "Install setter marker", func(tmp string) {
+ g.Expect(testutil.ReplaceMarker(filepath.Join(tmp, "deploy.yaml"), policyKey)).To(Succeed())
+ })
+ // Pull the pushed changes in the local repo.
+ testutil.WaitForNewHead(g, localRepo, branch, originRemote, preChangeCommitId)
+
+ // ======= Scenario 1 =======
+ // Push to a separate push branch.
+
+ checkoutBranchHead, err := testutil.HeadFromBranch(localRepo, branch)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ policies := []imagev1_reflect.ImagePolicy{*imgPolicy}
+ checkoutAndUpdate(ctx, g, kClient, updateAuto, policies, srcOpts, checkoutOpts, pushCfg)
+
+ // Pull the new changes to the local repo.
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
+ testutil.WaitForNewHead(g, localRepo, pushBranch, originRemote, preChangeCommitId)
+
+ // Check the commits in the branches.
+ pushBranchHead, err := testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
+ g.Expect(err).NotTo(HaveOccurred())
+ commit, err := localRepo.CommitObject(pushBranchHead)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(commit.Message).To(Equal(commitTemplate))
+
+ // previous commits should still exist in the tree.
+ // regression check to ensure previous commits were not squashed.
+ oldCommit, err := localRepo.CommitObject(checkoutBranchHead.Hash)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(oldCommit).ToNot(BeNil())
+
+ // ======= Scenario 2 =======
+ // Push branch gets updated.
+
+ checkoutBranchHead, err = testutil.HeadFromBranch(localRepo, branch)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Get the head of push branch before update.
+ pushBranchHead, err = testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Update latest image.
+ latestImage = "helloworld:v1.3.0"
+ imgPolicy.Status.LatestImage = latestImage
+ g.Expect(kClient.Update(ctx, imgPolicy)).To(Succeed())
+
+ policies = []imagev1_reflect.ImagePolicy{*imgPolicy}
+ checkoutAndUpdate(ctx, g, kClient, updateAuto, policies, srcOpts, checkoutOpts, pushCfg)
+
+ // Pull the new changes to the local repo.
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
+ testutil.WaitForNewHead(g, localRepo, pushBranch, originRemote, preChangeCommitId)
+
+ newPushBranchHead, err := testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(newPushBranchHead.String()).NotTo(Equal(pushBranchHead))
+
+ // previous commits should still exist in the tree.
+ // regression check to ensure previous commits were not squashed.
+ oldCommit, err = localRepo.CommitObject(checkoutBranchHead.Hash)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(oldCommit).ToNot(BeNil())
+
+ // ======= Scenario 3 =======
+ // Still pushes to push branch after it's merged.
+ checkoutBranchHead, err = testutil.HeadFromBranch(localRepo, branch)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Get the head of push branch before update.
+ pushBranchHead, err = testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Merge the push branch into checkout branch, and push the merge commit
+ // upstream.
+ // WaitForNewHead() leaves the repo at the head of the branch given, i.e., the
+ // push branch, so we have to check out the "main" branch first.
+ w, err := localRepo.Worktree()
+ g.Expect(err).ToNot(HaveOccurred())
+ w.Pull(&extgogit.PullOptions{
+ RemoteName: originRemote,
+ ReferenceName: plumbing.ReferenceName(fmt.Sprintf("refs/remotes/origin/%s", pushBranch)),
+ })
+ err = localRepo.Push(&extgogit.PushOptions{
+ RemoteName: originRemote,
+ RefSpecs: []config.RefSpec{
+ config.RefSpec(fmt.Sprintf("refs/heads/%s:refs/remotes/origin/%s", branch, pushBranch))},
+ })
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Update latest image.
+ latestImage = "helloworld:v1.3.1"
+ imgPolicy.Status.LatestImage = latestImage
+ g.Expect(kClient.Update(ctx, imgPolicy)).To(Succeed())
+
+ policies = []imagev1_reflect.ImagePolicy{*imgPolicy}
+ checkoutAndUpdate(ctx, g, kClient, updateAuto, policies, srcOpts, checkoutOpts, pushCfg)
+
+ // Pull the new changes to the local repo.
+ preChangeCommitId = testutil.CommitIdFromBranch(localRepo, branch)
+ testutil.WaitForNewHead(g, localRepo, pushBranch, originRemote, preChangeCommitId)
+
+ newPushBranchHead, err = testutil.GetRemoteHead(localRepo, pushBranch, originRemote)
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(newPushBranchHead.String()).NotTo(Equal(pushBranchHead))
+
+ // previous commits should still exist in the tree.
+ // regression check to ensure previous commits were not squashed.
+ oldCommit, err = localRepo.CommitObject(checkoutBranchHead.Hash)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(oldCommit).ToNot(BeNil())
+}
+
+func TestPushResult_Summary(t *testing.T) {
+ testRev := "a47b32f4814810acac804df5054ec37cbfdbfb53"
+ testRevShort := testRev[:7]
+ testBranch := "test-branch"
+
+ tests := []struct {
+ name string
+ rev string
+ commitMsg string
+ refspecs []string
+ wantSummary string
+ wantErr bool
+ }{
+ {
+ name: "only push branch",
+ rev: testRev,
+ commitMsg: defaultMessageTemplate,
+ wantSummary: fmt.Sprintf("pushed commit '%s' to branch '%s'\nUpdate from image update automation", testRevShort, testBranch),
+ },
+ {
+ name: "with custom template",
+ rev: testRev,
+ commitMsg: "test commit message",
+ wantSummary: fmt.Sprintf(`pushed commit '%s' to branch '%s'
+test commit message`,
+ testRevShort, testBranch),
+ },
+ {
+ name: "no template",
+ rev: testRev,
+ wantSummary: fmt.Sprintf("pushed commit '%s' to branch '%s'", testRevShort, testBranch),
+ },
+ {
+ name: "with refspec",
+ rev: testRev,
+ commitMsg: defaultMessageTemplate,
+ refspecs: []string{"refs/heads/auto:refs/heads/smth/else", "refs/heads/auto:refs/heads/foo"},
+ wantSummary: fmt.Sprintf(`pushed commit '%s' to branch '%s' and refspecs 'refs/heads/auto:refs/heads/smth/else', 'refs/heads/auto:refs/heads/foo'
+Update from image update automation`, testRevShort, testBranch),
+ },
+ {
+ name: "short rev",
+ rev: "foo",
+ commitMsg: defaultMessageTemplate,
+ wantSummary: fmt.Sprintf(`pushed commit '%s' to branch '%s'
+Update from image update automation`, "foo", testBranch),
+ },
+ {
+ name: "empty rev",
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ prOpts := []PushResultOption{WithPushResultRefspec(tt.refspecs)}
+ pr, err := NewPushResult(testBranch, tt.rev, tt.commitMsg, prOpts...)
+ if (err != nil) != tt.wantErr {
+ g.Fail("unexpected error")
+ return
+ }
+ if err == nil {
+ g.Expect(pr.Summary()).To(Equal(tt.wantSummary))
+ }
+ })
+ }
+}
+
+// checkoutAndUpdate performs source checkout, update and push for the given
+// arguments.
+func checkoutAndUpdate(ctx context.Context, g *WithT, kClient client.Client,
+ updateAuto *imagev1.ImageUpdateAutomation, policies []imagev1_reflect.ImagePolicy,
+ srcOpts []SourceOption, checkoutOpts []CheckoutOption, pushCfg []PushConfig) {
+ g.THelper()
+
+ sm, err := NewSourceManager(ctx, kClient, updateAuto, srcOpts...)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { g.Expect(sm.Cleanup()).ToNot(HaveOccurred()) }()
+
+ _, err = sm.CheckoutSource(ctx, checkoutOpts...)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ result, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), updateAuto, policies)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ _, err = sm.CommitAndPush(ctx, updateAuto, result, pushCfg...)
+ g.Expect(err).ToNot(HaveOccurred())
+}
+
+func getRepoURL(gitServer *gittestserver.GitServer, repoPath, proto string) (string, error) {
+ if proto == "http" {
+ return gitServer.HTTPAddressWithCredentials() + repoPath, nil
+ } else if proto == "ssh" {
+ // This is expected to use 127.0.0.1, but host key
+ // checking usually wants a hostname, so use
+ // "localhost".
+ sshURL := strings.Replace(gitServer.SSHAddress(), "127.0.0.1", "localhost", 1)
+ return sshURL + repoPath, nil
+ }
+ return "", fmt.Errorf("proto not set to http or ssh")
+}
+
+func getSSHIdentitySecret(name, namespace, repoURL string) (*corev1.Secret, error) {
+ url, err := url.Parse(repoURL)
+ if err != nil {
+ return nil, err
+ }
+ knownhosts, err := ssh.ScanHostKey(url.Host, 5*time.Second, []string{}, false)
+ if err != nil {
+ return nil, err
+ }
+ keygen := ssh.NewRSAGenerator(2048)
+ pair, err := keygen.Generate()
+ if err != nil {
+ return nil, err
+ }
+ sec := &corev1.Secret{
+ StringData: map[string]string{
+ "known_hosts": string(knownhosts),
+ "identity": string(pair.PrivateKey),
+ "identity.pub": string(pair.PublicKey),
+ },
+ // Without KAS, StringData and Data must be kept in sync manually.
+ Data: map[string][]byte{
+ "known_hosts": knownhosts,
+ "identity": pair.PrivateKey,
+ "identity.pub": pair.PublicKey,
+ },
+ }
+ sec.Name = name
+ sec.Namespace = namespace
+ return sec, nil
+}
diff --git a/internal/source/testdata/appconfig/deploy.yaml b/internal/source/testdata/appconfig/deploy.yaml
new file mode 100644
index 00000000..1ca5a035
--- /dev/null
+++ b/internal/source/testdata/appconfig/deploy.yaml
@@ -0,0 +1,10 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: test
+spec:
+ template:
+ spec:
+ containers:
+ - name: hello
+ image: helloworld:1.0.0 # SETTER_SITE
diff --git a/internal/testutil/util.go b/internal/testutil/util.go
new file mode 100644
index 00000000..53324df6
--- /dev/null
+++ b/internal/testutil/util.go
@@ -0,0 +1,458 @@
+/*
+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 testutil
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ securejoin "github.com/cyphar/filepath-securejoin"
+ "github.com/go-git/go-billy/v5/osfs"
+ 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-git/go-git/v5/plumbing/cache"
+ "github.com/go-git/go-git/v5/plumbing/object"
+ "github.com/go-git/go-git/v5/storage/filesystem"
+ . "github.com/onsi/gomega"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/rand"
+
+ "github.com/fluxcd/pkg/gittestserver"
+
+ "github.com/fluxcd/image-automation-controller/pkg/update"
+)
+
+const (
+ signingSecretKey = "git.asc"
+ signingPassphraseKey = "passphrase"
+)
+
+func CheckoutBranch(g *WithT, repo *extgogit.Repository, branch string) {
+ g.THelper()
+
+ wt, err := repo.Worktree()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ err = wt.Checkout(&extgogit.CheckoutOptions{
+ Branch: plumbing.NewBranchReferenceName(branch),
+ })
+ g.Expect(err).ToNot(HaveOccurred())
+}
+
+func ReplaceMarker(path string, policyKey types.NamespacedName) error {
+ return ReplaceMarkerWithMarker(path, policyKey, "SETTER_SITE")
+}
+
+func ReplaceMarkerWithMarker(path string, policyKey types.NamespacedName, marker string) error {
+ filebytes, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ newfilebytes := bytes.ReplaceAll(filebytes, []byte(marker), []byte(setterRef(policyKey)))
+ if err = os.WriteFile(path, 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 CommitInRepo(ctx context.Context, g *WithT, repoURL, branch, remote, msg string, changeFiles func(path string)) plumbing.Hash {
+ g.THelper()
+
+ repo, cloneDir, err := Clone(ctx, repoURL, branch, remote)
+ g.Expect(err).ToNot(HaveOccurred())
+ defer func() { os.RemoveAll(cloneDir) }()
+
+ wt, err := repo.Worktree()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ changeFiles(wt.Filesystem.Root())
+
+ id := CommitWorkDir(g, repo, branch, msg)
+
+ origin, err := repo.Remote(remote)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ g.Expect(origin.Push(&extgogit.PushOptions{
+ RemoteName: remote,
+ RefSpecs: []config.RefSpec{config.RefSpec(BranchRefName(branch))},
+ })).To(Succeed())
+ return id
+}
+
+func WaitForNewHead(g *WithT, repo *extgogit.Repository, branch, remote, preChangeHash string) {
+ g.THelper()
+
+ var commitToResetTo *object.Commit
+
+ origin, err := repo.Remote(remote)
+ 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: remote,
+ 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 preChangeHash != remoteHeadHash.String() {
+ commitToResetTo, _ = repo.CommitObject(remoteHeadHash)
+ return true
+ }
+ return false
+ }, 10*time.Second, 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())
+ }
+}
+
+// Initialise a git server with a repo including the files in dir.
+func InitGitRepo(g *WithT, gitServer *gittestserver.GitServer, fixture, branch, repoPath string) *extgogit.Repository {
+ g.THelper()
+
+ workDir, err := securejoin.SecureJoin(gitServer.Root(), repoPath)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ repo := InitGitRepoPlain(g, fixture, workDir)
+
+ headRef, err := repo.Head()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ ref := plumbing.NewHashReference(
+ plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branch)),
+ headRef.Hash())
+
+ g.Expect(repo.Storer.SetReference(ref)).ToNot(HaveOccurred())
+
+ return repo
+}
+
+func InitGitRepoPlain(g *WithT, fixture, repoPath string) *extgogit.Repository {
+ g.THelper()
+
+ wt := osfs.New(repoPath)
+ dot := osfs.New(filepath.Join(repoPath, extgogit.GitDirName))
+ storer := filesystem.NewStorage(dot, cache.NewObjectLRUDefault())
+
+ repo, err := extgogit.Init(storer, wt)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ g.Expect(copyDir(fixture, repoPath)).ToNot(HaveOccurred())
+
+ _ = CommitWorkDir(g, repo, "main", "Initial commit")
+ g.Expect(err).ToNot(HaveOccurred())
+
+ return repo
+}
+
+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(g *WithT, repo *extgogit.Repository, branchName, message string) plumbing.Hash {
+ g.THelper()
+
+ wt, err := repo.Worktree()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // 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()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ for file := range status {
+ wt.Add(file)
+ }
+
+ sig := mockSignature(time.Now())
+ c, err := wt.Commit(message, &extgogit.CommitOptions{
+ All: true,
+ Author: sig,
+ Committer: sig,
+ })
+ g.Expect(err).ToNot(HaveOccurred())
+
+ _, 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)
+ }
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Now the target branch exists, we can checkout to it.
+ err = wt.Checkout(&extgogit.CheckoutOptions{
+ Branch: plumbing.ReferenceName("refs/heads/" + branchName),
+ })
+ g.Expect(err).ToNot(HaveOccurred())
+
+ return c
+}
+
+func TagCommit(g *WithT, repo *extgogit.Repository, commit plumbing.Hash, annotated bool, tag string, time time.Time) (*plumbing.Reference, error) {
+ g.THelper()
+
+ var opts *extgogit.CreateTagOptions
+ if annotated {
+ opts = &extgogit.CreateTagOptions{
+ Tagger: mockSignature(time),
+ Message: "Annotated tag for: " + tag,
+ }
+ }
+ return repo.CreateTag(tag, commit, opts)
+}
+
+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 := os.ReadFile(srcFile)
+ if err != nil {
+ return err
+ }
+
+ if err = os.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 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, remote string) (*extgogit.Repository, string, error) {
+ dir, err := os.MkdirTemp("", "iac-clone-*")
+ if err != nil {
+ return nil, "", err
+ }
+
+ opts := &extgogit.CloneOptions{
+ URL: repoURL,
+ RemoteName: remote,
+ ReferenceName: plumbing.NewBranchReferenceName(branchName),
+ }
+
+ wt := osfs.New(dir, osfs.WithBoundOS())
+ dot := osfs.New(filepath.Join(dir, extgogit.GitDirName), osfs.WithBoundOS())
+ 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, dir, 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, remote string) (plumbing.Hash, error) {
+ rmt, err := repo.Remote(remote)
+ if err != nil {
+ return plumbing.ZeroHash, err
+ }
+
+ err = rmt.Fetch(&extgogit.FetchOptions{
+ RemoteName: remote,
+ 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
+}
+
+// SetUpGitTestServer creates and returns a git test server. The caller must
+// ensure it's stopped and cleaned up.
+func SetUpGitTestServer(g *WithT) *gittestserver.GitServer {
+ g.THelper()
+
+ gitServer, err := gittestserver.NewTempGitServer()
+ g.Expect(err).ToNot(HaveOccurred())
+
+ username := rand.String(5)
+ password := rand.String(5)
+
+ gitServer.Auth(username, password)
+ gitServer.AutoCreate()
+ g.Expect(gitServer.StartHTTP()).ToNot(HaveOccurred())
+ gitServer.KeyDir(filepath.Join(gitServer.Root(), "keys"))
+ g.Expect(gitServer.ListenSSH()).ToNot(HaveOccurred())
+ return gitServer
+}
+
+func GetSigningKeyPairSecret(g *WithT, name, namespace string) (*corev1.Secret, *openpgp.Entity) {
+ g.THelper()
+
+ passphrase := "abcde12345"
+ pgpEntity, key := GetSigningKeyPair(g, passphrase)
+
+ // Create the secret containing signing key.
+ sec := &corev1.Secret{
+ Data: map[string][]byte{
+ signingSecretKey: key,
+ signingPassphraseKey: []byte(passphrase),
+ },
+ }
+ sec.Name = name
+ sec.Namespace = namespace
+ return sec, pgpEntity
+}
+
+func GetSigningKeyPair(g *WithT, passphrase string) (*openpgp.Entity, []byte) {
+ g.THelper()
+
+ pgpEntity, err := openpgp.NewEntity("", "", "", nil)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Configure OpenPGP armor encoder.
+ b := bytes.NewBuffer(nil)
+ w, err := armor.Encode(b, openpgp.PrivateKeyType, nil)
+ g.Expect(err).ToNot(HaveOccurred())
+ // Serialize private key.
+ g.Expect(pgpEntity.SerializePrivate(w, nil)).To(Succeed())
+ g.Expect(w.Close()).To(Succeed())
+
+ if passphrase != "" {
+ g.Expect(pgpEntity.PrivateKey.Encrypt([]byte(passphrase))).To(Succeed())
+ }
+
+ return pgpEntity, b.Bytes()
+}
diff --git a/main.go b/main.go
index d5a54ac0..fed1fca6 100644
--- a/main.go
+++ b/main.go
@@ -48,7 +48,7 @@ import (
"github.com/fluxcd/pkg/git"
- imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
+ imagev1 "github.com/fluxcd/image-automation-controller/api/v1beta2"
"github.com/fluxcd/image-automation-controller/internal/features"
// +kubebuilder:scaffold:imports
@@ -204,6 +204,7 @@ func main() {
EventRecorder: eventRecorder,
Metrics: metricsH,
NoCrossNamespaceRef: aclOptions.NoCrossNamespaceRefs,
+ ControllerName: controllerName,
}).SetupWithManager(ctx, mgr, controller.ImageUpdateAutomationReconcilerOptions{
RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
}); err != nil {