diff --git a/components/control-plane/cmd/hypershell-controller/main.go b/components/control-plane/cmd/hypershell-controller/main.go index a142e98c2..3224a2fb0 100644 --- a/components/control-plane/cmd/hypershell-controller/main.go +++ b/components/control-plane/cmd/hypershell-controller/main.go @@ -182,7 +182,7 @@ func main() { } else { log.Printf("WARN ManagedDatabase watch disabled: both Kubernetes typed and dynamic clients are required") } - networkReconciler := reconciler.NewGatewayNetworkReconciler() + networkReconciler := reconciler.NewGatewayNetworkReconciler(conn) manifestsDir := os.Getenv("GATEWAY_MANIFESTS_DIR") if manifestsDir == "" { diff --git a/components/control-plane/internal/reconciler/gateway_network_test.go b/components/control-plane/internal/reconciler/gateway_network_test.go new file mode 100644 index 000000000..616463d73 --- /dev/null +++ b/components/control-plane/internal/reconciler/gateway_network_test.go @@ -0,0 +1,219 @@ +package reconciler + +import ( + "context" + "strings" + "testing" + + pb "github.com/openshift-online/hypershell/components/api-server/pkg/api/grpc/hypershell/v1" + "github.com/openshift-online/hypershell/components/control-plane/internal/watcher" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// fakeNetworkClient records UpdateGatewayNetwork calls and can inject an error. +type fakeNetworkClient struct { + pb.GatewayNetworkServiceClient + updates []*pb.UpdateGatewayNetworkRequest + updateErr error +} + +func (f *fakeNetworkClient) UpdateGatewayNetwork(ctx context.Context, in *pb.UpdateGatewayNetworkRequest, opts ...grpc.CallOption) (*pb.UpdateGatewayNetworkResponse, error) { + f.updates = append(f.updates, in) + if f.updateErr != nil { + return nil, f.updateErr + } + return &pb.UpdateGatewayNetworkResponse{}, nil +} + +// fakeNetworkGatewayClient resolves GetGateway against a fixed hub inventory and +// can inject a lookup error to simulate not-found or transient failures. +type fakeNetworkGatewayClient struct { + pb.GatewayServiceClient + existing map[string]bool + getErr error +} + +func (f *fakeNetworkGatewayClient) GetGateway(ctx context.Context, in *pb.GetGatewayRequest, opts ...grpc.CallOption) (*pb.GetGatewayResponse, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.existing[in.Id] { + return &pb.GetGatewayResponse{Gateway: &pb.Gateway{Metadata: &pb.ObjectReference{Id: in.Id}}}, nil + } + return nil, status.Error(codes.NotFound, "gateway not found") +} + +func newTestNetworkReconciler(gw pb.GatewayServiceClient, net pb.GatewayNetworkServiceClient) *GatewayNetworkReconciler { + return &GatewayNetworkReconciler{ + active: make(map[string]struct{}), + gateways: gw, + networks: net, + } +} + +func networkEvent(t watcher.EventType, id, topology, hubID, status string) watcher.Event[*pb.GatewayNetwork] { + net := &pb.GatewayNetwork{ + Metadata: &pb.ObjectReference{Id: id}, + Name: "net-" + id, + } + if topology != "" { + net.Topology = &topology + } + if hubID != "" { + net.HubGatewayId = &hubID + } + if status != "" { + net.Status = &status + } + return watcher.Event[*pb.GatewayNetwork]{Type: t, ResourceID: id, Resource: net} +} + +func TestGatewayNetwork_ValidHubSpokeSetsValid(t *testing.T) { + net := &fakeNetworkClient{} + gw := &fakeNetworkGatewayClient{existing: map[string]bool{"hub1": true}} + r := newTestNetworkReconciler(gw, net) + + if err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "hub-spoke", "hub1", "")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 1 || net.updates[0].GetStatus() != networkStatusValid { + t.Fatalf("expected status Valid, got updates=%v", net.updates) + } +} + +func TestGatewayNetwork_ValidMeshSetsValid(t *testing.T) { + net := &fakeNetworkClient{} + r := newTestNetworkReconciler(&fakeNetworkGatewayClient{}, net) + + if err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "mesh", "", "")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 1 || net.updates[0].GetStatus() != networkStatusValid { + t.Fatalf("expected status Valid, got updates=%v", net.updates) + } +} + +func TestGatewayNetwork_UnrecognizedTopologyIsInvalid(t *testing.T) { + net := &fakeNetworkClient{} + r := newTestNetworkReconciler(&fakeNetworkGatewayClient{}, net) + + if err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "ring", "", "")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 1 || !strings.HasPrefix(net.updates[0].GetStatus(), networkStatusInvalid) { + t.Fatalf("expected Invalid status, got updates=%v", net.updates) + } + if !strings.Contains(net.updates[0].GetStatus(), "ring") { + t.Fatalf("expected reason to mention the unrecognized topology, got %q", net.updates[0].GetStatus()) + } +} + +func TestGatewayNetwork_HubSpokeWithoutHubIsInvalid(t *testing.T) { + net := &fakeNetworkClient{} + r := newTestNetworkReconciler(&fakeNetworkGatewayClient{}, net) + + if err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "hub-spoke", "", "")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 1 || !strings.HasPrefix(net.updates[0].GetStatus(), networkStatusInvalid) { + t.Fatalf("expected Invalid status, got updates=%v", net.updates) + } +} + +func TestGatewayNetwork_DanglingHubIsInvalid(t *testing.T) { + net := &fakeNetworkClient{} + // hub inventory is empty, so GetGateway returns NotFound for hub1. + gw := &fakeNetworkGatewayClient{existing: map[string]bool{}} + r := newTestNetworkReconciler(gw, net) + + if err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "hub-spoke", "hub1", "")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 1 || !strings.HasPrefix(net.updates[0].GetStatus(), networkStatusInvalid) { + t.Fatalf("expected Invalid status, got updates=%v", net.updates) + } + if !strings.Contains(net.updates[0].GetStatus(), "hub1") { + t.Fatalf("expected reason to mention the missing hub gateway, got %q", net.updates[0].GetStatus()) + } +} + +func TestGatewayNetwork_NoRedundantStatusWrite(t *testing.T) { + net := &fakeNetworkClient{} + gw := &fakeNetworkGatewayClient{existing: map[string]bool{"hub1": true}} + r := newTestNetworkReconciler(gw, net) + + // Persisted status already equals the reconciled outcome. + if err := r.Handle(context.Background(), networkEvent(watcher.EventUpdated, "n1", "hub-spoke", "hub1", networkStatusValid)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 0 { + t.Fatalf("expected no status write when unchanged, got %v", net.updates) + } +} + +func TestGatewayNetwork_NoRedundantInvalidStatusWrite(t *testing.T) { + net := &fakeNetworkClient{} + r := newTestNetworkReconciler(&fakeNetworkGatewayClient{}, net) + + // Persisted status already equals the recomputed Invalid outcome (same reason). + persisted := networkStatusInvalid + ": unrecognized topology \"ring\"" + if err := r.Handle(context.Background(), networkEvent(watcher.EventUpdated, "n1", "ring", "", persisted)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 0 { + t.Fatalf("expected no status write when Invalid status unchanged, got %v", net.updates) + } +} + +func TestGatewayNetwork_DeleteIsNoOp(t *testing.T) { + net := &fakeNetworkClient{} + r := newTestNetworkReconciler(&fakeNetworkGatewayClient{}, net) + + if err := r.Handle(context.Background(), networkEvent(watcher.EventDeleted, "n1", "hub-spoke", "hub1", "")); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 0 { + t.Fatalf("expected no status write on delete, got %v", net.updates) + } +} + +func TestGatewayNetwork_NilResourceIsNoOp(t *testing.T) { + net := &fakeNetworkClient{} + r := newTestNetworkReconciler(&fakeNetworkGatewayClient{}, net) + + ev := watcher.Event[*pb.GatewayNetwork]{Type: watcher.EventCreated, ResourceID: "n1", Resource: nil} + if err := r.Handle(context.Background(), ev); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(net.updates) != 0 { + t.Fatalf("expected no status write for nil resource, got %v", net.updates) + } +} + +func TestGatewayNetwork_TransientHubLookupSurfacesAsError(t *testing.T) { + net := &fakeNetworkClient{} + gw := &fakeNetworkGatewayClient{getErr: status.Error(codes.Unavailable, "hub lookup down")} + r := newTestNetworkReconciler(gw, net) + + err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "hub-spoke", "hub1", "")) + if err == nil { + t.Fatalf("expected transient hub lookup failure to return an error") + } + // The network must not be settled to Invalid on account of a transient failure. + if len(net.updates) != 0 { + t.Fatalf("expected no status write on transient failure, got %v", net.updates) + } +} + +func TestGatewayNetwork_StatusWriteFailureSurfacesAsError(t *testing.T) { + net := &fakeNetworkClient{updateErr: status.Error(codes.Unavailable, "api down")} + gw := &fakeNetworkGatewayClient{existing: map[string]bool{"hub1": true}} + r := newTestNetworkReconciler(gw, net) + + err := r.Handle(context.Background(), networkEvent(watcher.EventCreated, "n1", "hub-spoke", "hub1", "")) + if err == nil { + t.Fatalf("expected status write failure to return an error") + } +} diff --git a/components/control-plane/internal/reconciler/reconciler.go b/components/control-plane/internal/reconciler/reconciler.go index 3931d2df2..df62a158c 100644 --- a/components/control-plane/internal/reconciler/reconciler.go +++ b/components/control-plane/internal/reconciler/reconciler.go @@ -2417,13 +2417,49 @@ func (r *StubGatewayReconciler) Handle(ctx context.Context, event watcher.Event[ return nil } +// GatewayNetwork topology vocabulary. See +// specs/platform/gateway-network-reconciliation.spec.md. +const ( + networkTopologyMesh = "mesh" + networkTopologyHubSpoke = "hub-spoke" +) + +// GatewayNetwork control-plane-owned status values. A network owns no Kubernetes +// resources in this scope, so status reflects configuration validity only, not +// provisioned connectivity. +const ( + networkStatusValid = "Valid" + networkStatusInvalid = "Invalid" +) + +// GatewayNetworkReconciler reconciles GatewayNetwork resources. A network owns no +// Kubernetes resources in this scope, so reconciliation means: validate the +// network's topology vocabulary and topology/hub coherence, validate that a +// designated hub_gateway_id references an existing Gateway, and write a +// deterministic status back to the API server. Applying real gateway-to-gateway +// connectivity (mesh/tunnel provisioning) is future work owned by a sibling spec +// once product defines the network membership model and connectivity technology; +// this reconciler only records whether the declared configuration is well-formed. type GatewayNetworkReconciler struct { mu sync.Mutex active map[string]struct{} + + gateways pb.GatewayServiceClient + networks pb.GatewayNetworkServiceClient } -func NewGatewayNetworkReconciler() *GatewayNetworkReconciler { - return &GatewayNetworkReconciler{active: make(map[string]struct{})} +// NewGatewayNetworkReconciler builds the network reconciler. conn is the API +// server gRPC connection used to look up the designated hub gateway and to write +// network status back. conn may be nil (e.g. in unit tests), in which case the +// hub existence check and status write-back are skipped but the rest of +// validation still runs. +func NewGatewayNetworkReconciler(conn *grpc.ClientConn) *GatewayNetworkReconciler { + r := &GatewayNetworkReconciler{active: make(map[string]struct{})} + if conn != nil { + r.gateways = pb.NewGatewayServiceClient(conn) + r.networks = pb.NewGatewayNetworkServiceClient(conn) + } + return r } func (r *GatewayNetworkReconciler) Handle(ctx context.Context, event watcher.Event[*pb.GatewayNetwork]) error { @@ -2441,8 +2477,102 @@ func (r *GatewayNetworkReconciler) Handle(ctx context.Context, event watcher.Eve }() _, endSpan := cpotel.StartReconcileSpan(ctx, "GatewayNetwork", event.Type.String(), event.Resource.GetMetadata().GetTraceparent()) - defer func() { endSpan(nil) }() + var reconcileErr error + defer func() { endSpan(reconcileErr) }() - log.Printf("INFO reconciling GatewayNetwork %s (event=%d)", event.ResourceID, event.Type) + // A network owns no cluster resources, so a delete is a terminal, idempotent + // no-op with respect to Kubernetes: gateways designated by the network are + // left untouched. + if event.Type == watcher.EventDeleted { + log.Printf("INFO gateway network %s deleted; no cluster resources to remove", event.ResourceID) + return nil + } + + net := event.Resource + if net == nil { + log.Printf("WARN gateway network event %s has nil resource, skipping", event.ResourceID) + return nil + } + + // Validate the declared configuration. A transient dependency failure (e.g. a + // transient hub lookup error) is returned so the failure is surfaced (logged + // by the watch loop) rather than silently swallowed or settled to a misleading + // Invalid. The network watch is inline log-only (no reconcile queue) and does + // not replay state on reconnect, so a surfaced error re-converges only when the + // network is next mutated, not automatically. + desiredStatus, retryErr := r.validate(ctx, net) + if retryErr != nil { + reconcileErr = fmt.Errorf("validate gateway network %s: %w", event.ResourceID, retryErr) + return reconcileErr + } + + // Deterministic, idempotent status write-back: only update when the persisted + // status differs from the reconciled outcome. + if net.GetStatus() != desiredStatus { + if err := r.updateStatus(ctx, event.ResourceID, desiredStatus); err != nil { + reconcileErr = fmt.Errorf("update gateway network %s status: %w", event.ResourceID, err) + return reconcileErr + } + } return nil } + +// validate applies the network's structural and referential coherence rules and +// returns the deterministic desired status (networkStatusValid, or +// "networkStatusInvalid: reason"). It returns a non-nil error only for a +// transient dependency failure that should be surfaced rather than swallowed; a +// definitive not-found for the hub gateway is a deterministic Invalid, not an +// error. +func (r *GatewayNetworkReconciler) validate(ctx context.Context, net *pb.GatewayNetwork) (string, error) { + invalid := func(reason string) string { + return fmt.Sprintf("%s: %s", networkStatusInvalid, reason) + } + + topology := net.GetTopology() + switch topology { + case "": + return invalid("topology is required"), nil + case networkTopologyMesh, networkTopologyHubSpoke: + // recognized + default: + return invalid(fmt.Sprintf("unrecognized topology %q", topology)), nil + } + + hubID := net.GetHubGatewayId() + if topology == networkTopologyHubSpoke && hubID == "" { + return invalid("hub-spoke network requires a hub_gateway_id"), nil + } + + if hubID != "" { + // A configured hub must reference an existing Gateway. Skip the lookup when + // no gateway client is configured (started without an API-server gRPC + // connection, e.g. in unit tests). + if r.gateways == nil { + return networkStatusValid, nil + } + _, err := r.gateways.GetGateway(ctx, &pb.GetGatewayRequest{Id: hubID}) + if err != nil { + if status.Code(err) == codes.NotFound { + return invalid(fmt.Sprintf("hub gateway %q does not exist", hubID)), nil + } + // Transient failure: surface as an error rather than settle to a + // misleading Invalid. + return "", err + } + } + + return networkStatusValid, nil +} + +// updateStatus writes the network's reconciled status back to the API server. It +// is a no-op when the network client is not configured. +func (r *GatewayNetworkReconciler) updateStatus(ctx context.Context, id, desired string) error { + if r.networks == nil { + return nil + } + _, err := r.networks.UpdateGatewayNetwork(ctx, &pb.UpdateGatewayNetworkRequest{ + Id: id, + Status: &desired, + }) + return err +} diff --git a/specs/platform/control-plane.spec.md b/specs/platform/control-plane.spec.md index 146ff273a..f623d0006 100644 --- a/specs/platform/control-plane.spec.md +++ b/specs/platform/control-plane.spec.md @@ -53,6 +53,7 @@ Gateway reconciliation is defined in detail across dedicated sub-specs: | [`openshell-gateway-health.spec.md`](./openshell-gateway-health.spec.md) | Phase lifecycle, workload-readiness gating, continuous health reconciliation | | [`gateway-version-selection.spec.md`](./gateway-version-selection.spec.md) | Database-backed version selection: resolving `release_id` to a GatewayRelease image and its precedence over a direct image | | [`gateway-release-reconciliation.spec.md`](./gateway-release-reconciliation.spec.md) | GatewayRelease reconciliation: image validation, deterministic release status, change propagation to referencing gateways | +| [`gateway-network-reconciliation.spec.md`](./gateway-network-reconciliation.spec.md) | GatewayNetwork reconciliation: topology vocabulary, topology/hub coherence and hub-reference validation, deterministic network status write-back | ### Config diff --git a/specs/platform/gateway-network-reconciliation.spec.md b/specs/platform/gateway-network-reconciliation.spec.md new file mode 100644 index 000000000..982a6d1b6 --- /dev/null +++ b/specs/platform/gateway-network-reconciliation.spec.md @@ -0,0 +1,197 @@ +# GatewayNetwork Reconciliation + +**Date:** 2026-09-04 +**Status:** Active + +## Purpose + +This spec defines how the HyperShell control plane reconciles `GatewayNetwork` +resources. A `GatewayNetwork` is a database-backed record describing the intended +connectivity topology between gateways: its `topology` (network shape), its +`tunnel_mode` (encapsulation method), and, for hub-and-spoke shapes, the +`hub_gateway_id` that designates the hub. Like a `GatewayRelease`, a +`GatewayNetwork` has **no direct Kubernetes footprint** of its own in this +ticket. Reconciling a network therefore means (1) validating the network's +structural and referential coherence and (2) recording a deterministic, +observable `status` back on the network so operators can tell whether the +declared topology is well-formed and whether its designated hub actually exists. + +Today the `GatewayNetworkReconciler` is a no-op: it dedups, opens a trace span, +logs, and returns `nil` without validating the network or writing any status. +This spec replaces that behavior with a deterministic reconciliation contract, +mirroring the contract established for +[`gateway-release-reconciliation.spec.md`](./gateway-release-reconciliation.spec.md). + +This spec is a sub-spec of [`control-plane.spec.md`](./control-plane.spec.md) and +refines its "Configure network meshes between gateways" and "Update resource +status back to the API server" responsibilities for the network resource +specifically. + +### Scope Boundary + +- **In scope:** validating a network's topology vocabulary and topology/hub + coherence, validating that a designated `hub_gateway_id` references an existing + Gateway, writing back a deterministic `status`, and doing so idempotently and + serialized per network. +- **Out of scope (future work, pending product definition):** applying any real + gateway-to-gateway connectivity in the cluster (mesh/tunnel provisioning, + NetworkPolicy, ServiceExport, or any inter-cluster networking technology); a + member-gateway list (the model today designates only a single `hub_gateway_id`, + not a set of members); the enumerated `tunnel_mode` encapsulation vocabulary and + its semantics; and routing spoke traffic through the hub. Actual connectivity + provisioning is deferred until product defines the network membership model and + selects a connectivity technology. This reconciler only guarantees that a + network's declared configuration is validated and its outcome recorded. + +## Domain Vocabulary + +A `GatewayNetwork` `topology` SHALL be one of the following canonical values, +which describe the intended network shape: + +| Topology | Meaning | +|---|---| +| `mesh` | Every gateway in the network connects to every other; no designated hub. | +| `hub-spoke` | Spoke gateways connect through a single designated hub gateway. | + +A `GatewayNetwork` carries a `status` field that the control plane owns and keeps +current to reflect the reconciled validation outcome. Allowed +control-plane-owned values: + +- **`Valid`** - the network's topology is a recognized value, its topology/hub + coherence rules are satisfied, and any designated hub gateway exists. The + declared configuration is well-formed. +- **`Invalid`** - the network failed validation; the value includes a short + human-readable reason (for example, an unrecognized topology, a hub-spoke + network with no hub, or a `hub_gateway_id` that does not reference an existing + Gateway). + +The `status` string is a short, human-readable descriptor surfaced in the console +and CLI alongside the network. It reflects only configuration validity, not the +existence of any provisioned connectivity (which is out of scope, see above). + +## Requirements + +### Requirement: Network Configuration Validation + +The control plane SHALL validate a `GatewayNetwork` on every create and update +event against the following rules, in order: + +1. The `topology`, if set, SHALL be one of the canonical values (`mesh`, + `hub-spoke`). An unrecognized topology SHALL be treated as invalid. An empty + topology SHALL be treated as invalid, because a network with no declared shape + cannot be reconciled. +2. A `hub-spoke` network SHALL designate a `hub_gateway_id`; a `hub-spoke` + network with no hub SHALL be treated as invalid. +3. When a `hub_gateway_id` is set (for any topology), it SHALL reference an + existing Gateway. A `hub_gateway_id` that does not resolve to an existing + Gateway SHALL be treated as invalid. + +#### Scenario: Well-formed hub-spoke network passes validation + +- GIVEN a `GatewayNetwork` with `topology: hub-spoke` and a `hub_gateway_id` that + references an existing Gateway +- WHEN the control plane reconciles the network +- THEN validation succeeds + +#### Scenario: Unrecognized topology fails validation + +- GIVEN a `GatewayNetwork` with `topology: ring` +- WHEN the control plane reconciles the network +- THEN validation fails with a reason describing the unrecognized topology + +#### Scenario: Hub-spoke without a hub fails validation + +- GIVEN a `GatewayNetwork` with `topology: hub-spoke` and no `hub_gateway_id` +- WHEN the control plane reconciles the network +- THEN validation fails with a reason stating a hub-spoke network requires a hub + +#### Scenario: Dangling hub reference fails validation + +- GIVEN a `GatewayNetwork` whose `hub_gateway_id` references a Gateway that does + not exist +- WHEN the control plane reconciles the network +- THEN validation fails with a reason describing the missing hub gateway + +### Requirement: Deterministic Network Status Write-Back + +The control plane SHALL write the reconciled network's status back to the API +server so the persisted `status` deterministically reflects the reconcile +outcome: `Valid` on successful validation, or `Invalid` with a reason on failed +validation. The write-back SHALL be idempotent: the control plane SHALL NOT issue +a status update when the persisted `status` already equals the desired value. + +#### Scenario: Status settles to Valid + +- GIVEN a `GatewayNetwork` that passes validation and whose persisted `status` is + unset or not `Valid` +- WHEN the control plane reconciles the network +- THEN the control plane updates the network `status` to `Valid` + +#### Scenario: Status settles to Invalid with a reason + +- GIVEN a `GatewayNetwork` that fails validation +- WHEN the control plane reconciles the network +- THEN the control plane updates the network `status` to `Invalid` including the + validation reason + +#### Scenario: No redundant status write + +- GIVEN a `GatewayNetwork` whose persisted `status` is already `Valid` +- WHEN the control plane reconciles the network and validation still passes +- THEN the control plane makes no status update call for the network + +### Requirement: Network Deletion Has No Cluster Footprint + +A `GatewayNetwork` delete event SHALL NOT remove or disrupt any running Gateway +workload, because a network owns no Kubernetes resources in this scope. The +control plane SHALL treat a network delete as a terminal, idempotent no-op with +respect to cluster state, and SHALL NOT error when the network is already absent. + +#### Scenario: Deleting a network leaves gateways untouched + +- GIVEN a `GatewayNetwork` `n1` whose `hub_gateway_id` designates Gateway `g1` +- WHEN `n1` is deleted +- THEN `g1`'s running workload is unchanged +- AND the control plane reports the network reconcile as successful + +### Requirement: Idempotent, Serialized Reconciliation + +Network reconciliation SHALL be idempotent and SHALL be serialized per network so +that a retry never runs concurrently with a live event for the same network. +Re-reconciling an unchanged network SHALL converge to the same status and SHALL +NOT produce redundant status writes. + +#### Scenario: Repeated reconciles are stable + +- GIVEN a `GatewayNetwork` already reconciled to `Valid` +- WHEN the control plane reconciles the same network again with no change +- THEN no status update is requested + +### Requirement: Transient Failures Surface as Errors, Not Silent Success + +When a network reconcile cannot complete because a dependency is transiently +unavailable (for example, the API server rejects the status write, or the hub +gateway lookup fails with a transient error rather than a definitive not-found), +the reconciler SHALL return an error rather than reporting success, so the +failure is surfaced and not silently swallowed. A definitive not-found for the +hub gateway is a deterministic validation failure (status `Invalid`), not a +transient error, and SHALL NOT be reported as an error. The reconciler SHALL NOT +settle a network's status to `Invalid` on account of a transient failure. The +network watch is inline and log-only (there is no reconcile queue for networks, +matching the sibling release reconciler) and does not replay state on reconnect, +so a surfaced error re-converges only when the network is next mutated, not +automatically. Partial failures SHALL NOT be silently swallowed. + +#### Scenario: Status write failure surfaces as an error + +- GIVEN a `GatewayNetwork` whose status must be updated to `Valid` +- WHEN the status write to the API server fails transiently +- THEN the reconcile returns an error rather than reporting success + +#### Scenario: Transient hub lookup failure surfaces as an error + +- GIVEN a `GatewayNetwork` with a `hub_gateway_id` +- WHEN the hub gateway lookup fails with a transient error +- THEN the reconcile returns an error rather than reporting success +- AND the network status is not settled to `Invalid` on account of the transient + failure