From 30be6928f5835728edb6d4582719d82e2772c56d Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:30:23 -0400 Subject: [PATCH 1/7] spec: gate gateway re-provisioning on desired-state convergence The provisioning gate currently skips re-applying manifests for any Gateway in phase Running/Provisioning/Degraded. This masks drift: a spec change to a Running gateway (new image, route, DNS SANs, OIDC) is never re-applied, yet the gateway keeps reporting Running/Healthy so it looks converged when it is not. Introduce a desired-state generation primitive and re-key the gate on it: - data-model: add `generation` (API-server-incremented on any desired-spec change) and `observed_generation` (control-plane-owned, last successfully applied) to Gateway; a Gateway is converged when they are equal. `generation` is read-only across all client-facing REST/gRPC contracts; `observed_generation` is read-only in REST/create but control-plane-writable via UpdateGatewayRequest. New gateways initialize generation=1, observed_generation=0 so they are never spuriously converged. observed_generation writes are bounded to a monotonic latch (current <= new <= generation), rejecting regressions and overshoot. - health: replace "Health Reconciliation Not Suppressed By Phase" with "Provisioning Gate Keyed On Desired State" -- skip re-apply only when converged; re-provision on generation advance regardless of phase; set observed_generation on success, leave it on failure to retry. Health phase/status updates remain unsuppressed. - control-plane: Status Synchronization now gates re-application on convergence, not phase, with a spec-change-to-Running scenario. Scope: closes spec-change drift only. Periodic re-apply to heal out-of-band edits to managed resources is intentionally left out pending a separate decision. Assisted-by: Claude Opus 4.8 --- specs/platform/control-plane.spec.md | 8 +- specs/platform/data-model.spec.md | 75 +++++++++++++++++++ .../platform/openshell-gateway-health.spec.md | 63 +++++++++++++--- 3 files changed, 135 insertions(+), 11 deletions(-) diff --git a/specs/platform/control-plane.spec.md b/specs/platform/control-plane.spec.md index 834eaf4e..919497ae 100644 --- a/specs/platform/control-plane.spec.md +++ b/specs/platform/control-plane.spec.md @@ -95,7 +95,7 @@ When a Gateway is deleted, the control plane SHALL clean up all associated Kuber ### Requirement: Status Synchronization -The control plane SHALL continuously reconcile the `phase` and `status` fields of Gateway resources in the API server to reflect actual cluster state, even after a Gateway has reached `Running`. The phase gate that prevents redundant re-provisioning SHALL NOT suppress these health updates. Full lifecycle semantics are defined in the [health spec](./openshell-gateway-health.spec.md). +The control plane SHALL continuously reconcile the `phase` and `status` fields of Gateway resources in the API server to reflect actual cluster state, even after a Gateway has reached `Running`. The provisioning gate that prevents redundant re-provisioning SHALL NOT suppress these health updates, and SHALL gate re-application on convergence (`observed_generation == generation`) rather than on `phase`, so that a desired-spec change falls through the gate and triggers re-application regardless of phase. Full lifecycle semantics are defined in the [health spec](./openshell-gateway-health.spec.md). #### Scenario: Gateway Health Check - GIVEN a Gateway with `phase` `Running` on a managed cluster @@ -104,6 +104,12 @@ The control plane SHALL continuously reconcile the `phase` and `status` fields o - AND set `phase` to `Degraded` when ready replicas fall below desired - AND set `phase` back to `Running` when the workload recovers +#### Scenario: Spec Change to a Running Gateway +- GIVEN a converged Gateway with `phase` `Running` (`observed_generation == generation`) +- WHEN its desired spec changes and the API server advances `generation` +- THEN the control plane SHALL re-apply the gateway manifests despite the `Running` phase +- AND set `observed_generation` to the applied `generation` upon success + ## Design Decisions | Decision | Rationale | diff --git a/specs/platform/data-model.spec.md b/specs/platform/data-model.spec.md index 5168e152..b59821b4 100644 --- a/specs/platform/data-model.spec.md +++ b/specs/platform/data-model.spec.md @@ -97,6 +97,8 @@ erDiagram string service_type string status string phase + int generation + int observed_generation time created_at time updated_at time deleted_at @@ -197,6 +199,79 @@ A Gateway SHALL track its deployment lifecycle through the `phase` field. The `s - THEN the phase SHALL transition to "Provisioning" - AND upon successful deployment, to "Running" +### Requirement: Gateway Generation Tracking + +A Gateway SHALL carry a monotonic `generation` and an `observed_generation` +that together let the control plane distinguish a genuine desired-state change +from a redundant reconciliation event. + +The API server SHALL increment `generation` whenever any field of the Gateway's +desired spec changes, including `image`, `supervisor_image`, `server_dns_names`, +`oidc`, `route`, `database`, `credential_driver`, `external_dns`, `tls_mode`, +`service_type`, `release_id`, `database_id`, and `cluster_id`. It SHALL NOT +increment `generation` for changes to control-plane-owned observed fields +(`status`, `phase`, `route_address`, `observed_generation`). + +On creation, a Gateway SHALL initialize with `generation = 1` and +`observed_generation = 0`, so that a newly created Gateway is never spuriously +*converged* (`observed_generation < generation`) and always undergoes initial +provisioning. + +`observed_generation` is control-plane-owned. The control plane SHALL set it to +the `generation` it last successfully applied to the cluster. A Gateway is +*converged* when `observed_generation == generation`. + +The two fields differ in contract writability: + +- `generation` SHALL be read-only across all client-facing REST and gRPC + contracts (create, update, and patch); it is managed exclusively by the API + server. +- `observed_generation` SHALL be read-only in the REST API and in all create + requests, but SHALL be writable by the control plane through the gRPC + `UpdateGatewayRequest` (the same back-channel by which the control plane + reports `phase`, `status`, and `route_address`). + +The API server SHALL treat `observed_generation` as a monotonic convergence +latch. On an `UpdateGatewayRequest` it SHALL accept a new `observed_generation` +only when `current observed_generation <= new <= generation`, and SHALL reject a +write that regresses the value below the current `observed_generation` or +exceeds the current `generation`. This prevents a stale or reordered update from +regressing the marker, and prevents any write from declaring a `generation` +converged before it has been applied — a false-converged state would otherwise +permanently mask drift. + +#### Scenario: New gateway starts unconverged +- GIVEN a valid Gateway create request +- WHEN the API server persists the Gateway +- THEN it SHALL set `generation = 1` and `observed_generation = 0` +- AND the Gateway SHALL NOT be *converged*, so the control plane provisions it + +#### Scenario: Spec change advances generation +- GIVEN a Gateway with `generation` N that has been applied + (`observed_generation == N`) +- WHEN a client updates a desired-spec field (e.g. `image`) +- THEN the API server SHALL increment `generation` to N+1 +- AND `observed_generation` SHALL remain N until the control plane re-applies + +#### Scenario: Control plane writes observed_generation back +- GIVEN a Gateway with `generation` N+1 and `observed_generation` N +- WHEN the control plane successfully re-applies the manifests +- THEN it SHALL set `observed_generation` to N+1 via `UpdateGatewayRequest` +- AND the API server SHALL accept the write despite `observed_generation` being + read-only to REST clients + +#### Scenario: Out-of-range observed_generation is rejected +- GIVEN a Gateway with `generation` N+1 and `observed_generation` N +- WHEN an `UpdateGatewayRequest` sets `observed_generation` below N or above N+1 +- THEN the API server SHALL reject the write +- AND `observed_generation` SHALL remain N + +#### Scenario: Health update does not advance generation +- GIVEN a converged Gateway with `generation` N +- WHEN the control plane writes an observed `phase`/`status`/`route_address` +- THEN `generation` SHALL remain N +- AND the Gateway SHALL remain converged + ### Requirement: Canary Release Strategy A GatewayRelease SHALL support canary deployment via `rollout_strategy`, `canary_percent`, and `canary_duration` fields. diff --git a/specs/platform/openshell-gateway-health.spec.md b/specs/platform/openshell-gateway-health.spec.md index ac394da1..41f6323a 100644 --- a/specs/platform/openshell-gateway-health.spec.md +++ b/specs/platform/openshell-gateway-health.spec.md @@ -45,6 +45,20 @@ A Gateway carries two independently-observable fields: (e.g. the reason a gateway is `Degraded`). It complements `phase` and is surfaced alongside it in the console. +A Gateway additionally carries two generation markers (see +[`data-model.spec.md`](./data-model.spec.md) § Gateway Generation Tracking): + +- **`generation`** - the desired-spec version, incremented by the API server on + any desired-spec change. +- **`observed_generation`** - the `generation` the control plane last + successfully applied. A Gateway is *converged* when + `observed_generation == generation`. + +`generation`/`observed_generation` describe whether desired state has been +applied; `phase`/`status` describe observed workload health. The two axes are +independent: a Gateway can be `Running` yet not converged (a newer spec has not +been applied). + `Running` is the only phase that asserts the gateway is serving. `Degraded` and `Failed` are the two distinct unhealthy states: `Degraded` is recoverable without user action; `Failed` is not. @@ -162,23 +176,52 @@ back to `Running`. - AND, for a routed gateway, its external exposure is observed Ready - THEN the control plane SHALL set the `phase` back to `Running` -### Requirement: Health Reconciliation Not Suppressed By Phase +### Requirement: Provisioning Gate Keyed On Desired State + +The control plane's provisioning gate SHALL prevent redundant re-provisioning +(re-applying manifests) **only when a Gateway is converged** +(`observed_generation == generation`). A `phase` of `Running`, `Provisioning`, +or `Degraded` SHALL NOT, by itself, suppress re-application. + +When a Gateway's desired spec changes (its `generation` advances beyond +`observed_generation`), the control plane SHALL re-run provisioning regardless +of the current `phase`, and SHALL set `observed_generation` to the applied +`generation` upon success. If re-application fails, `observed_generation` SHALL +remain unchanged so the change is retried, and the `phase` SHALL be set per the +existing provisioning failure semantics. -The control plane's phase gate SHALL prevent redundant re-provisioning -(re-applying manifests) of a Gateway that is already `Provisioning` or -`Running`, but SHALL NOT prevent phase or status updates that reflect the -Gateway's actual observed workload health. A Gateway that has reached `Running` -SHALL still be able to transition to `Degraded`, and a `Degraded` Gateway SHALL -still be able to return to `Running`. +The gate SHALL NOT, in any case, prevent `phase`/`status` updates that reflect +observed workload health: a `Running` Gateway SHALL still be able to transition +to `Degraded`, and a `Degraded` Gateway SHALL still be able to return to +`Running`, independently of convergence. -#### Scenario: Health update proceeds despite provisioning gate +#### Scenario: Spec change to a Running gateway re-provisions -- GIVEN a Gateway with `phase` `Running` whose manifests are unchanged -- WHEN a reconciliation or health check occurs +- GIVEN a converged Gateway with `phase` `Running` + (`observed_generation == generation`) +- WHEN a client updates its desired spec (e.g. `image`, `route`, + `server_dns_names`, `oidc`) and the API server advances `generation` +- THEN the control plane SHALL re-apply the gateway manifests despite the + `Running` phase +- AND upon success SHALL set `observed_generation` to the applied `generation` + +#### Scenario: Converged gateway is not re-applied + +- GIVEN a converged Gateway with `phase` `Running` whose desired spec is + unchanged (`observed_generation == generation`) +- WHEN a duplicate watch event, reconciliation, or health check occurs - THEN the control plane SHALL skip re-applying the gateway manifests - BUT it SHALL still update the `phase` to `Degraded` if the workload is observed unhealthy +#### Scenario: Degraded gateway re-provisions on spec change + +- GIVEN a Gateway with `phase` `Degraded` that is not converged + (`generation` advanced after a spec fix) +- WHEN the control plane processes the change +- THEN it SHALL re-apply the gateway manifests rather than skip on phase +- AND SHALL set `observed_generation` to the applied `generation` upon success + ### Requirement: Console Reflects Recoverable States The web console SHALL treat recoverable, non-terminal phases - including From 5874d4f8fead07cf41d99cd2761bdda6ff662976 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:37:19 -0400 Subject: [PATCH 2/7] api(gateway): add generation/observed_generation to contract Add the desired-state convergence primitive to the Gateway API surface: - OpenAPI: `generation` and `observed_generation` (int64, readOnly) on the Gateway response schema; omitted from create/patch (client-read-only). - proto: `int64 generation = 21` and `optional int64 observed_generation = 22` on Gateway; `optional int64 observed_generation = 20` on UpdateGatewayRequest (control-plane write-back channel). Not on CreateGatewayRequest. Regenerates pkg/api/openapi and pkg/api/grpc stubs. No behavior wired yet. Assisted-by: Claude Opus 4.8 --- .../api-server/openapi/openapi.gateways.yaml | 10 ++ .../pkg/api/grpc/hypershell/v1/gateways.pb.go | 133 +++++++++++------- .../pkg/api/openapi/api/openapi.yaml | 82 ++++++----- .../pkg/api/openapi/docs/Gateway.md | 52 +++++++ .../pkg/api/openapi/model_gateway.go | 74 ++++++++++ .../proto/hypershell/v1/gateways.proto | 3 + 6 files changed, 271 insertions(+), 83 deletions(-) diff --git a/components/api-server/openapi/openapi.gateways.yaml b/components/api-server/openapi/openapi.gateways.yaml index 54508cc7..a71b1cdf 100644 --- a/components/api-server/openapi/openapi.gateways.yaml +++ b/components/api-server/openapi/openapi.gateways.yaml @@ -283,6 +283,16 @@ components: credential_driver: type: string description: JSON-encoded credential storage driver configuration + generation: + type: integer + format: int64 + readOnly: true + description: Monotonic desired-state revision, incremented by the API server on any desired-spec change + observed_generation: + type: integer + format: int64 + readOnly: true + description: Generation the control plane last successfully applied; converged when equal to generation GatewayCreateRequest: type: object required: diff --git a/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go b/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go index 2cdbd11d..e0b6cc8a 100644 --- a/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go +++ b/components/api-server/pkg/api/grpc/hypershell/v1/gateways.pb.go @@ -22,29 +22,31 @@ const ( ) type Gateway struct { - state protoimpl.MessageState `protogen:"open.v1"` - Metadata *ObjectReference `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - FleetId string `protobuf:"bytes,3,opt,name=fleet_id,json=fleetId,proto3" json:"fleet_id,omitempty"` - ClusterId string `protobuf:"bytes,4,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"` - ReleaseId string `protobuf:"bytes,5,opt,name=release_id,json=releaseId,proto3" json:"release_id,omitempty"` - DatabaseId string `protobuf:"bytes,6,opt,name=database_id,json=databaseId,proto3" json:"database_id,omitempty"` - Namespace string `protobuf:"bytes,7,opt,name=namespace,proto3" json:"namespace,omitempty"` - ExternalDns *string `protobuf:"bytes,8,opt,name=external_dns,json=externalDns,proto3,oneof" json:"external_dns,omitempty"` - TlsMode *string `protobuf:"bytes,9,opt,name=tls_mode,json=tlsMode,proto3,oneof" json:"tls_mode,omitempty"` - ServiceType *string `protobuf:"bytes,10,opt,name=service_type,json=serviceType,proto3,oneof" json:"service_type,omitempty"` - Status *string `protobuf:"bytes,11,opt,name=status,proto3,oneof" json:"status,omitempty"` - Phase *string `protobuf:"bytes,12,opt,name=phase,proto3,oneof" json:"phase,omitempty"` - Image *string `protobuf:"bytes,13,opt,name=image,proto3,oneof" json:"image,omitempty"` - SupervisorImage *string `protobuf:"bytes,19,opt,name=supervisor_image,json=supervisorImage,proto3,oneof" json:"supervisor_image,omitempty"` - ServerDnsNames []string `protobuf:"bytes,14,rep,name=server_dns_names,json=serverDnsNames,proto3" json:"server_dns_names,omitempty"` - RouteAddress *string `protobuf:"bytes,15,opt,name=route_address,json=routeAddress,proto3,oneof" json:"route_address,omitempty"` - Oidc *string `protobuf:"bytes,16,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` - Route *string `protobuf:"bytes,17,opt,name=route,proto3,oneof" json:"route,omitempty"` - DatabaseConfig *string `protobuf:"bytes,18,opt,name=database_config,json=databaseConfig,proto3,oneof" json:"database_config,omitempty"` - CredentialDriver *string `protobuf:"bytes,20,opt,name=credential_driver,json=credentialDriver,proto3,oneof" json:"credential_driver,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *ObjectReference `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + FleetId string `protobuf:"bytes,3,opt,name=fleet_id,json=fleetId,proto3" json:"fleet_id,omitempty"` + ClusterId string `protobuf:"bytes,4,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"` + ReleaseId string `protobuf:"bytes,5,opt,name=release_id,json=releaseId,proto3" json:"release_id,omitempty"` + DatabaseId string `protobuf:"bytes,6,opt,name=database_id,json=databaseId,proto3" json:"database_id,omitempty"` + Namespace string `protobuf:"bytes,7,opt,name=namespace,proto3" json:"namespace,omitempty"` + ExternalDns *string `protobuf:"bytes,8,opt,name=external_dns,json=externalDns,proto3,oneof" json:"external_dns,omitempty"` + TlsMode *string `protobuf:"bytes,9,opt,name=tls_mode,json=tlsMode,proto3,oneof" json:"tls_mode,omitempty"` + ServiceType *string `protobuf:"bytes,10,opt,name=service_type,json=serviceType,proto3,oneof" json:"service_type,omitempty"` + Status *string `protobuf:"bytes,11,opt,name=status,proto3,oneof" json:"status,omitempty"` + Phase *string `protobuf:"bytes,12,opt,name=phase,proto3,oneof" json:"phase,omitempty"` + Image *string `protobuf:"bytes,13,opt,name=image,proto3,oneof" json:"image,omitempty"` + SupervisorImage *string `protobuf:"bytes,19,opt,name=supervisor_image,json=supervisorImage,proto3,oneof" json:"supervisor_image,omitempty"` + ServerDnsNames []string `protobuf:"bytes,14,rep,name=server_dns_names,json=serverDnsNames,proto3" json:"server_dns_names,omitempty"` + RouteAddress *string `protobuf:"bytes,15,opt,name=route_address,json=routeAddress,proto3,oneof" json:"route_address,omitempty"` + Oidc *string `protobuf:"bytes,16,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` + Route *string `protobuf:"bytes,17,opt,name=route,proto3,oneof" json:"route,omitempty"` + DatabaseConfig *string `protobuf:"bytes,18,opt,name=database_config,json=databaseConfig,proto3,oneof" json:"database_config,omitempty"` + CredentialDriver *string `protobuf:"bytes,20,opt,name=credential_driver,json=credentialDriver,proto3,oneof" json:"credential_driver,omitempty"` + Generation int64 `protobuf:"varint,21,opt,name=generation,proto3" json:"generation,omitempty"` + ObservedGeneration *int64 `protobuf:"varint,22,opt,name=observed_generation,json=observedGeneration,proto3,oneof" json:"observed_generation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Gateway) Reset() { @@ -217,6 +219,20 @@ func (x *Gateway) GetCredentialDriver() string { return "" } +func (x *Gateway) GetGeneration() int64 { + if x != nil { + return x.Generation + } + return 0 +} + +func (x *Gateway) GetObservedGeneration() int64 { + if x != nil && x.ObservedGeneration != nil { + return *x.ObservedGeneration + } + return 0 +} + type CreateGatewayRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -522,28 +538,29 @@ func (x *GetGatewayResponse) GetGateway() *Gateway { } type UpdateGatewayRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` - FleetId *string `protobuf:"bytes,3,opt,name=fleet_id,json=fleetId,proto3,oneof" json:"fleet_id,omitempty"` - ClusterId *string `protobuf:"bytes,4,opt,name=cluster_id,json=clusterId,proto3,oneof" json:"cluster_id,omitempty"` - ReleaseId *string `protobuf:"bytes,5,opt,name=release_id,json=releaseId,proto3,oneof" json:"release_id,omitempty"` - DatabaseId *string `protobuf:"bytes,6,opt,name=database_id,json=databaseId,proto3,oneof" json:"database_id,omitempty"` - ExternalDns *string `protobuf:"bytes,7,opt,name=external_dns,json=externalDns,proto3,oneof" json:"external_dns,omitempty"` - TlsMode *string `protobuf:"bytes,8,opt,name=tls_mode,json=tlsMode,proto3,oneof" json:"tls_mode,omitempty"` - ServiceType *string `protobuf:"bytes,9,opt,name=service_type,json=serviceType,proto3,oneof" json:"service_type,omitempty"` - Status *string `protobuf:"bytes,10,opt,name=status,proto3,oneof" json:"status,omitempty"` - Phase *string `protobuf:"bytes,11,opt,name=phase,proto3,oneof" json:"phase,omitempty"` - Image *string `protobuf:"bytes,12,opt,name=image,proto3,oneof" json:"image,omitempty"` - SupervisorImage *string `protobuf:"bytes,13,opt,name=supervisor_image,json=supervisorImage,proto3,oneof" json:"supervisor_image,omitempty"` - ServerDnsNames []string `protobuf:"bytes,14,rep,name=server_dns_names,json=serverDnsNames,proto3" json:"server_dns_names,omitempty"` - RouteAddress *string `protobuf:"bytes,15,opt,name=route_address,json=routeAddress,proto3,oneof" json:"route_address,omitempty"` - Oidc *string `protobuf:"bytes,16,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` - Route *string `protobuf:"bytes,17,opt,name=route,proto3,oneof" json:"route,omitempty"` - DatabaseConfig *string `protobuf:"bytes,18,opt,name=database_config,json=databaseConfig,proto3,oneof" json:"database_config,omitempty"` - CredentialDriver *string `protobuf:"bytes,19,opt,name=credential_driver,json=credentialDriver,proto3,oneof" json:"credential_driver,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` + FleetId *string `protobuf:"bytes,3,opt,name=fleet_id,json=fleetId,proto3,oneof" json:"fleet_id,omitempty"` + ClusterId *string `protobuf:"bytes,4,opt,name=cluster_id,json=clusterId,proto3,oneof" json:"cluster_id,omitempty"` + ReleaseId *string `protobuf:"bytes,5,opt,name=release_id,json=releaseId,proto3,oneof" json:"release_id,omitempty"` + DatabaseId *string `protobuf:"bytes,6,opt,name=database_id,json=databaseId,proto3,oneof" json:"database_id,omitempty"` + ExternalDns *string `protobuf:"bytes,7,opt,name=external_dns,json=externalDns,proto3,oneof" json:"external_dns,omitempty"` + TlsMode *string `protobuf:"bytes,8,opt,name=tls_mode,json=tlsMode,proto3,oneof" json:"tls_mode,omitempty"` + ServiceType *string `protobuf:"bytes,9,opt,name=service_type,json=serviceType,proto3,oneof" json:"service_type,omitempty"` + Status *string `protobuf:"bytes,10,opt,name=status,proto3,oneof" json:"status,omitempty"` + Phase *string `protobuf:"bytes,11,opt,name=phase,proto3,oneof" json:"phase,omitempty"` + Image *string `protobuf:"bytes,12,opt,name=image,proto3,oneof" json:"image,omitempty"` + SupervisorImage *string `protobuf:"bytes,13,opt,name=supervisor_image,json=supervisorImage,proto3,oneof" json:"supervisor_image,omitempty"` + ServerDnsNames []string `protobuf:"bytes,14,rep,name=server_dns_names,json=serverDnsNames,proto3" json:"server_dns_names,omitempty"` + RouteAddress *string `protobuf:"bytes,15,opt,name=route_address,json=routeAddress,proto3,oneof" json:"route_address,omitempty"` + Oidc *string `protobuf:"bytes,16,opt,name=oidc,proto3,oneof" json:"oidc,omitempty"` + Route *string `protobuf:"bytes,17,opt,name=route,proto3,oneof" json:"route,omitempty"` + DatabaseConfig *string `protobuf:"bytes,18,opt,name=database_config,json=databaseConfig,proto3,oneof" json:"database_config,omitempty"` + CredentialDriver *string `protobuf:"bytes,19,opt,name=credential_driver,json=credentialDriver,proto3,oneof" json:"credential_driver,omitempty"` + ObservedGeneration *int64 `protobuf:"varint,20,opt,name=observed_generation,json=observedGeneration,proto3,oneof" json:"observed_generation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateGatewayRequest) Reset() { @@ -709,6 +726,13 @@ func (x *UpdateGatewayRequest) GetCredentialDriver() string { return "" } +func (x *UpdateGatewayRequest) GetObservedGeneration() int64 { + if x != nil && x.ObservedGeneration != nil { + return *x.ObservedGeneration + } + return 0 +} + type UpdateGatewayResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Gateway *Gateway `protobuf:"bytes,1,opt,name=gateway,proto3" json:"gateway,omitempty"` @@ -1037,7 +1061,7 @@ var File_hypershell_v1_gateways_proto protoreflect.FileDescriptor const file_hypershell_v1_gateways_proto_rawDesc = "" + "\n" + - "\x1chypershell/v1/gateways.proto\x12\rhypershell.v1\x1a\x1ahypershell/v1/common.proto\"\xfe\x06\n" + + "\x1chypershell/v1/gateways.proto\x12\rhypershell.v1\x1a\x1ahypershell/v1/common.proto\"\xec\a\n" + "\aGateway\x12:\n" + "\bmetadata\x18\x01 \x01(\v2\x1e.hypershell.v1.ObjectReferenceR\bmetadata\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x19\n" + @@ -1063,7 +1087,11 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x05route\x18\x11 \x01(\tH\tR\x05route\x88\x01\x01\x12,\n" + "\x0fdatabase_config\x18\x12 \x01(\tH\n" + "R\x0edatabaseConfig\x88\x01\x01\x120\n" + - "\x11credential_driver\x18\x14 \x01(\tH\vR\x10credentialDriver\x88\x01\x01B\x0f\n" + + "\x11credential_driver\x18\x14 \x01(\tH\vR\x10credentialDriver\x88\x01\x01\x12\x1e\n" + + "\n" + + "generation\x18\x15 \x01(\x03R\n" + + "generation\x124\n" + + "\x13observed_generation\x18\x16 \x01(\x03H\fR\x12observedGeneration\x88\x01\x01B\x0f\n" + "\r_external_dnsB\v\n" + "\t_tls_modeB\x0f\n" + "\r_service_typeB\t\n" + @@ -1075,7 +1103,8 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x05_oidcB\b\n" + "\x06_routeB\x12\n" + "\x10_database_configB\x14\n" + - "\x12_credential_driver\"\xf5\x05\n" + + "\x12_credential_driverB\x16\n" + + "\x14_observed_generation\"\xf5\x05\n" + "\x14CreateGatewayRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + "\bfleet_id\x18\x02 \x01(\tR\afleetId\x12\x1d\n" + @@ -1115,7 +1144,7 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x11GetGatewayRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"F\n" + "\x12GetGatewayResponse\x120\n" + - "\agateway\x18\x01 \x01(\v2\x16.hypershell.v1.GatewayR\agateway\"\x9e\a\n" + + "\agateway\x18\x01 \x01(\v2\x16.hypershell.v1.GatewayR\agateway\"\xec\a\n" + "\x14UpdateGatewayRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + "\x04name\x18\x02 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1e\n" + @@ -1140,7 +1169,8 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x04oidc\x18\x10 \x01(\tH\rR\x04oidc\x88\x01\x01\x12\x19\n" + "\x05route\x18\x11 \x01(\tH\x0eR\x05route\x88\x01\x01\x12,\n" + "\x0fdatabase_config\x18\x12 \x01(\tH\x0fR\x0edatabaseConfig\x88\x01\x01\x120\n" + - "\x11credential_driver\x18\x13 \x01(\tH\x10R\x10credentialDriver\x88\x01\x01B\a\n" + + "\x11credential_driver\x18\x13 \x01(\tH\x10R\x10credentialDriver\x88\x01\x01\x124\n" + + "\x13observed_generation\x18\x14 \x01(\x03H\x11R\x12observedGeneration\x88\x01\x01B\a\n" + "\x05_nameB\v\n" + "\t_fleet_idB\r\n" + "\v_cluster_idB\r\n" + @@ -1157,7 +1187,8 @@ const file_hypershell_v1_gateways_proto_rawDesc = "" + "\x05_oidcB\b\n" + "\x06_routeB\x12\n" + "\x10_database_configB\x14\n" + - "\x12_credential_driver\"I\n" + + "\x12_credential_driverB\x16\n" + + "\x14_observed_generation\"I\n" + "\x15UpdateGatewayResponse\x120\n" + "\agateway\x18\x01 \x01(\v2\x16.hypershell.v1.GatewayR\agateway\"&\n" + "\x14DeleteGatewayRequest\x12\x0e\n" + diff --git a/components/api-server/pkg/api/openapi/api/openapi.yaml b/components/api-server/pkg/api/openapi/api/openapi.yaml index 13b8ecf4..0dd9c762 100644 --- a/components/api-server/pkg/api/openapi/api/openapi.yaml +++ b/components/api-server/pkg/api/openapi/api/openapi.yaml @@ -2554,6 +2554,18 @@ components: credential_driver: description: JSON-encoded credential storage driver configuration type: string + generation: + description: "Monotonic desired-state revision, incremented by the API\ + \ server on any desired-spec change" + format: int64 + readOnly: true + type: integer + observed_generation: + description: Generation the control plane last successfully applied; converged + when equal to generation + format: int64 + readOnly: true + type: integer required: - cluster_id - database_id @@ -2563,31 +2575,33 @@ components: - release_id type: object example: - phase: phase release_id: release_id - image: image - kind: kind server_dns_names: - server_dns_names - server_dns_names created_at: 2000-01-23T04:56:07.000+00:00 credential_driver: credential_driver + cluster_id: cluster_id + updated_at: 2000-01-23T04:56:07.000+00:00 + database_config: database_config + id: id + href: href + fleet_id: fleet_id + external_dns: external_dns + observed_generation: 5 + phase: phase + generation: 5 + image: image + kind: kind route_address: route_address oidc: oidc supervisor_image: supervisor_image - cluster_id: cluster_id service_type: service_type tls_mode: tls_mode route: route - updated_at: 2000-01-23T04:56:07.000+00:00 database_id: database_id - database_config: database_config name: name namespace: namespace - id: id - href: href - fleet_id: fleet_id - external_dns: external_dns status: status GatewayList: allOf: @@ -2608,57 +2622,61 @@ components: id: id href: href items: - - phase: phase - release_id: release_id - image: image - kind: kind + - release_id: release_id server_dns_names: - server_dns_names - server_dns_names created_at: 2000-01-23T04:56:07.000+00:00 credential_driver: credential_driver + cluster_id: cluster_id + updated_at: 2000-01-23T04:56:07.000+00:00 + database_config: database_config + id: id + href: href + fleet_id: fleet_id + external_dns: external_dns + observed_generation: 5 + phase: phase + generation: 5 + image: image + kind: kind route_address: route_address oidc: oidc supervisor_image: supervisor_image - cluster_id: cluster_id service_type: service_type tls_mode: tls_mode route: route - updated_at: 2000-01-23T04:56:07.000+00:00 database_id: database_id - database_config: database_config name: name namespace: namespace - id: id - href: href - fleet_id: fleet_id - external_dns: external_dns status: status - - phase: phase - release_id: release_id - image: image - kind: kind + - release_id: release_id server_dns_names: - server_dns_names - server_dns_names created_at: 2000-01-23T04:56:07.000+00:00 credential_driver: credential_driver + cluster_id: cluster_id + updated_at: 2000-01-23T04:56:07.000+00:00 + database_config: database_config + id: id + href: href + fleet_id: fleet_id + external_dns: external_dns + observed_generation: 5 + phase: phase + generation: 5 + image: image + kind: kind route_address: route_address oidc: oidc supervisor_image: supervisor_image - cluster_id: cluster_id service_type: service_type tls_mode: tls_mode route: route - updated_at: 2000-01-23T04:56:07.000+00:00 database_id: database_id - database_config: database_config name: name namespace: namespace - id: id - href: href - fleet_id: fleet_id - external_dns: external_dns status: status GatewayPatchRequest: example: diff --git a/components/api-server/pkg/api/openapi/docs/Gateway.md b/components/api-server/pkg/api/openapi/docs/Gateway.md index bcbda2f6..7346063b 100644 --- a/components/api-server/pkg/api/openapi/docs/Gateway.md +++ b/components/api-server/pkg/api/openapi/docs/Gateway.md @@ -28,6 +28,8 @@ Name | Type | Description | Notes **Route** | Pointer to **string** | JSON-encoded route configuration | [optional] **DatabaseConfig** | Pointer to **string** | JSON-encoded database provisioning configuration | [optional] **CredentialDriver** | Pointer to **string** | JSON-encoded credential storage driver configuration | [optional] +**Generation** | Pointer to **int64** | Monotonic desired-state revision, incremented by the API server on any desired-spec change | [optional] [readonly] +**ObservedGeneration** | Pointer to **int64** | Generation the control plane last successfully applied; converged when equal to generation | [optional] [readonly] ## Methods @@ -618,6 +620,56 @@ SetCredentialDriver sets CredentialDriver field to given value. HasCredentialDriver returns a boolean if a field has been set. +### GetGeneration + +`func (o *Gateway) GetGeneration() int64` + +GetGeneration returns the Generation field if non-nil, zero value otherwise. + +### GetGenerationOk + +`func (o *Gateway) GetGenerationOk() (*int64, bool)` + +GetGenerationOk returns a tuple with the Generation field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetGeneration + +`func (o *Gateway) SetGeneration(v int64)` + +SetGeneration sets Generation field to given value. + +### HasGeneration + +`func (o *Gateway) HasGeneration() bool` + +HasGeneration returns a boolean if a field has been set. + +### GetObservedGeneration + +`func (o *Gateway) GetObservedGeneration() int64` + +GetObservedGeneration returns the ObservedGeneration field if non-nil, zero value otherwise. + +### GetObservedGenerationOk + +`func (o *Gateway) GetObservedGenerationOk() (*int64, bool)` + +GetObservedGenerationOk returns a tuple with the ObservedGeneration field if it's non-nil, zero value otherwise +and a boolean to check if the value has been set. + +### SetObservedGeneration + +`func (o *Gateway) SetObservedGeneration(v int64)` + +SetObservedGeneration sets ObservedGeneration field to given value. + +### HasObservedGeneration + +`func (o *Gateway) HasObservedGeneration() bool` + +HasObservedGeneration returns a boolean if a field has been set. + [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/components/api-server/pkg/api/openapi/model_gateway.go b/components/api-server/pkg/api/openapi/model_gateway.go index 61d12c57..f6127317 100644 --- a/components/api-server/pkg/api/openapi/model_gateway.go +++ b/components/api-server/pkg/api/openapi/model_gateway.go @@ -55,6 +55,10 @@ type Gateway struct { DatabaseConfig *string `json:"database_config,omitempty"` // JSON-encoded credential storage driver configuration CredentialDriver *string `json:"credential_driver,omitempty"` + // Monotonic desired-state revision, incremented by the API server on any desired-spec change + Generation *int64 `json:"generation,omitempty"` + // Generation the control plane last successfully applied; converged when equal to generation + ObservedGeneration *int64 `json:"observed_generation,omitempty"` } type _Gateway Gateway @@ -802,6 +806,70 @@ func (o *Gateway) SetCredentialDriver(v string) { o.CredentialDriver = &v } +// GetGeneration returns the Generation field value if set, zero value otherwise. +func (o *Gateway) GetGeneration() int64 { + if o == nil || IsNil(o.Generation) { + var ret int64 + return ret + } + return *o.Generation +} + +// GetGenerationOk returns a tuple with the Generation field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Gateway) GetGenerationOk() (*int64, bool) { + if o == nil || IsNil(o.Generation) { + return nil, false + } + return o.Generation, true +} + +// HasGeneration returns a boolean if a field has been set. +func (o *Gateway) HasGeneration() bool { + if o != nil && !IsNil(o.Generation) { + return true + } + + return false +} + +// SetGeneration gets a reference to the given int64 and assigns it to the Generation field. +func (o *Gateway) SetGeneration(v int64) { + o.Generation = &v +} + +// GetObservedGeneration returns the ObservedGeneration field value if set, zero value otherwise. +func (o *Gateway) GetObservedGeneration() int64 { + if o == nil || IsNil(o.ObservedGeneration) { + var ret int64 + return ret + } + return *o.ObservedGeneration +} + +// GetObservedGenerationOk returns a tuple with the ObservedGeneration field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Gateway) GetObservedGenerationOk() (*int64, bool) { + if o == nil || IsNil(o.ObservedGeneration) { + return nil, false + } + return o.ObservedGeneration, true +} + +// HasObservedGeneration returns a boolean if a field has been set. +func (o *Gateway) HasObservedGeneration() bool { + if o != nil && !IsNil(o.ObservedGeneration) { + return true + } + + return false +} + +// SetObservedGeneration gets a reference to the given int64 and assigns it to the ObservedGeneration field. +func (o *Gateway) SetObservedGeneration(v int64) { + o.ObservedGeneration = &v +} + func (o Gateway) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -872,6 +940,12 @@ func (o Gateway) ToMap() (map[string]interface{}, error) { if !IsNil(o.CredentialDriver) { toSerialize["credential_driver"] = o.CredentialDriver } + if !IsNil(o.Generation) { + toSerialize["generation"] = o.Generation + } + if !IsNil(o.ObservedGeneration) { + toSerialize["observed_generation"] = o.ObservedGeneration + } return toSerialize, nil } diff --git a/components/api-server/proto/hypershell/v1/gateways.proto b/components/api-server/proto/hypershell/v1/gateways.proto index bc1c98c1..8f04483c 100644 --- a/components/api-server/proto/hypershell/v1/gateways.proto +++ b/components/api-server/proto/hypershell/v1/gateways.proto @@ -27,6 +27,8 @@ message Gateway { optional string route = 17; optional string database_config = 18; optional string credential_driver = 20; + int64 generation = 21; + optional int64 observed_generation = 22; } message CreateGatewayRequest { @@ -81,6 +83,7 @@ message UpdateGatewayRequest { optional string route = 17; optional string database_config = 18; optional string credential_driver = 19; + optional int64 observed_generation = 20; } message UpdateGatewayResponse { From 2fd0409f4a3311d20d01ca22a93779f58dd3ee81 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:39:47 -0400 Subject: [PATCH 3/7] api-server(gateway): implement generation convergence tracking Wire the generation primitive through the backend and gRPC: - model: add Generation/ObservedGeneration (int64); BeforeCreate initializes generation=1, observed_generation=0 so a new Gateway is never spuriously converged. Migration adds both columns (default 1 -> existing rows converged). - service.Replace centralizes ownership: increments generation iff a desired-spec field changed (identity/observed fields excluded via desiredStateChanged), never trusting a client-supplied generation; and enforces observed_generation as a monotonic latch, rejecting a write below the current value or above the (possibly advanced) generation with 400. - gRPC UpdateGateway accepts observed_generation (control-plane write-back); REST/gRPC presenters surface both fields. Unit tests cover BeforeCreate init and desiredStateChanged field selection. Assisted-by: Claude Opus 4.8 --- .../plugins/gateways/grpc_handler.go | 3 ++ .../plugins/gateways/grpc_presenter.go | 38 ++++++------- .../api-server/plugins/gateways/migration.go | 23 ++++++++ .../api-server/plugins/gateways/model.go | 42 ++++++++------- .../api-server/plugins/gateways/model_test.go | 54 +++++++++++++++++++ .../api-server/plugins/gateways/plugin.go | 1 + .../api-server/plugins/gateways/presenter.go | 48 +++++++++-------- .../api-server/plugins/gateways/service.go | 49 +++++++++++++++++ 8 files changed, 198 insertions(+), 60 deletions(-) diff --git a/components/api-server/plugins/gateways/grpc_handler.go b/components/api-server/plugins/gateways/grpc_handler.go index 2c4b4df9..140005e4 100644 --- a/components/api-server/plugins/gateways/grpc_handler.go +++ b/components/api-server/plugins/gateways/grpc_handler.go @@ -195,6 +195,9 @@ func (h *gatewayGRPCHandler) UpdateGateway(ctx context.Context, req *pb.UpdateGa if req.DatabaseConfig != nil { gateway.DatabaseConfig = req.DatabaseConfig } + if req.ObservedGeneration != nil { + gateway.ObservedGeneration = *req.ObservedGeneration + } result, svcErr := h.service.Replace(ctx, gateway) if svcErr != nil { return nil, grpcutil.ServiceErrorToGRPC(svcErr) diff --git a/components/api-server/plugins/gateways/grpc_presenter.go b/components/api-server/plugins/gateways/grpc_presenter.go index d5a70501..e4db380c 100644 --- a/components/api-server/plugins/gateways/grpc_presenter.go +++ b/components/api-server/plugins/gateways/grpc_presenter.go @@ -16,24 +16,26 @@ func gatewayToProto(d *Gateway) *pb.Gateway { Kind: "Gateway", Href: "/api/hypershell/v1/gateways/" + d.ID, }, - Name: d.Name, - FleetId: d.FleetId, - ClusterId: d.ClusterId, - ReleaseId: d.ReleaseId, - DatabaseId: d.DatabaseId, - Namespace: d.Namespace, - ExternalDns: d.ExternalDns, - TlsMode: d.TlsMode, - ServiceType: d.ServiceType, - Status: d.Status, - Phase: d.Phase, - Image: d.Image, - SupervisorImage: d.SupervisorImage, - RouteAddress: d.RouteAddress, - Oidc: d.Oidc, - Route: d.Route, - DatabaseConfig: d.DatabaseConfig, - CredentialDriver: d.CredentialDriver, + Name: d.Name, + FleetId: d.FleetId, + ClusterId: d.ClusterId, + ReleaseId: d.ReleaseId, + DatabaseId: d.DatabaseId, + Namespace: d.Namespace, + ExternalDns: d.ExternalDns, + TlsMode: d.TlsMode, + ServiceType: d.ServiceType, + Status: d.Status, + Phase: d.Phase, + Image: d.Image, + SupervisorImage: d.SupervisorImage, + RouteAddress: d.RouteAddress, + Oidc: d.Oidc, + Route: d.Route, + DatabaseConfig: d.DatabaseConfig, + CredentialDriver: d.CredentialDriver, + Generation: d.Generation, + ObservedGeneration: &d.ObservedGeneration, } if d.ServerDnsNames != nil { diff --git a/components/api-server/plugins/gateways/migration.go b/components/api-server/plugins/gateways/migration.go index 8d6dbc9d..ab3b27bc 100644 --- a/components/api-server/plugins/gateways/migration.go +++ b/components/api-server/plugins/gateways/migration.go @@ -94,3 +94,26 @@ func migrationAddSupervisorImage() *gormigrate.Migration { }, } } + +func migrationAddGenerationTracking() *gormigrate.Migration { + type Gateway struct { + db.Model + Generation int64 `gorm:"not null;default:1"` + ObservedGeneration int64 `gorm:"not null;default:1"` + } + + return &gormigrate.Migration{ + ID: "2026081912000006", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate(&Gateway{}) + }, + Rollback: func(tx *gorm.DB) error { + for _, col := range []string{"generation", "observed_generation"} { + if err := tx.Migrator().DropColumn(&Gateway{}, col); err != nil { + return err + } + } + return nil + }, + } +} diff --git a/components/api-server/plugins/gateways/model.go b/components/api-server/plugins/gateways/model.go index e9ee7989..e6ce9f78 100644 --- a/components/api-server/plugins/gateways/model.go +++ b/components/api-server/plugins/gateways/model.go @@ -13,25 +13,27 @@ const gatewayNamespacePrefix = "openshell-" type Gateway struct { api.Meta - Name string `json:"name"` - FleetId string `json:"fleet_id"` - ClusterId string `json:"cluster_id"` - ReleaseId string `json:"release_id"` - DatabaseId string `json:"database_id"` - Namespace string `json:"namespace"` - ExternalDns *string `json:"external_dns"` - TlsMode *string `json:"tls_mode"` - ServiceType *string `json:"service_type"` - Status *string `json:"status"` - Phase *string `json:"phase"` - Image *string `json:"image"` - SupervisorImage *string `json:"supervisor_image"` - ServerDnsNames *string `json:"server_dns_names" gorm:"type:jsonb"` - RouteAddress *string `json:"route_address"` - Oidc *string `json:"oidc" gorm:"type:jsonb"` - Route *string `json:"route" gorm:"type:jsonb"` - DatabaseConfig *string `json:"database_config" gorm:"type:jsonb"` - CredentialDriver *string `json:"credential_driver" gorm:"type:jsonb"` + Name string `json:"name"` + FleetId string `json:"fleet_id"` + ClusterId string `json:"cluster_id"` + ReleaseId string `json:"release_id"` + DatabaseId string `json:"database_id"` + Namespace string `json:"namespace"` + ExternalDns *string `json:"external_dns"` + TlsMode *string `json:"tls_mode"` + ServiceType *string `json:"service_type"` + Status *string `json:"status"` + Phase *string `json:"phase"` + Image *string `json:"image"` + SupervisorImage *string `json:"supervisor_image"` + ServerDnsNames *string `json:"server_dns_names" gorm:"type:jsonb"` + RouteAddress *string `json:"route_address"` + Oidc *string `json:"oidc" gorm:"type:jsonb"` + Route *string `json:"route" gorm:"type:jsonb"` + DatabaseConfig *string `json:"database_config" gorm:"type:jsonb"` + CredentialDriver *string `json:"credential_driver" gorm:"type:jsonb"` + Generation int64 `json:"generation" gorm:"not null;default:1"` + ObservedGeneration int64 `json:"observed_generation" gorm:"not null;default:1"` } type GatewayList []*Gateway @@ -53,6 +55,8 @@ func (d *Gateway) BeforeCreate(tx *gorm.DB) error { return fmt.Errorf("parse generated gateway ID: %w", err) } d.Namespace = gatewayNamespacePrefix + hex.EncodeToString(id.Payload()[:8]) + d.Generation = 1 + d.ObservedGeneration = 0 return nil } diff --git a/components/api-server/plugins/gateways/model_test.go b/components/api-server/plugins/gateways/model_test.go index de23b9d3..f43dc1a5 100644 --- a/components/api-server/plugins/gateways/model_test.go +++ b/components/api-server/plugins/gateways/model_test.go @@ -38,3 +38,57 @@ func TestBeforeCreateAssignsUniqueKubernetesNamespaces(t *testing.T) { t.Errorf("two gateways received the same namespace %q", first.Namespace) } } + +func TestBeforeCreateInitializesGenerationUnconverged(t *testing.T) { + gw := &Gateway{} + if err := gw.BeforeCreate(nil); err != nil { + t.Fatalf("assign gateway identity: %v", err) + } + if gw.Generation != 1 { + t.Errorf("generation = %d, want 1", gw.Generation) + } + if gw.ObservedGeneration != 0 { + t.Errorf("observed_generation = %d, want 0", gw.ObservedGeneration) + } + if gw.ObservedGeneration >= gw.Generation { + t.Errorf("new gateway must be unconverged: observed %d >= generation %d", + gw.ObservedGeneration, gw.Generation) + } +} + +func TestDesiredStateChanged(t *testing.T) { + str := func(s string) *string { return &s } + base := func() *Gateway { + return &Gateway{ + Name: "gw", FleetId: "f1", ClusterId: "c1", ReleaseId: "r1", DatabaseId: "d1", + Image: str("img:1"), Oidc: str(`{"issuer":"a"}`), + } + } + + tests := []struct { + name string + mutate func(*Gateway) + want bool + }{ + {"no change", func(*Gateway) {}, false}, + {"identity name ignored", func(g *Gateway) { g.Name = "renamed" }, false}, + {"identity fleet ignored", func(g *Gateway) { g.FleetId = "f2" }, false}, + {"observed phase ignored", func(g *Gateway) { g.Phase = str("Running") }, false}, + {"observed route_address ignored", func(g *Gateway) { g.RouteAddress = str("grpcs://x") }, false}, + {"observed generation ignored", func(g *Gateway) { g.ObservedGeneration = 5 }, false}, + {"desired image changed", func(g *Gateway) { g.Image = str("img:2") }, true}, + {"desired cluster changed", func(g *Gateway) { g.ClusterId = "c2" }, true}, + {"desired oidc changed", func(g *Gateway) { g.Oidc = str(`{"issuer":"b"}`) }, true}, + {"desired route set from nil", func(g *Gateway) { g.Route = str(`{"host":"h"}`) }, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + current := base() + next := base() + tc.mutate(next) + if got := desiredStateChanged(current, next); got != tc.want { + t.Errorf("desiredStateChanged = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/components/api-server/plugins/gateways/plugin.go b/components/api-server/plugins/gateways/plugin.go index 3bc888aa..cc36cfe9 100644 --- a/components/api-server/plugins/gateways/plugin.go +++ b/components/api-server/plugins/gateways/plugin.go @@ -112,4 +112,5 @@ func init() { db.RegisterMigration(migrationAddProvisioningFields()) db.RegisterMigration(migrationAddSupervisorImage()) db.RegisterMigration(migrationAddCredentialDriver()) + db.RegisterMigration(migrationAddGenerationTracking()) } diff --git a/components/api-server/plugins/gateways/presenter.go b/components/api-server/plugins/gateways/presenter.go index 11e25ba1..6fd6c706 100644 --- a/components/api-server/plugins/gateways/presenter.go +++ b/components/api-server/plugins/gateways/presenter.go @@ -38,29 +38,31 @@ func ConvertGateway(gateway openapi.GatewayCreateRequest) *Gateway { func PresentGateway(gateway *Gateway) openapi.Gateway { reference := presenters.PresentReference(gateway.ID, gateway) g := openapi.Gateway{ - Id: reference.Id, - Kind: reference.Kind, - Href: reference.Href, - CreatedAt: openapi.PtrTime(gateway.CreatedAt), - UpdatedAt: openapi.PtrTime(gateway.UpdatedAt), - Name: gateway.Name, - FleetId: gateway.FleetId, - ClusterId: gateway.ClusterId, - ReleaseId: gateway.ReleaseId, - DatabaseId: gateway.DatabaseId, - Namespace: gateway.Namespace, - ExternalDns: gateway.ExternalDns, - TlsMode: gateway.TlsMode, - ServiceType: gateway.ServiceType, - Status: gateway.Status, - Phase: gateway.Phase, - Image: gateway.Image, - SupervisorImage: gateway.SupervisorImage, - RouteAddress: gateway.RouteAddress, - Oidc: gateway.Oidc, - Route: gateway.Route, - DatabaseConfig: gateway.DatabaseConfig, - CredentialDriver: gateway.CredentialDriver, + Id: reference.Id, + Kind: reference.Kind, + Href: reference.Href, + CreatedAt: openapi.PtrTime(gateway.CreatedAt), + UpdatedAt: openapi.PtrTime(gateway.UpdatedAt), + Name: gateway.Name, + FleetId: gateway.FleetId, + ClusterId: gateway.ClusterId, + ReleaseId: gateway.ReleaseId, + DatabaseId: gateway.DatabaseId, + Namespace: gateway.Namespace, + ExternalDns: gateway.ExternalDns, + TlsMode: gateway.TlsMode, + ServiceType: gateway.ServiceType, + Status: gateway.Status, + Phase: gateway.Phase, + Image: gateway.Image, + SupervisorImage: gateway.SupervisorImage, + RouteAddress: gateway.RouteAddress, + Oidc: gateway.Oidc, + Route: gateway.Route, + DatabaseConfig: gateway.DatabaseConfig, + CredentialDriver: gateway.CredentialDriver, + Generation: &gateway.Generation, + ObservedGeneration: &gateway.ObservedGeneration, } if gateway.ServerDnsNames != nil { diff --git a/components/api-server/plugins/gateways/service.go b/components/api-server/plugins/gateways/service.go index a6a6b228..0dbf1533 100644 --- a/components/api-server/plugins/gateways/service.go +++ b/components/api-server/plugins/gateways/service.go @@ -102,6 +102,27 @@ func (s *sqlGatewayService) Replace(ctx context.Context, gateway *Gateway) (*Gat } defer s.lockFactory.Unlock(ctx, lockOwnerID) + // generation is API-server-owned: increment it iff a desired-spec field + // changed, and never trust a client-supplied value. observed_generation is a + // monotonic convergence latch written only by the control plane. See + // data-model.spec.md § Gateway Generation Tracking. + current, getErr := s.gatewayDao.Get(ctx, gateway.ID) + if getErr != nil { + return nil, services.HandleGetError("Gateway", "id", gateway.ID, getErr) + } + newGeneration := current.Generation + if desiredStateChanged(current, gateway) { + newGeneration = current.Generation + 1 + } + gateway.Generation = newGeneration + if gateway.ObservedGeneration != current.ObservedGeneration { + if gateway.ObservedGeneration < current.ObservedGeneration || gateway.ObservedGeneration > newGeneration { + return nil, errors.BadRequest( + "observed_generation %d out of range [%d, %d]", + gateway.ObservedGeneration, current.ObservedGeneration, newGeneration) + } + } + gateway, err = s.gatewayDao.Replace(ctx, gateway) if err != nil { return nil, services.HandleUpdateError("Gateway", err) @@ -119,6 +140,34 @@ func (s *sqlGatewayService) Replace(ctx context.Context, gateway *Gateway) (*Gat return gateway, nil } +// desiredStateChanged reports whether any workload-altering (desired-spec) field +// differs between the persisted Gateway and the incoming update. Observed fields +// (status, phase, route_address, generation, observed_generation) and identity +// fields (name, fleet_id, namespace) are excluded: they do not alter the live +// workload and must not advance generation. See data-model.spec.md. +func desiredStateChanged(current, next *Gateway) bool { + return current.ClusterId != next.ClusterId || + current.ReleaseId != next.ReleaseId || + current.DatabaseId != next.DatabaseId || + !strEq(current.ExternalDns, next.ExternalDns) || + !strEq(current.TlsMode, next.TlsMode) || + !strEq(current.ServiceType, next.ServiceType) || + !strEq(current.Image, next.Image) || + !strEq(current.SupervisorImage, next.SupervisorImage) || + !strEq(current.ServerDnsNames, next.ServerDnsNames) || + !strEq(current.Oidc, next.Oidc) || + !strEq(current.Route, next.Route) || + !strEq(current.DatabaseConfig, next.DatabaseConfig) || + !strEq(current.CredentialDriver, next.CredentialDriver) +} + +func strEq(a, b *string) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + func (s *sqlGatewayService) Delete(ctx context.Context, id string) *errors.ServiceError { if _, svcErr := s.Get(ctx, id); svcErr != nil { return svcErr From ef6032cb8c5a56b598c3bf3bcfc05cf7708831c6 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:40:20 -0400 Subject: [PATCH 4/7] sdk-go(gateway): regenerate with generation fields Adds Generation/ObservedGeneration (int64) to the Gateway type from the updated OpenAPI contract. Assisted-by: Claude Opus 4.8 --- components/sdk-go/client/client.go | 4 ++-- components/sdk-go/client/fleet_api.go | 4 ++-- components/sdk-go/client/gateway_api.go | 4 ++-- .../sdk-go/client/gateway_network_api.go | 4 ++-- .../sdk-go/client/gateway_release_api.go | 4 ++-- components/sdk-go/client/iterator.go | 4 ++-- .../sdk-go/client/managed_cluster_api.go | 4 ++-- .../sdk-go/client/managed_database_api.go | 4 ++-- components/sdk-go/client/role_api.go | 4 ++-- components/sdk-go/client/role_binding_api.go | 4 ++-- components/sdk-go/types/base.go | 4 ++-- components/sdk-go/types/fleet.go | 4 ++-- components/sdk-go/types/gateway.go | 22 +++++++++++++------ components/sdk-go/types/gateway_network.go | 4 ++-- components/sdk-go/types/gateway_release.go | 4 ++-- components/sdk-go/types/list_options.go | 4 ++-- components/sdk-go/types/managed_cluster.go | 4 ++-- components/sdk-go/types/managed_database.go | 4 ++-- components/sdk-go/types/role.go | 4 ++-- components/sdk-go/types/role_binding.go | 4 ++-- 20 files changed, 53 insertions(+), 45 deletions(-) diff --git a/components/sdk-go/client/client.go b/components/sdk-go/client/client.go index 2d89eb3e..91bd7592 100644 --- a/components/sdk-go/client/client.go +++ b/components/sdk-go/client/client.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/fleet_api.go b/components/sdk-go/client/fleet_api.go index 6c78902b..6754fadd 100644 --- a/components/sdk-go/client/fleet_api.go +++ b/components/sdk-go/client/fleet_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/gateway_api.go b/components/sdk-go/client/gateway_api.go index c1a84c3a..a1e778cb 100644 --- a/components/sdk-go/client/gateway_api.go +++ b/components/sdk-go/client/gateway_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/gateway_network_api.go b/components/sdk-go/client/gateway_network_api.go index 017703e1..d397bf5f 100644 --- a/components/sdk-go/client/gateway_network_api.go +++ b/components/sdk-go/client/gateway_network_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/gateway_release_api.go b/components/sdk-go/client/gateway_release_api.go index 89a193d7..2824824c 100644 --- a/components/sdk-go/client/gateway_release_api.go +++ b/components/sdk-go/client/gateway_release_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/iterator.go b/components/sdk-go/client/iterator.go index 391736f6..bbb5ae6b 100644 --- a/components/sdk-go/client/iterator.go +++ b/components/sdk-go/client/iterator.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/managed_cluster_api.go b/components/sdk-go/client/managed_cluster_api.go index e5cf138a..1de7111f 100644 --- a/components/sdk-go/client/managed_cluster_api.go +++ b/components/sdk-go/client/managed_cluster_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/managed_database_api.go b/components/sdk-go/client/managed_database_api.go index 275c1233..6673ddcd 100644 --- a/components/sdk-go/client/managed_database_api.go +++ b/components/sdk-go/client/managed_database_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/role_api.go b/components/sdk-go/client/role_api.go index 68a422a1..82ddde79 100644 --- a/components/sdk-go/client/role_api.go +++ b/components/sdk-go/client/role_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/client/role_binding_api.go b/components/sdk-go/client/role_binding_api.go index 78e9f932..53a0979d 100644 --- a/components/sdk-go/client/role_binding_api.go +++ b/components/sdk-go/client/role_binding_api.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package client diff --git a/components/sdk-go/types/base.go b/components/sdk-go/types/base.go index a44e3afb..63c703e0 100644 --- a/components/sdk-go/types/base.go +++ b/components/sdk-go/types/base.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/fleet.go b/components/sdk-go/types/fleet.go index b592c875..dc36f009 100644 --- a/components/sdk-go/types/fleet.go +++ b/components/sdk-go/types/fleet.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/gateway.go b/components/sdk-go/types/gateway.go index 8a420061..e1d6f16b 100644 --- a/components/sdk-go/types/gateway.go +++ b/components/sdk-go/types/gateway.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types @@ -14,13 +14,16 @@ type Gateway struct { ObjectReference ClusterID string `json:"cluster_id"` + CredentialDriver string `json:"credential_driver,omitempty"` DatabaseConfig string `json:"database_config,omitempty"` DatabaseID string `json:"database_id"` ExternalDNS string `json:"external_dns,omitempty"` FleetID string `json:"fleet_id"` + Generation int64 `json:"generation,omitempty"` Image string `json:"image,omitempty"` Name string `json:"name"` Namespace string `json:"namespace"` + ObservedGeneration int64 `json:"observed_generation,omitempty"` Oidc string `json:"oidc,omitempty"` Phase string `json:"phase,omitempty"` ReleaseID string `json:"release_id"` @@ -58,6 +61,11 @@ func (b *GatewayBuilder) ClusterID(v string) *GatewayBuilder { return b } +func (b *GatewayBuilder) CredentialDriver(v string) *GatewayBuilder { + b.resource.CredentialDriver = v + return b +} + func (b *GatewayBuilder) DatabaseConfig(v string) *GatewayBuilder { b.resource.DatabaseConfig = v return b @@ -88,11 +96,6 @@ func (b *GatewayBuilder) Name(v string) *GatewayBuilder { return b } -func (b *GatewayBuilder) Oidc(v string) *GatewayBuilder { - b.resource.Oidc = v - return b -} - func (b *GatewayBuilder) Phase(v string) *GatewayBuilder { b.resource.Phase = v return b @@ -173,6 +176,11 @@ func (b *GatewayPatchBuilder) ClusterID(v string) *GatewayPatchBuilder { return b } +func (b *GatewayPatchBuilder) CredentialDriver(v string) *GatewayPatchBuilder { + b.patch["credential_driver"] = v + return b +} + func (b *GatewayPatchBuilder) DatabaseConfig(v string) *GatewayPatchBuilder { b.patch["database_config"] = v return b diff --git a/components/sdk-go/types/gateway_network.go b/components/sdk-go/types/gateway_network.go index 3a3b6b2d..57ab1950 100644 --- a/components/sdk-go/types/gateway_network.go +++ b/components/sdk-go/types/gateway_network.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/gateway_release.go b/components/sdk-go/types/gateway_release.go index db607e67..d8acf00a 100644 --- a/components/sdk-go/types/gateway_release.go +++ b/components/sdk-go/types/gateway_release.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/list_options.go b/components/sdk-go/types/list_options.go index f4d3c6d5..3874fbf0 100644 --- a/components/sdk-go/types/list_options.go +++ b/components/sdk-go/types/list_options.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/managed_cluster.go b/components/sdk-go/types/managed_cluster.go index 2af3066e..2668ba1a 100644 --- a/components/sdk-go/types/managed_cluster.go +++ b/components/sdk-go/types/managed_cluster.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/managed_database.go b/components/sdk-go/types/managed_database.go index cbebfe51..d4ca3a14 100644 --- a/components/sdk-go/types/managed_database.go +++ b/components/sdk-go/types/managed_database.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/role.go b/components/sdk-go/types/role.go index 709b9749..1f131b27 100644 --- a/components/sdk-go/types/role.go +++ b/components/sdk-go/types/role.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types diff --git a/components/sdk-go/types/role_binding.go b/components/sdk-go/types/role_binding.go index af4eae2d..06aca8d0 100644 --- a/components/sdk-go/types/role_binding.go +++ b/components/sdk-go/types/role_binding.go @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. -// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell/components/api-server/openapi/openapi.yaml -// Spec SHA256: 300e25bd78ab5a10ddac513022a9b1cf8a214c26cfb75c3bd73b2d05e648508e +// Source: /home/mturansk/projects/src/github.com/openshift-online/hypershell-wk-2/components/api-server/openapi/openapi.yaml +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 // Generated: 1970-01-01T00:00:00Z package types From 1c5b7daade99724c0a52317f637102a21edd722e Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:40:20 -0400 Subject: [PATCH 5/7] sdk-typescript(gateway): regenerate with generation fields Adds generation/observed_generation to the Gateway type from the updated OpenAPI contract. Assisted-by: Claude Opus 4.8 --- components/sdk-typescript/src/base.ts | 2 +- components/sdk-typescript/src/client.ts | 2 +- components/sdk-typescript/src/fleet.ts | 2 +- components/sdk-typescript/src/fleet_api.ts | 2 +- components/sdk-typescript/src/gateway.ts | 4 +++- components/sdk-typescript/src/gateway_api.ts | 2 +- components/sdk-typescript/src/gateway_network.ts | 2 +- components/sdk-typescript/src/gateway_network_api.ts | 2 +- components/sdk-typescript/src/gateway_release.ts | 2 +- components/sdk-typescript/src/gateway_release_api.ts | 2 +- components/sdk-typescript/src/index.ts | 2 +- components/sdk-typescript/src/managed_cluster.ts | 2 +- components/sdk-typescript/src/managed_cluster_api.ts | 2 +- components/sdk-typescript/src/managed_database.ts | 2 +- components/sdk-typescript/src/managed_database_api.ts | 2 +- components/sdk-typescript/src/role.ts | 2 +- components/sdk-typescript/src/role_api.ts | 2 +- components/sdk-typescript/src/role_binding.ts | 2 +- components/sdk-typescript/src/role_binding_api.ts | 2 +- 19 files changed, 21 insertions(+), 19 deletions(-) diff --git a/components/sdk-typescript/src/base.ts b/components/sdk-typescript/src/base.ts index 6d2a5603..c140746c 100644 --- a/components/sdk-typescript/src/base.ts +++ b/components/sdk-typescript/src/base.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 export type ObjectReference = { id: string; diff --git a/components/sdk-typescript/src/client.ts b/components/sdk-typescript/src/client.ts index b617a346..31ab881a 100644 --- a/components/sdk-typescript/src/client.ts +++ b/components/sdk-typescript/src/client.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig } from './base.js'; import { FleetAPI } from './fleet_api.js'; diff --git a/components/sdk-typescript/src/fleet.ts b/components/sdk-typescript/src/fleet.ts index d54d49c4..0a2dbe7c 100644 --- a/components/sdk-typescript/src/fleet.ts +++ b/components/sdk-typescript/src/fleet.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/fleet_api.ts b/components/sdk-typescript/src/fleet_api.ts index b17cb752..224523ae 100644 --- a/components/sdk-typescript/src/fleet_api.ts +++ b/components/sdk-typescript/src/fleet_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway.ts b/components/sdk-typescript/src/gateway.ts index 6eae3193..30128978 100644 --- a/components/sdk-typescript/src/gateway.ts +++ b/components/sdk-typescript/src/gateway.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; @@ -11,9 +11,11 @@ export type Gateway = ObjectReference & { database_id: string; external_dns: string; fleet_id: string; + generation: number; image: string; name: string; namespace: string; + observed_generation: number; oidc: string; phase: string; release_id: string; diff --git a/components/sdk-typescript/src/gateway_api.ts b/components/sdk-typescript/src/gateway_api.ts index 5e8abc03..68d57466 100644 --- a/components/sdk-typescript/src/gateway_api.ts +++ b/components/sdk-typescript/src/gateway_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network.ts b/components/sdk-typescript/src/gateway_network.ts index b8a9d4bd..172aae9c 100644 --- a/components/sdk-typescript/src/gateway_network.ts +++ b/components/sdk-typescript/src/gateway_network.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_network_api.ts b/components/sdk-typescript/src/gateway_network_api.ts index 882b2b2e..d7f09dc0 100644 --- a/components/sdk-typescript/src/gateway_network_api.ts +++ b/components/sdk-typescript/src/gateway_network_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release.ts b/components/sdk-typescript/src/gateway_release.ts index 14caf441..f2b61e91 100644 --- a/components/sdk-typescript/src/gateway_release.ts +++ b/components/sdk-typescript/src/gateway_release.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/gateway_release_api.ts b/components/sdk-typescript/src/gateway_release_api.ts index 1d510287..c501e753 100644 --- a/components/sdk-typescript/src/gateway_release_api.ts +++ b/components/sdk-typescript/src/gateway_release_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/index.ts b/components/sdk-typescript/src/index.ts index 93a69737..06d6cfe7 100644 --- a/components/sdk-typescript/src/index.ts +++ b/components/sdk-typescript/src/index.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 export { SDKClient } from './client.js'; export type { SDKClientConfig, ListOptions, RequestOptions, ObjectReference, ListMeta, APIError } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster.ts b/components/sdk-typescript/src/managed_cluster.ts index 372f34b4..543ea71d 100644 --- a/components/sdk-typescript/src/managed_cluster.ts +++ b/components/sdk-typescript/src/managed_cluster.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_cluster_api.ts b/components/sdk-typescript/src/managed_cluster_api.ts index 6c0998f7..2c960260 100644 --- a/components/sdk-typescript/src/managed_cluster_api.ts +++ b/components/sdk-typescript/src/managed_cluster_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database.ts b/components/sdk-typescript/src/managed_database.ts index 0d9b4ff4..0785af7e 100644 --- a/components/sdk-typescript/src/managed_database.ts +++ b/components/sdk-typescript/src/managed_database.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/managed_database_api.ts b/components/sdk-typescript/src/managed_database_api.ts index a521865e..f8523f0f 100644 --- a/components/sdk-typescript/src/managed_database_api.ts +++ b/components/sdk-typescript/src/managed_database_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role.ts b/components/sdk-typescript/src/role.ts index cd935763..d08fac5e 100644 --- a/components/sdk-typescript/src/role.ts +++ b/components/sdk-typescript/src/role.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_api.ts b/components/sdk-typescript/src/role_api.ts index c3778537..236f84fd 100644 --- a/components/sdk-typescript/src/role_api.ts +++ b/components/sdk-typescript/src/role_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding.ts b/components/sdk-typescript/src/role_binding.ts index 51d77c83..bcbe5cfc 100644 --- a/components/sdk-typescript/src/role_binding.ts +++ b/components/sdk-typescript/src/role_binding.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { ObjectReference, ListMeta } from './base.js'; diff --git a/components/sdk-typescript/src/role_binding_api.ts b/components/sdk-typescript/src/role_binding_api.ts index 77cd61c6..7443fe33 100644 --- a/components/sdk-typescript/src/role_binding_api.ts +++ b/components/sdk-typescript/src/role_binding_api.ts @@ -1,6 +1,6 @@ // Code generated by trex-sdk-generator from openapi.yaml - DO NOT EDIT. // Source: components/api-server/openapi/openapi.yaml -// Spec SHA256: 2729bb434158cd2fcd889ae589ced55e74740af810b6d4fd5b506123a2738d24 +// Spec SHA256: db26dc317d17c6621c5f070f33e57b38975b3ed3228dcbe94857d09c5888f8d3 import type { SDKClientConfig, ListOptions, RequestOptions } from './base.js'; import { sdkFetch, buildQueryString } from './base.js'; From 8444b24ec72f9829e9f5ae6e59d10e25d925b29e Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:41:50 -0400 Subject: [PATCH 6/7] control-plane(gateway): gate re-provisioning on convergence Replace the phase gate in GatewayReconciler.Handle with a convergence gate: skip re-applying manifests only when the Gateway is converged (observed_generation == generation). A desired-spec change advances generation past observed_generation, so it now falls through the gate and re-provisions regardless of Running/Provisioning/Degraded phase. After ReconcileGateway succeeds, write observed_generation = generation via the gRPC back-channel, marking the Gateway converged. On apply failure the write is skipped so the change is retried. Health phase/status updates are unchanged. Assisted-by: Claude Opus 4.8 --- .../internal/reconciler/reconciler.go | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/components/control-plane/internal/reconciler/reconciler.go b/components/control-plane/internal/reconciler/reconciler.go index 4ccbc6fc..cd5447ca 100644 --- a/components/control-plane/internal/reconciler/reconciler.go +++ b/components/control-plane/internal/reconciler/reconciler.go @@ -258,13 +258,14 @@ func (r *GatewayReconciler) Handle(ctx context.Context, event watcher.Event[*pb. log.Printf("INFO reconciling Gateway %s name=%s namespace=%s (event=%d)", event.ResourceID, gw.Name, gw.Namespace, event.Type) - // The phase gate prevents redundant re-provisioning (re-applying manifests) - // of a Gateway that has already been acted upon. Running, Provisioning, and - // Degraded gateways are owned by the continuous health reconciler, which - // keeps their phase synchronized with workload health via a separate path - // that this gate does not suppress. See openshell-gateway-health.spec.md. - if gw.Phase != nil && (*gw.Phase == "Running" || *gw.Phase == "Provisioning" || *gw.Phase == "Degraded") { - log.Printf("DEBUG gateway %s phase=%s, skipping reconciliation", event.ResourceID, *gw.Phase) + // The convergence gate prevents redundant re-provisioning: skip re-applying + // manifests only when the Gateway is converged (observed_generation == + // generation). A desired-spec change advances generation past + // observed_generation, so the change falls through this gate regardless of + // phase. Health phase/status updates flow through a separate path that this + // gate does not suppress. See openshell-gateway-health.spec.md. + if gw.ObservedGeneration != nil && *gw.ObservedGeneration == gw.Generation { + log.Printf("DEBUG gateway %s converged at generation %d, skipping reconciliation", event.ResourceID, gw.Generation) return nil } @@ -359,6 +360,11 @@ func (r *GatewayReconciler) Handle(ctx context.Context, event watcher.Event[*pb. return fmt.Errorf("reconcile gateway %s: %w", gw.Name, err) } + // Manifests applied successfully: acknowledge the generation we converged on. + // Readiness/health below only affects phase, not convergence. On failure + // above we returned without writing, so the change is retried. + r.updateObservedGeneration(ctx, event.ResourceID, gw.Generation) + // Manifests are applied, but the gateway is not Running until its workload is // observed Ready. Wait within the provisioning readiness window; if the // Deployment never becomes ready, set Degraded and record why. @@ -433,6 +439,21 @@ func (r *GatewayReconciler) updateGatewayPhase(ctx context.Context, gatewayID st } } +// updateObservedGeneration records the generation the control plane has +// successfully applied to the cluster, marking the Gateway converged. It is the +// only writer of observed_generation, via the same gRPC back-channel used for +// phase/status/route_address. +func (r *GatewayReconciler) updateObservedGeneration(ctx context.Context, gatewayID string, generation int64) { + client := pb.NewGatewayServiceClient(r.grpcConn) + _, err := client.UpdateGateway(ctx, &pb.UpdateGatewayRequest{ + Id: gatewayID, + ObservedGeneration: &generation, + }) + if err != nil { + log.Printf("WARN failed to update gateway %s observed_generation to %d: %v", gatewayID, generation, err) + } +} + // makeRouteAddressUpdater returns a RouteAddressUpdater callback that PATCHes // the route_address field on the API-server Gateway via gRPC. func (r *GatewayReconciler) makeRouteAddressUpdater(gatewayID string) gateway.RouteAddressUpdater { From 435d8348e0256edbccf418d7cdbc9e98751ed8d8 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 19 Aug 2026 12:44:36 -0400 Subject: [PATCH 7/7] reconcile: checkpoint gateway generation convergence (GEN wave) Record DM-8 (Gateway Generation Tracking) and CP-2j (convergence-gated re-provisioning) as Present, and add the GEN wave history entry for the downstream implementation of PR #151. Assisted-by: Claude Opus 4.8 --- skills/RECONCILE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index e994507a..d4ebd4c6 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -106,6 +106,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | DM-5 | Canary release strategy fields | Present | Fields exist; no logic implements canary | `plugins/gatewayReleases/model.go` | Future | | DM-6 | Network topology fields | Present | Fields exist; reconciler is a stub | `plugins/gatewayNetworks/model.go` | Future | | DM-7 | API endpoints (all 6 resources) | Present | - | `plugins/*/` | - | +| DM-8 | Gateway Generation Tracking (generation/observed_generation) | Present | int64 fields; BeforeCreate inits 1/0; service.Replace increments generation on desired-spec change and bounds observed_generation as a monotonic latch | `plugins/gateways/model.go`, `service.go`, `migration.go` | GEN ✅ | ### control-plane.spec.md @@ -121,6 +122,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | CP-2g | Canary release rollout | Missing | Stub: only logs | `reconciler.go:99-124` | Future | | CP-2h | Update resource status/phase | Partial | Only updates `phase`, not `status` | `updateGatewayPhase()` | Future | | CP-2i | Read provisioning fields from proto | Present | GatewayReconciler populates GatewayConfig from proto fields via JSON unmarshal | `reconciler.go:248-280` | W5 ✅ | +| CP-2j | Convergence-gated re-provisioning | Present | GatewayReconciler.Handle gates on observed_generation==generation (not phase); writes observed_generation after successful ReconcileGateway | `reconciler.go:261-270,366` | GEN ✅ | | CP-3 | Delete K8s resources on Gateway deletion | Present | Label-based deletion of all namespaced resources + per-tenant ClusterRoleBinding | `gateway/reconciler.go:DeleteGatewayResources()` | W6 ✅ | | CP-4 | Status synchronization / health checks | Missing | No periodic health polling | - | Future | | CP-5 | Multi-cluster client pool | Missing | Single in-cluster client for all gateways | `main.go:58-68` | Future | @@ -531,3 +533,4 @@ Added `database/sql` + `lib/pq` to control plane. `rotateDatabaseCredentials()` | 2026-08-12 | working tree | OIDC always-on + Keycloak stability | 77% | Removed KIND_ENABLE_OIDC toggle; OIDC unconditional in kind-up; Keycloak memory 1Gi→2Gi + startup/liveness probes | | 2026-08-13 | 1055647 | Gap analysis for keycloak + secret-rotation specs | 73% | 2 new specs: keycloak (9 reqs, 6 deferred, 1 partial), secret-rotation (8 reqs, 6 deferred, 1 present, 1 partial). OIDC spec updated: 2 new requirements (O8 read-only, O9 auto-provisioned roles) deferred to KC wave. Data model spec: Sector→Fleet naming aligned. 4 new waves planned (KC-W1/W2/W3, SR-W1). Overall coverage drops from 78% to 73% due to new spec requirements. | | 2026-08-13 | working tree | Executed KC-W1/W2/W3 + SR-W1 | 80% | Keycloak Admin REST API client (token cache, atomic provisioning, cleanup). RoleBinding gRPC watch stream with role name enrichment. RoleBindingReconciler for OIDC Role Bridge (gateway:owner→openshell-admin, gateway:viewer→openshell-user). Gateway visibility filtering via FindGatewayIDsByUserID. Database password rotation (ALTER ROLE, config-hash). Coverage: 138/183 present (80%), keycloak 100%, secret-rotation 69%. | +| 2026-08-19 | 8444b24 | Executed GEN wave: gateway desired-state convergence | 80% | Downstream of PR #151 spec. generation/observed_generation added to OpenAPI+proto (regenerated stubs/SDKs); BeforeCreate inits 1/0; service.Replace increments generation on desired-spec change (desiredStateChanged) and enforces observed_generation monotonic latch (reject generation); gRPC UpdateGateway accepts observed_generation write-back; control-plane reconciler gates on convergence instead of phase and acks generation on apply success. Per-component commits; api-server unit tests pass; gateways integration suite pre-broken on main (unrelated plugin migration param bug). |