Skip to content

Commit 4542457

Browse files
Work-in-progress test implementation.
1 parent 6af39f2 commit 4542457

4 files changed

Lines changed: 193 additions & 81 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Bug Report: HTTPRouteSimpleSameNamespace
2+
3+
## Test Description
4+
This conformance test validates basic HTTP routing from an HTTPRoute to a backend service in the same namespace. It creates:
5+
- A Gateway named `same-namespace` in `gateway-conformance-infra` namespace
6+
- An HTTPRoute named `gateway-conformance-infra-test` that routes to `infra-backend-v1:8080`
7+
- Makes an HTTP request to the Gateway and expects a 200 response from the backend
8+
9+
## Issues Found
10+
11+
### Issue 1: Missing ResolvedRefs Condition on Gateway Listeners (FIXED)
12+
13+
**Failure Message:**
14+
```
15+
gateway gateway-conformance-infra/same-namespace doesn't have ResolvedRefs condition set to True on http listener
16+
```
17+
18+
**Root Cause:**
19+
Gateway listeners only had the `Accepted` condition set, but Gateway API requires listeners to also have `ResolvedRefs` and `Programmed` conditions.
20+
21+
**Fix Applied:**
22+
Added `ResolvedRefs` and `Programmed` conditions to `GatewayStatusListeners` in `crates/controlplane/src/core/reconcile.rs:288-333`.
23+
24+
### Issue 2: Missing observedGeneration on HTTPRoute Conditions (FIXED)
25+
26+
**Failure Message:**
27+
```
28+
HTTPRoute expected observedGeneration to be updated to 1 for all conditions, only 0/2 were updated. stale conditions are: Accepted (generation 0), ResolvedRefs (generation 0)
29+
```
30+
31+
**Root Cause:**
32+
HTTPRoute parent status conditions had `observed_generation: None` instead of the route's generation.
33+
34+
**Fix Applied:**
35+
Updated `build_parent_status` function in `crates/controlplane/src/core/reconcile.rs:672-713` to accept and use the route's generation.
36+
37+
### Issue 3: ConfigMap Server-Side Apply Conflict (IN PROGRESS)
38+
39+
**Failure Message:**
40+
```
41+
Failed to execute upsert error=Kubernetes API error: ApiError: Apply failed with 1 conflict: conflict with "unknown" using v1: .data.config.json
42+
```
43+
44+
**Root Cause Analysis:**
45+
The controller uses Server-Side Apply (SSA) to update ConfigMaps containing data plane configuration. There's a conflict with an "unknown" field manager on the `.data.config.json` field.
46+
47+
**Investigation Findings:**
48+
49+
1. **Original Code Issue:** The upsert functions in `executor.rs` used a "get-then-create" pattern:
50+
- If resource exists: Update with SSA using field manager "multiway-controller"
51+
- If resource doesn't exist: Create with `api.create()` using `PostParams::default()` (no field manager = "unknown")
52+
53+
This meant ConfigMaps created by our controller were tagged with field manager "unknown", causing conflicts on subsequent SSA updates.
54+
55+
2. **Attempted Fix:** Changed all upsert functions to use pure SSA with `PatchParams::apply("multiway-controller").force()`:
56+
- SSA handles both creation and updates idempotently
57+
- The `.force()` flag should force the controller to take ownership of conflicting fields
58+
59+
3. **Current Status:** Even with `.force()` enabled, the SSA conflict persists:
60+
```
61+
Apply failed with 1 conflict: conflict with "unknown" using v1: .data.config.json
62+
```
63+
64+
**Remaining Questions:**
65+
1. **Is `.force()` being properly passed to the API server?** The kube-rs `PatchParams::apply().force()` should set `force: true` in the patch request, but this needs verification.
66+
67+
2. **Source of "unknown" field manager:** Something is creating/modifying ConfigMaps with field manager "unknown" before our SSA update runs. Possible sources:
68+
- Another controller or admission webhook
69+
- Race condition between Gateway and HTTPRoute reconciliation
70+
- The conformance test setup process
71+
72+
3. **kube-rs SSA behavior:** According to Kubernetes docs, SSA with force should always succeed by taking over field ownership. If `.force()` isn't working as expected, there may be:
73+
- A bug in kube-rs's SSA implementation
74+
- Missing TypeMeta (apiVersion/kind) in the ConfigMap object being applied
75+
- Serialization issues with the patch request
76+
77+
**Proposed Next Steps:**
78+
1. Add debug logging to capture the exact HTTP request being sent for SSA patches
79+
2. Verify the ConfigMap object has proper TypeMeta fields set for SSA
80+
3. Check if the kube-rs `PatchParams::force()` method is correctly setting the force flag
81+
4. Test with `kubectl apply --server-side --force-conflicts` to confirm the API server accepts force apply
82+
83+
**Files Modified (for this issue):**
84+
- `crates/controlplane/src/shell/executor.rs`:
85+
- Removed `PostParams` import (no longer using `api.create()`)
86+
- Changed all `upsert_*` functions to use pure SSA with `.force()`
87+
- Lines 178-197: `upsert_deployment` now uses SSA
88+
- Lines 199-214: `upsert_service` now uses SSA
89+
- Lines 216-231: `upsert_configmap` now uses SSA with force
90+
- Lines 233-248: `upsert_serviceaccount` now uses SSA
91+
92+
## Test Status
93+
94+
The status condition fixes are correct and working:
95+
- Gateway listener ResolvedRefs condition: PASSING
96+
- HTTPRoute observedGeneration on conditions: PASSING
97+
98+
The HTTP request test fails due to the ConfigMap SSA conflict:
99+
- Simple HTTP request should reach infra-backend: FAILING (timeout due to ConfigMap not being updated)
100+
101+
## Files Modified
102+
103+
1. `crates/controlplane/src/core/reconcile.rs`:
104+
- Lines 288-334: Added ResolvedRefs and Programmed conditions to listener status
105+
- Lines 639-646: Pass route generation to build_parent_status
106+
- Lines 672-713: Updated build_parent_status to accept and use generation parameter
107+
- Lines 385-405: Updated build_configmap to explicitly set all fields
108+
109+
2. `crates/controlplane/src/shell/executor.rs`:
110+
- Lines 178-248: Changed all upsert functions from get-then-create to pure SSA with force

