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