Skip to content

Conversation

@rh-amarin
Copy link
Contributor

@rh-amarin rh-amarin commented Jan 22, 2026

https://issues.redhat.com/browse/HYPERFLEET-386

Depends on:

This PR aligns the API with the changes in the openshift-hyperfleet/architecture#79

Removes from the root of resources state:

  • phase
  • observed_generation
  • last_transition_time
  • last_updated_time

The information on these fields is superseded by the information in the status.conditions

  • Available
  • Ready

It introduces also a way to query by status.conditions

curl -G http://localhost:8000/api/hyperfleet/v1/clusters \
  --data-urlencode "search=status.conditions.Ready='True

And it creates a postgreSQL index for this (usage of the index has to be better tested when we do performance tests)

The PR is split in different commits to make it easier the reviewing

What follows is a guide to do the PR:

  • Explaining the changes in each commit
  • Explaining the introduced tests in cucumber format

PR Review Guide: HYPERFLEET-386

Summary

This PR replaces the phase-based status model with Kubernetes-style conditions for Clusters and NodePools. The change provides:

  • Ready condition: Indicates all adapters have reported at the current spec generation
  • Available condition: Indicates all adapters report Available=True (at any known good generation)

Review Order

The PR begins with establishing the new API contract (81ea64b - feat: update OpenAPI spec for conditions-based status), defining the ResourceConditionStatus (True/False) and AdapterConditionStatus
(True/False/Unknown) types that form the foundation of the Kubernetes-style conditions model. With the contract in place, the next commit (823ac7e - refactor: remove phase validation from types) cleans up the
old phase-based logic from domain types, making room for the new condition semantics. The test infrastructure is then updated (5dd7445 - test: update test factories for conditions) to use conditions instead
of phases, ensuring factories produce valid test data under the new model. Comprehensive integration tests follow (d7cac7f - test: add integration tests for conditions), covering the full lifecycle of
Ready/Available transitions, adapter status processing, idempotency, and the 204 No Content behavior for Unknown statuses. Finally, the documentation is updated (db6bb14 - docs: update documentation for
conditions) to reflect the new search syntax (status.conditions.Ready='True'), condition semantics, and API response formats.

We recommend reviewing in this logical order, which groups related changes together:

1. Domain Types (Start Here)

Understanding the new condition model is key to reviewing everything else.

File What Changed
pkg/api/status_types.go Defines ResourceConditionStatus (True/False) vs AdapterConditionStatus (True/False/Unknown)
pkg/api/status_types_test.go Tests for condition type validation

Key points:

  • Resources (Cluster, NodePool) only use True or False - never Unknown
  • Adapters can report Unknown when status is indeterminate
  • This separation prevents ambiguous states in aggregated resource status
2. Status Aggregation Logic

Core business logic for computing Ready/Available from adapter reports.

File What Changed
pkg/services/status_aggregation.go New ComputeReadyCondition(), ComputeAvailableCondition(), and InitializeConditions() replacing ComputePhase()

Key logic:

  • Ready = All required adapters have Available=True at current spec.generation
  • Available = All required adapters have Available=True at ANY generation (last known good)
  • After spec change: Ready→False immediately, Available stays True until adapters report failure
  • InitializeConditions() creates empty conditions array for new resources
3. Presenters (API Response Formatting)

How conditions are presented in API responses.

File What Changed
pkg/api/presenters/cluster.go Formats response, converts domain types to OpenAPI types
pkg/api/presenters/cluster_test.go Tests for cluster presentation
pkg/api/presenters/node_pool.go Formats response, converts domain types to OpenAPI types
pkg/api/presenters/node_pool_test.go Tests for nodepool presentation
pkg/api/presenters/adapter_status.go Now uses AdapterConditionStatus type
pkg/api/presenters/adapter_status_test.go Tests for adapter status presentation

Key points:

  • Presenters only format responses - condition initialization is handled by the service layer
  • Response formatting converts domain types to OpenAPI types
4. Service Layer Changes

Business logic for condition initialization and updates based on adapter reports.

File What Changed
pkg/services/cluster.go Condition initialization on creation, UpdateConditionsFromAdapterStatus() logic
pkg/services/cluster_test.go Comprehensive tests for condition initialization and updates
pkg/services/node_pool.go Same pattern as cluster
pkg/services/node_pool_test.go Tests for nodepool condition initialization and updates
pkg/services/generic.go Removed phase-related code
pkg/services/generic_test.go Updated tests

Key behavior:

  • New resources get default conditions: Available=False, Ready=False, reason AwaitingAdapters (initialized by service layer)
  • Conditions are updated when adapter status is POSTed
  • If adapter report doesn't change conditions → returns nil, nil (no-op)
  • Ready/Available conditions have different update semantics (see status_aggregation.go)
5. Handler Changes

HTTP layer changes.

File What Changed
pkg/handlers/framework.go New handleCreateWithNoContent() for 204 responses
pkg/handlers/cluster_status.go Uses new handler, returns 204 on no-op
pkg/handlers/nodepool_status.go Uses new handler, returns 204 on no-op

Key behavior:

  • POST /clusters/{id}/statuses returns:
    • 201 Created - status updated, conditions changed
    • 204 No Content - adapter report received but no condition change (no-op)
6. Database & Search

Query support for condition-based filtering.

File What Changed
pkg/db/sql_helpers.go conditionsNodeConverter() for TSL→SQL translation
pkg/db/sql_helpers_test.go Tests for condition query parsing
pkg/db/migrations/202601210001_add_conditions_gin_index.go New - GIN index for condition queries
pkg/db/migrations/202511111044_add_clusters.go Removed status_phase column
pkg/db/migrations/202511111055_add_node_pools.go Removed status_phase column
pkg/db/migrations/migration_structs.go Migration registry update

New search syntax:

# Old (removed)
status.phase='Ready'

# New
status.conditions.Ready='True'
status.conditions.Available='True'

Query translation:

-- status.conditions.Ready='True' becomes:
jsonb_path_query_first(status_conditions, '$[*] ? (@.type == "Ready")') ->> 'status' = 'True'
7. API Contract & OpenAPI

API specification updates.

File What Changed
openapi/openapi.yaml Removed phase, added ResourceConditionStatus vs AdapterConditionStatus

Breaking changes:

  • Removed: status.phase field
  • Added: status.conditions[] array with Ready/Available conditions
  • ResourceCondition.status enum: True, False only (not Unknown)
  • AdapterCondition.status enum: True, False, Unknown
8. Type Cleanup

Removed obsolete phase-related code.

File What Changed
pkg/api/cluster_types.go Removed phase validation
pkg/api/cluster_types_test.go Removed phase tests
pkg/api/node_pool_types.go Removed phase validation
pkg/api/node_pool_types_test.go Removed phase tests
9. Test Factories

Updated test helpers.

File What Changed
test/factories/clusters.go Updated to use conditions instead of phase
test/factories/node_pools.go Updated to use conditions instead of phase
10. Integration Tests

End-to-end test coverage.

File What Changed
test/integration/adapter_status_test.go Tests for condition updates via adapter status POST
test/integration/api_contract_test.go Updated contract tests for new response format
test/integration/search_field_mapping_test.go Tests for status.conditions.* search
11. Documentation
File What Changed
docs/api-resources.md Updated examples, search syntax, condition semantics
AGENTS.md Updated agent configuration docs

