Skip to content

Commit ca604f3

Browse files
authored
Merge pull request #1502 from fluxcd/status-history
Track reconciliation attempts over time in `.status.history`
2 parents 6b2e5e8 + cf3e7b7 commit ca604f3

File tree

8 files changed

+165
-7
lines changed

8 files changed

+165
-7
lines changed

api/v1/kustomization_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,11 @@ type KustomizationStatus struct {
297297
// have been successfully applied.
298298
// +optional
299299
Inventory *ResourceInventory `json:"inventory,omitempty"`
300+
301+
// History contains a set of snapshots of the last reconciliation attempts
302+
// tracking the revision, the state and the duration of each attempt.
303+
// +optional
304+
History meta.History `json:"history,omitempty"`
300305
}
301306

302307
// GetTimeout returns the timeout with default.

api/v1/zz_generated.deepcopy.go

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,57 @@ spec:
591591
- type
592592
type: object
593593
type: array
594+
history:
595+
description: |-
596+
History contains a set of snapshots of the last reconciliation attempts
597+
tracking the revision, the state and the duration of each attempt.
598+
items:
599+
description: |-
600+
Snapshot represents a point-in-time record of a group of resources reconciliation,
601+
including timing information, status, and a unique digest identifier.
602+
properties:
603+
digest:
604+
description: Digest is the checksum in the format `<algo>:<hex>`
605+
of the resources in this snapshot.
606+
type: string
607+
firstReconciled:
608+
description: FirstReconciled is the time when this revision
609+
was first reconciled to the cluster.
610+
format: date-time
611+
type: string
612+
lastReconciled:
613+
description: LastReconciled is the time when this revision was
614+
last reconciled to the cluster.
615+
format: date-time
616+
type: string
617+
lastReconciledDuration:
618+
description: LastReconciledDuration is time it took to reconcile
619+
the resources in this revision.
620+
type: string
621+
lastReconciledStatus:
622+
description: LastReconciledStatus is the status of the last
623+
reconciliation.
624+
type: string
625+
metadata:
626+
additionalProperties:
627+
type: string
628+
description: Metadata contains additional information about
629+
the snapshot.
630+
type: object
631+
totalReconciliations:
632+
description: TotalReconciliations is the total number of reconciliations
633+
that have occurred for this snapshot.
634+
format: int64
635+
type: integer
636+
required:
637+
- digest
638+
- firstReconciled
639+
- lastReconciled
640+
- lastReconciledDuration
641+
- lastReconciledStatus
642+
- totalReconciliations
643+
type: object
644+
type: array
594645
inventory:
595646
description: |-
596647
Inventory contains the list of Kubernetes resource object references that

docs/api/v1/kustomize.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,21 @@ ResourceInventory
11471147
have been successfully applied.</p>
11481148
</td>
11491149
</tr>
1150+
<tr>
1151+
<td>
1152+
<code>history</code><br>
1153+
<em>
1154+
<a href="https://godoc.org/github.com/fluxcd/pkg/apis/meta#History">
1155+
github.com/fluxcd/pkg/apis/meta.History
1156+
</a>
1157+
</em>
1158+
</td>
1159+
<td>
1160+
<em>(Optional)</em>
1161+
<p>History contains a set of snapshots of the last reconciliation attempts
1162+
tracking the revision, the state and the duration of each attempt.</p>
1163+
</td>
1164+
</tr>
11501165
</tbody>
11511166
</table>
11521167
</div>

docs/spec/v1/kustomizations.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,6 +2066,37 @@ configuration issue in the Kustomization spec. When a reconciliation fails, the
20662066
`Reconciling` Condition `reason` would be `ProgressingWithRetry`. When the
20672067
reconciliation is performed again after the failure, the `reason` is updated to `Progressing`.
20682068

2069+
### History
2070+
2071+
The kustomize-controller maintains a history of the last 5 reconciliations
2072+
in `.status.history`, including the digest of the applied manifests, the
2073+
source and origin revision, the timestamps and the duration of the reconciliations,
2074+
the status and the total number of times a specific digest was reconciled.
2075+
2076+
```yaml
2077+
status:
2078+
history:
2079+
- digest: sha256:43ad78c94b2655429d84f21488f29d7cca9cd45b7f54d2b27e16bbec8eff9228
2080+
firstReconciled: "2025-08-15T10:11:00Z"
2081+
lastReconciled: "2025-08-15T11:12:00Z"
2082+
lastReconciledDuration: 2.818583s
2083+
lastReconciledStatus: ReconciliationSucceeded
2084+
totalReconciliations: 2
2085+
metadata:
2086+
revision: "v1.0.1@sha1:450796ddb2ab6724ee1cc32a4be56da032d1cca0"
2087+
- digest: sha256:ec8dbfe61777b65001190260cf873ffe454451bd2e464bd6f9a154cffcdcd7e5
2088+
firstReconciled: "2025-07-14T13:10:00Z"
2089+
lastReconciled: "2025-08-15T10:00:00Z"
2090+
lastReconciledDuration: 49.813292s
2091+
lastReconciledStatus: HealthCheckFailed
2092+
totalReconciliations: 120
2093+
metadata:
2094+
revision: "v1.0.0@sha1:67e2c98a60dc92283531412a9e604dd4bae005a9"
2095+
```
2096+
2097+
The kustomize-controller deduplicates entries based on the digest and status, with the
2098+
most recent reconciliation being the first entry in the list.
2099+
20692100
### Inventory
20702101

