Skip to content

Commit f371eca

Browse files
HiranAdikariclaude
andcommitted
Address self-review findings on the plumbing slice
Pre-CodeRabbit review pass (the local pr-review gate) on this branch: - Declare StatusFallbackOK: false explicitly on the vpc and subnet capabilities — their Get consumers are full-object read-modify- writes, so the status-only degrade must never engage; spell out the zero value the way vmCapability spells out its true. - Add VerbIsRoutable (direct lookup, no map copy) and use it in the Get/List/Apply routability guards. - Explain why Create/Update are exempt from Apply's guard, and add the missing in-method comment on DisassociateRouteTable. - Test gaps: force=false forwarding, Apply wire-error translation, Apply unmapped-GVR refusal; seam Get/Apply failure propagation for the plumbing ops (no apply after a failed read); an explicit assertion that the legacy CIDR-fallback List engaged in the fail-closed test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC3BDHCFmJBdxvGVW69FAr
1 parent eee3e4b commit f371eca

5 files changed

Lines changed: 167 additions & 6 deletions

File tree

dc-api/internal/providers/clusteraccess/agent.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func (a *AgentBacked) Get(ctx context.Context, gvr schema.GroupVersionResource,
134134
// routable family for Get — e.g. the pre-seeded virtualmachineinstances, which
135135
// has no RouteVerbs. Refuse rather than issue a read the agent's SA may not be
136136
// permitted to serve; the caller falls back to the direct path.
137-
if verbs, ok := RoutableVerbs(gvr); !ok || !verbs[VerbGet] {
137+
if !VerbIsRoutable(gvr, VerbGet) {
138138
return nil, fmt.Errorf("clusteraccess: get of %s not routable: %w", gvr.String(), agentgw.ErrOpNotRoutable)
139139
}
140140
ref, err := a.ref(gvr, ns, name)
@@ -250,7 +250,7 @@ func (a *AgentBacked) List(ctx context.Context, gvr schema.GroupVersionResource,
250250
// virtualmachineinstances, which is in the mapper as a superset but has no
251251
// RouteVerbs. Refuse to route such a family to the agent rather than issuing a
252252
// list the agent's SA may not be permitted (least-privilege) to serve.
253-
if verbs, ok := RoutableVerbs(gvr); !ok || !verbs[VerbList] {
253+
if !VerbIsRoutable(gvr, VerbList) {
254254
return nil, fmt.Errorf("clusteraccess: list of %s not routable: %w", gvr.String(), agentgw.ErrOpNotRoutable)
255255
}
256256
apiVersion, kind, ok := a.mapper.GVK(gvr)
@@ -333,9 +333,13 @@ func (a *AgentBacked) Apply(ctx context.Context, gvr schema.GroupVersionResource
333333
// for a family that does not declare VerbApply, rather than performing a write
334334
// the allow-set never granted. The Routed decision already gates this per
335335
// call; the guard keeps the contract uniform for any direct AgentBacked use.
336-
// Create/Update do NOT pass through here (they call applyWith directly), so
337-
// families that route VerbCreate without VerbApply are unaffected.
338-
if verbs, ok := RoutableVerbs(gvr); !ok || !verbs[VerbApply] {
336+
// Create/Update are DELIBERATELY exempt: they call applyWith directly, are
337+
// gated per call by the Routed decision's verb allow-set (VerbCreate/
338+
// VerbUpdate), always use the accessor's own fieldManager, and have no direct
339+
// AgentBacked callers — so a family that routes VerbCreate without VerbApply
340+
// (SA/RoleBinding, provider-network-style creates) still creates via SSA
341+
// without needing VerbApply in its routable set.
342+
if !VerbIsRoutable(gvr, VerbApply) {
339343
return nil, fmt.Errorf("clusteraccess: apply of %s not routable: %w", gvr.String(), agentgw.ErrOpNotRoutable)
340344
}
341345
if fieldManager == "" {

dc-api/internal/providers/clusteraccess/capabilities.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ var (
156156
Namespaced: false,
157157
RouteVerbs: []Verb{VerbGet, VerbCreate, VerbApply, VerbDelete},
158158
AgentVerbs: []Verb{VerbGet, VerbList, VerbWatch, VerbCreate, VerbApply, VerbDelete},
159+
// Explicitly false (the zero value, spelled out like vmCapability's explicit
160+
// true): the Vpc Get consumers are full-object read-modify-writes — the
161+
// peering (spec.vpcPeerings) and static-route (spec.staticRoutes) ops read
162+
// .spec to rebuild it — so the status-only degrade must never engage.
163+
StatusFallbackOK: false,
159164
}
160165
// subnetCapability onboards the Subnet CRUD that CreateSubnet/GetSubnet/
161166
// DeleteSubnet route through the kubeovn seam, plus the spec-write plumbing
@@ -169,6 +174,10 @@ var (
169174
Namespaced: false,
170175
RouteVerbs: []Verb{VerbGet, VerbCreate, VerbApply, VerbDelete},
171176
AgentVerbs: []Verb{VerbGet, VerbList, VerbWatch, VerbCreate, VerbApply, VerbDelete},
177+
// Explicitly false (mirrors vpcCapability): the Subnet Get consumers are
178+
// full-object read-modify-writes — the NSG ACL (spec.acls) ops read .spec
179+
// to rebuild it — so the status-only degrade must never engage.
180+
StatusFallbackOK: false,
172181
}
173182
// serviceAccountCapability onboards the ServiceAccount create that
174183
// EnsureCloudProviderSA routes through the harvester seam (the cloud-provider
@@ -323,6 +332,15 @@ func RoutableVerbs(gvr schema.GroupVersionResource) (map[Verb]bool, bool) {
323332
return out, true
324333
}
325334

335+
// VerbIsRoutable reports whether a single verb is in a GVR's routable set — a
336+
// direct lookup with no defensive map copy, for the per-call guards on the hot
337+
// read/write paths (AgentBacked.Get/List/Apply). Callers that need the whole
338+
// set keep using RoutableVerbs.
339+
func VerbIsRoutable(gvr schema.GroupVersionResource, v Verb) bool {
340+
buildDerived()
341+
return derivedRouteSet[gvr][v]
342+
}
343+
326344
// StatusFallbackOK reports whether AgentBacked.Get may degrade a routed read of
327345
// this GVR to a status-only get_status when an older agent lacks get_object. True
328346
// only for families whose Get consumers read status alone (VMs); false (the

dc-api/internal/providers/clusteraccess/clusteraccess_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,13 +581,62 @@ func TestAgentBackedApply_ForwardsCallerFieldManager(t *testing.T) {
581581
t.Error("wire force = false, want the caller's true")
582582
}
583583

584-
// Empty caller manager → the accessor default.
584+
// Empty caller manager → the accessor default; force=false forwards too.
585585
if _, err := a.Apply(context.Background(), vpcGVR, "", obj, "", false); err != nil {
586586
t.Fatalf("Apply (empty manager) error: %v", err)
587587
}
588588
if gotFM != "dc-api" {
589589
t.Errorf("wire fieldManager = %q for an empty caller value, want the accessor default %q", gotFM, "dc-api")
590590
}
591+
if gotForce {
592+
t.Error("wire force = true for the second call, want the caller's false")
593+
}
594+
}
595+
596+
// TestAgentBackedApply_SessionErrorTranslated mirrors the Get/List
597+
// agent-unavailable tests: a wire failure from Session.Apply must surface
598+
// through translateErr so callers' errors.Is(ErrAgentUnavailable) checks fire.
599+
func TestAgentBackedApply_SessionErrorTranslated(t *testing.T) {
600+
vpcGVR := schema.GroupVersionResource{Group: "kubeovn.io", Version: "v1", Resource: "vpcs"}
601+
sess := &fakeSession{apply: func(json.RawMessage, string, bool) (agentgw.ApplyResult, error) {
602+
return agentgw.ApplyResult{}, agentgw.ErrAgentUnavailable
603+
}}
604+
a := NewAgentBacked(sess, "lk", "zone-1", "dc-api", DefaultGVKMapper(), zerolog.Nop())
605+
606+
obj := &unstructured.Unstructured{Object: map[string]interface{}{
607+
"apiVersion": "kubeovn.io/v1", "kind": "Vpc",
608+
"metadata": map[string]interface{}{"name": "vnet-a"},
609+
}}
610+
_, err := a.Apply(context.Background(), vpcGVR, "", obj, "dc-api-kubeovn-staticroutes", true)
611+
if !errors.Is(err, agentgw.ErrAgentUnavailable) {
612+
t.Errorf("error does not wrap ErrAgentUnavailable: %v", err)
613+
}
614+
}
615+
616+
// TestAgentBackedApply_UnmappedGVRNotRoutable mirrors the Get/List unmapped-GVR
617+
// tests: an apply for a GVR outside the capability registry is refused with
618+
// ErrOpNotRoutable before any agent op is issued (the routability guard fires
619+
// first — an unmapped GVR is by definition not Apply-routable).
620+
func TestAgentBackedApply_UnmappedGVRNotRoutable(t *testing.T) {
621+
bogusGVR := schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"}
622+
applyCalled := false
623+
sess := &fakeSession{apply: func(json.RawMessage, string, bool) (agentgw.ApplyResult, error) {
624+
applyCalled = true
625+
return agentgw.ApplyResult{}, nil
626+
}}
627+
a := NewAgentBacked(sess, "lk", "zone-1", "dc-api", DefaultGVKMapper(), zerolog.Nop())
628+
629+
obj := &unstructured.Unstructured{Object: map[string]interface{}{
630+
"apiVersion": "example.com/v1", "kind": "Widget",
631+
"metadata": map[string]interface{}{"name": "w"},
632+
}}
633+
_, err := a.Apply(context.Background(), bogusGVR, "", obj, "dc-api", true)
634+
if !errors.Is(err, agentgw.ErrOpNotRoutable) {
635+
t.Errorf("error = %v, want ErrOpNotRoutable", err)
636+
}
637+
if applyCalled {
638+
t.Error("agent Apply was issued for an unmapped GVR")
639+
}
591640
}
592641

593642
// TestAgentBackedApply_NotRoutableFamilyRefused mirrors the Get/List guards: an

dc-api/internal/providers/kubeovn/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,9 @@ func (c *Client) AssociateRouteTable(_ context.Context, _, _ string) error {
10211021

10221022
// DisassociateRouteTable is a no-op for the same reason as AssociateRouteTable.
10231023
func (c *Client) DisassociateRouteTable(_ context.Context, _, _ string) error {
1024+
// M2 stance (a): routes apply VPC-wide. No backend change (and therefore no
1025+
// c.dynamic guard — a remote zone's disassociation is the same pure no-op).
1026+
// See AssociateRouteTable.
10241027
return nil
10251028
}
10261029

dc-api/internal/providers/kubeovn/network_plumbing_seam_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ package kubeovn
3030
import (
3131
"context"
3232
"encoding/json"
33+
"fmt"
3334
"strings"
3435
"sync"
3536
"testing"
@@ -69,6 +70,11 @@ type plumbRecordingAccessor struct {
6970

7071
listCalls int
7172
listErr error
73+
74+
// getErr/applyErr, when set, make every Get/Apply fail — for asserting the
75+
// driver propagates seam errors and stops (no apply after a failed read).
76+
getErr error
77+
applyErr error
7278
}
7379

7480
var _ clusteraccess.Accessor = (*plumbRecordingAccessor)(nil)
@@ -77,6 +83,9 @@ func (a *plumbRecordingAccessor) Get(_ context.Context, gvr schema.GroupVersionR
7783
a.mu.Lock()
7884
defer a.mu.Unlock()
7985
a.getCalls = append(a.getCalls, gvr)
86+
if a.getErr != nil {
87+
return nil, a.getErr
88+
}
8089
obj, ok := a.objects[name]
8190
if !ok {
8291
return nil, k8serrors.NewNotFound(gvr.GroupResource(), name)
@@ -101,6 +110,9 @@ func (a *plumbRecordingAccessor) Create(_ context.Context, _ schema.GroupVersion
101110
func (a *plumbRecordingAccessor) Apply(_ context.Context, gvr schema.GroupVersionResource, ns string, obj *unstructured.Unstructured, fieldManager string, force bool) (*unstructured.Unstructured, error) {
102111
a.mu.Lock()
103112
defer a.mu.Unlock()
113+
if a.applyErr != nil {
114+
return nil, a.applyErr
115+
}
104116
// Store the object as-is (no DeepCopy): the driver builds list entries from
105117
// model structs whose ints are plain `int`, which the unstructured JSON
106118
// deep-copier rejects — json.Marshal (what both real seams do) handles them
@@ -690,12 +702,16 @@ func TestRemoteClient_LegacyPeeringCIDRFallback_FailsClosed(t *testing.T) {
690702
// Mirror buildRemoteSet: Routed whose Direct fallback is NoCreds, decision
691703
// routing only the verbs the plumbing families declare (Get/Apply here; List
692704
// deliberately absent — mirroring subnetCapability.RouteVerbs).
705+
sawListRequest := false
693706
routed := clusteraccess.NewRouted(
694707
clusteraccess.NewNoCreds("lk", "zone-2"),
695708
func(v clusteraccess.Verb, _ schema.GroupVersionResource) (clusteraccess.Accessor, bool) {
696709
switch v {
697710
case clusteraccess.VerbGet, clusteraccess.VerbApply:
698711
return agent, true
712+
case clusteraccess.VerbList:
713+
sawListRequest = true // the legacy CIDR fallback engaged
714+
return nil, false
699715
default:
700716
return nil, false
701717
}
@@ -711,4 +727,75 @@ func TestRemoteClient_LegacyPeeringCIDRFallback_FailsClosed(t *testing.T) {
711727
if !strings.Contains(err.Error(), "no agent connected for zone lk/zone-2") {
712728
t.Errorf("error = %q, want the NoCreds fail-closed message naming the zone", err.Error())
713729
}
730+
// Explicit: the failure came from the CIDR-fallback List actually engaging —
731+
// a refactor that silently skips the fallback must not pass this test.
732+
if !sawListRequest {
733+
t.Error("the legacy subnet-CIDR fallback List was never requested — the fail-closed path did not engage")
734+
}
735+
}
736+
737+
// ── Seam error propagation (Get/Apply failures) ──────────────────────────────
738+
739+
// TestAppendVpcPeering_GetErrorPropagates_NoApply: a seam Get failure aborts the
740+
// read-modify-apply before any write — the error propagates and Apply is never
741+
// issued (no blind write on unknown state).
742+
func TestAppendVpcPeering_GetErrorPropagates_NoApply(t *testing.T) {
743+
boom := fmt.Errorf("seam get exploded")
744+
acc := &plumbRecordingAccessor{getErr: boom}
745+
c := &Client{access: acc}
746+
747+
err := c.appendVpcPeering(context.Background(), "vnet-a", "vnet-b", "100.64.10.0/24")
748+
if err == nil || !strings.Contains(err.Error(), "seam get exploded") {
749+
t.Fatalf("appendVpcPeering must propagate the Get error, got %v", err)
750+
}
751+
if got := acc.applyCount(); got != 0 {
752+
t.Errorf("seam Apply called %d times after a failed Get, want 0", got)
753+
}
754+
}
755+
756+
// TestAppendVpcPeering_ApplyErrorPropagates: a seam Apply failure propagates to
757+
// the caller (the handler surfaces it; nothing swallows the write error).
758+
func TestAppendVpcPeering_ApplyErrorPropagates(t *testing.T) {
759+
boom := fmt.Errorf("seam apply exploded")
760+
acc := &plumbRecordingAccessor{
761+
objects: map[string]*unstructured.Unstructured{
762+
"vnet-a": seededVpc("vnet-a", []interface{}{}, []interface{}{}),
763+
},
764+
applyErr: boom,
765+
}
766+
c := &Client{access: acc}
767+
768+
err := c.appendVpcPeering(context.Background(), "vnet-a", "vnet-b", "100.64.10.0/24")
769+
if err == nil || !strings.Contains(err.Error(), "seam apply exploded") {
770+
t.Fatalf("appendVpcPeering must propagate the Apply error, got %v", err)
771+
}
772+
}
773+
774+
// TestPatchSubnetACLs_ApplyErrorPropagates: the pure-apply subnet-ACL op (its
775+
// callers do the feeding Get) propagates a seam Apply failure unchanged.
776+
func TestPatchSubnetACLs_ApplyErrorPropagates(t *testing.T) {
777+
boom := fmt.Errorf("seam apply exploded")
778+
acc := &plumbRecordingAccessor{applyErr: boom}
779+
c := &Client{access: acc}
780+
err := c.patchSubnetACLs(context.Background(), "subnet-1", []interface{}{})
781+
if err == nil || !strings.Contains(err.Error(), "seam apply exploded") {
782+
t.Fatalf("patchSubnetACLs must propagate the Apply error, got %v", err)
783+
}
784+
}
785+
786+
// TestUpdateRouteTableRoutes_GetErrorPropagates_NoApply: the route-table op's
787+
// FEEDING read fails → the error propagates and the write never runs (same
788+
// no-blind-write property as the peering op, on the routes code path).
789+
func TestUpdateRouteTableRoutes_GetErrorPropagates_NoApply(t *testing.T) {
790+
boom := fmt.Errorf("seam get exploded")
791+
acc := &plumbRecordingAccessor{getErr: boom}
792+
c := &Client{access: acc}
793+
794+
err := c.UpdateRouteTableRoutes(context.Background(), "vnet-a/rt-11111111-1111-1111-1111-111111111111", nil)
795+
if err == nil || !strings.Contains(err.Error(), "seam get exploded") {
796+
t.Fatalf("UpdateRouteTableRoutes must propagate the Get error, got %v", err)
797+
}
798+
if got := acc.applyCount(); got != 0 {
799+
t.Errorf("seam Apply called %d times after a failed Get, want 0", got)
800+
}
714801
}

0 commit comments

Comments
 (0)