Skip to content

Commit 896dc6e

Browse files
HiranAdikariclaude
andcommitted
Prevent silent partial reads on older agents
AgentBacked.Get falls back from full-object get_object to status-only get_status when an older agent doesn't support get_object. That is correct for a status-only consumer (the VM read), but a full-object consumer — the cloud-provider SA token read (.data.token), and the future KubeOVN read-modify-patch ops (.spec/.metadata.labels) — would silently receive a partial object missing the fields it needs. The SA bootstrap in particular would poll a token-less Secret until timeout. Gate the fallback on a new per-capability StatusFallbackOK flag, set only on the VM family. Every other routable-Get family now returns a clear error (still wrapping ErrOpNotRoutable) on an old agent instead of a silently-partial object, so the failure is immediate and actionable. Routed.Get is terminal on agent activation, so the error propagates rather than degrading. Adds a companion test asserting a full-object family errors (and never calls GetStatus) on OP_UNSUPPORTED, alongside the VM fallback test. Addresses the review on this PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC3BDHCFmJBdxvGVW69FAr
1 parent adee03a commit 896dc6e

3 files changed

Lines changed: 102 additions & 14 deletions

File tree

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,14 @@ func (a *AgentBacked) ref(gvr schema.GroupVersionResource, ns, name string) (age
117117
// what the read-modify-patch write ops (NAT/DNS/cloud-provider SA) need: they
118118
// read .spec/.data to modify it.
119119
//
120-
// COMPAT: an OLDER agent that predates get_object replies OP_UNSUPPORTED; this
121-
// FALLS BACK to Session.GetStatus and synthesizes the prior status-only partial
122-
// object (apiVersion, kind, metadata.name/namespace, resourceVersion,
123-
// generation, status). The behavior degrades to the prior status-only read
124-
// rather than erroring, keeping the change non-breaking during a rolling deploy.
125-
// Callers that only read status (e.g. the VM read slice's
126-
// status.printableStatus) are unaffected by which path served them.
120+
// COMPAT: an OLDER agent that predates get_object replies OP_UNSUPPORTED. For a
121+
// family whose Get consumers read status alone (StatusFallbackOK — the VM read
122+
// slice), this FALLS BACK to Session.GetStatus and synthesizes the prior
123+
// status-only partial object, keeping that read non-breaking during a rolling
124+
// deploy. For a FULL-object family (a Secret's .data, the KubeOVN .spec/.labels
125+
// read-modify-patch ops) the status-only partial would drop the fields the caller
126+
// needs, so a CLEAR error is returned instead of a silent partial — the zone's
127+
// agent must be upgraded. See AgentCapability.StatusFallbackOK.
127128
//
128129
// Found==false (on either path) is translated to a k8serrors.NewNotFound-shaped
129130
// error so callers' existing IsNotFound / "not found" checks fire unchanged on
@@ -152,13 +153,26 @@ func (a *AgentBacked) Get(ctx context.Context, gvr schema.GroupVersionResource,
152153
// (agent unavailable, RBAC, timeout) propagates.
153154
terr := a.translateErr(err)
154155
if errors.Is(terr, agentgw.ErrOpNotRoutable) {
155-
a.log.Info().
156-
Str("seam", "agent").
157-
Str("region", a.region).Str("zone", a.zone).
158-
Str("api_version", ref.APIVersion).Str("kind", ref.Kind).
159-
Str("namespace", ref.Namespace).Str("name", ref.Name).
160-
Msg("agent does not support get_object; falling back to status-only get_status")
161-
return a.getStatusFallback(ctx, gvr, ref, ns, name)
156+
// OP_UNSUPPORTED: the agent predates get_object. Degrading to the
157+
// status-only get_status is valid ONLY for families whose Get consumers
158+
// read status alone (VMs). For a full-object consumer (a Secret's
159+
// .data.token, the KubeOVN read-modify-patch ops' .spec/.metadata.labels)
160+
// the synthesized partial would silently drop the very fields the caller
161+
// needs — e.g. the cloud-provider SA bootstrap would poll a token-less
162+
// Secret until it times out. So gate the fallback on the family's
163+
// StatusFallbackOK; otherwise surface a clear, immediate error (Routed.Get
164+
// is terminal on agent activation, so this propagates rather than silently
165+
// degrading). ErrOpNotRoutable stays wrapped for callers' errors.Is checks.
166+
if StatusFallbackOK(gvr) {
167+
a.log.Info().
168+
Str("seam", "agent").
169+
Str("region", a.region).Str("zone", a.zone).
170+
Str("api_version", ref.APIVersion).Str("kind", ref.Kind).
171+
Str("namespace", ref.Namespace).Str("name", ref.Name).
172+
Msg("agent does not support get_object; falling back to status-only get_status")
173+
return a.getStatusFallback(ctx, gvr, ref, ns, name)
174+
}
175+
return nil, fmt.Errorf("clusteraccess: full-object read of %s in zone %s/%s requires an agent that supports get_object, but the zone's agent predates it (upgrade the agent): %w", gvr.String(), a.region, a.zone, terr)
162176
}
163177
return nil, terr
164178
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ type AgentCapability struct {
4848
// get_status/watch_status read+watch, and SSA-create needs create+patch. A
4949
// capability that grants no RBAC (pre-seeded GVK-only) leaves this nil.
5050
AgentVerbs []Verb
51+
// StatusFallbackOK allows AgentBacked.Get to degrade to a status-only read
52+
// (Session.GetStatus) when an OLDER agent predates full-object get_object and
53+
// replies OP_UNSUPPORTED. Set it ONLY for families whose Get consumers read
54+
// status alone (the VM read slice's status.printableStatus). Families whose Get
55+
// consumers need the full object — Secrets (.data.token) and the KubeOVN
56+
// read-modify-patch ops that read .spec / .metadata.labels — MUST leave this
57+
// false: the status-only partial the fallback synthesizes drops .spec/.data/
58+
// .labels, so degrading to it would silently return a useless object (the
59+
// cloud-provider SA bootstrap would poll a token-less Secret until timeout).
60+
// With the flag false, an old agent yields a clear error instead — see
61+
// AgentBacked.Get.
62+
StatusFallbackOK bool
5163
}
5264

5365
// vmCapability is the one truly onboarded capability in phase 1.
@@ -71,6 +83,11 @@ var vmCapability = AgentCapability{
7183
// needs create+patch. Reproduces the old rule verbatim:
7284
// get,list,watch,create,patch,delete.
7385
AgentVerbs: []Verb{VerbGet, VerbList, VerbWatch, VerbCreate, VerbApply, VerbDelete},
86+
// GetVM reads only status.printableStatus, so the status-only fallback is a
87+
// valid degrade for an older agent without get_object (preserves the
88+
// rolling-deploy compat added with the full-object Get). Every other
89+
// routable-Get family reads the full object and leaves this false.
90+
StatusFallbackOK: true,
7491
}
7592

7693
// The network families (NAD, Vpc, Subnet) are onboarded for the kubeovn CRD
@@ -223,15 +240,21 @@ var (
223240
derivedGVKTable map[schema.GroupVersionResource]gvk
224241
derivedRouteSet map[schema.GroupVersionResource]map[Verb]bool
225242
derivedUnionRoute map[Verb]bool
243+
244+
derivedStatusFallback map[schema.GroupVersionResource]bool
226245
)
227246

228247
func buildDerived() {
229248
derivedOnce.Do(func() {
230249
derivedGVKTable = make(map[schema.GroupVersionResource]gvk, len(AgentCapabilities))
231250
derivedRouteSet = make(map[schema.GroupVersionResource]map[Verb]bool, len(AgentCapabilities))
232251
derivedUnionRoute = make(map[Verb]bool)
252+
derivedStatusFallback = make(map[schema.GroupVersionResource]bool, len(AgentCapabilities))
233253
for _, c := range AgentCapabilities {
234254
derivedGVKTable[c.GVR] = gvk{APIVersion: c.APIVersion, Kind: c.Kind}
255+
if c.StatusFallbackOK {
256+
derivedStatusFallback[c.GVR] = true
257+
}
235258
if len(c.RouteVerbs) > 0 {
236259
set := make(map[Verb]bool, len(c.RouteVerbs))
237260
for _, v := range c.RouteVerbs {
@@ -286,6 +309,16 @@ func RoutableVerbs(gvr schema.GroupVersionResource) (map[Verb]bool, bool) {
286309
return out, true
287310
}
288311

312+
// StatusFallbackOK reports whether AgentBacked.Get may degrade a routed read of
313+
// this GVR to a status-only get_status when an older agent lacks get_object. True
314+
// only for families whose Get consumers read status alone (VMs); false (the
315+
// default) for full-object consumers, which then get a clear error on an old
316+
// agent instead of a silently-partial object. See AgentCapability.StatusFallbackOK.
317+
func StatusFallbackOK(gvr schema.GroupVersionResource) bool {
318+
buildDerived()
319+
return derivedStatusFallback[gvr]
320+
}
321+
289322
// IsReadVerb classifies a seam Verb as a read for the reads/writes toggle split.
290323
// VerbList is included so it falls on the read side once it becomes routable.
291324
func IsReadVerb(v Verb) bool {

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7+
"strings"
78
"testing"
89
"time"
910

@@ -326,6 +327,46 @@ func TestAgentBackedGet_FallsBackToStatusOnUnsupported(t *testing.T) {
326327
}
327328
}
328329

330+
// TestAgentBackedGet_FullObjectFamilyErrorsOnUnsupported proves the other half of
331+
// the compat gate: a FULL-object family (Secrets — StatusFallbackOK=false) whose
332+
// caller needs .data must NOT degrade to the status-only partial on an older
333+
// agent. Get returns a clear error (still wrapping ErrOpNotRoutable) and never
334+
// calls GetStatus — so the cloud-provider SA bootstrap fails fast instead of
335+
// polling a token-less Secret until timeout.
336+
func TestAgentBackedGet_FullObjectFamilyErrorsOnUnsupported(t *testing.T) {
337+
secretsGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}
338+
if verbs, ok := RoutableVerbs(secretsGVR); !ok || !verbs[VerbGet] {
339+
t.Fatal("precondition: secrets Get must be a routable family")
340+
}
341+
if StatusFallbackOK(secretsGVR) {
342+
t.Fatal("precondition: secrets must NOT permit the status-only fallback")
343+
}
344+
sess := &fakeSession{
345+
getObject: func(ref agentgw.ResourceRef) (agentgw.GetObjectResult, error) {
346+
return agentgw.GetObjectResult{}, &agentgw.AgentError{Code: agentgw.CodeOpUnsupported, Message: "unknown op"}
347+
},
348+
getStatus: func(ref agentgw.ResourceRef) (agentgw.StatusSnapshot, error) {
349+
t.Error("full-object family must NOT fall back to GetStatus")
350+
return agentgw.StatusSnapshot{}, nil
351+
},
352+
}
353+
a := NewAgentBacked(sess, "lk", "zone-1", "dc-api", DefaultGVKMapper(), zerolog.Nop())
354+
355+
_, err := a.Get(context.Background(), secretsGVR, "dc-t-p", "sa-token", metav1.GetOptions{})
356+
if err == nil {
357+
t.Fatal("Get must return an error for a full-object family on OP_UNSUPPORTED, got nil")
358+
}
359+
if !errors.Is(err, agentgw.ErrOpNotRoutable) {
360+
t.Errorf("error must wrap ErrOpNotRoutable for callers' errors.Is checks, got %v", err)
361+
}
362+
if sess.getStatusCalled {
363+
t.Error("Get fell back to GetStatus for a full-object family; it must error instead")
364+
}
365+
if !strings.Contains(err.Error(), "get_object") {
366+
t.Errorf("error should mention get_object upgrade guidance, got %q", err.Error())
367+
}
368+
}
369+
329370
func TestAgentBackedGet_NotFoundIsK8sNotFound(t *testing.T) {
330371
sess := &fakeSession{
331372
getObject: func(ref agentgw.ResourceRef) (agentgw.GetObjectResult, error) {

0 commit comments

Comments
 (0)