Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ The status object contains synthesized conditions computed from adapter reports:
- All above fields plus:
- `observed_generation` - Generation this condition reflects
- `created_time` - When condition was first created (API-managed)
- `last_updated_time` - When adapter last reported (API-managed, from AdapterStatus.last_report_time)
- `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).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority: Inconsistency

The doc on line 459 says Available's last_updated_time is "always the
evaluation time", but the code in BuildSyntheticConditions preserves the
existing value when status and ObservedGeneration are unchanged (lines 359-365
of status_aggregation.go). The test
TestBuildSyntheticConditions_AvailableLastUpdatedTime_Stable confirms the
preservation behavior.

Whichever direction the fix goes for the Available behavior (per Xue Li's
comment), please update the doc to match. If preservation is kept, something
like: "For Available, the evaluation time when the status or
observed_generation changes; otherwise preserved from the previous
evaluation."

- `last_transition_time` - When status last changed (API-managed)

## Parameter Restrictions
Expand Down
2 changes: 2 additions & 0 deletions pkg/services/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func NewClusterService(dao, adapterStatusDao, config) ClusterService
- **Available**: True if all required adapters report `Available=True` (any generation)
- **Ready**: True if all adapters report `Available=True` AND `observed_generation` matches current generation

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.

`ProcessAdapterStatus()` validates mandatory conditions (`Available`, `Applied`, `Health`) before persisting. Rejects `Available=Unknown` on subsequent reports (only allowed on first report).

## GenericService
Expand Down
15 changes: 13 additions & 2 deletions pkg/services/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,9 @@ func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
StatusConditions: initialConditionsJSON,
}
cluster.ID = clusterID
beforeCreate := time.Now()
created, svcErr := service.Create(ctx, cluster)
afterCreate := time.Now()
Expect(svcErr).To(BeNil())

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

beforeUpdate := time.Now()
updated, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID)
afterUpdate := time.Now()
Expect(err).To(BeNil())

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

// TestProcessAdapterStatus_MissingMandatoryCondition_Available tests that updates missing Available are rejected
Expand Down
15 changes: 13 additions & 2 deletions pkg/services/node_pool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,9 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
StatusConditions: initialConditionsJSON,
}
nodePool.ID = nodePoolID
beforeCreate := time.Now()
created, svcErr := service.Create(ctx, nodePool)
afterCreate := time.Now()
Expect(svcErr).To(BeNil())

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

beforeUpdate := time.Now()
updated, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID)
afterUpdate := time.Now()
Expect(err).To(BeNil())

var updatedConds []api.ResourceCondition
Expand All @@ -672,8 +679,12 @@ func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) {
Expect(updatedReady).ToNot(BeNil())
Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow))
Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow))
// Available.LastUpdatedTime is stable when status and generation are unchanged.
Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow))
Expect(updatedReady.CreatedTime).To(Equal(fixedNow))
Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow))
Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow))
// Ready.LastUpdatedTime is refreshed to the evaluation time when isReady=false;
// assert it lies in the UpdateNodePoolStatusFromAdapters() window.
Expect(updatedReady.LastUpdatedTime).To(BeTemporally(">=", beforeUpdate))
Expect(updatedReady.LastUpdatedTime).To(BeTemporally("<=", afterUpdate))
}
164 changes: 133 additions & 31 deletions pkg/services/status_aggregation.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package services

import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"unicode"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger"
)

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

// adapterConditionStatusTrue is the string value for a True adapter condition status.
const adapterConditionStatusTrue = "True"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority: Pattern

adapterConditionStatusTrue = "True" (line 26) duplicates
api.AdapterConditionTrue (AdapterConditionStatus("True") in
status_types.go:19). This creates a second source of truth.

Consider either:

  • Using string(api.AdapterConditionTrue) at the comparison sites (lines 161,
    239, 273), or
  • Deriving the constant explicitly: const adapterConditionStatusTrue =
    string(api.AdapterConditionTrue)

Both options keep a single source of truth while satisfying the string comparison.


// Required adapter lists configured via pkg/config/adapter.go (see AdapterRequirementsConfig)

// adapterConditionSuffixMap allows overriding the default suffix for specific adapters
Expand Down Expand Up @@ -118,18 +124,21 @@ func ComputeAvailableCondition(adapterStatuses api.AdapterStatusList, requiredAd
Status string `json:"status"`
}
if len(adapterStatus.Conditions) > 0 {
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err == nil {
for _, cond := range conditions {
if cond.Type == api.ConditionTypeAvailable {
adapterMap[adapterStatus.Adapter] = struct {
available string
observedGeneration int32
}{
available: cond.Status,
observedGeneration: adapterStatus.ObservedGeneration,
}
break
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil {
logger.WithError(context.Background(), err).Warn(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority: Pattern

context.Background() at lines 128 and 202 means these warn logs won't carry
request-scoped metadata (cluster_id, trace_id, request_id) that callers like
UpdateClusterStatusFromAdapters have available. If a corrupt conditions blob
causes unexpected Available/Ready results in production, correlating these
logs to a specific cluster would require timestamp matching.

Not blocking, but consider threading context.Context through
ComputeAvailableCondition and ComputeReadyCondition so the logs inherit the
caller's context. Alternatively, a // TODO noting the limitation would help
future maintainers.

fmt.Sprintf("failed to parse adapter conditions for adapter %s", adapterStatus.Adapter))
continue
}
for _, cond := range conditions {
if cond.Type == api.ConditionTypeAvailable {
adapterMap[adapterStatus.Adapter] = struct {
available string
observedGeneration int32
}{
available: cond.Status,
observedGeneration: adapterStatus.ObservedGeneration,
}
break
}
}
}
Expand All @@ -149,7 +158,7 @@ func ComputeAvailableCondition(adapterStatuses api.AdapterStatusList, requiredAd

// For Available condition, we don't check generation matching
// We just need Available=True at ANY generation
if adapterInfo.available == "True" {
if adapterInfo.available == adapterConditionStatusTrue {
numAvailable++
if adapterInfo.observedGeneration < minObservedGeneration {
minObservedGeneration = adapterInfo.observedGeneration
Expand Down Expand Up @@ -189,18 +198,21 @@ func ComputeReadyCondition(
Status string `json:"status"`
}
if len(adapterStatus.Conditions) > 0 {
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err == nil {
for _, cond := range conditions {
if cond.Type == api.ConditionTypeAvailable {
adapterMap[adapterStatus.Adapter] = struct {
available string
observedGeneration int32
}{
available: cond.Status,
observedGeneration: adapterStatus.ObservedGeneration,
}
break
if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil {
logger.WithError(context.Background(), err).Warn(
fmt.Sprintf("failed to parse adapter conditions for adapter %s", adapterStatus.Adapter))
continue
}
for _, cond := range conditions {
if cond.Type == api.ConditionTypeAvailable {
adapterMap[adapterStatus.Adapter] = struct {
available string
observedGeneration int32
}{
available: cond.Status,
observedGeneration: adapterStatus.ObservedGeneration,
}
break
}
}
}
Expand All @@ -224,7 +236,7 @@ func ComputeReadyCondition(
}

// Check available status
if adapterInfo.available == "True" {
if adapterInfo.available == adapterConditionStatusTrue {
numReady++
}
}
Expand All @@ -234,6 +246,79 @@ func ComputeReadyCondition(
return numReady == numRequired
}

// findAdapterStatus returns the first adapter status in the list with the given adapter name, or (nil, false).
func findAdapterStatus(adapterStatuses api.AdapterStatusList, adapterName string) (*api.AdapterStatus, bool) {
for _, s := range adapterStatuses {
if s.Adapter == adapterName {
return s, true
}
}
return nil, false
}

// adapterConditionsHasAvailableTrue returns true if the adapter conditions JSON
// contains a condition with type Available and status True.
func adapterConditionsHasAvailableTrue(conditions []byte) bool {
if len(conditions) == 0 {
return false
}
var conds []struct {
Type string `json:"type"`
Status string `json:"status"`
}
if err := json.Unmarshal(conditions, &conds); err != nil {
return false
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Double JSON unmarshal per adapter

BuildSyntheticConditions calls ComputeReadyCondition() which unmarshals each adapter's conditions JSON to check Available=True. Then computeReadyLastUpdated() unmarshals the same JSON again via adapterConditionsHasAvailableTrue().

Each adapter's conditions are parsed twice per evaluation cycle. Not a problem at current scale, but ComputeReadyCondition already builds an adapterMap with parsed results — computeReadyLastUpdated could accept that map instead of re-parsing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a micro-optimization, building a map of 2 entries
I explicitly changed it to make the code more readable

Copy link
Contributor

@rafabene rafabene Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority: Inconsistency

This also silently returns false on
json.Unmarshal error, but this same PR adds explicit
logger.WithError(...).Warn(...) for the identical unmarshal failure in
ComputeAvailableCondition (line 128) and ComputeReadyCondition (line 202).
Corrupt adapter conditions reaching computeReadyLastUpdated would produce no
log entry, making the silent skip invisible to operators.

Consider adding logging here too, or restructuring so computeReadyLastUpdated
reuses the already-parsed results from ComputeReadyCondition (which would also
address @xueli181114 's double-unmarshal comment).

}
for _, c := range conds {
if c.Type == api.ConditionTypeAvailable && c.Status == adapterConditionStatusTrue {
return true
}
}
return false
}

// computeReadyLastUpdated returns the timestamp to use for the Ready condition's LastUpdatedTime.
// When isReady is false, it returns now (Ready=False changes frequently; 10s threshold applies).
// When isReady is true, it returns the minimum LastReportTime across all required adapters
// that have Available=True at the current generation. Falls back to now if no timestamps found.
func computeReadyLastUpdated(
adapterStatuses api.AdapterStatusList,
requiredAdapters []string,
resourceGeneration int32,
now time.Time,
isReady bool,
) time.Time {
if !isReady {
return now
}

var minTime *time.Time
for _, adapterName := range requiredAdapters {
status, ok := findAdapterStatus(adapterStatuses, adapterName)
if !ok {
return now // safety: required adapter missing
}
if status.LastReportTime == nil {
return now // safety: no timestamp
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Guards are unreachable when isReady=true

When isReady=true, ComputeReadyCondition has already confirmed ALL required adapters have Available=True at current generation. So:

  • The ObservedGeneration != resourceGenerationcontinue branch can't fire
  • The !adapterConditionsHasAvailableTrue()continue branch can't fire
  • minTime can never be nil after the loop

These aren't harmful (defense-in-depth), but a comment like // Redundant when isReady=true; provides safety if called independently would clarify intent and prevent someone from thinking these are load-bearing checks.

if status.ObservedGeneration != resourceGeneration {
continue // not at current gen, skip
Comment on lines +304 to +305
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep the generation filter aligned with ComputeReadyCondition.

Line 304 is stricter than ComputeReadyCondition, which only enforces generation equality when resourceGeneration > 0. If a caller ever passes 0 here, BuildSyntheticConditions can return Ready=True while computeReadyLastUpdated() skips every adapter and falls back to now, so the new staleness signal disappears on that path. Please mirror the same guard here and add a regression test for the zero-generation case.

Suggested fix
-		if status.ObservedGeneration != resourceGeneration {
+		if resourceGeneration > 0 && status.ObservedGeneration != resourceGeneration {
 			continue // not at current gen, skip
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if status.ObservedGeneration != resourceGeneration {
continue // not at current gen, skip
if resourceGeneration > 0 && status.ObservedGeneration != resourceGeneration {
continue // not at current gen, skip
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/services/status_aggregation.go` around lines 304 - 305, The generation
check that currently skips adapters when status.ObservedGeneration !=
resourceGeneration should be relaxed to match ComputeReadyCondition's behavior:
only enforce generation equality when resourceGeneration > 0; i.e., if
resourceGeneration == 0 allow adapters regardless of ObservedGeneration. Update
the condition inside the function containing that check (look for references to
status.ObservedGeneration, resourceGeneration, and computeReadyLastUpdated)
accordingly, and add a regression unit test that calls BuildSyntheticConditions
(or the public entry that uses it) with resourceGeneration == 0 to assert Ready
remains computed and the staleness signal is produced rather than being skipped.

}
if !adapterConditionsHasAvailableTrue(status.Conditions) {
continue
}
if minTime == nil || status.LastReportTime.Before(*minTime) {
t := *status.LastReportTime
minTime = &t
}
}

