diff --git a/api/v1beta1/git.go b/api/v1beta1/git.go index a40c7d24..5d4eb69c 100644 --- a/api/v1beta1/git.go +++ b/api/v1beta1/git.go @@ -85,6 +85,14 @@ 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. - // +required - Branch string `json:"branch"` + // +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"` } diff --git a/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml b/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml index dd6b3598..d27da149 100644 --- a/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml +++ b/config/crd/bases/image.toolkit.fluxcd.io_imageupdateautomations.yaml @@ -135,8 +135,13 @@ spec: to the branch named. The branch is created using `.spec.checkout.branch` as the starting point, if it doesn't already exist. type: string - required: - - branch + 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 diff --git a/docs/api/v1beta1/image-automation.md b/docs/api/v1beta1/image-automation.md index 62d01510..6c5ebfa6 100644 --- a/docs/api/v1beta1/image-automation.md +++ b/docs/api/v1beta1/image-automation.md @@ -638,11 +638,28 @@ 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

+ + diff --git a/docs/spec/v1beta1/imageupdateautomations.md b/docs/spec/v1beta1/imageupdateautomations.md index db5522f0..d6382bef 100644 --- a/docs/spec/v1beta1/imageupdateautomations.md +++ b/docs/spec/v1beta1/imageupdateautomations.md @@ -398,6 +398,7 @@ spec: name: fluxcdbot ``` 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 The optional `push` field defines how commits are pushed to the origin. @@ -408,17 +409,29 @@ 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. - // +required - Branch string `json:"branch"` + // +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"` } ``` -If `push` is not present, commits are made on the branch given in `.spec.git.checkout.branch` and +If `.push` is not present, commits are made on the branch given in `.spec.git.checkout.branch` and pushed to the same branch at the origin. If `.spec.git.checkout` is not present, it will fall back to the branch given in the `GitRepository` referenced by `.spec.sourceRef`. If none of these yield a branch name, the automation will fail. -When `push` is present, the `branch` field specifies a branch to push to at the origin. The branch +If `.push.refspec` is present, the refspec specified is used to perform the push operation. +An example of a valid refspec is `refs/heads/branch:refs/heads/branch`. This allows users to +push to an arbitary destination reference. + +If `.push.branch` is present, the specified branch is pushed to at the origin. The branch will be created locally if it does not already exist, starting from the checkout branch. If it does already exist, 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 `--feature-gates=GitForcePushBranch=false`, @@ -427,6 +440,16 @@ Note that without force push in push branches, if the target branch is stale, th be able to conclude the operation and will consistently fail until the branch is either deleted or refreshed. +If both `.push.refspec` and `.push.branch` are specified, then the reconciler will perform +two push operations, one to the specified branch and another using the specified refspec. +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 to perform the second push +operation and error out with an `already up-to-date` error. + 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: @@ -441,6 +464,102 @@ spec: branch: auto ``` +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 +``` + +#### 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 }} + + Files: + {{ range $filename, $_ := .Updated.Files -}} + - {{ $filename }} + {{ end }} + Objects: + {{ range $resource, $_ := .Updated.Objects -}} + - {{ $resource.Kind }} {{ $resource.Name }} + {{ end }} + Images: + {{ range .Updated.Images -}} + - {{ . }} + {{ end }} + {{- $ChangeId := .AutomationObject -}} + {{- $ChangeId = printf "%s%s" $ChangeId ( .Updated.Files | toString ) -}} + {{- $ChangeId = printf "%s%s" $ChangeId ( .Updated.Objects | toString ) -}} + {{- $ChangeId = printf "%s%s" $ChangeId ( .Updated.Images | 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. + ## Update strategy The `.spec.update` field specifies how to carry out updates on the git repository. There is one diff --git a/internal/controller/imageupdateautomation_controller.go b/internal/controller/imageupdateautomation_controller.go index 61d1c91a..bda3c386 100644 --- a/internal/controller/imageupdateautomation_controller.go +++ b/internal/controller/imageupdateautomation_controller.go @@ -214,30 +214,15 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctr } // validate the git spec and default any values needed later, before proceeding - var ref *sourcev1.GitRepositoryRef + var checkoutRef *sourcev1.GitRepositoryRef if gitSpec.Checkout != nil { - ref = &gitSpec.Checkout.Reference - tracelog.Info("using git repository ref from .spec.git.checkout", "ref", ref) + checkoutRef = &gitSpec.Checkout.Reference + tracelog.Info("using git repository ref from .spec.git.checkout", "ref", checkoutRef) } else if r := origin.Spec.Reference; r != nil { - ref = r - tracelog.Info("using git repository ref from GitRepository spec", "ref", ref) + checkoutRef = r + tracelog.Info("using git repository ref from GitRepository spec", "ref", checkoutRef) } // else remain as `nil` and git.DefaultBranch will be used. - var pushBranch string - if gitSpec.Push != nil { - pushBranch = gitSpec.Push.Branch - tracelog.Info("using push branch from .spec.push.branch", "branch", pushBranch) - } 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 ref == nil || ref.Branch == "" { - return failWithError(fmt.Errorf("Push branch not given explicitly, and cannot be inferred from .spec.git.checkout.ref or GitRepository .spec.ref")) - } - pushBranch = ref.Branch - tracelog.Info("using push branch from $ref.branch", "branch", pushBranch) - } - tmp, err := os.MkdirTemp("", fmt.Sprintf("%s-%s", originName.Namespace, originName.Name)) if err != nil { return failWithError(err) @@ -248,42 +233,43 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctr } }() - debuglog.Info("attempting to clone git repository", "gitrepository", originName, "ref", ref, "working", tmp) - - authOpts, err := r.getAuthOpts(ctx, &origin) - if err != nil { - return failWithError(err) - } - - clientOpts := []gogit.ClientOption{gogit.WithDiskStorage()} - if authOpts.Transport == git.HTTP { - clientOpts = append(clientOpts, gogit.WithInsecureCredentialsOverHTTP()) + var pushBranch string + var switchBranch bool + if gitSpec.Push != nil { + // 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 != "" && gitSpec.Push.Branch != checkoutRef.Branch { + pushBranch = gitSpec.Push.Branch + switchBranch = true + tracelog.Info("using push branch from .spec.push.branch", "branch", pushBranch) + } + } 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) } - // 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 pushBranch != ref.Branch { - clientOpts = append(clientOpts, gogit.WithSingleBranch(!allReferences)) - } + debuglog.Info("attempting to clone git repository", "gitrepository", originName, "ref", checkoutRef, "working", tmp) - gitClient, err := gogit.NewClient(tmp, authOpts, clientOpts...) + gitClient, err := r.constructGitClient(ctx, &origin, tmp, switchBranch) if err != nil { return failWithError(err) } defer gitClient.Close() opts := repository.CloneConfig{} - if ref != nil { - opts.Tag = ref.Tag - opts.SemVer = ref.SemVer - opts.Commit = ref.Commit - opts.Branch = ref.Branch + 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 { @@ -297,9 +283,9 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctr return failWithError(err) } - // When there's a push spec, the pushed-to branch is where commits + // When there's a push branch specified, the pushed-to branch is where commits // shall be made - if gitSpec.Push != nil && !(ref != nil && ref.Branch == pushBranch) { + if switchBranch { // Use the git operations timeout for the repo. fetchCtx, cancel := context.WithTimeout(ctx, origin.Spec.Timeout.Duration) defer cancel() @@ -352,7 +338,6 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctr debuglog.Info("ran updates to working dir", "working", tmp) - var statusMessage string var signingEntity *openpgp.Entity if gitSpec.Commit.SigningKey != nil { if signingEntity, err = r.getSigningEntity(ctx, auto); err != nil { @@ -386,40 +371,80 @@ func (r *ImageUpdateAutomationReconciler) Reconcile(ctx context.Context, req ctr 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 = "no updates made" + statusMessage.WriteString("no updates made") if auto.Status.LastPushTime != nil && len(auto.Status.LastPushCommit) >= 7 { - statusMessage = fmt.Sprintf("%s; last commit %s at %s", statusMessage, auto.Status.LastPushCommit[:7], auto.Status.LastPushTime.Format(time.RFC3339)) + 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() - opts := repository.PushConfig{} - forcePush := r.features[features.GitForcePushBranch] - if forcePush && pushBranch != ref.Branch { - opts.Force = true + + var pushToBranch bool + var pushWithRefspec bool + // If a refspec is specified, then we need to perform a push using + // that refspec. + if gitSpec.Push != nil && gitSpec.Push.Refspec != "" { + pushWithRefspec = true } - if err := gitClient.Push(pushCtx, opts); err != nil { - return failWithError(err) + // We need to push the commit to the push branch if one was specified, or if + // no push config was specified, then we need to push to the branch we checked + // out to. + if (gitSpec.Push != nil && gitSpec.Push.Branch != "") || gitSpec.Push == nil { + pushToBranch = true + } + + if pushToBranch { + // 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. + var pushConfig repository.PushConfig + forcePush := r.features[features.GitForcePushBranch] + if forcePush && switchBranch { + pushConfig.Force = true + } + + 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("commited and pushed commit '%s' to branch '%s'", rev, pushBranch)) } - r.event(ctx, auto, eventv1.EventSeverityInfo, fmt.Sprintf("Committed and pushed change %s to %s\n%s", rev, pushBranch, message)) - log.Info("pushed commit to origin", "revision", rev, "branch", pushBranch) + if pushWithRefspec { + pushConfig := repository.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) + + if pushToBranch { + 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)) + } + } + + r.event(ctx, auto, eventv1.EventSeverityInfo, fmt.Sprintf("%s\n%s", statusMessage.String(), message)) + auto.Status.LastPushCommit = rev auto.Status.LastPushTime = &metav1.Time{Time: start} - statusMessage = "committed and pushed " + rev + " to " + pushBranch } // Getting to here is a successful run. auto.Status.LastAutomationRunTime = &metav1.Time{Time: start} - imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionTrue, imagev1.ReconciliationSucceededReason, statusMessage) + 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 } @@ -545,6 +570,38 @@ func (r *ImageUpdateAutomationReconciler) getAuthOpts(ctx context.Context, repos return opts, nil } +// constructGitClient constructs and returns a new gogit client. +func (r *ImageUpdateAutomationReconciler) constructGitClient(ctx context.Context, + origin *sourcev1.GitRepository, repoDir string, switchBranch bool) (*gogit.Client, error) { + authOpts, err := r.getAuthOpts(ctx, origin) + if err != nil { + return nil, err + } + + clientOpts := []gogit.ClientOption{gogit.WithDiskStorage()} + if authOpts.Transport == git.HTTP { + clientOpts = append(clientOpts, gogit.WithInsecureCredentialsOverHTTP()) + } + + // 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 switchBranch { + clientOpts = append(clientOpts, gogit.WithSingleBranch(!allReferences)) + } + + gitClient, err := gogit.NewClient(repoDir, authOpts, clientOpts...) + if err != nil { + return nil, err + } + return gitClient, 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) { diff --git a/internal/controller/update_test.go b/internal/controller/update_test.go index 372f49a2..3cccc876 100644 --- a/internal/controller/update_test.go +++ b/internal/controller/update_test.go @@ -206,7 +206,7 @@ func TestImageAutomationReconciler_commitMessage(t *testing.T) { updateStrategy := &imagev1.UpdateStrategy{ Strategy: imagev1.UpdateStrategySetters, } - err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy) + err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy) g.Expect(err).ToNot(HaveOccurred()) // Wait for a new commit to be made by the controller. @@ -273,7 +273,7 @@ func TestImageAutomationReconciler_crossNamespaceRef(t *testing.T) { updateStrategy := &imagev1.UpdateStrategy{ Strategy: imagev1.UpdateStrategySetters, } - err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy) + err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy) g.Expect(err).ToNot(HaveOccurred()) // Wait for a new commit to be made by the controller. @@ -308,7 +308,7 @@ func TestImageAutomationReconciler_crossNamespaceRef(t *testing.T) { updateStrategy := &imagev1.UpdateStrategy{ Strategy: imagev1.UpdateStrategySetters, } - err := createImageUpdateAutomation(r.Client, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy) + err := createImageUpdateAutomation(r.Client, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy) g.Expect(err).ToNot(HaveOccurred()) imageUpdateKey := types.NamespacedName{ @@ -374,7 +374,7 @@ func TestImageAutomationReconciler_updatePath(t *testing.T) { Strategy: imagev1.UpdateStrategySetters, Path: "./yes", } - err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, "", updateStrategy) + err := createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, "", updateStrategy) g.Expect(err).ToNot(HaveOccurred()) // Wait for a new commit to be made by the controller. @@ -433,7 +433,7 @@ func TestImageAutomationReconciler_signedCommit(t *testing.T) { updateStrategy := &imagev1.UpdateStrategy{ Strategy: imagev1.UpdateStrategySetters, } - err = createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", testCommitTemplate, signingKeySecretName, updateStrategy) + err = createImageUpdateAutomation(testEnv, "update-test", s.namespace, s.gitRepoName, s.gitRepoNamespace, s.branch, "", "", testCommitTemplate, signingKeySecretName, updateStrategy) g.Expect(err).ToNot(HaveOccurred()) // Wait for a new commit to be made by the controller. @@ -463,6 +463,61 @@ func TestImageAutomationReconciler_signedCommit(t *testing.T) { }) } +func TestImageAutomationReconciler_push_refspec(t *testing.T) { + policySpec := imagev1_reflect.ImagePolicySpec{ + ImageRepositoryRef: meta.NamespacedObjectReference{ + Name: "not-expected-to-exist", + }, + Policy: imagev1_reflect.ImagePolicyChoice{ + SemVer: &imagev1_reflect.SemVerPolicy{ + Range: "1.x", + }, + }, + } + fixture := "testdata/appconfig" + latest := "helloworld:v1.0.0" + + t.Run(gogit.ClientName, func(t *testing.T) { + testWithRepoAndImagePolicy( + NewWithT(t), testEnv, fixture, policySpec, latest, gogit.ClientName, + func(g *WithT, s repoAndPolicyArgs, repoURL string, localRepo *extgogit.Repository) { + // 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) + + // 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()) + + // 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())) + }, + ) + }) +} + func TestImageAutomationReconciler_e2e(t *testing.T) { protos := []string{"http", "ssh"} @@ -585,7 +640,7 @@ func TestImageAutomationReconciler_e2e(t *testing.T) { // 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) + err = createImageUpdateAutomation(r.Client, imageUpdateAutomationName, namespace, gitRepoName, namespace, branch, pushBranch, "", commitMessage, "", updateStrategy) g.Expect(err).ToNot(HaveOccurred()) _, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: automationKey}) @@ -750,7 +805,7 @@ func TestImageAutomationReconciler_e2e(t *testing.T) { Namespace: namespace, Name: "update-" + randStringRunes(5), } - err = createImageUpdateAutomation(r.Client, updateKey.Name, namespace, gitRepoName, namespace, branch, "", commitMessage, "", updateStrategy) + err = createImageUpdateAutomation(r.Client, updateKey.Name, namespace, gitRepoName, namespace, branch, "", "", commitMessage, "", updateStrategy) g.Expect(err).ToNot(HaveOccurred()) defer func() { g.Expect(deleteImageUpdateAutomation(r.Client, updateKey.Name, namespace)).To(Succeed()) @@ -800,7 +855,7 @@ func TestImageAutomationReconciler_e2e(t *testing.T) { Namespace: namespace, Name: "update-" + randStringRunes(5), } - err = createImageUpdateAutomation(testEnv, updateKey.Name, namespace, gitRepoName, namespace, branch, "", commitMessage, "", updateStrategy) + 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()) @@ -1252,6 +1307,27 @@ func clone(ctx context.Context, repoURL, branchName string) (*extgogit.Repositor 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) { var commitToResetTo *object.Commit @@ -1561,7 +1637,7 @@ func updateImagePolicyWithLatestImage(kClient client.Client, name, namespace, la } func createImageUpdateAutomation(kClient client.Client, name, namespace, - gitRepo, gitRepoNamespace, checkoutBranch, pushBranch, commitTemplate, signingKeyRef string, + gitRepo, gitRepoNamespace, checkoutBranch, pushBranch, pushRefspec, commitTemplate, signingKeyRef string, updateStrategy *imagev1.UpdateStrategy) error { updateAutomation := &imagev1.ImageUpdateAutomation{ Spec: imagev1.ImageUpdateAutomationSpec{ @@ -1590,9 +1666,10 @@ func createImageUpdateAutomation(kClient client.Client, name, namespace, } updateAutomation.Name = name updateAutomation.Namespace = namespace - if pushBranch != "" { + if pushRefspec != "" || pushBranch != "" { updateAutomation.Spec.GitSpec.Push = &imagev1.PushSpec{ - Branch: pushBranch, + Refspec: pushRefspec, + Branch: pushBranch, } } if signingKeyRef != "" {