20712102
In order to perform operations such as drift detection, garbage collection, etc.

internal/controller/kustomization_controller.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727

2828
securejoin "github.com/cyphar/filepath-securejoin"
2929
celtypes "github.com/google/cel-go/common/types"
30+
"github.com/opencontainers/go-digest"
3031
corev1 "k8s.io/api/core/v1"
3132
apierrors "k8s.io/apimachinery/pkg/api/errors"
3233
apimeta "k8s.io/apimachinery/pkg/api/meta"
@@ -281,6 +282,7 @@ func (r *KustomizationReconciler) reconcile(
281282
src sourcev1.Source,
282283
patcher *patch.SerialPatcher,
283284
statusReaders []func(apimeta.RESTMapper) engine.StatusReader) error {
285+
reconcileStart := time.Now()
284286
log := ctrl.LoggerFrom(ctx)
285287

286288
// Update status with the reconciliation progress.
@@ -314,13 +316,14 @@ func (r *KustomizationReconciler) reconcile(
314316
}(tmpDir)
315317

316318
// Download artifact and extract files to the tmp dir.
317-
if err = fetch.NewArchiveFetcherWithLogger(
318-
r.artifactFetchRetries,
319-
tar.UnlimitedUntarSize,
320-
tar.UnlimitedUntarSize,
321-
os.Getenv("SOURCE_CONTROLLER_LOCALHOST"),
322-
ctrl.LoggerFrom(ctx),
323-
).Fetch(src.GetArtifact().URL, src.GetArtifact().Digest, tmpDir); err != nil {
319+
fetcher := fetch.New(
320+
fetch.WithLogger(ctrl.LoggerFrom(ctx)),
321+
fetch.WithRetries(r.artifactFetchRetries),
322+
fetch.WithMaxDownloadSize(tar.UnlimitedUntarSize),
323+
fetch.WithUntar(tar.WithMaxUntarSize(tar.UnlimitedUntarSize)),
324+
fetch.WithHostnameOverwrite(os.Getenv("SOURCE_CONTROLLER_LOCALHOST")),
325+
)
326+
if err = fetcher.Fetch(src.GetArtifact().URL, src.GetArtifact().Digest, tmpDir); err != nil {
324327
conditions.MarkFalse(obj, meta.ReadyCondition, meta.ArtifactFailedReason, "%s", err)
325328
return err
326329
}
@@ -398,6 +401,13 @@ func (r *KustomizationReconciler) reconcile(
398401
return err
399402
}
400403

404+
// Calculate the digest of the built resources for history tracking.
405+
checksum := digest.FromBytes(resources).String()
406+
historyMeta := map[string]string{"revision": revision}
407+
if originRevision != "" {
408+
historyMeta["originRevision"] = originRevision
409+
}
410+
401411
// Convert the build result into Kubernetes unstructured objects.
402412
objects, err := ssautil.ReadObjects(bytes.NewReader(resources))
403413
if err != nil {
@@ -423,6 +433,7 @@ func (r *KustomizationReconciler) reconcile(
423433
// Validate and apply resources in stages.
424434
drifted, changeSet, err := r.apply(ctx, resourceManager, obj, revision, originRevision, objects)
425435
if err != nil {
436+
obj.Status.History.Upsert(checksum, time.Now(), time.Since(reconcileStart), meta.ReconciliationFailedReason, historyMeta)
426437
conditions.MarkFalse(obj, meta.ReadyCondition, meta.ReconciliationFailedReason, "%s", err)
427438
return err
428439
}
@@ -431,6 +442,7 @@ func (r *KustomizationReconciler) reconcile(
431442
newInventory := inventory.New()
432443
err = inventory.AddChangeSet(newInventory, changeSet)
433444
if err != nil {
445+
obj.Status.History.Upsert(checksum, time.Now(), time.Since(reconcileStart), meta.ReconciliationFailedReason, historyMeta)
434446
conditions.MarkFalse(obj, meta.ReadyCondition, meta.ReconciliationFailedReason, "%s", err)
435447
return err
436448
}
@@ -441,12 +453,14 @@ func (r *KustomizationReconciler) reconcile(
441453
// Detect stale resources which are subject to garbage collection.
442454
staleObjects, err := inventory.Diff(oldInventory, newInventory)
443455
if err != nil {
456+
obj.Status.History.Upsert(checksum, time.Now(), time.Since(reconcileStart), meta.ReconciliationFailedReason, historyMeta)
444457
conditions.MarkFalse(obj, meta.ReadyCondition, meta.ReconciliationFailedReason, "%s", err)
445458
return err
446459
}
447460

448461
// Run garbage collection for stale resources that do not have pruning disabled.
449462
if _, err := r.prune(ctx, resourceManager, obj, revision, originRevision, staleObjects); err != nil {
463+
obj.Status.History.Upsert(checksum, time.Now(), time.Since(reconcileStart), meta.PruneFailedReason, historyMeta)
450464
conditions.MarkFalse(obj, meta.ReadyCondition, meta.PruneFailedReason, "%s", err)
451465
return err
452466
}
@@ -462,6 +476,7 @@ func (r *KustomizationReconciler) reconcile(
462476
isNewRevision,
463477
drifted,
464478
changeSet.ToObjMetadataSet()); err != nil {
479+
obj.Status.History.Upsert(checksum, time.Now(), time.Since(reconcileStart), meta.HealthCheckFailedReason, historyMeta)
465480
conditions.MarkFalse(obj, meta.ReadyCondition, meta.HealthCheckFailedReason, "%s", err)
466481
return err
467482
}
@@ -475,6 +490,11 @@ func (r *KustomizationReconciler) reconcile(
475490
meta.ReadyCondition,
476491
meta.ReconciliationSucceededReason,
477492
"Applied revision: %s", revision)
493+
obj.Status.History.Upsert(checksum,
494+
time.Now(),
495+
time.Since(reconcileStart),
496+
meta.ReconciliationSucceededReason,
497+
historyMeta)
478498

479499
return nil
480500
}

internal/controller/kustomization_origin_revision_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,15 @@ stringData:
115115

116116
g.Expect(resultK.Status.LastAppliedOriginRevision).To(Equal("orev"))
117117

118+
g.Expect(resultK.Status.History).To(HaveLen(1))
119+
g.Expect(resultK.Status.History[0].Digest).ToNot(BeEmpty())
120+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(1))
121+
g.Expect(resultK.Status.History[0].FirstReconciled.Time).ToNot(BeZero())
122+
g.Expect(resultK.Status.History[0].LastReconciled.Time).ToNot(BeZero())
123+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
124+
g.Expect(resultK.Status.History[0].LastReconciledDuration.Duration).To(BeNumerically(">", 0))
125+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(revision, "orev"))
126+
118127
events := getEvents(kustomizationKey.Name, nil)
119128
g.Expect(events).To(Not(BeEmpty()))
120129

internal/controller/kustomization_wait_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ parameters:
136136
g.Expect(resultK.Status.ObservedGeneration).To(BeIdenticalTo(resultK.Generation))
137137

138138
kstatusCheck.CheckErr(ctx, resultK)
139+
140+
g.Expect(resultK.Status.History).To(HaveLen(1))
141+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(1))
142+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
143+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(revision))
139144
})
140145

