Skip to content

Commit b8e4bd4

Browse files
committed
HYPERFLEET-706 - fix: lastUpdateTime for Ready
1 parent 89c8376 commit b8e4bd4

6 files changed

Lines changed: 392 additions & 36 deletions

File tree

docs/api-resources.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ The status object contains synthesized conditions computed from adapter reports:
456456
- All above fields plus:
457457
- `observed_generation` - Generation this condition reflects
458458
- `created_time` - When condition was first created (API-managed)
459-
- `last_updated_time` - When adapter last reported (API-managed, from AdapterStatus.last_report_time)
459+
- `last_updated_time` - When this condition was last refreshed (API-managed). For **Available**, always the evaluation time. For **Ready**: when Ready=True, the minimum of `last_report_time` across all required adapters that report Available=True at the current generation; when Ready=False, the evaluation time (so consumers can detect staleness).
460460
- `last_transition_time` - When status last changed (API-managed)
461461

462462
## Parameter Restrictions

pkg/services/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func NewClusterService(dao, adapterStatusDao, config) ClusterService
2525
- **Available**: True if all required adapters report `Available=True` (any generation)
2626
- **Ready**: True if all adapters report `Available=True` AND `observed_generation` matches current generation
2727

28+
Ready's `LastUpdatedTime` is computed in `status_aggregation.computeReadyLastUpdated`: when Ready=False it is the evaluation time (so Sentinel can apply a freshness threshold); when Ready=True it is the minimum of `LastReportTime` across required adapters that have Available=True at the current generation.
29+
2830
`ProcessAdapterStatus()` validates mandatory conditions (`Available`, `Applied`, `Health`) before persisting. Rejects `Available=Unknown` on subsequent reports (only allowed on first report).
2931

3032
## GenericService

pkg/services/cluster_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,9 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
767767
StatusConditions: initialConditionsJSON,
768768
}
769769
cluster.ID = clusterID
770+
beforeCreate := time.Now()
770771
created, svcErr := service.Create(ctx, cluster)
772+
afterCreate := time.Now()
771773
Expect(svcErr).To(BeNil())
772774

773775
var createdConds []api.ResourceCondition
@@ -787,12 +789,17 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
787789
Expect(createdReady).ToNot(BeNil())
788790
Expect(createdAvailable.CreatedTime).To(Equal(fixedNow))
789791
Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow))
792+
// Available.LastUpdatedTime is preserved from the initial conditions when status and generation are unchanged.
790793
Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow))
791794
Expect(createdReady.CreatedTime).To(Equal(fixedNow))
792795
Expect(createdReady.LastTransitionTime).To(Equal(fixedNow))
793-
Expect(createdReady.LastUpdatedTime).To(Equal(fixedNow))
796+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false; assert it lies in the Create() window.
797+
Expect(createdReady.LastUpdatedTime).To(BeTemporally(">=", beforeCreate))
798+
Expect(createdReady.LastUpdatedTime).To(BeTemporally("<=", afterCreate))
794799

800+
beforeUpdate := time.Now()
795801
updated, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID)
802+
afterUpdate := time.Now()
796803
Expect(err).To(BeNil())
797804

798805
var updatedConds []api.ResourceCondition
@@ -812,10 +819,14 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
812819
Expect(updatedReady).ToNot(BeNil())
813820
Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow))
814821
Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow))
822+
// Available.LastUpdatedTime is stable when status and generation are unchanged.
815823
Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow))
816824
Expect(updatedReady.CreatedTime).To(Equal(fixedNow))
817825
Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow))
818-
Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow))
826+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false;
827+
// assert it lies in the UpdateClusterStatusFromAdapters() window.
828+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
829+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
819830
}
820831

821832
// TestProcessAdapterStatus_MissingMandatoryCondition_Available tests that updates missing Available are rejected