Test Coverage

  • Unit tests: pkg/api/status_types_test.go, pkg/services/*_test.go
  • Integration tests: test/integration/adapter_status_test.go
  • Search tests: test/integration/search_field_mapping_test.go

Test Scenarios (Cucumber Format)

Feature: Adapter Status Processing

Scenario: ProcessAdapterStatus returns nil for Unknown Available (unit test)

Source: pkg/services/cluster_test.goTestProcessAdapterStatus_UnknownCondition
Source: pkg/services/node_pool_test.goTestNodePoolProcessAdapterStatus_UnknownCondition

Given a cluster service with mock DAOs
When ProcessAdapterStatus is called with Available=Unknown
Then err is nil
And result is nil
And no adapter status is stored in the mock DAO

Scenario: POST with Unknown Available returns 204 (integration test)

Source: test/integration/adapter_status_test.goTestClusterStatusPost_UnknownReturns204

Given a cluster exists
When an adapter POSTs status with Available=Unknown
Then the API returns 204 No Content
And GET /clusters/{id}/statuses does not contain "test-adapter-unknown"

Scenario: ProcessAdapterStatus stores status for Available=True (unit test)

Source: pkg/services/cluster_test.goTestProcessAdapterStatus_TrueCondition
Source: pkg/services/node_pool_test.goTestNodePoolProcessAdapterStatus_TrueCondition

Given a cluster exists with generation 1 in mock DAO
When ProcessAdapterStatus is called with Available=True
Then err is nil
And result is not nil
And result.Adapter equals "test-adapter"
And 1 adapter status is stored in the mock DAO

Scenario: POST adapter status returns 201 Created (integration test)

Source: test/integration/adapter_status_test.goTestClusterStatusPost

Given a cluster exists
When an adapter POSTs status with Ready=True at observedGeneration=cluster.Generation
Then the API returns 201 Created
And response.Adapter equals "test-adapter"
And response.ObservedGeneration equals cluster.Generation
And response.Conditions has at least 1 item

Scenario: ProcessAdapterStatus stores status for Available=False (unit test)

Source: pkg/services/cluster_test.goTestProcessAdapterStatus_FalseCondition

Given a cluster exists with generation 1 in mock DAO
When ProcessAdapterStatus is called with Available=False
Then err is nil
And result is not nil
And 1 adapter status is stored in the mock DAO

Scenario: ProcessAdapterStatus returns nil when multiple conditions include Available=Unknown (unit test)

Source: pkg/services/cluster_test.goTestProcessAdapterStatus_MultipleConditions_AvailableUnknown
Source: pkg/services/node_pool_test.goTestNodePoolProcessAdapterStatus_MultipleConditions_AvailableUnknown

Given a cluster service with mock DAOs
When ProcessAdapterStatus is called with conditions:
  | Type        | Status  |
  | Ready       | True    |
  | Available   | Unknown |
  | Progressing | True    |
Then err is nil
And result is nil
And no adapter status is stored in the mock DAO

Scenario: POST with multiple conditions including Available=Unknown returns 204 (integration test)

Source: test/integration/adapter_status_test.goTestClusterStatusPost_MultipleConditionsWithUnknownAvailable

Given a cluster exists
When an adapter POSTs status with Ready=True, Available=Unknown, Progressing=True
Then the API returns 204 No Content

Scenario: ProcessAdapterStatus stores status when no Available condition present (unit test)

Source: pkg/services/cluster_test.goTestProcessAdapterStatus_NoAvailableCondition

Given a cluster exists with generation 1 in mock DAO
When ProcessAdapterStatus is called with only Ready=True (no Available condition)
Then err is nil
And result is not nil
And 1 adapter status is stored in the mock DAO

Scenario: NodePool POST with Unknown Available returns 204 (integration test)

Source: test/integration/adapter_status_test.goTestNodePoolStatusPost_UnknownReturns204

Given a nodepool exists
When an adapter POSTs status with Available=Unknown
Then the API returns 204 No Content
And GET nodepool statuses does not contain "test-nodepool-adapter-unknown"
Feature: Available/Ready Condition Transitions

Scenario: Full lifecycle of condition transitions for Cluster (unit test)

Source: pkg/services/cluster_test.goTestClusterAvailableReadyTransitions

Given a cluster with generation=1 and required adapters ["validation", "dns"]
When the cluster is created
Then Available=False and Ready=False

When adapter "validation" reports Available=True at generation=1
Then Available=False and Ready=False (partial adapters)

When adapter "dns" reports Available=True at generation=1
Then Available=True and Ready=True (all required adapters reported)

When cluster generation is bumped to 2
Then Available=True (last known good) and Ready=False (not at current generation)
And Available.ObservedGeneration=1 and Ready.ObservedGeneration=2

When adapter "validation" reports Available=True at generation=2
Then Available=True and Ready=False (still waiting for all adapters at gen=2)
And Available.ObservedGeneration=1 (minObservedGeneration still 1)

When adapter "dns" reports Available=True at generation=2
Then Available=True and Ready=True (all adapters at current generation)
And Available.ObservedGeneration=2 and Ready.ObservedGeneration=2

Scenario: Full lifecycle of condition transitions for NodePool (unit test)

Source: pkg/services/node_pool_test.goTestNodePoolAvailableReadyTransitions

Given a nodepool with generation=1 and required adapters ["validation", "hypershift"]
When the nodepool is created
Then Available=False and Ready=False

When adapter "validation" reports Available=True at generation=1
Then Available=False and Ready=False (partial adapters)

When adapter "hypershift" reports Available=True at generation=1
Then Available=True and Ready=True (all required adapters reported)

When nodepool generation is bumped to 2
Then Available=True (last known good) and Ready=False (not at current generation)
And Available.ObservedGeneration=1 and Ready.ObservedGeneration=2

When adapter "validation" reports Available=True at generation=2
Then Available=True and Ready=False (still waiting for all adapters at gen=2)
And Available.ObservedGeneration=1 (minObservedGeneration still 1)

When adapter "hypershift" reports Available=True at generation=2
Then Available=True and Ready=True (all adapters at current generation)
And Available.ObservedGeneration=2 and Ready.ObservedGeneration=2
Feature: Adapter Status Idempotency

Scenario: Same adapter posts twice - upsert not duplicate (integration test)

Source: test/integration/adapter_status_test.goTestAdapterStatusIdempotency

Given a cluster exists
When adapter "idempotency-test-adapter" POSTs status with Ready=False
Then the API returns 201 Created
And response.Conditions[0].Status equals False

When adapter "idempotency-test-adapter" POSTs status with Ready=True
Then the API returns 201 Created
And response.Conditions[0].Status equals True

When GET /clusters/{id}/statuses is called
Then exactly 1 adapter status exists for "idempotency-test-adapter"
And that status shows Conditions[0].Status equals True
Feature: Condition Search Query Translation

Scenario: Ready condition translates to correct SQL

Source: pkg/db/sql_helpers_test.goTestConditionsNodeConverter (case: "Ready condition True")

Given a TSL node for "status.conditions.Ready = 'True'"
When conditionsNodeConverter is called
Then the SQL is: jsonb_path_query_first(status_conditions, ?::jsonpath) ->> 'status' = ?
And args[0] is: $[*] ? (@.type == "Ready")
And args[1] is: True

Scenario: Available condition translates to correct SQL

Source: pkg/db/sql_helpers_test.goTestConditionsNodeConverter (case: "Available condition True")

Given a TSL node for "status.conditions.Available = 'True'"
When conditionsNodeConverter is called
Then the SQL is: jsonb_path_query_first(status_conditions, ?::jsonpath) ->> 'status' = ?
And args[0] is: $[*] ? (@.type == "Available")
And args[1] is: True

Scenario: Invalid condition status returns error

Source: pkg/db/sql_helpers_test.goTestConditionsNodeConverter (case: "Invalid condition status")

Given a TSL node for "status.conditions.Ready = 'Invalid'"
When conditionsNodeConverter is called
Then an error is returned

Scenario: Lowercase condition type returns error

Source: pkg/db/sql_helpers_test.goTestConditionsNodeConverter (case: "Invalid condition type - lowercase")

Given a TSL node for "status.conditions.ready = 'True'"
When conditionsNodeConverter is called
Then an error is returned

Scenario: ExtractConditionQueries extracts single condition

Source: pkg/db/sql_helpers_test.goTestExtractConditionQueries (case: "Single condition query")

Given search query "status.conditions.Ready='True'"
When ExtractConditionQueries is called
Then 1 condition query is extracted
And the condition SQL matches expected format

Scenario: ExtractConditionQueries extracts multiple conditions

Source: pkg/db/sql_helpers_test.goTestExtractConditionQueries (case: "Multiple condition queries")

Given search query "status.conditions.Ready='True' AND status.conditions.Available='True'"
When ExtractConditionQueries is called
Then 2 condition queries are extracted

Scenario: ExtractConditionQueries handles mixed queries

Source: pkg/db/sql_helpers_test.goTestExtractConditionQueries (case: "Mixed query with condition")

Given search query "name='test' AND status.conditions.Ready='True'"
When ExtractConditionQueries is called
Then 1 condition query is extracted

Scenario: ExtractConditionQueries handles no conditions

Source: pkg/db/sql_helpers_test.goTestExtractConditionQueries (case: "No condition queries")

Given search query "name='test'"
When ExtractConditionQueries is called
Then 0 condition queries are extracted
Feature: Condition Field Detection

Scenario: hasCondition detects condition fields

Source: pkg/db/sql_helpers_test.goTestHasCondition

Given field "status.conditions.Ready"
When hasCondition is called
Then result is true

Given field "status.other_field"
When hasCondition is called
Then result is false

Given field "labels.environment"
When hasCondition is called
Then result is false

Given field "name"
When hasCondition is called
Then result is false
Feature: Condition Type Validation

Scenario Outline: conditionTypePattern matches valid PascalCase types

Source: pkg/db/sql_helpers_test.goTestConditionTypeValidation

Given condition type "<type>"
When conditionTypePattern.MatchString is called
Then result is true

Examples:
  | type            |
  | Ready           |
  | Available       |
  | Progressing     |
  | CustomCondition |
  | Ready2          |

Scenario Outline: conditionTypePattern rejects invalid types

Source: pkg/db/sql_helpers_test.goTestConditionTypeValidation

Given condition type "<type>"
When conditionTypePattern.MatchString is called
Then result is false

Examples:
  | type         |
  | ready        |
  | 2Ready       |
  | Ready_State  |
  | Ready-State  |
  | (empty)      |
Feature: Condition Status Values

Scenario: ResourceConditionStatus constants have correct string values

Source: pkg/api/status_types_test.goTestConditionStatus_Constants

When checking ResourceConditionStatus constants
Then string(ConditionTrue) equals "True"
And string(ConditionFalse) equals "False"

Scenario: AdapterConditionStatus constants have correct string values

Source: pkg/api/status_types_test.goTestAdapterConditionStatus_Constants

When checking AdapterConditionStatus constants
Then string(AdapterConditionTrue) equals "True"
And string(AdapterConditionFalse) equals "False"
And string(AdapterConditionUnknown) equals "Unknown"

Scenario: AdapterConditionStatus string conversion

Source: pkg/api/status_types_test.goTestAdapterConditionStatus_StringConversion

Given string "Unknown"
When cast to AdapterConditionStatus
Then result equals AdapterConditionUnknown

Given AdapterConditionFalse
When cast to string
Then result equals "False"

Scenario: ResourceConditionStatus string conversion

Source: pkg/api/status_types_test.goTestConditionStatus_StringConversion

Given string "True"
When cast to ResourceConditionStatus
Then result equals ConditionTrue

Given ConditionFalse
When cast to string
Then result equals "False"

Scenario: validConditionStatuses map contains correct values

Source: pkg/db/sql_helpers_test.goTestConditionStatusValidation

When checking validConditionStatuses map
Then "True" maps to true
And "False" maps to true
And "Unknown" maps to true
And "true" maps to false
And "false" maps to false
And "unknown" maps to false
And "Yes" maps to false
And "No" maps to false
And "" maps to false
Feature: Condition JSON Serialization

Scenario: ResourceCondition serializes to JSON with all fields

Source: pkg/api/status_types_test.goTestResourceCondition_JSONSerialization

Given a ResourceCondition with type="Ready", status=True, observedGeneration=5, reason="TestReason"
When json.Marshal is called
Then JSON contains "observed_generation": 5
And JSON contains "type": "Ready"
And JSON contains "status": "True"
And JSON contains "reason": "TestReason"

Given a ResourceCondition with reason=nil and message=nil
When json.Marshal is called
Then JSON does not contain "reason" key
And JSON does not contain "message" key

Scenario: ResourceCondition deserializes from JSON

Source: pkg/api/status_types_test.goTestResourceCondition_JSONDeserialization

Given JSON with observed_generation=7, type="Validated", status="True", reason="Success"
When json.Unmarshal is called
Then condition.ObservedGeneration equals 7
And condition.Type equals "Validated"
And condition.Status equals ConditionTrue
And *condition.Reason equals "Success"

Given JSON without reason and message fields
When json.Unmarshal is called
Then condition.Reason is nil
And condition.Message is nil

Scenario: ResourceCondition round-trip preserves data

Source: pkg/api/status_types_test.goTestResourceCondition_RoundTrip

Given a ResourceCondition with observedGeneration=10, type="HealthCheck", status=True
When json.Marshal then json.Unmarshal is called
Then decoded.ObservedGeneration equals original.ObservedGeneration
And decoded.Type equals original.Type
And decoded.Status equals original.Status
And decoded.Reason equals original.Reason

Scenario: AdapterCondition serializes Unknown status

Source: pkg/api/status_types_test.goTestAdapterCondition_JSONSerialization

Given an AdapterCondition with status=AdapterConditionUnknown
When json.Marshal is called
Then JSON contains "status": "Unknown"

Scenario: AdapterCondition deserializes Unknown status

Source: pkg/api/status_types_test.goTestAdapterCondition_JSONDeserialization

Given JSON with type="Available", status="Unknown"
When json.Unmarshal is called
Then condition.Status equals AdapterConditionUnknown

Scenario: AdapterCondition round-trip preserves data

Source: pkg/api/status_types_test.goTestAdapterCondition_RoundTrip

Given an AdapterCondition with type="Provisioned", status=True, reason="TestReason"
When json.Marshal then json.Unmarshal is called
Then decoded.Type equals original.Type
And decoded.Status equals original.Status
And decoded.Reason equals original.Reason
Feature: Pagination

Scenario: Pagination returns correct page size (integration test)

Source: test/integration/adapter_status_test.goTestAdapterStatusPaging

Given a cluster with 10 adapter statuses created
When GET /clusters/{id}/statuses with page=1, pageSize=5
Then response.Items has at most 5 items
And response.Page equals 1

Scenario: Page beyond total returns empty (integration test)

Source: test/integration/adapter_status_test.goTestAdapterStatusPagingEdgeCases

Given a cluster with 10 adapter statuses
When GET /clusters/{id}/statuses with page=100, pageSize=5
Then response.Items has 0 items
And response.Total equals 10

Scenario: Empty cluster returns zero total (integration test)

Source: test/integration/adapter_status_test.goTestAdapterStatusPagingEdgeCases

Given a cluster with no adapter statuses
When GET /clusters/{id}/statuses
Then response.Total equals 0
And response.Items has 0 items

Scenario: Single item pagination (integration test)

Source: test/integration/adapter_status_test.goTestAdapterStatusPagingEdgeCases

Given a cluster with 1 adapter status
When GET /clusters/{id}/statuses
Then response.Total equals 1
And response.Items has 1 item
And response.Page equals 1

Scenario: Pagination across pages returns all items without duplicates (integration test)

Source: test/integration/adapter_status_test.goTestAdapterStatusPagingEdgeCases

Given a cluster with 10 adapter statuses
When iterating GET /clusters/{id}/statuses with pageSize=3 until empty
Then all 10 unique adapters are retrieved
And no adapter appears more than once

Checklist for Reviewer

  • Condition semantics are clear and documented
  • 204 No Content behavior is appropriate
  • Search query translation handles edge cases
  • Database migration is idempotent
  • OpenAPI spec matches implementation
  • Breaking change is acceptable (phase removal)

Summary by CodeRabbit

  • New Features

    • Replaced single-phase status model with Kubernetes-style conditions (Available and Ready) for improved resource state tracking.
    • Updated status query syntax to use status.conditions.<Type> instead of status.phase for searching and filtering.
    • API version bumped to 1.0.4 with revised status structure across cluster and node pool responses.
  • Documentation

    • Updated API documentation to reflect condition-based status model and new condition semantics.
    • Added examples of condition payloads and condition-based query syntax in API responses.

✏️ Tip: You can customize this high-level summary in your review settings.

@openshift-ci
Copy link

openshift-ci bot commented Jan 22, 2026

Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with /test all

@coderabbitai
Copy link

coderabbitai bot commented Jan 22, 2026

Walkthrough

Replaces the flat phase/timestamp/generation status model with a condition-centric model. Introduces synthesized top-level Kubernetes-style conditions (Available and Ready) and per-condition metadata, migrates Cluster/NodePool statuses to a single JSONB status_conditions column, updates OpenAPI schemas and enums, adds DB expression indexes and a migration for condition lookups, implements condition extraction and SQL expression builders, replaces ComputePhase with ComputeAvailable/ComputeReady and BuildSyntheticConditions, adds ProcessAdapterStatus flows with 204 semantics for Unknown, and updates presenters, handlers, factories, and tests to the new model.

Sequence Diagram(s)

sequenceDiagram
    participant Adapter
    participant API as API Server
    participant Svc as Cluster/NodePool Service
    participant Agg as Status Aggregation
    participant DB as Database

    Adapter->>API: POST /resources/{id}/status (adapter conditions JSON)
    API->>Svc: ProcessAdapterStatus(resourceID, adapterStatus)
    Svc->>DB: Read existing adapter status
    Svc->>DB: Upsert adapter status (if applicable)
    Svc->>Agg: ComputeAvailableCondition(adapterStatuses, requiredAdapters)
    Agg-->>Svc: (isAvailable, minObservedGeneration)
    Svc->>Agg: ComputeReadyCondition(adapterStatuses, requiredAdapters, resourceGeneration)
    Agg-->>Svc: isReady
    Svc->>Svc: BuildSyntheticConditions(Available, Ready, adapter conditions)
    Svc->>DB: Update resource.status_conditions (JSONB)
    API-->>Adapter: 201 Created or 204 No Content
Loading
sequenceDiagram
    participant Client
    participant API as API Server
    participant GenericSvc as Generic List Service
    participant SQLHelper
    participant DB as Database

    Client->>API: GET /clusters?filter=status.conditions.Ready='True'
    API->>GenericSvc: BuildListQuery(TSL)
    GenericSvc->>SQLHelper: ExtractConditionQueries(TSL, tableName)
    SQLHelper->>SQLHelper: Validate condition type/status, build jsonb_path_query_first expr
    SQLHelper-->>GenericSvc: (modifiedTSL, conditionExpressions)
    GenericSvc->>DB: Execute SELECT with appended conditionExpressions (indexed)
    DB->>DB: Use idx_*_ready_status indexes for lookup
    DB-->>API: Return filtered rows
    API-->>Client: Filtered results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • #9 — Modifies status aggregation logic in pkg/services/status_aggregation.go and overlaps with the new ComputeAvailable/ComputeReady approach.
  • #33 — Changes OpenAPI-generated types and presenter/handler code; likely to conflict with openapi/openapi.yaml and presenters updates here.
  • #4 — Implements an earlier condition-centric status model and touches migrations, OpenAPI, and aggregation logic that this PR replaces or extends.

Suggested labels

lgtm, approved

Suggested reviewers

  • mbrudnoy
  • jsell-rh
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.72% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: replacing phase-based status with Kubernetes-style Ready/Available conditions, which is the primary objective throughout the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
pkg/services/generic_test.go (1)

59-71: Add a real status.conditions SQL-translation test.

The comment signals coverage for condition queries, but the test table doesn’t include a status.conditions case. Adding a small test that exercises buildSearchValues with a condition query will protect the new path.

🧪 Proposed test addition
+func TestSQLTranslationStatusConditions(t *testing.T) {
+	RegisterTestingT(t)
+	var dbFactory db.SessionFactory = dbmocks.NewMockSessionFactory()
+	defer dbFactory.Close() //nolint:errcheck
+
+	g := dao.NewGenericDao(&dbFactory)
+	genericService := sqlGenericService{genericDao: g}
+
+	var list []api.Cluster
+	listCtx, model, serviceErr := genericService.newListContext(
+		context.Background(),
+		"",
+		&ListArguments{Search: "status.conditions.Ready='True'"},
+		&list,
+	)
+	Expect(serviceErr).ToNot(HaveOccurred())
+
+	d := g.GetInstanceDao(context.Background(), model)
+	sql, values, serviceErr := genericService.buildSearchValues(listCtx, &d)
+	Expect(serviceErr).ToNot(HaveOccurred())
+	Expect(sql).To(ContainSubstring("jsonb_path_query_first"))
+	Expect(values).To(ConsistOf(`$[*] ? (@.type == "Ready")`, "True"))
+}
pkg/services/generic.go (1)

169-223: Condition extraction + global AND breaks OR/NOT semantics.

Replacing each condition node with 1=1 and then AND‑ing all extracted condition expressions forces every condition to be true, even when the user wrote OR or negated logic. This changes query results (e.g., A OR status.conditions.Ready='True' becomes A AND Ready). Consider preserving the boolean structure by embedding condition predicates into the TSL tree (or extending the SQL walker) instead of post‑ANDing, and add an OR/parentheses test to lock the behavior.

test/integration/search_field_mapping_test.go (1)

88-153: TestSearchCombinedQuery no longer tests a combined query.

The test name/comments say “combined query,” but the search string is label-only. Either rename/adjust comments or reintroduce a combined status+label query.

🧪 Suggested rename/comment fix (label-only)
-// TestSearchCombinedQuery verifies that combined queries (AND/OR)
-// work correctly with field mapping
-func TestSearchCombinedQuery(t *testing.T) {
+// TestSearchLabelRegionQuery verifies label-only queries work correctly
+func TestSearchLabelRegionQuery(t *testing.T) {
@@
-	// Query using combined AND condition with labels (labels search still works)
+	// Query using region label
 	searchStr := "labels.region='us-east'"
@@
-	// Should return matchCluster and wrongStatusCluster but not wrongRegionCluster
+	// Should return the us-east cluster but not wrongRegionCluster
🤖 Fix all issues with AI agents
In `@docs/api-resources.md`:
- Around line 340-355: The NodePool GET example's conditions array omits
required ResourceCondition timestamp fields; update the example (or add a note)
to include created_time, last_updated_time, and last_transition_time for each
condition, referencing the ResourceCondition type and the NodePool "conditions"
field so readers see the expected schema; ensure the example JSON includes those
three timestamp properties for each condition (or add a brief parenthetical like
"timestamps omitted for brevity" if you intentionally leave them out).

In `@openapi/openapi.yaml`:
- Around line 1-4: The openapi/openapi.yaml file is generated and must not be
edited directly; instead, update the TypeSpec source in the hyperfleet-api-spec
repository (the API spec that generates the OpenAPI document) to make your
changes and then run the generation/regeneration process so that the top-level
OpenAPI document (openapi: 3.0.0, info.title: HyperFleet API, info.version) is
rebuilt from the canonical TypeSpec source.

In `@pkg/db/migrations/202601210001_add_conditions_gin_index.go`:
- Around line 8-10: The function addConditionsGinIndex is misnamed because it
creates BTREE expression indexes; rename the function to addConditionsBtreeIndex
and update the migration description/comment accordingly (and any exported
references) so the name matches the index type, or alternatively change the
index creation SQL to use GIN if you intended GIN; locate addConditionsGinIndex
in the migration file and update its name, the migration ID/description inside
the gormigrate.Migration, and any callers to keep identifiers consistent.

In `@pkg/db/sql_helpers_test.go`:
- Around line 99-113: The test's error assertion incorrectly treats any
non-empty error as a match; in the table-driven test block that checks
tt.expectError / tt.errorContains (variables tt, expectError, errorContains,
err), replace the bogus found logic with a proper substring check using
strings.Contains(err.Error(), tt.errorContains) and call t.Errorf (or t.Fatalf)
when the expected substring is not present; ensure you import "strings" if
needed and only perform the contains check when tt.errorContains is non-empty
and err is non-nil.

In `@pkg/db/sql_helpers.go`:
- Around line 148-235: conditionsNodeConverter currently emits an unqualified
status_conditions column which causes "column reference is ambiguous" in
multi-table queries; change the signature of conditionsNodeConverter to accept a
tableName string and use it to qualify the column (e.g., tableName +
".status_conditions") when building the jsonb_path_query_first expression, then
propagate the new parameter through extractConditionsWalk and
ExtractConditionQueries (and all call sites) and update unit tests to pass the
tableName; ensure the jsonPath and rightStr handling remains unchanged and the
returned sq.Expr uses the qualified column reference.

In `@pkg/services/cluster.go`:
- Around line 181-211: The Available/Ready conditions are recreating CreatedTime
and LastTransitionTime as now on every aggregation; preserve historical
timestamps by unmarshaling cluster.StatusConditions to find previous "Available"
and "Ready" api.ResourceCondition entries, copy their CreatedTime into the new
availableCondition/readyCondition, and only update LastTransitionTime when the
Status actually changes (otherwise keep the previous LastTransitionTime); leave
LastUpdatedTime as now and set ObservedGeneration as currently done
(minObservedGeneration and cluster.Generation respectively).
🧹 Nitpick comments (7)
pkg/api/presenters/node_pool.go (1)

30-53: Consider extracting common condition initialization logic.

This initialization pattern for Available and Ready conditions is likely duplicated in the cluster presenter. Consider extracting a helper function like NewInitialConditions() to reduce duplication across presenters.

♻️ Suggested helper function
// In a shared location (e.g., pkg/api/status_types.go or pkg/api/presenters/common.go)
func NewInitialConditions() []api.ResourceCondition {
    now := time.Now()
    return []api.ResourceCondition{
        {
            Type:               "Available",
            Status:             api.ConditionUnknown,
            ObservedGeneration: 0,
            LastTransitionTime: now,
            CreatedTime:        now,
            LastUpdatedTime:    now,
        },
        {
            Type:               "Ready",
            Status:             api.ConditionUnknown,
            ObservedGeneration: 0,
            LastTransitionTime: now,
            CreatedTime:        now,
            LastUpdatedTime:    now,
        },
    }
}
pkg/services/node_pool.go (2)

188-195: Consider preserving CreatedTime from existing conditions.

Setting CreatedTime: now on every status update means the synthetic conditions' creation timestamp will be overwritten each time. Kubernetes convention is to preserve CreatedTime and only update LastUpdatedTime and LastTransitionTime (when status changes).

Consider fetching the existing conditions first and preserving their CreatedTime:

♻️ Suggested approach
+	// Unmarshal existing conditions to preserve CreatedTime
+	var existingConditions []api.ResourceCondition
+	if len(nodePool.StatusConditions) > 0 {
+		_ = json.Unmarshal(nodePool.StatusConditions, &existingConditions)
+	}
+	existingAvailableCreatedTime := now
+	existingReadyCreatedTime := now
+	for _, c := range existingConditions {
+		if c.Type == "Available" {
+			existingAvailableCreatedTime = c.CreatedTime
+		} else if c.Type == "Ready" {
+			existingReadyCreatedTime = c.CreatedTime
+		}
+	}
+
 	availableCondition := api.ResourceCondition{
 		Type:               "Available",
 		Status:             availableStatus,
 		ObservedGeneration: minObservedGeneration,
 		LastTransitionTime: now,
-		CreatedTime:        now,
+		CreatedTime:        existingAvailableCreatedTime,
 		LastUpdatedTime:    now,
 	}

202-209: Same consideration for Ready condition's CreatedTime.

Apply the same preservation logic for the Ready condition's CreatedTime to maintain Kubernetes-style condition semantics.

pkg/services/status_aggregation.go (2)

94-122: Consider extracting adapter map building to reduce duplication.

Both ComputeAvailableCondition and ComputeReadyCondition contain identical logic for building the adapterMap. Extracting this to a helper would improve maintainability.

♻️ Suggested refactor
// adapterInfo holds parsed condition data for an adapter
type adapterInfo struct {
	available          string
	observedGeneration int32
}

// buildAdapterMap extracts Available condition status and generation from adapter statuses
func buildAdapterMap(adapterStatuses api.AdapterStatusList) map[string]adapterInfo {
	adapterMap := make(map[string]adapterInfo)
	for _, adapterStatus := range adapterStatuses {
		var conditions []struct {
			Type   string `json:"type"`
			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 == "Available" {
						adapterMap[adapterStatus.Adapter] = adapterInfo{
							available:          cond.Status,
							observedGeneration: adapterStatus.ObservedGeneration,
						}
						break
					}
				}
			}
		}
	}
	return adapterMap
}

Also applies to: 163-191


204-208: The resourceGeneration > 0 check is always true and unnecessary.

Generation is always initialized to 1 in BeforeCreate hooks (cluster_types.go:49, node_pool_types.go:58). Since ComputeReadyCondition is always called with cluster.Generation or nodePool.Generation (see cluster.go:183, node_pool.go:181), resourceGeneration is guaranteed to be >= 1. The defensive check makes the condition always true; consider removing it to simplify the logic, or documenting why this guard exists if intentionally kept for safety.

test/factories/node_pools.go (1)

125-127: Prefer context-bound DB sessions in factories.

Both updates create a new session with context.Background(). Consider threading a caller context.Context through the factory helpers and retrieving the session via db.NewContext(ctx) to align with transaction lifecycle and cancellation expectations. As per coding guidelines, please prefer db.NewContext(ctx) for DB sessions.

Also applies to: 178-179

test/factories/clusters.go (1)

105-107: Prefer context-bound DB sessions in factories.

Both updates spin up sessions with context.Background(). Consider threading a caller context.Context and using db.NewContext(ctx) for DB access to preserve transaction lifecycle and cancellation semantics. As per coding guidelines, please prefer db.NewContext(ctx) for DB sessions.

Also applies to: 158-160

Comment on lines +8 to +10
// addConditionsGinIndex adds expression indexes on the Ready condition
// within status_conditions JSONB columns for efficient lookups.
func addConditionsGinIndex() *gormigrate.Migration {
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

Function name inconsistent with index type.

The function is named addConditionsGinIndex but creates BTREE indexes, not GIN indexes. This could cause confusion for maintainers.

🔧 Suggested fix
-// addConditionsGinIndex adds expression indexes on the Ready condition
+// addConditionsReadyIndex adds BTREE expression indexes on the Ready condition
 // within status_conditions JSONB columns for efficient lookups.
-func addConditionsGinIndex() *gormigrate.Migration {
+func addConditionsReadyIndex() *gormigrate.Migration {
🤖 Prompt for AI Agents
In `@pkg/db/migrations/202601210001_add_conditions_gin_index.go` around lines 8 -
10, The function addConditionsGinIndex is misnamed because it creates BTREE
expression indexes; rename the function to addConditionsBtreeIndex and update
the migration description/comment accordingly (and any exported references) so
the name matches the index type, or alternatively change the index creation SQL
to use GIN if you intended GIN; locate addConditionsGinIndex in the migration
file and update its name, the migration ID/description inside the
gormigrate.Migration, and any callers to keep identifiers consistent.

Comment on lines +99 to +113
if tt.expectError {
if err == nil {
t.Errorf("Expected error containing %q, but got nil", tt.errorContains)
return
}
if tt.errorContains != "" && err.Error() != "" {
// Check if error message contains expected string
found := false
if errMsg := err.Error(); errMsg != "" {
found = true
}
if !found {
t.Logf("Error message: %s", err.Error())
}
}
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

Error assertions don’t validate the expected message.

The current logic marks any non-empty error as “found,” so errorContains never fails. Use strings.Contains and fail when it doesn’t match.

✅ Fix the assertion
 import (
+	"strings"
 	"testing"
@@
-			if tt.errorContains != "" && err.Error() != "" {
-				// Check if error message contains expected string
-				found := false
-				if errMsg := err.Error(); errMsg != "" {
-					found = true
-				}
-				if !found {
-					t.Logf("Error message: %s", err.Error())
-				}
-			}
+			if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+				t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error())
+			}
 			return
 		}
📝 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 tt.expectError {
if err == nil {
t.Errorf("Expected error containing %q, but got nil", tt.errorContains)
return
}
if tt.errorContains != "" && err.Error() != "" {
// Check if error message contains expected string
found := false
if errMsg := err.Error(); errMsg != "" {
found = true
}
if !found {
t.Logf("Error message: %s", err.Error())
}
}
if tt.expectError {
if err == nil {
t.Errorf("Expected error containing %q, but got nil", tt.errorContains)
return
}
if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error())
}
🤖 Prompt for AI Agents
In `@pkg/db/sql_helpers_test.go` around lines 99 - 113, The test's error assertion
incorrectly treats any non-empty error as a match; in the table-driven test
block that checks tt.expectError / tt.errorContains (variables tt, expectError,
errorContains, err), replace the bogus found logic with a proper substring check
using strings.Contains(err.Error(), tt.errorContains) and call t.Errorf (or
t.Fatalf) when the expected substring is not present; ensure you import
"strings" if needed and only perform the contains check when tt.errorContains is
non-empty and err is non-nil.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/services/node_pool.go (1)

161-168: Guard against Unknown leaking into ResourceCondition status.
Casting AdapterConditionStatus to ResourceConditionStatus can emit "Unknown", which isn’t a valid resource condition status per the new model.

✅ Suggested fix
-		condResource := api.ResourceCondition{
+		if availableCondition.Status == api.AdapterConditionUnknown {
+			continue
+		}
+		status := api.ConditionFalse
+		if availableCondition.Status == api.AdapterConditionTrue {
+			status = api.ConditionTrue
+		}
+		condResource := api.ResourceCondition{
 			Type:               MapAdapterToConditionType(adapterStatus.Adapter),
-			Status:             api.ResourceConditionStatus(availableCondition.Status),
+			Status:             status,
 			Reason:             availableCondition.Reason,
 			Message:            availableCondition.Message,
 			ObservedGeneration: adapterStatus.ObservedGeneration,
 			LastTransitionTime: availableCondition.LastTransitionTime,
 		}
🤖 Fix all issues with AI agents
In `@pkg/api/presenters/cluster_test.go`:
- Around line 355-360: Update the misleading test comment to reflect the actual
assertions: change the comment that says "Verify Status defaults - Available and
Ready conditions initialized as Unknown" to indicate that the defaults are False
(ResourceConditionStatusFalse) for the "Available" and "Ready" conditions; this
refers to the assertions that check result.Status.Conditions,
result.Status.Conditions[0].Type == "Available",
result.Status.Conditions[0].Status == openapi.ResourceConditionStatusFalse and
result.Status.Conditions[1].Type == "Ready", result.Status.Conditions[1].Status
== openapi.ResourceConditionStatusFalse.

In `@pkg/services/node_pool.go`:
- Around line 184-214: When building the synthetic Available and Ready
conditions after ComputeAvailableCondition/ComputeReadyCondition, preserve the
previous CreatedTime and LastTransitionTime when the condition Status did not
change: look up the existing conditions in nodePool.Status.Conditions for Types
"Available" and "Ready", compare their Status to the newly computed
availableStatus/readyStatus, and if unchanged reuse the previous CreatedTime and
LastTransitionTime instead of always using now; still update ObservedGeneration
(use minObservedGeneration for Available and nodePool.Generation for Ready) and
set LastTransitionTime to now only when Status changes.
♻️ Duplicate comments (2)
pkg/services/cluster.go (1)

186-216: Preserve CreatedTime/LastTransitionTime across aggregations.

These timestamps are reset to now on every aggregation, which discards historical transition data. Prefer carrying forward CreatedTime and only updating LastTransitionTime when the condition’s status actually changes.

docs/api-resources.md (1)

356-370: NodePool GET example still omits condition timestamps.
ResourceCondition includes created_time, last_updated_time, and last_transition_time, but the example omits them.

📝 Suggested doc fix
       {
         "type": "Available",
         "status": "True",
         "reason": "ResourceAvailable",
         "message": "NodePool is accessible",
-        "observed_generation": 1
+        "observed_generation": 1,
+        "created_time": "2025-01-01T00:00:00Z",
+        "last_updated_time": "2025-01-01T00:00:00Z",
+        "last_transition_time": "2025-01-01T00:00:00Z"
       },
       {
         "type": "Ready",
         "status": "True",
         "reason": "ResourceReady",
         "message": "All adapters report ready at current generation",
-        "observed_generation": 1
+        "observed_generation": 1,
+        "created_time": "2025-01-01T00:00:00Z",
+        "last_updated_time": "2025-01-01T00:00:00Z",
+        "last_transition_time": "2025-01-01T00:00:00Z"
       }
🧹 Nitpick comments (3)
test/integration/api_contract_test.go (1)

12-27: Test correctly validates condition status constant mappings.

The test ensures domain constants (api.ConditionTrue, api.ConditionFalse) stay synchronized with generated OpenAPI constants for both AdapterConditionStatus and ResourceConditionStatus.

Consider adding validation for the Unknown status as well, since AdapterConditionStatusUnknown is now used in the API (e.g., for startup pending scenarios):

💡 Optional: Add Unknown constant validation
 	Expect(string(api.ConditionFalse)).To(Equal(string(openapi.ResourceConditionStatusFalse)),
 		"api.ConditionFalse must match openapi.ResourceConditionStatusFalse")
+	// Verify Unknown status mapping for adapter conditions
+	Expect(string(api.ConditionUnknown)).To(Equal(string(openapi.AdapterConditionStatusUnknown)),
+		"api.ConditionUnknown must match openapi.AdapterConditionStatusUnknown")
pkg/api/presenters/node_pool.go (1)

30-50: Align initial ObservedGeneration with initial generation.

NodePool generation defaults to 1 (and Cluster init uses 1). Starting at 0 can make Available/Ready appear unobserved. Consider setting 1 or documenting the rationale.

♻️ Suggested change
-			ObservedGeneration: 0,
+			ObservedGeneration: 1,
-			ObservedGeneration: 0,
+			ObservedGeneration: 1,
openapi/openapi.yaml (1)

446-450: Align transition description with allowed AdapterConditionStatus values.
The enum allows Unknown, but the text mentions only True/False.

♻️ Suggested wording update
-            Only updated when status changes (True/False), not when reason/message changes
+            Only updated when status changes (True/False/Unknown), not when reason/message changes

…onStatus types

Defines separate condition status types:
- ResourceConditionStatus (True/False) for Cluster/NodePool conditions
- AdapterConditionStatus (True/False/Unknown) for adapter-reported conditions

This separation ensures resource status is always definitive while
adapters can report Unknown when status is indeterminate.
…Condition

Replaces ComputePhase() with condition-based status aggregation:
- ComputeReadyCondition(): True when all required adapters report
  Available=True at current spec.generation
- ComputeAvailableCondition(): True when all required adapters report
  Available=True at any generation (last known good)

Key behavior: After spec change, Ready becomes False immediately while
Available stays True until adapters report failure.
Updates API response formatting:
- New resources get default conditions: Available=False, Ready=False
  with reason AwaitingAdapters
- Converts domain condition types to OpenAPI types for responses
- Adapter status now uses AdapterConditionStatus type
Service layer changes for condition-based status:
- UpdateConditionsFromAdapterStatus() updates Ready/Available conditions
  when adapter status is POSTed
- Returns nil, nil for no-op when conditions don't change
- Removes phase-related code from generic service
- Adds comprehensive tests for condition update logic
Handler changes for condition-based status:
- Add handleCreateWithNoContent() framework helper for 204 responses
- POST /clusters/{id}/statuses returns:
  - 201 Created when conditions changed
  - 204 No Content when adapter report received but no condition change
- Same behavior for nodepool status endpoints
Database and search changes:
- Add conditionsNodeConverter() for TSL to SQL translation
- New search syntax: status.conditions.Ready='True'
- Remove status_phase column from migrations
- Add GIN index for efficient condition queries

Query translation:
  status.conditions.Ready='True' becomes:
  jsonb_path_query_first(status_conditions, '$[*] ? (@.type == "Ready")') ->> 'status' = 'True'
Breaking API changes:
- Remove status.phase field from Cluster and NodePool
- Add status.conditions[] array with Ready/Available conditions
- ResourceCondition.status enum: True, False (not Unknown)
- AdapterCondition.status enum: True, False, Unknown
Removes obsolete phase-related code:
- Remove phase validation from cluster types
- Remove phase validation from node pool types
- Remove associated phase tests
Updates test helpers to use conditions instead of phase:
- Cluster factory creates default Ready/Available conditions
- NodePool factory creates default Ready/Available conditions
End-to-end test coverage:
- Tests for condition updates via adapter status POST
- Updated contract tests for new response format
- Tests for status.conditions.* search syntax
Documentation updates:
- Updated API examples with condition-based status
- Updated search syntax documentation
- Documented condition semantics (Ready vs Available)
- Updated agent configuration docs
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
pkg/services/status_aggregation.go (1)

61-130: Inconsistent minObservedGeneration return values for "not available" cases.

When no adapters or required adapters exist (line 67), the function returns 1. When adapters exist but none are available (line 129), it returns 0. This inconsistency may confuse callers.

🐛 Proposed fix for consistency
 func ComputeAvailableCondition(adapterStatuses api.AdapterStatusList, requiredAdapters []string) (bool, int32) {
 	if len(adapterStatuses) == 0 || len(requiredAdapters) == 0 {
-		return false, 1
+		return false, 0
 	}

Alternatively, document the intended semantics for the minObservedGeneration return value in the "not available" case.

test/integration/search_field_mapping_test.go (1)

97-152: TestSearchCombinedQuery no longer exercises a combined predicate.
The test name/comments say “combined,” but the query is labels‑only and the comment references a non‑existent wrongStatusCluster. Consider updating the query (or renaming) so intent matches behavior.

✏️ Suggested fix to keep it “combined”
-	// Query using combined AND condition with labels (labels search still works)
-	searchStr := "labels.region='us-east'"
+	// Query using combined AND condition: Ready=False AND labels.region='us-east'
+	searchStr := "status.conditions.Ready='False' AND labels.region='us-east'"
@@
-	// Should return matchCluster and wrongStatusCluster but not wrongRegionCluster
+	// Should return matchCluster only (Ready=False, region=us-east)
🤖 Fix all issues with AI agents
In `@pkg/services/cluster_test.go`:
- Around line 515-520: Update the explanatory comments describing the edge case
where an adapter reports gen=1 after gen=2 so they precisely reflect the
asserted behavior: state that because minObservedGeneration is not downgraded,
the cluster conditions (Ready and Available) must not be overwritten and
Available remains True in this test, even though that is a stale/incorrect
reality until an adapter reports gen=2; mention minObservedGeneration, the
gen=1→gen=2 sequence, and that the test asserts Available stays True (and Ready
stays False) to avoid misleading future maintainers.

In `@pkg/services/node_pool_test.go`:
- Around line 416-427: The comment is contradictory: update either the comment
or the assertions around the upsert("validation", api.AdapterConditionFalse, 1)
call so they match; specifically either change the comment "Stale False is more
restrictive and should override." to reflect that Available remains True with
ObservedGeneration 2, or change the Expect calls on available.Status /
available.ObservedGeneration (from getAvailable()) to assert that Available
becomes False and ObservedGeneration reflects the stale update; adjust the test
around the upsert, getAvailable, and Expect lines to be consistent.

In `@test/integration/api_contract_test.go`:
- Around line 12-27: The test TestAPIContract_ConditionStatusConstants is
missing an assertion for the 'Unknown' value — add an Expect comparing
api.AdapterConditionUnknown to openapi.AdapterConditionStatusUnknown (i.e. add
an assertion that string(api.AdapterConditionUnknown) equals
string(openapi.AdapterConditionStatusUnknown) with a clear failure message)
alongside the existing ConditionTrue/ConditionFalse assertions so the
AdapterConditionUnknown constant is validated against the OpenAPI constant.
♻️ Duplicate comments (3)
pkg/db/sql_helpers.go (1)

184-235: Qualify status_conditions with the table name to avoid ambiguous column errors.

Line 234 builds jsonb_path_query_first(status_conditions, ...) without a qualifier; in joins (e.g., clusters ↔ node_pools) this can become ambiguous. ExtractConditionQueries already threads tableName, but conditionsNodeConverter ignores it—please pass and use it when building the expression.

Also applies to: 251-259

AGENTS.md (1)

259-263: Tighten wording to “last known good configuration.”

Line 262 would read more precisely as “last known good configuration.”

✏️ Suggested wording tweak
-  - Indicates the resource is running at some known good configuration
+  - Indicates the resource is running at the last known good configuration
docs/api-resources.md (1)

356-371: NodePool Get example still missing condition timestamps.

The NodePool Get example at lines 356-371 omits the required created_time, last_updated_time, and last_transition_time fields from the conditions, while the Cluster Get example (lines 102-122) includes them. This inconsistency was flagged in a past review but appears unaddressed.

📝 Suggested fix
       {
         "type": "Available",
         "status": "True",
         "reason": "ResourceAvailable",
         "message": "NodePool is accessible",
-        "observed_generation": 1
+        "observed_generation": 1,
+        "created_time": "2025-01-01T00:00:00Z",
+        "last_updated_time": "2025-01-01T00:00:00Z",
+        "last_transition_time": "2025-01-01T00:00:00Z"
       },
       {
         "type": "Ready",
         "status": "True",
         "reason": "ResourceReady",
         "message": "All adapters report ready at current generation",
-        "observed_generation": 1
+        "observed_generation": 1,
+        "created_time": "2025-01-01T00:00:00Z",
+        "last_updated_time": "2025-01-01T00:00:00Z",
+        "last_transition_time": "2025-01-01T00:00:00Z"
       }
🧹 Nitpick comments (8)
test/factories/node_pools.go (1)

82-136: Prefer context-bound DB sessions for these updates.

Line 126 creates a new session via dbFactory.New(context.Background()). Consider threading a context into this helper and retrieving sessions from that context (e.g., via db.NewContext(ctx)) to keep transaction lifecycles consistent. As per coding guidelines.

test/factories/clusters.go (1)

145-170: Consider reusing NewClusterWithLabels to reduce duplication.

Lines 152-166 duplicate the label-update logic already present in NewClusterWithLabels. This can be simplified by delegating to the existing helper.

♻️ Proposed refactor
 func NewClusterWithStatusAndLabels(f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReady bool, labels map[string]string) (*api.Cluster, error) {
 	cluster, err := NewClusterWithStatus(f, dbFactory, id, isAvailable, isReady)
 	if err != nil {
 		return nil, err
 	}
 
-	if labels != nil {
-		labelsJSON, err := json.Marshal(labels)
-		if err != nil {
-			return nil, err
-		}
-
-		dbSession := dbFactory.New(context.Background())
-		err = dbSession.Model(cluster).Update("labels", labelsJSON).Error
-		if err != nil {
-			return nil, err
-		}
-
-		if err := reloadCluster(dbSession, cluster); err != nil {
-			return nil, err
-		}
-	}
+	if labels != nil && len(labels) > 0 {
+		labelsJSON, err := json.Marshal(labels)
+		if err != nil {
+			return nil, err
+		}
+		dbSession := dbFactory.New(context.Background())
+		if err := dbSession.Model(cluster).Update("labels", labelsJSON).Error; err != nil {
+			return nil, err
+		}
+		if err := reloadCluster(dbSession, cluster); err != nil {
+			return nil, err
+		}
+	}
 
 	return cluster, nil
 }

Alternatively, you could extract a private applyLabels(dbFactory, cluster, labels) helper to share between NewClusterWithLabels and NewClusterWithStatusAndLabels.

pkg/services/status_aggregation.go (1)

70-98: Consider extracting shared adapter map building logic.

The adapter map construction (parsing conditions JSON, finding "Available" condition, building map) is duplicated between ComputeAvailableCondition and ComputeReadyCondition. Extracting a helper would reduce duplication and ensure consistent behavior.

♻️ Suggested helper extraction
type adapterInfo struct {
	available          string
	observedGeneration int32
}

func buildAdapterAvailabilityMap(adapterStatuses api.AdapterStatusList) map[string]adapterInfo {
	adapterMap := make(map[string]adapterInfo)
	for _, adapterStatus := range adapterStatuses {
		var conditions []struct {
			Type   string `json:"type"`
			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 == "Available" {
						adapterMap[adapterStatus.Adapter] = adapterInfo{
							available:          cond.Status,
							observedGeneration: adapterStatus.ObservedGeneration,
						}
						break
					}
				}
			}
		}
	}
	return adapterMap
}

Then both ComputeAvailableCondition and ComputeReadyCondition can call buildAdapterAvailabilityMap(adapterStatuses).

Also applies to: 140-167

pkg/services/node_pool_test.go (1)

124-131: Consider using the NodePool test factory for fixtures.

Inline fixtures here are fine, but using test/factories keeps consistency and reduces drift with future schema changes. Based on learnings, please use test factories for NodePool fixtures when possible.

pkg/services/cluster.go (2)

234-244: Fragile error detection using string matching.

The check strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) on line 237 is brittle. If the error message format changes, this will silently break. The code already checks for gorm.ErrRecordNotFound on line 236, but the string-based check appears to handle a custom not-found error from the DAO layer.

Consider using a typed error check or exporting a helper from the errors package:

♻️ Suggested approach
-	if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) {
-		if !strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) {
-			return nil, errors.GeneralError("Failed to get adapter status: %s", findErr)
-		}
-	}
+	if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) && !errors.IsNotFound(findErr) {
+		return nil, errors.GeneralError("Failed to get adapter status: %s", findErr)
+	}

This assumes adding an IsNotFound(err error) bool helper to the errors package that checks the error code properly.


241-244: Stale generation check returns (nil, nil) without logging.

When discarding stale status updates (line 243), a debug/info log would aid troubleshooting adapter timing issues. Silent discards can make it hard to diagnose why an adapter's status isn't being applied.

📝 Suggested improvement
 	if existingStatus != nil && adapterStatus.ObservedGeneration < existingStatus.ObservedGeneration {
 		// Discard stale status updates (older observed_generation).
+		logger.WithClusterID(ctx, clusterID).Debug("Discarding stale adapter status",
+			"adapter", adapterStatus.Adapter,
+			"incoming_generation", adapterStatus.ObservedGeneration,
+			"existing_generation", existingStatus.ObservedGeneration)
 		return nil, nil
 	}
pkg/services/node_pool.go (2)

232-242: Same fragile error detection pattern as cluster.go.

The string-based error check strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) has the same brittleness concern as in pkg/services/cluster.go.

♻️ Suggested approach
-	if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) {
-		if !strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) {
-			return nil, errors.GeneralError("Failed to get adapter status: %s", findErr)
-		}
-	}
+	if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) && !errors.IsNotFound(findErr) {
+		return nil, errors.GeneralError("Failed to get adapter status: %s", findErr)
+	}

136-227: Significant code duplication with cluster.go.

The UpdateNodePoolStatusFromAdapters and ProcessAdapterStatus methods are nearly identical to their cluster counterparts. Consider extracting common logic into a shared helper or generic function to reduce maintenance burden.

This could be addressed in a follow-up PR to avoid scope creep, but worth tracking as tech debt.

Also applies to: 232-283

@yasun1
Copy link
Contributor

yasun1 commented Jan 29, 2026

/lgtm

@openshift-ci
Copy link

openshift-ci bot commented Jan 29, 2026

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: yasun1

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-merge-bot openshift-merge-bot bot merged commit ef43dfc into openshift-hyperfleet:main Jan 29, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants