diff --git a/api/v1/kustomization_types.go b/api/v1/kustomization_types.go index 22b6b330..8d54113a 100644 --- a/api/v1/kustomization_types.go +++ b/api/v1/kustomization_types.go @@ -263,6 +263,14 @@ type KustomizationStatus struct { // +optional LastAppliedRevision string `json:"lastAppliedRevision,omitempty"` + // The last successfully applied origin revision. + // Equals the origin revision of the applied Artifact from the referenced Source. + // Usually present on the Metadata of the applied Artifact and depends on the + // Source type, e.g. for OCI it's the value associated with the key + // "org.opencontainers.image.revision". + // +optional + LastAppliedOriginRevision string `json:"lastAppliedOriginRevision,omitempty"` + // LastAttemptedRevision is the revision of the last reconciliation attempt. // +optional LastAttemptedRevision string `json:"lastAttemptedRevision,omitempty"` diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml index 9fa9f991..a47520a8 100644 --- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml +++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml @@ -520,6 +520,14 @@ spec: required: - entries type: object + lastAppliedOriginRevision: + description: |- + The last successfully applied origin revision. + Equals the origin revision of the applied Artifact from the referenced Source. + Usually present on the Metadata of the applied Artifact and depends on the + Source type, e.g. for OCI it's the value associated with the key + "org.opencontainers.image.revision". + type: string lastAppliedRevision: description: |- The last successfully applied revision. diff --git a/docs/api/v1/kustomize.md b/docs/api/v1/kustomize.md index 9973006f..25e0e636 100644 --- a/docs/api/v1/kustomize.md +++ b/docs/api/v1/kustomize.md @@ -994,6 +994,22 @@ Equals the Revision of the applied Artifact from the referenced Source.

+lastAppliedOriginRevision
+ +string + + + +(Optional) +

The last successfully applied origin revision. +Equals the origin revision of the applied Artifact from the referenced Source. +Usually present on the Metadata of the applied Artifact and depends on the +Source type, e.g. for OCI it’s the value associated with the key +“org.opencontainers.image.revision”.

+ + + + lastAttemptedRevision
string diff --git a/go.mod b/go.mod index b8556ea9..e90db310 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/fluxcd/cli-utils v0.36.0-flux.11 github.com/fluxcd/kustomize-controller/api v1.4.0 github.com/fluxcd/pkg/apis/acl v0.5.0 - github.com/fluxcd/pkg/apis/event v0.13.0 + github.com/fluxcd/pkg/apis/event v0.15.0 github.com/fluxcd/pkg/apis/kustomize v1.8.0 github.com/fluxcd/pkg/apis/meta v1.9.0 github.com/fluxcd/pkg/http/fetch v0.14.0 diff --git a/go.sum b/go.sum index be7a7749..895d5814 100644 --- a/go.sum +++ b/go.sum @@ -181,8 +181,8 @@ github.com/fluxcd/cli-utils v0.36.0-flux.11 h1:W0y2uvCVkcE8bgV9jgoGSjzWbLFiNq1Aj github.com/fluxcd/cli-utils v0.36.0-flux.11/go.mod h1:WZ7xUpZbK+O6HBxA5UWqzWTLSSltdmj4wS1LstS5Dqs= github.com/fluxcd/pkg/apis/acl v0.5.0 h1:+ykKezgerKUlZwSYFUy03lPMOIAyWlqvMNNLIWWqOhk= github.com/fluxcd/pkg/apis/acl v0.5.0/go.mod h1:IVDZx3MAoDWjlLrJHMF9Z27huFuXAEQlnbWw0M6EcTs= -github.com/fluxcd/pkg/apis/event v0.13.0 h1:m5qHAhYIC0+mRFy5OC8FZxBVBGJM3qxJ/sEg2Vgx4T8= -github.com/fluxcd/pkg/apis/event v0.13.0/go.mod h1:aRK2AONnjjSNW61B6Iy3SW4YHozACntnJeGm3fFqDqA= +github.com/fluxcd/pkg/apis/event v0.15.0 h1:k1suqIfVxnhEeKlGkvlHAbOYXjY8wRixT/OZcIuakqA= +github.com/fluxcd/pkg/apis/event v0.15.0/go.mod h1:aRK2AONnjjSNW61B6Iy3SW4YHozACntnJeGm3fFqDqA= github.com/fluxcd/pkg/apis/kustomize v1.8.0 h1:HH6YRa3SMS72KK4cUyb9m5sK/dZH+Eti1qhjWDCgwKg= github.com/fluxcd/pkg/apis/kustomize v1.8.0/go.mod h1:QCKIFj1ocdndaWSkrLs5JKvdGNYyTzQX1ZB3lYTwma0= github.com/fluxcd/pkg/apis/meta v1.9.0 h1:wPgm7bWNJZ/ImS5GqikOxt362IgLPFBG73dZ27uWRiQ= diff --git a/internal/controller/constants.go b/internal/controller/constants.go new file mode 100644 index 00000000..ed9a9866 --- /dev/null +++ b/internal/controller/constants.go @@ -0,0 +1,19 @@ +/* +Copyright 2025 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 controller + +const OCIArtifactOriginRevisionAnnotation = "org.opencontainers.image.revision" diff --git a/internal/controller/kustomization_controller.go b/internal/controller/kustomization_controller.go index bd2ef9a1..9a5f7f01 100644 --- a/internal/controller/kustomization_controller.go +++ b/internal/controller/kustomization_controller.go @@ -193,7 +193,7 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques time.Since(reconcileStart).String(), obj.Spec.Interval.Duration.String()) log.Info(msg, "revision", obj.Status.LastAttemptedRevision) - r.event(obj, obj.Status.LastAppliedRevision, eventv1.EventSeverityInfo, msg, + r.event(obj, obj.Status.LastAppliedRevision, obj.Status.LastAppliedOriginRevision, eventv1.EventSeverityInfo, msg, map[string]string{ kustomizev1.GroupVersion.Group + "/" + eventv1.MetaCommitStatusKey: eventv1.MetaCommitStatusUpdateValue, }) @@ -234,7 +234,7 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques if acl.IsAccessDenied(err) { conditions.MarkFalse(obj, meta.ReadyCondition, apiacl.AccessDeniedReason, "%s", err) log.Error(err, "Access denied to cross-namespace source") - r.event(obj, "unknown", eventv1.EventSeverityError, err.Error(), nil) + r.event(obj, "", "", eventv1.EventSeverityError, err.Error(), nil) return ctrl.Result{RequeueAfter: obj.GetRetryInterval()}, nil } @@ -249,6 +249,8 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques log.Info(msg) return ctrl.Result{RequeueAfter: r.requeueDependency}, nil } + revision := artifactSource.GetArtifact().Revision + originRevision := getOriginRevision(artifactSource) // Check dependencies and requeue the reconciliation if the check fails. if len(obj.Spec.DependsOn) > 0 { @@ -256,7 +258,7 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques conditions.MarkFalse(obj, meta.ReadyCondition, meta.DependencyNotReadyReason, "%s", err) msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", r.requeueDependency.String()) log.Info(msg) - r.event(obj, artifactSource.GetArtifact().Revision, eventv1.EventSeverityInfo, msg, nil) + r.event(obj, revision, originRevision, eventv1.EventSeverityInfo, msg, nil) return ctrl.Result{RequeueAfter: r.requeueDependency}, nil } log.Info("All dependencies are ready, proceeding with reconciliation") @@ -279,8 +281,8 @@ func (r *KustomizationReconciler) Reconcile(ctx context.Context, req ctrl.Reques time.Since(reconcileStart).String(), obj.GetRetryInterval().String()), "revision", - artifactSource.GetArtifact().Revision) - r.event(obj, artifactSource.GetArtifact().Revision, eventv1.EventSeverityError, + revision) + r.event(obj, revision, originRevision, eventv1.EventSeverityError, reconcileErr.Error(), nil) return ctrl.Result{RequeueAfter: obj.GetRetryInterval()}, nil } @@ -298,6 +300,7 @@ func (r *KustomizationReconciler) reconcile( // Update status with the reconciliation progress. revision := src.GetArtifact().Revision + originRevision := getOriginRevision(src) progressingMsg := fmt.Sprintf("Fetching manifests for revision %s with a timeout of %s", revision, obj.GetTimeout().String()) conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "%s", "Reconciliation in progress") conditions.MarkReconciling(obj, meta.ProgressingReason, "%s", progressingMsg) @@ -419,7 +422,7 @@ func (r *KustomizationReconciler) reconcile( } // Validate and apply resources in stages. - drifted, changeSet, err := r.apply(ctx, resourceManager, obj, revision, objects) + drifted, changeSet, err := r.apply(ctx, resourceManager, obj, revision, originRevision, objects) if err != nil { conditions.MarkFalse(obj, meta.ReadyCondition, meta.ReconciliationFailedReason, "%s", err) return err @@ -444,7 +447,7 @@ func (r *KustomizationReconciler) reconcile( } // Run garbage collection for stale resources that do not have pruning disabled. - if _, err := r.prune(ctx, resourceManager, obj, revision, staleObjects); err != nil { + if _, err := r.prune(ctx, resourceManager, obj, revision, originRevision, staleObjects); err != nil { conditions.MarkFalse(obj, meta.ReadyCondition, meta.PruneFailedReason, "%s", err) return err } @@ -456,6 +459,7 @@ func (r *KustomizationReconciler) reconcile( patcher, obj, revision, + originRevision, isNewRevision, drifted, changeSet.ToObjMetadataSet()); err != nil { @@ -463,8 +467,9 @@ func (r *KustomizationReconciler) reconcile( return err } - // Set last applied revision. + // Set last applied revisions. obj.Status.LastAppliedRevision = revision + obj.Status.LastAppliedOriginRevision = originRevision // Mark the object as ready. conditions.MarkTrue(obj, @@ -656,6 +661,7 @@ func (r *KustomizationReconciler) apply(ctx context.Context, manager *ssa.ResourceManager, obj *kustomizev1.Kustomization, revision string, + originRevision string, objects []*unstructured.Unstructured) (bool, *ssa.ChangeSet, error) { log := ctrl.LoggerFrom(ctx) @@ -841,7 +847,7 @@ func (r *KustomizationReconciler) apply(ctx context.Context, // emit event only if the server-side apply resulted in changes applyLog := strings.TrimSuffix(changeSetLog.String(), "\n") if applyLog != "" { - r.event(obj, revision, eventv1.EventSeverityInfo, applyLog, nil) + r.event(obj, revision, originRevision, eventv1.EventSeverityInfo, applyLog, nil) } return applyLog != "", resultSet, nil @@ -852,6 +858,7 @@ func (r *KustomizationReconciler) checkHealth(ctx context.Context, patcher *patch.SerialPatcher, obj *kustomizev1.Kustomization, revision string, + originRevision string, isNewRevision bool, drifted bool, objects object.ObjMetadataSet) error { @@ -910,7 +917,7 @@ func (r *KustomizationReconciler) checkHealth(ctx context.Context, // Emit recovery event if the previous health check failed. msg := fmt.Sprintf("Health check passed in %s", time.Since(checkStart).String()) if !wasHealthy || (isNewRevision && drifted) { - r.event(obj, revision, eventv1.EventSeverityInfo, msg, nil) + r.event(obj, revision, originRevision, eventv1.EventSeverityInfo, msg, nil) } conditions.MarkTrue(obj, meta.HealthyCondition, meta.SucceededReason, "%s", msg) @@ -925,6 +932,7 @@ func (r *KustomizationReconciler) prune(ctx context.Context, manager *ssa.ResourceManager, obj *kustomizev1.Kustomization, revision string, + originRevision string, objects []*unstructured.Unstructured) (bool, error) { if !obj.Spec.Prune { return false, nil @@ -949,7 +957,7 @@ func (r *KustomizationReconciler) prune(ctx context.Context, // emit event only if the prune operation resulted in changes if changeSet != nil && len(changeSet.Entries) > 0 { log.Info(fmt.Sprintf("garbage collection completed: %s", changeSet.String())) - r.event(obj, revision, eventv1.EventSeverityInfo, changeSet.String(), nil) + r.event(obj, revision, originRevision, eventv1.EventSeverityInfo, changeSet.String(), nil) return true, nil } @@ -1004,19 +1012,19 @@ func (r *KustomizationReconciler) finalize(ctx context.Context, changeSet, err := resourceManager.DeleteAll(ctx, objects, opts) if err != nil { - r.event(obj, obj.Status.LastAppliedRevision, eventv1.EventSeverityError, "pruning for deleted resource failed", nil) + r.event(obj, obj.Status.LastAppliedRevision, obj.Status.LastAppliedOriginRevision, eventv1.EventSeverityError, "pruning for deleted resource failed", nil) // Return the error so we retry the failed garbage collection return ctrl.Result{}, err } if changeSet != nil && len(changeSet.Entries) > 0 { - r.event(obj, obj.Status.LastAppliedRevision, eventv1.EventSeverityInfo, changeSet.String(), nil) + r.event(obj, obj.Status.LastAppliedRevision, obj.Status.LastAppliedOriginRevision, eventv1.EventSeverityInfo, changeSet.String(), nil) } } else { // when the account to impersonate is gone, log the stale objects and continue with the finalization msg := fmt.Sprintf("unable to prune objects: \n%s", ssautil.FmtUnstructuredList(objects)) log.Error(fmt.Errorf("skiping pruning, failed to find account to impersonate"), msg) - r.event(obj, obj.Status.LastAppliedRevision, eventv1.EventSeverityError, msg, nil) + r.event(obj, obj.Status.LastAppliedRevision, obj.Status.LastAppliedOriginRevision, eventv1.EventSeverityError, msg, nil) } } @@ -1027,13 +1035,16 @@ func (r *KustomizationReconciler) finalize(ctx context.Context, } func (r *KustomizationReconciler) event(obj *kustomizev1.Kustomization, - revision, severity, msg string, + revision, originRevision, severity, msg string, metadata map[string]string) { if metadata == nil { metadata = map[string]string{} } if revision != "" { - metadata[kustomizev1.GroupVersion.Group+"/revision"] = revision + metadata[kustomizev1.GroupVersion.Group+"/"+eventv1.MetaRevisionKey] = revision + } + if originRevision != "" { + metadata[kustomizev1.GroupVersion.Group+"/"+eventv1.MetaOriginRevisionKey] = originRevision } reason := severity @@ -1108,3 +1119,14 @@ func (r *KustomizationReconciler) patch(ctx context.Context, return nil } + +// getOriginRevision returns the origin revision of the source artifact, +// or the empty string if it's not present, or if the artifact itself +// is not present. +func getOriginRevision(src sourcev1.Source) string { + a := src.GetArtifact() + if a == nil { + return "" + } + return a.Metadata[OCIArtifactOriginRevisionAnnotation] +} diff --git a/internal/controller/kustomization_origin_revision_test.go b/internal/controller/kustomization_origin_revision_test.go new file mode 100644 index 00000000..241bbfdb --- /dev/null +++ b/internal/controller/kustomization_origin_revision_test.go @@ -0,0 +1,127 @@ +/* +Copyright 2022 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 controller + +import ( + "context" + "fmt" + "testing" + "time" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/testserver" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + . "github.com/onsi/gomega" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" +) + +func TestKustomizationReconciler_OriginRevision(t *testing.T) { + g := NewWithT(t) + id := "force-" + randStringRunes(5) + revision := "v1.0.0" + + err := createNamespace(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace") + + err = createKubeConfigSecret(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret") + + manifests := func(name string, data string) []testserver.File { + return []testserver.File{ + { + Name: "secret.yaml", + Body: fmt.Sprintf(`--- +apiVersion: v1 +kind: Secret +metadata: + name: %[1]s +stringData: + key: "%[2]s" +`, name, data), + }, + } + } + + artifact, err := testServer.ArtifactFromFiles(manifests(id, randStringRunes(5))) + g.Expect(err).NotTo(HaveOccurred(), "failed to create artifact from files") + + sourceNamespace := fmt.Sprintf("source-%v", id) + err = createNamespace(sourceNamespace) + g.Expect(err).NotTo(HaveOccurred(), "failed to create source namespace") + + repositoryName := types.NamespacedName{ + Name: randStringRunes(5), + Namespace: sourceNamespace, + } + + err = applyGitRepository(repositoryName, artifact, revision, + withGitRepoArtifactMetadata(OCIArtifactOriginRevisionAnnotation, "orev")) + g.Expect(err).NotTo(HaveOccurred()) + + kustomizationKey := types.NamespacedName{ + Name: fmt.Sprintf("force-%s", randStringRunes(5)), + Namespace: id, + } + kustomization := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: kustomizationKey.Name, + Namespace: kustomizationKey.Namespace, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: reconciliationInterval}, + Path: "./", + KubeConfig: &meta.KubeConfigReference{ + SecretRef: meta.SecretKeyReference{ + Name: "kubeconfig", + }, + }, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Name: repositoryName.Name, + Namespace: repositoryName.Namespace, + Kind: sourcev1.GitRepositoryKind, + }, + TargetNamespace: id, + }, + } + + g.Expect(k8sClient.Create(context.Background(), kustomization)).To(Succeed()) + + resultK := &kustomizev1.Kustomization{} + readyCondition := &metav1.Condition{} + + g.Eventually(func() bool { + _ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK) + readyCondition = apimeta.FindStatusCondition(resultK.Status.Conditions, meta.ReadyCondition) + return resultK.Status.LastAppliedRevision == revision + }, timeout, time.Second).Should(BeTrue()) + + g.Expect(readyCondition.Reason).To(Equal(meta.ReconciliationSucceededReason)) + + events := getEvents(kustomizationKey.Name, nil) + g.Expect(events).To(Not(BeEmpty())) + + annotationKey := kustomizev1.GroupVersion.Group + "/" + eventv1.MetaOriginRevisionKey + for _, e := range events { + g.Expect(e.GetAnnotations()).To(HaveKeyWithValue(annotationKey, "orev")) + } +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index a9640b8f..bd4bbb6f 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -276,7 +276,29 @@ func createKubeConfigSecret(namespace string) error { return k8sClient.Create(context.Background(), secret) } -func applyGitRepository(objKey client.ObjectKey, artifactName string, revision string) error { +type gitRepoOption func(*gitRepoOptions) + +type gitRepoOptions struct { + artifactMetadata map[string]string +} + +func withGitRepoArtifactMetadata(k, v string) gitRepoOption { + return func(o *gitRepoOptions) { + if o.artifactMetadata == nil { + o.artifactMetadata = make(map[string]string) + } + o.artifactMetadata[k] = v + } +} + +func applyGitRepository(objKey client.ObjectKey, artifactName string, + revision string, opts ...gitRepoOption) error { + + var opt gitRepoOptions + for _, o := range opts { + o(&opt) + } + repo := &sourcev1.GitRepository{ TypeMeta: metav1.TypeMeta{ Kind: sourcev1.GitRepositoryKind, @@ -312,15 +334,16 @@ func applyGitRepository(objKey client.ObjectKey, artifactName string, revision s Revision: revision, Digest: dig.String(), LastUpdateTime: metav1.Now(), + Metadata: opt.artifactMetadata, }, } - opt := []client.PatchOption{ + patchOpts := []client.PatchOption{ client.ForceOwnership, client.FieldOwner("kustomize-controller"), } - if err := k8sClient.Patch(context.Background(), repo, client.Apply, opt...); err != nil { + if err := k8sClient.Patch(context.Background(), repo, client.Apply, patchOpts...); err != nil { return err }