.claude/skills/development-loop/test-tiers/tier-1-essential.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
test_name,description,implemented
2-
HTTPRouteSimpleSameNamespace,Basic HTTP routing from a route to a backend service in the same namespace. Foundation of all routing.,false
2+
HTTPRouteSimpleSameNamespace,Basic HTTP routing from a route to a backend service in the same namespace. Foundation of all routing.,in-progress
33
HTTPRouteMatching,Path and header matching for routing requests to different backends based on request criteria.,false
44
HTTPRouteExactPathMatching,Exact path matching where /foo matches only /foo and not /foo/bar.,false
55
HTTPRouteWeight,Traffic distribution across multiple backends based on specified weights for load balancing.,false

crates/controlplane/src/core/reconcile.rs

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -289,14 +289,48 @@ fn build_gateway_status_with_routes(
289289
name: l.name.clone(),
290290
attached_routes,
291291
supported_kinds,
292-
conditions: vec![Condition {
293-
type_: "Accepted".to_string(),
294-
status: status_value.to_string(),
295-
observed_generation: gateway.metadata.generation,
296-
last_transition_time: time.clone(),
297-
reason: reason.to_string(),
298-
message: message.to_string(),
299-
}],
292+
conditions: vec![
293+
Condition {
294+
type_: "Accepted".to_string(),
295+
status: status_value.to_string(),
296+
observed_generation: gateway.metadata.generation,
297+
last_transition_time: time.clone(),
298+
reason: reason.to_string(),
299+
message: message.to_string(),
300+
},
301+
Condition {
302+
type_: "ResolvedRefs".to_string(),
303+
status: status_value.to_string(),
304+
observed_generation: gateway.metadata.generation,
305+
last_transition_time: time.clone(),
306+
reason: if accepted {
307+
"ResolvedRefs".to_string()
308+
} else {
309+
reason.to_string()
310+
},
311+
message: if accepted {
312+
"All references resolved".to_string()
313+
} else {
314+
message.to_string()
315+
},
316+
},
317+
Condition {
318+
type_: "Programmed".to_string(),
319+
status: status_value.to_string(),
320+
observed_generation: gateway.metadata.generation,
321+
last_transition_time: time.clone(),
322+
reason: if accepted {
323+
"Programmed".to_string()
324+
} else {
325+
"Invalid".to_string()
326+
},
327+
message: if accepted {
328+
"Listener is programmed".to_string()
329+
} else {
330+
message.to_string()
331+
},
332+
},
333+
],
300334
}
301335
})
302336
.collect();
@@ -364,7 +398,9 @@ fn build_configmap(names: &DataPlaneNames, config: &GatewayConfig, gateway: &Gat
364398
..Default::default()
365399
},
366400
data: Some(data),
367-
..Default::default()
401+
// Explicitly set immutable to None to avoid SSA conflicts with unmanaged fields
402+
immutable: None,
403+
binary_data: None,
368404
}
369405
}
370406