pkg/services/node_pool_test.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,9 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
627627
StatusConditions: initialConditionsJSON,
628628
}
629629
nodePool.ID = nodePoolID
630+
beforeCreate := time.Now()
630631
created, svcErr := service.Create(ctx, nodePool)
632+
afterCreate := time.Now()
631633
Expect(svcErr).To(BeNil())
632634

633635
var createdConds []api.ResourceCondition
@@ -647,12 +649,17 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
647649
Expect(createdReady).ToNot(BeNil())
648650
Expect(createdAvailable.CreatedTime).To(Equal(fixedNow))
649651
Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow))
652+
// Available.LastUpdatedTime is preserved from the initial conditions when status and generation are unchanged.
650653
Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow))
651654
Expect(createdReady.CreatedTime).To(Equal(fixedNow))
652655
Expect(createdReady.LastTransitionTime).To(Equal(fixedNow))
653-
Expect(createdReady.LastUpdatedTime).To(Equal(fixedNow))
656+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false; assert it lies in the Create() window.
657+
Expect(createdReady.LastUpdatedTime).To(BeTemporally(">=", beforeCreate))
658+
Expect(createdReady.LastUpdatedTime).To(BeTemporally("<=", afterCreate))
654659

660+
beforeUpdate := time.Now()
655661
updated, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID)
662+
afterUpdate := time.Now()
656663
Expect(err).To(BeNil())
657664

658665
var updatedConds []api.ResourceCondition
@@ -672,8 +679,12 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
672679
Expect(updatedReady).ToNot(BeNil())
673680
Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow))
674681
Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow))
682+
// Available.LastUpdatedTime is stable when status and generation are unchanged.
675683
Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow))
676684
Expect(updatedReady.CreatedTime).To(Equal(fixedNow))
677685
Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow))
678-
Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow))
686+
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false;
687+
// assert it lies in the UpdateNodePoolStatusFromAdapters() window.
688+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
689+
Expect(updatedReady.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
679690
}

pkg/services/status_aggregation.go

Lines changed: 133 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package services
22

33
import (
4+
"context"
45
"encoding/json"
6+
"fmt"
57
"math"
68
"strings"
79
"time"
810
"unicode"
911

1012
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
13+
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
1114
)
1215

1316
// Mandatory condition types that must be present in all adapter status updates
@@ -19,6 +22,9 @@ const (
1922
ConditionValidationErrorMissing = "missing"
2023
)
2124

25+
// adapterConditionStatusTrue is the string value for a True adapter condition status.
26+
const adapterConditionStatusTrue = "True"
27+
2228
// Required adapter lists configured via pkg/config/adapter.go (see AdapterRequirementsConfig)
2329