if minTime == nil {
return now // safety fallback
}
return *minTime
}

func BuildSyntheticConditions(
existingConditionsJSON []byte,
adapterStatuses api.AdapterStatusList,
Expand Down Expand Up @@ -271,7 +356,14 @@ func BuildSyntheticConditions(
CreatedTime: now,
LastUpdatedTime: now,
}
preserveSyntheticCondition(&availableCondition, existingAvailable, now)
availableLastUpdated := now
if existingAvailable != nil &&
existingAvailable.Status == availableStatus &&
existingAvailable.ObservedGeneration == minObservedGeneration &&
!existingAvailable.LastUpdatedTime.IsZero() {
availableLastUpdated = existingAvailable.LastUpdatedTime
}
applyConditionHistory(&availableCondition, existingAvailable, now, availableLastUpdated)

isReady := ComputeReadyCondition(adapterStatuses, requiredAdapters, resourceGeneration)
readyStatus := api.ConditionFalse
Expand All @@ -286,13 +378,25 @@ func BuildSyntheticConditions(
CreatedTime: now,
LastUpdatedTime: now,
}
preserveSyntheticCondition(&readyCondition, existingReady, now)
readyLastUpdated := computeReadyLastUpdated(
adapterStatuses, requiredAdapters, resourceGeneration, now, isReady,
)
applyConditionHistory(&readyCondition, existingReady, now, readyLastUpdated)

return availableCondition, readyCondition
}