@@ -608,6 +644,7 @@ pub fn reconcile_httproute(
608644
validation.accepted,
609645
validation.reason,
610646
&validation.message,
647+
httproute.metadata.generation,
611648
);
612649
parent_statuses.push(parent_status);
613650

@@ -641,6 +678,7 @@ fn build_parent_status(
641678
accepted: bool,
642679
reason: &str,
643680
message: &str,
681+
generation: Option<i64>,
644682
) -> HttpRouteStatusParents {
645683
let time = Time(now);
646684
let status_value = if accepted { "True" } else { "False" };
@@ -659,15 +697,15 @@ fn build_parent_status(
659697
Condition {
660698
type_: "Accepted".to_string(),
661699
status: status_value.to_string(),
662-
observed_generation: None,
700+
observed_generation: generation,
663701
last_transition_time: time.clone(),
664702
reason: reason.to_string(),
665703
message: message.to_string(),
666704
},
667705
Condition {
668706
type_: "ResolvedRefs".to_string(),
669707
status: status_value.to_string(),
670-
observed_generation: None,
708+
observed_generation: generation,
671709
last_transition_time: time,
672710
reason: if accepted { "ResolvedRefs" } else { reason }.to_string(),
673711
message: message.to_string(),

crates/controlplane/src/shell/executor.rs

Lines changed: 33 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::time::Duration;
99
use gateway_crds::{Gateway, GatewayClass, HTTPRoute};
1010
use k8s_openapi::api::apps::v1::Deployment;
1111
use k8s_openapi::api::core::v1::{ConfigMap, Service, ServiceAccount};
12-
use kube::api::{DeleteParams, Patch, PatchParams, PostParams};
12+
use kube::api::{DeleteParams, Patch, PatchParams};
1313
use kube::runtime::controller::Action;
1414
use kube::{Api, Client};
1515
use tracing::{debug, error, info, warn};
@@ -175,7 +175,7 @@ impl ReconcileExecutor {
175175
Ok(())
176176
}
177177

178-
/// Upsert a Deployment
178+
/// Upsert a Deployment using Server-Side Apply
179179
async fn upsert_deployment(&self, deployment: &Deployment) -> Result<()> {
180180
let namespace = deployment
181181
.metadata
@@ -185,100 +185,64 @@ impl ReconcileExecutor {
185185
let name = deployment.metadata.name.as_deref().unwrap();
186186
let api: Api<Deployment> = Api::namespaced(self.client.clone(), namespace);
187187

188-
match api.get(name).await {
189-
Ok(_) => {
190-
debug!(namespace, name, "Updating Deployment");
191-
api.patch(
192-
name,
193-
&PatchParams::apply("multiway-controller"),
194-
&Patch::Apply(deployment),
195-
)
196-
.await?;
197-
}
198-
Err(kube::Error::Api(err)) if err.code == 404 => {
199-
info!(namespace, name, "Creating Deployment");
200-
api.create(&PostParams::default(), deployment).await?;
201-
}
202-
Err(e) => return Err(e.into()),
203-
}
188+
debug!(namespace, name, "Applying Deployment");
189+
api.patch(
190+
name,
191+
&PatchParams::apply("multiway-controller").force(),
192+
&Patch::Apply(deployment),
193+
)
194+
.await?;
204195

205196
Ok(())
206197
}
207198

208-
/// Upsert a Service
199+
/// Upsert a Service using Server-Side Apply
209200
async fn upsert_service(&self, service: &Service) -> Result<()> {
210201
let namespace = service.metadata.namespace.as_deref().unwrap_or("default");
211202
let name = service.metadata.name.as_deref().unwrap();
212203
let api: Api<Service> = Api::namespaced(self.client.clone(), namespace);
213204

214-
match api.get(name).await {
215-
Ok(_) => {
216-
debug!(namespace, name, "Updating Service");
217-
api.patch(
218-
name,
219-
&PatchParams::apply("multiway-controller"),
220-
&Patch::Apply(service),
221-
)
222-
.await?;
223-
}
224-
Err(kube::Error::Api(err)) if err.code == 404 => {
225-
info!(namespace, name, "Creating Service");
226-
api.create(&PostParams::default(), service).await?;
227-
}
228-
Err(e) => return Err(e.into()),
229-
}
205+
debug!(namespace, name, "Applying Service");
206+
api.patch(
207+
name,
208+
&PatchParams::apply("multiway-controller").force(),
209+
&Patch::Apply(service),
210+
)
211+
.await?;
230212

231213
Ok(())
232214
}
233215

234-
/// Upsert a ConfigMap
216+
/// Upsert a ConfigMap using Server-Side Apply
235217
async fn upsert_configmap(&self, configmap: &ConfigMap) -> Result<()> {
236218
let namespace = configmap.metadata.namespace.as_deref().unwrap_or("default");
237219
let name = configmap.metadata.name.as_deref().unwrap();
238220
let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), namespace);
239221

240-
match api.get(name).await {
241-
Ok(_) => {
242-
debug!(namespace, name, "Updating ConfigMap");
243-
api.patch(
244-
name,
245-
&PatchParams::apply("multiway-controller"),
246-
&Patch::Apply(configmap),
247-
)
248-
.await?;
249-
}
250-
Err(kube::Error::Api(err)) if err.code == 404 => {
251-
info!(namespace, name, "Creating ConfigMap");
252-
api.create(&PostParams::default(), configmap).await?;
253-
}
254-
Err(e) => return Err(e.into()),
255-
}
222+
debug!(namespace, name, "Applying ConfigMap");
223+
api.patch(
224+
name,
225+
&PatchParams::apply("multiway-controller").force(),
226+
&Patch::Apply(configmap),
227+
)
228+
.await?;
256229

257230
Ok(())
258231
}
259232

260-
/// Upsert a ServiceAccount
233+
/// Upsert a ServiceAccount using Server-Side Apply
261234
async fn upsert_serviceaccount(&self, sa: &ServiceAccount) -> Result<()> {
262235
let namespace = sa.metadata.namespace.as_deref().unwrap_or("default");
263236
let name = sa.metadata.name.as_deref().unwrap();
264237
let api: Api<ServiceAccount> = Api::namespaced(self.client.clone(), namespace);
265238

266-
match api.get(name).await {
267-
Ok(_) => {
268-
debug!(namespace, name, "Updating ServiceAccount");
269-
api.patch(
270-
name,
271-
&PatchParams::apply("multiway-controller"),
272-
&Patch::Apply(sa),
273-
)
274-
.await?;
275-
}
276-
Err(kube::Error::Api(err)) if err.code == 404 => {
277-
info!(namespace, name, "Creating ServiceAccount");
278-
api.create(&PostParams::default(), sa).await?;
279-
}
280-
Err(e) => return Err(e.into()),
281-
}
239+
debug!(namespace, name, "Applying ServiceAccount");
240+
api.patch(
241+
name,
242+
&PatchParams::apply("multiway-controller").force(),
243+
&Patch::Apply(sa),
244+
)
245+
.await?;
282246

283247
Ok(())
284248
}

0 commit comments

Comments
 (0)