2430
// adapterConditionSuffixMap allows overriding the default suffix for specific adapters
@@ -118,18 +124,21 @@ func ComputeAvailableCondition(adapterStatuses api.AdapterStatusList, requiredAd
118124
Status string `json:"status"`
119125
}
120126
if len(adapterStatus.Conditions) > 0 {
121-
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err == nil {
122-
for _, cond := range conditions {
123-
if cond.Type == api.ConditionTypeAvailable {
124-
adapterMap[adapterStatus.Adapter] = struct {
125-
available string
126-
observedGeneration int32
127-
}{
128-
available: cond.Status,
129-
observedGeneration: adapterStatus.ObservedGeneration,
130-
}
131-
break
127+
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil {
128+
logger.WithError(context.Background(), err).Warn(
129+
fmt.Sprintf("failed to parse adapter conditions for adapter %s", adapterStatus.Adapter))
130+
continue
131+
}
132+
for _, cond := range conditions {
133+
if cond.Type == api.ConditionTypeAvailable {
134+
adapterMap[adapterStatus.Adapter] = struct {
135+
available string
136+
observedGeneration int32
137+
}{
138+
available: cond.Status,
139+
observedGeneration: adapterStatus.ObservedGeneration,
132140
}
141+
break
133142
}
134143
}
135144
}
@@ -149,7 +158,7 @@ func ComputeAvailableCondition(adapterStatuses api.AdapterStatusList, requiredAd
149158

150159
// For Available condition, we don't check generation matching
151160
// We just need Available=True at ANY generation
152-
if adapterInfo.available == "True" {
161+
if adapterInfo.available == adapterConditionStatusTrue {
153162
numAvailable++
154163
if adapterInfo.observedGeneration < minObservedGeneration {
155164
minObservedGeneration = adapterInfo.observedGeneration
@@ -189,18 +198,21 @@ func ComputeReadyCondition(
189198
Status string `json:"status"`
190199
}
191200
if len(adapterStatus.Conditions) > 0 {
192-
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err == nil {
193-
for _, cond := range conditions {
194-
if cond.Type == api.ConditionTypeAvailable {
195-
adapterMap[adapterStatus.Adapter] = struct {
196-
available string
197-
observedGeneration int32
198-
}{
199-
available: cond.Status,
200-
observedGeneration: adapterStatus.ObservedGeneration,
201-
}
202-
break
201+
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil {
202+
logger.WithError(context.Background(), err).Warn(
203+
fmt.Sprintf("failed to parse adapter conditions for adapter %s", adapterStatus.Adapter))
204+
continue
205+
}
206+
for _, cond := range conditions {
207+
if cond.Type == api.ConditionTypeAvailable {
208+
adapterMap[adapterStatus.Adapter] = struct {
209+
available string
210+
observedGeneration int32
211+
}{
212+
available: cond.Status,
213+
observedGeneration: adapterStatus.ObservedGeneration,
203214
}
215+
break
204216
}
205217
}
206218
}
@@ -224,7 +236,7 @@ func ComputeReadyCondition(
224236
}
225237

226238
// Check available status
227-
if adapterInfo.available == "True" {
239+
if adapterInfo.available == adapterConditionStatusTrue {
228240
numReady++
229241
}
230242
}
@@ -234,6 +246,79 @@ func ComputeReadyCondition(
234246
return numReady == numRequired
235247
}
236248

249+
// findAdapterStatus returns the first adapter status in the list with the given adapter name, or (nil, false).
250+
func findAdapterStatus(adapterStatuses api.AdapterStatusList, adapterName string) (*api.AdapterStatus, bool) {
251+
for _, s := range adapterStatuses {
252+
if s.Adapter == adapterName {
253+
return s, true
254+
}
255+
}
256+
return nil, false
257+
}
258+
259+
// adapterConditionsHasAvailableTrue returns true if the adapter conditions JSON
260+
// contains a condition with type Available and status True.
261+
func adapterConditionsHasAvailableTrue(conditions []byte) bool {
262+
if len(conditions) == 0 {
263+
return false
264+
}
265+
var conds []struct {
266+
Type string `json:"type"`
267+
Status string `json:"status"`
268+
}
269+
if err := json.Unmarshal(conditions, &conds); err != nil {
270+
return false
271+
}
272+
for _, c := range conds {
273+
if c.Type == api.ConditionTypeAvailable && c.Status == adapterConditionStatusTrue {
274+
return true
275+
}
276+
}
277+
return false
278+
}
279+
280+
// computeReadyLastUpdated returns the timestamp to use for the Ready condition's LastUpdatedTime.
281+
// When isReady is false, it returns now (Ready=False changes frequently; 10s threshold applies).
282+
// When isReady is true, it returns the minimum LastReportTime across all required adapters
283+
// that have Available=True at the current generation. Falls back to now if no timestamps found.
284+
func computeReadyLastUpdated(
285+
adapterStatuses api.AdapterStatusList,
286+
requiredAdapters []string,
287+
resourceGeneration int32,
288+
now time.Time,
289+
isReady bool,
290+
) time.Time {
291+
if !isReady {
292+
return now
293+
}
294+
295+
var minTime *time.Time
296+
for _, adapterName := range requiredAdapters {
297+
status, ok := findAdapterStatus(adapterStatuses, adapterName)
298+
if !ok {
299+
return now // safety: required adapter missing
300+
}
301+
if status.LastReportTime == nil {
302+
return now // safety: no timestamp
303+
}
304+
if status.ObservedGeneration != resourceGeneration {
305+
continue // not at current gen, skip
306+
}
307+
if !adapterConditionsHasAvailableTrue(status.Conditions) {
308+
continue
309+
}
310+
if minTime == nil || status.LastReportTime.Before(*minTime) {
311+
t := *status.LastReportTime
312+
minTime = &t
313+
}
314+
}
315+
316+
if minTime == nil {
317+
return now // safety fallback
318+
}
319+
return *minTime
320+
}
321+
237322
func BuildSyntheticConditions(
238323
existingConditionsJSON []byte,
239324
adapterStatuses api.AdapterStatusList,
@@ -271,7 +356,14 @@ func BuildSyntheticConditions(
271356
CreatedTime: now,
272357
LastUpdatedTime: now,
273358
}
274-
preserveSyntheticCondition(&availableCondition, existingAvailable, now)
359+
availableLastUpdated := now
360+
if existingAvailable != nil &&
361+
existingAvailable.Status == availableStatus &&
362+
existingAvailable.ObservedGeneration == minObservedGeneration &&
363+
!existingAvailable.LastUpdatedTime.IsZero() {
364+
availableLastUpdated = existingAvailable.LastUpdatedTime
365+
}
366+
applyConditionHistory(&availableCondition, existingAvailable, now, availableLastUpdated)
275367

276368
isReady := ComputeReadyCondition(adapterStatuses, requiredAdapters, resourceGeneration)
277369
readyStatus := api.ConditionFalse
@@ -286,13 +378,25 @@ func BuildSyntheticConditions(
286378
CreatedTime: now,
287379
LastUpdatedTime: now,
288380
}
289-
preserveSyntheticCondition(&readyCondition, existingReady, now)
381+
readyLastUpdated := computeReadyLastUpdated(
382+
adapterStatuses, requiredAdapters, resourceGeneration, now, isReady,
383+
)
384+
applyConditionHistory(&readyCondition, existingReady, now, readyLastUpdated)
290385

291386
return availableCondition, readyCondition
292387
}
293388

294-
func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.ResourceCondition, now time.Time) {
389+
// applyConditionHistory copies stable timestamps and metadata from an existing condition.
390+
// lastUpdatedTime is used unconditionally for LastUpdatedTime — the caller is responsible
391+
// for computing the correct value (e.g. now, computeReadyLastUpdated(...)).
392+
func applyConditionHistory(
393+
target *api.ResourceCondition,
394+
existing *api.ResourceCondition,
395+
now time.Time,
396+
lastUpdatedTime time.Time,
397+
) {
295398
if existing == nil {
399+
target.LastUpdatedTime = lastUpdatedTime
296400
return
297401
}
298402

@@ -303,9 +407,7 @@ func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.Res
303407
if !existing.LastTransitionTime.IsZero() {
304408
target.LastTransitionTime = existing.LastTransitionTime
305409
}
306-
if !existing.LastUpdatedTime.IsZero() {
307-
target.LastUpdatedTime = existing.LastUpdatedTime
308-
}
410+
target.LastUpdatedTime = lastUpdatedTime
309411
if target.Reason == nil && existing.Reason != nil {
310412
target.Reason = existing.Reason
311413
}
@@ -319,5 +421,5 @@ func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.Res
319421
target.CreatedTime = existing.CreatedTime
320422
}
321423
target.LastTransitionTime = now
322-
target.LastUpdatedTime = now
424+
target.LastUpdatedTime = lastUpdatedTime
323425
}

0 commit comments

Comments
 (0)