func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.ResourceCondition, now time.Time) {
// applyConditionHistory copies stable timestamps and metadata from an existing condition.
// lastUpdatedTime is used unconditionally for LastUpdatedTime — the caller is responsible
// for computing the correct value (e.g. now, computeReadyLastUpdated(...)).
func applyConditionHistory(
target *api.ResourceCondition,
existing *api.ResourceCondition,
now time.Time,
lastUpdatedTime time.Time,
) {
if existing == nil {
target.LastUpdatedTime = lastUpdatedTime
return
}

Expand All @@ -303,9 +407,7 @@ func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.Res
if !existing.LastTransitionTime.IsZero() {
target.LastTransitionTime = existing.LastTransitionTime
}
if !existing.LastUpdatedTime.IsZero() {
target.LastUpdatedTime = existing.LastUpdatedTime
}
target.LastUpdatedTime = lastUpdatedTime
if target.Reason == nil && existing.Reason != nil {
target.Reason = existing.Reason
}
Expand All @@ -319,5 +421,5 @@ func preserveSyntheticCondition(target *api.ResourceCondition, existing *api.Res
target.CreatedTime = existing.CreatedTime
}
target.LastTransitionTime = now
target.LastUpdatedTime = now
target.LastUpdatedTime = lastUpdatedTime
}
Loading