141146
t.Run("reports progressing status", func(t *testing.T) {
@@ -192,6 +197,11 @@ parameters:
192197
g.Expect(resultK.Status.ObservedGeneration).To(BeIdenticalTo(resultK.Generation - 1))
193198

194199
kstatusCheck.CheckErr(ctx, resultK)
200+
201+
g.Expect(resultK.Status.History).To(HaveLen(2))
202+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(1))
203+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.HealthCheckFailedReason))
204+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(revision))
195205
})
196206

197207
t.Run("emits unhealthy event", func(t *testing.T) {
@@ -228,6 +238,11 @@ parameters:
228238
g.Expect(resultK.Status.ObservedGeneration).To(BeIdenticalTo(resultK.Generation))
229239

230240
kstatusCheck.CheckErr(ctx, resultK)
241+
242+
g.Expect(resultK.Status.History).To(HaveLen(2))
243+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(2))
244+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
245+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(revision))
231246
})
232247

233248
t.Run("emits recovery event", func(t *testing.T) {
@@ -258,6 +273,11 @@ parameters:
258273
g.Expect(resultK.Status.LastAttemptedRevision).To(BeIdenticalTo(resultK.Status.LastAppliedRevision))
259274

260275
kstatusCheck.CheckErr(ctx, resultK)
276+
277+
g.Expect(resultK.Status.History).To(HaveLen(3))
278+
g.Expect(resultK.Status.History[0].TotalReconciliations).To(BeEquivalentTo(1))
279+
g.Expect(resultK.Status.History[0].LastReconciledStatus).To(Equal(meta.ReconciliationSucceededReason))
280+
g.Expect(resultK.Status.History[0].Metadata).To(ContainElements(revision))
261281
})
262282

263283
t.Run("emits event for the new revision", func(t *testing.T) {

0 commit comments

Comments
 (0)