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:

+ +

image.toolkit.fluxcd.io/v1beta2

+

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: + +

CommitSpec +

+

+(Appears on: +GitSpec) +

+

CommitSpec specifies how to commit changes to the git repository

+
+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+author
+ + +CommitUser + + +
+

Author gives the email and optionally the name to use as the +author of commits.

+
+signingKey
+ + +SigningKey + + +
+(Optional) +

SigningKey provides the option to sign commits with a GPG key

+
+messageTemplate
+ +string + +
+(Optional) +

MessageTemplate provides a template for the commit message, +into which will be interpolated the details of the change made.

+
+
+
+

CommitUser +

+

+(Appears on: +CommitSpec) +

+
+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+name
+ +string + +
+(Optional) +

Name gives the name to provide when making a commit.

+
+email
+ +string + +
+

Email gives the email to provide when making a commit.

+
+
+
+

CrossNamespaceSourceReference +

+

+(Appears on: +ImageUpdateAutomationSpec) +

+

CrossNamespaceSourceReference contains enough information to let you locate the +typed Kubernetes resource object at cluster level.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+apiVersion
+ +string + +
+(Optional) +

API version of the referent.

+
+kind
+ +string + +
+

Kind of the referent.

+
+name
+ +string + +
+

Name of the referent.

+
+namespace
+ +string + +
+(Optional) +

Namespace of the referent, defaults to the namespace of the Kubernetes resource object that contains the reference.

+
+
+
+

GitCheckoutSpec +

+

+(Appears on: +GitSpec) +

+
+
+ + + + + + + + + + + + + +
FieldDescription
+ref
+ + +Source /v1.GitRepositoryRef + + +
+

Reference gives a branch, tag or commit to clone from the Git +repository.

+
+
+
+

GitSpec +

+

+(Appears on: +ImageUpdateAutomationSpec) +

+
+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+checkout
+ + +GitCheckoutSpec + + +
+(Optional) +

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.

+
+commit
+ + +CommitSpec + + +
+

Commit specifies how to commit to the git repository.

+
+push
+ + +PushSpec + + +
+(Optional) +

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.

+
+
+
+

ImageRef +

+

ImageRef represents an image reference.

+
+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+name
+ +string + +
+

Name is the bare image’s name.

+
+tag
+ +string + +
+

Tag is the image’s tag.

+
+
+
+

ImageUpdateAutomation +

+

ImageUpdateAutomation is the Schema for the imageupdateautomations API

+
+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+metadata
+ + +Kubernetes meta/v1.ObjectMeta + + +
+Refer to the Kubernetes API documentation for the fields of the +metadata field. +
+spec
+ + +ImageUpdateAutomationSpec + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+sourceRef
+ + +CrossNamespaceSourceReference + + +
+

SourceRef refers to the resource giving access details +to a git repository.

+
+git
+ + +GitSpec + + +
+(Optional) +

GitSpec contains all the git-specific definitions. This is +technically optional, but in practice mandatory until there are +other kinds of source allowed.

+
+interval
+ + +Kubernetes meta/v1.Duration + + +
+

Interval gives an lower bound for how often the automation +run should be attempted.

+
+policySelector
+ + +Kubernetes meta/v1.LabelSelector + + +
+(Optional) +

PolicySelector allows to filter applied policies based on labels. +By default includes all policies in namespace.

+
+update
+ + +UpdateStrategy + + +
+

Update gives the specification for how to update the files in +the repository. This can be left empty, to use the default +value.

+
+suspend
+ +bool + +
+(Optional) +

Suspend tells the controller to not run this automation, until +it is unset (or set to false). Defaults to false.

+
+
+status
+ + +ImageUpdateAutomationStatus + + +
+
+
+
+

ImageUpdateAutomationSpec +

+

+(Appears on: +ImageUpdateAutomation) +

+

ImageUpdateAutomationSpec defines the desired state of ImageUpdateAutomation

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+sourceRef
+ + +CrossNamespaceSourceReference + + +
+

SourceRef refers to the resource giving access details +to a git repository.

+
+git
+ + +GitSpec + + +
+(Optional) +

GitSpec contains all the git-specific definitions. This is +technically optional, but in practice mandatory until there are +other kinds of source allowed.

+
+interval
+ + +Kubernetes meta/v1.Duration + + +
+

Interval gives an lower bound for how often the automation +run should be attempted.

+
+policySelector
+ + +Kubernetes meta/v1.LabelSelector + + +
+(Optional) +

PolicySelector allows to filter applied policies based on labels. +By default includes all policies in namespace.

+
+update
+ + +UpdateStrategy + + +
+

Update gives the specification for how to update the files in +the repository. This can be left empty, to use the default +value.

+
+suspend
+ +bool + +
+(Optional) +

Suspend tells the controller to not run this automation, until +it is unset (or set to false). Defaults to false.

+
+
+
+

ImageUpdateAutomationStatus +

+

+(Appears on: +ImageUpdateAutomation) +

+

ImageUpdateAutomationStatus defines the observed state of ImageUpdateAutomation

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+lastAutomationRunTime
+ + +Kubernetes meta/v1.Time + + +
+(Optional) +

LastAutomationRunTime records the last time the controller ran +this automation through to completion (even if no updates were +made).

+
+lastPushCommit
+ +string + +
+(Optional) +

LastPushCommit records the SHA1 of the last commit made by the +controller, for this automation object

+
+lastPushTime
+ + +Kubernetes meta/v1.Time + + +
+(Optional) +

LastPushTime records the time of the last pushed change.

+
+observedGeneration
+ +int64 + +
+(Optional) +
+conditions
+ + +[]Kubernetes meta/v1.Condition + + +
+(Optional) +
+observedPolicies
+ + +ObservedPolicies + + +
+(Optional) +

ObservedPolicies is the list of observed ImagePolicies that were +considered by the ImageUpdateAutomation update process.

+
+observedSourceRevision
+ +string + +
+(Optional) +

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.

+
+ReconcileRequestStatus
+ + +github.com/fluxcd/pkg/apis/meta.ReconcileRequestStatus + + +
+

+(Members of ReconcileRequestStatus are embedded into this type.) +

+
+
+
+

ObservedPolicies +(map[string]./api/v1beta2.ImageRef alias)

+

+(Appears on: +ImageUpdateAutomationStatus) +

+

ObservedPolicies is a map of policy name and ImageRef of their latest +ImageRef.

+

PushSpec +

+

+(Appears on: +GitSpec) +

+

PushSpec specifies how and where to push commits.

+
+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+branch
+ +string + +
+(Optional) +

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.

+
+refspec
+ +string + +
+(Optional) +

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

+
+options
+ +map[string]string + +
+(Optional) +

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

+
+
+
+

SigningKey +

+

+(Appears on: +CommitSpec) +

+

SigningKey references a Kubernetes secret that contains a GPG keypair

+
+
+ + + + + + + + + + + + + +
FieldDescription
+secretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+

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.

+
+
+
+

UpdateStrategy +

+

+(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.

+
+
+ + + + + + + + + + + + + + + + + +
FieldDescription
+strategy
+ + +UpdateStrategyName + + +
+

Strategy names the strategy to be used.

+
+path
+ +string + +
+(Optional) +

Path to the directory containing the manifests to be updated. +Defaults to ‘None’, which translates to the root path +of the GitRepositoryRef.

+
+
+
+

UpdateStrategyName +(string alias)

+

+(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 {