Skip to content

Commit c2ac05b

Browse files
committed
Add snapshot topology support for Immediate binding (KEP-5943)
1 parent f21f59f commit c2ac05b

9 files changed

Lines changed: 290 additions & 3 deletions

File tree

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,8 @@ replace k8s.io/cri-streaming => k8s.io/cri-streaming v0.36.1
214214
replace k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.36.1
215215

216216
replace k8s.io/streaming => k8s.io/streaming v0.36.1
217+
218+
// TODO(KEP-5943): points at the fork carrying VolumeSnapshotContent.Spec.NodeAffinity.
219+
// Drop this replace and depend on an official external-snapshotter client release
220+
// once the snapshot-topology change merges upstream and a client version is tagged.
221+
replace github.com/kubernetes-csi/external-snapshotter/client/v8 => github.com/mdzraf/external-snapshotter/client/v8 v8.4.1-0.20260728152935-a33775e126a5

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,12 @@ github.com/kubernetes-csi/csi-lib-utils v0.24.0 h1:hpL5ecxtr07/DNIF9Qn/gbNG/ZlgM
143143
github.com/kubernetes-csi/csi-lib-utils v0.24.0/go.mod h1:JbvkvtWghDcVZnwQoSi6Np9ITwqN7+sqLiSsM9y4kRE=
144144
github.com/kubernetes-csi/csi-test/v5 v5.5.0 h1:21NYP33XXfzsAGwFuFHJUIf60hY08B4ANLj819++f98=
145145
github.com/kubernetes-csi/csi-test/v5 v5.5.0/go.mod h1:5ZyneETi47SniZuPA9e8fIL6TTkkKv8/+jkaF0IHqKY=
146-
github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0 h1:FtGewu2k6HWw6evLGXY8JqUZ9eHpti1kd3e4amj+ilA=
147-
github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0/go.mod h1:Vxl89NySJ45J+ah3NTMan/KJXW+NpcGHE2Tw0GSw53k=
148146
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
149147
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
150148
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
151149
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
150+
github.com/mdzraf/external-snapshotter/client/v8 v8.4.1-0.20260728152935-a33775e126a5 h1:R4RteZre0F0Nm8VfePxuNoLZPHGGaeQVlaY7LyjbzHI=
151+
github.com/mdzraf/external-snapshotter/client/v8 v8.4.1-0.20260728152935-a33775e126a5/go.mod h1:Vxl89NySJ45J+ah3NTMan/KJXW+NpcGHE2Tw0GSw53k=
152152
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
153153
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
154154
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=

pkg/controller/controller.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,37 @@ func (p *csiProvisioner) prepareProvision(ctx context.Context, claim *v1.Persist
737737
return nil, controller.ProvisioningNoChange, fmt.Errorf("error generating accessibility requirements: %v", err)
738738
}
739739
req.AccessibilityRequirements = requirements
740+
741+
// POC: KEP-5943 (VolumeSnapshotTopology). For Immediate volume binding
742+
// (no selected node) where the PVC restores from a snapshot, constrain
743+
// provisioning to a topology where the snapshot is accessible by
744+
// intersecting the snapshot's NodeAffinity with the StorageClass's
745+
// AllowedTopologies. For WaitForFirstConsumer the scheduler plugin has
746+
// already restricted the selected node, so no extra work is needed here.
747+
if utilfeature.DefaultFeatureGate.Enabled(features.VolumeSnapshotTopology) &&
748+
rc.snapshot && selectedNodeName == "" {
749+
snapTopology, err := p.getSnapshotNodeAffinity(ctx, claim, dataSource)
750+
if err != nil {
751+
return nil, controller.ProvisioningNoChange, err
752+
}
753+
if len(snapTopology) > 0 {
754+
intersected := IntersectSnapshotTopology(sc.AllowedTopologies, snapTopology)
755+
if len(intersected) == 0 {
756+
// The snapshot is not accessible from any topology the
757+
// StorageClass allows. Fail fast with a clear, actionable
758+
// error instead of issuing a CreateVolume that the SP would
759+
// reject.
760+
p.eventRecorder.Event(claim, v1.EventTypeWarning, "ProvisioningFailed",
761+
"snapshot NodeAffinity has no intersection with StorageClass.AllowedTopologies; volume cannot be provisioned in a topology where the snapshot is accessible")
762+
return nil, controller.ProvisioningFinished, fmt.Errorf("no compatible topology: snapshot %s NodeAffinity does not intersect StorageClass %s AllowedTopologies", dataSource.Name, sc.Name)
763+
}
764+
req.AccessibilityRequirements = &csi.TopologyRequirement{
765+
Requisite: intersected,
766+
Preferred: intersected,
767+
}
768+
klog.V(4).Infof("prepareProvision: constrained snapshot-sourced PVC %s to %d topology terms from snapshot NodeAffinity ∩ StorageClass.AllowedTopologies", claim.Name, len(intersected))
769+
}
770+
}
740771
}
741772

742773
// Resolve provision secret credentials.
@@ -1284,6 +1315,25 @@ func (p *csiProvisioner) getPVCSource(ctx context.Context, claim *v1.PersistentV
12841315
return volumeContentSource, nil
12851316
}
12861317

1318+
// getSnapshotNodeAffinity resolves a VolumeSnapshot data source to its bound
1319+
// VolumeSnapshotContent and returns the Spec.NodeAffinity terms (KEP-5943).
1320+
// Returns nil terms when the snapshot has no topology constraint. Errors are
1321+
// returned for transient lookup failures so provisioning can be retried.
1322+
func (p *csiProvisioner) getSnapshotNodeAffinity(ctx context.Context, claim *v1.PersistentVolumeClaim, dataSource *v1.ObjectReference) ([]v1.TopologySelectorTerm, error) {
1323+
snapshotObj, err := p.snapshotClient.SnapshotV1().VolumeSnapshots(dataSource.Namespace).Get(ctx, dataSource.Name, metav1.GetOptions{})
1324+
if err != nil {
1325+
return nil, fmt.Errorf("error getting snapshot %s from api server: %v", dataSource.Name, err)
1326+
}
1327+
if snapshotObj.Status == nil || snapshotObj.Status.BoundVolumeSnapshotContentName == nil {
1328+
return nil, fmt.Errorf(snapshotNotBound, dataSource.Name)
1329+
}
1330+
snapContentObj, err := p.snapshotClient.SnapshotV1().VolumeSnapshotContents().Get(ctx, *snapshotObj.Status.BoundVolumeSnapshotContentName, metav1.GetOptions{})
1331+
if err != nil {
1332+
return nil, fmt.Errorf(errorGettingSnapshotContent, *snapshotObj.Status.BoundVolumeSnapshotContentName, dataSource.Name)
1333+
}
1334+
return snapContentObj.Spec.NodeAffinity, nil
1335+
}
1336+
12871337
// getSnapshotSource verifies DataSource.Kind of type VolumeSnapshot, making sure that the requested Snapshot is available/ready
12881338
// returns the VolumeContentSource for the requested snapshot
12891339
func (p *csiProvisioner) getSnapshotSource(ctx context.Context, claim *v1.PersistentVolumeClaim, sc *storagev1.StorageClass, dataSource *v1.ObjectReference) (*csi.VolumeContentSource, error) {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controller
18+
19+
import (
20+
"testing"
21+
22+
"github.com/container-storage-interface/spec/lib/go/csi"
23+
"github.com/google/go-cmp/cmp"
24+
"google.golang.org/protobuf/testing/protocmp"
25+
v1 "k8s.io/api/core/v1"
26+
)
27+
28+
const (
29+
snapRegionKey = "topology.kubernetes.io/region"
30+
snapZoneKey = "topology.kubernetes.io/zone"
31+
)
32+
33+
func zoneTerm(values ...string) v1.TopologySelectorTerm {
34+
return v1.TopologySelectorTerm{
35+
MatchLabelExpressions: []v1.TopologySelectorLabelRequirement{
36+
{Key: snapZoneKey, Values: values},
37+
},
38+
}
39+
}
40+
41+
// TestIntersectSnapshotTopology covers the Immediate-binding intersection of
42+
// StorageClass.AllowedTopologies with a snapshot's NodeAffinity (KEP-5943).
43+
func TestIntersectSnapshotTopology(t *testing.T) {
44+
testcases := map[string]struct {
45+
scTopology []v1.TopologySelectorTerm
46+
snapTopology []v1.TopologySelectorTerm
47+
expected []*csi.Topology
48+
}{
49+
"no snapshot topology falls back to StorageClass terms": {
50+
scTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2a", "us-west-2b")},
51+
snapTopology: nil,
52+
expected: []*csi.Topology{
53+
{Segments: map[string]string{snapZoneKey: "us-west-2a"}},
54+
{Segments: map[string]string{snapZoneKey: "us-west-2b"}},
55+
},
56+
},
57+
"no StorageClass topology falls back to snapshot terms": {
58+
scTopology: nil,
59+
snapTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2c")},
60+
expected: []*csi.Topology{
61+
{Segments: map[string]string{snapZoneKey: "us-west-2c"}},
62+
},
63+
},
64+
"overlapping zones intersect to the common subset": {
65+
scTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2a", "us-west-2b")},
66+
snapTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2b", "us-west-2c")},
67+
expected: []*csi.Topology{
68+
{Segments: map[string]string{snapZoneKey: "us-west-2b"}},
69+
},
70+
},
71+
"identical single zone": {
72+
scTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2a")},
73+
snapTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2a")},
74+
expected: []*csi.Topology{
75+
{Segments: map[string]string{snapZoneKey: "us-west-2a"}},
76+
},
77+
},
78+
"disjoint zones yield empty intersection": {
79+
scTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2d")},
80+
snapTopology: []v1.TopologySelectorTerm{zoneTerm("us-west-2a", "us-west-2b", "us-west-2c")},
81+
expected: []*csi.Topology{},
82+
},
83+
"snapshot zone within StorageClass region keeps the more specific snapshot term": {
84+
scTopology: []v1.TopologySelectorTerm{{
85+
MatchLabelExpressions: []v1.TopologySelectorLabelRequirement{
86+
{Key: snapRegionKey, Values: []string{"us-west-2"}},
87+
},
88+
}},
89+
snapTopology: []v1.TopologySelectorTerm{{
90+
MatchLabelExpressions: []v1.TopologySelectorLabelRequirement{
91+
{Key: snapRegionKey, Values: []string{"us-west-2"}},
92+
{Key: snapZoneKey, Values: []string{"us-west-2a"}},
93+
},
94+
}},
95+
expected: []*csi.Topology{
96+
{Segments: map[string]string{snapRegionKey: "us-west-2", snapZoneKey: "us-west-2a"}},
97+
},
98+
},
99+
}
100+
101+
for name, tc := range testcases {
102+
t.Run(name, func(t *testing.T) {
103+
got := IntersectSnapshotTopology(tc.scTopology, tc.snapTopology)
104+
if !cmp.Equal(got, tc.expected, protocmp.Transform()) {
105+
t.Errorf("IntersectSnapshotTopology() mismatch (-got +want):\n%s",
106+
cmp.Diff(got, tc.expected, protocmp.Transform()))
107+
}
108+
})
109+
}
110+
}
111+
112+
// TestIntersectSnapshotTopologyEmptyIsFatalSignal documents that an empty (but
113+
// non-nil) result is the signal callers use to fail provisioning fast when the
114+
// snapshot and StorageClass topologies are incompatible.
115+
func TestIntersectSnapshotTopologyEmptyIsFatalSignal(t *testing.T) {
116+
got := IntersectSnapshotTopology(
117+
[]v1.TopologySelectorTerm{zoneTerm("us-west-2d")},
118+
[]v1.TopologySelectorTerm{zoneTerm("us-west-2a")},
119+
)
120+
if len(got) != 0 {
121+
t.Fatalf("expected empty intersection for disjoint topologies, got %v", got)
122+
}
123+
}

pkg/controller/topology.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,61 @@ func toCSITopology(terms []topologyTerm) []*csi.Topology {
692692
return out
693693
}
694694

695+
// IntersectSnapshotTopology computes the intersection of the StorageClass's
696+
// AllowedTopologies and a snapshot's NodeAffinity (both expressed as
697+
// []v1.TopologySelectorTerm), returning the result as CSI Topology terms
698+
// suitable for CreateVolumeRequest.AccessibilityRequirements.Preferred /
699+
// Requisite.
700+
//
701+
// POC: KEP-5943 (VolumeSnapshotTopology). Used for Immediate volume binding
702+
// where there is no scheduler step to reconcile the snapshot's topology with
703+
// the StorageClass.
704+
//
705+
// Semantics:
706+
// - Each input is an OR of terms; within a term, label expressions are ANDed
707+
// and a label expression's values are ORed. We flatten both inputs to the
708+
// same disjunctive-normal-form (a list of fully-specified topologyTerms),
709+
// then keep the flattened StorageClass terms that are a superset of (i.e.
710+
// satisfy) at least one flattened snapshot term.
711+
// - If the snapshot has no NodeAffinity, the StorageClass terms are returned
712+
// unchanged (no additional constraint from the snapshot).
713+
// - If the StorageClass has no AllowedTopologies, the snapshot terms are
714+
// returned (the snapshot is the only constraint).
715+
// - A nil, empty result means the two constraints are incompatible; callers
716+
// should treat this as a fatal provisioning error.
717+
func IntersectSnapshotTopology(scTopology, snapTopology []v1.TopologySelectorTerm) []*csi.Topology {
718+
scTerms := flatten(scTopology)
719+
snapTerms := flatten(snapTopology)
720+
721+
if len(snapTerms) == 0 {
722+
return toCSITopology(scTerms)
723+
}
724+
if len(scTerms) == 0 {
725+
return toCSITopology(snapTerms)
726+
}
727+
728+
var intersected []topologyTerm
729+
for _, snapTerm := range snapTerms {
730+
for _, scTerm := range scTerms {
731+
// snapTerm.subset(scTerm) is true when every segment in the
732+
// snapshot term is present in the StorageClass term, i.e. the
733+
// StorageClass term provisions into a topology the snapshot is
734+
// accessible from. Keep the more-specific StorageClass term.
735+
if snapTerm.subset(scTerm) {
736+
intersected = append(intersected, scTerm)
737+
} else if scTerm.subset(snapTerm) {
738+
// The snapshot term is more specific (e.g. SC allows a whole
739+
// region, snapshot pins a zone within it). Keep the snapshot term.
740+
intersected = append(intersected, snapTerm)
741+
}
742+
}
743+
}
744+
745+
slices.SortFunc(intersected, topologyTerm.compare)
746+
intersected = slices.CompactFunc(intersected, slices.Equal)
747+
return toCSITopology(intersected)
748+
}
749+
695750
// identical to logic in getPVCNameHashAndIndexOffset in pkg/volume/util/util.go in-tree
696751
// [https://github.com/kubernetes/kubernetes/blob/master/pkg/volume/util/util.go]
697752
func getPVCNameHashAndIndexOffset(pvcName string) (hash uint32, index uint32) {

pkg/features/features.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ const (
5151
//
5252
// Releases leader election lease on sigterm / sigint.
5353
ReleaseLeaderElectionOnExit featuregate.Feature = "ReleaseLeaderElectionOnExit"
54+
55+
// owner: @mdzraf
56+
// alpha: KEP-5943 POC
57+
//
58+
// For PVCs that reference a VolumeSnapshot data source with Immediate
59+
// volume binding, intersects the snapshot's NodeAffinity (from
60+
// VolumeSnapshotContent.Spec) with StorageClass.AllowedTopologies when
61+
// building CreateVolume AccessibilityRequirements, so the volume is
62+
// provisioned in a topology where the snapshot is accessible.
63+
VolumeSnapshotTopology featuregate.Feature = "VolumeSnapshotTopology"
5464
)
5565

5666
func init() {
@@ -64,6 +74,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
6474
CrossNamespaceVolumeDataSource: {Default: false, PreRelease: featuregate.Alpha},
6575
VolumeAttributesClass: {Default: true, PreRelease: featuregate.GA},
6676
ReleaseLeaderElectionOnExit: {Default: false, PreRelease: featuregate.Alpha},
77+
VolumeSnapshotTopology: {Default: false, PreRelease: featuregate.Alpha},
6778
}
6879

6980
// IsVolumeAttributesClassV1Enabled checks if the VolumeAttributesClass v1 API is enabled.

vendor/github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1/types.go

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1/zz_generated.deepcopy.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/modules.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ github.com/kubernetes-csi/csi-lib-utils/standardflags
223223
## explicit; go 1.25.0
224224
github.com/kubernetes-csi/csi-test/v5/driver
225225
github.com/kubernetes-csi/csi-test/v5/utils
226-
# github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0
226+
# github.com/kubernetes-csi/external-snapshotter/client/v8 v8.6.0 => github.com/mdzraf/external-snapshotter/client/v8 v8.4.1-0.20260728152935-a33775e126a5
227227
## explicit; go 1.26.0
228228
github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumegroupsnapshot/v1
229229
github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumegroupsnapshot/v1beta1
@@ -1660,3 +1660,4 @@ sigs.k8s.io/yaml
16601660
# k8s.io/cri-streaming => k8s.io/cri-streaming v0.36.1
16611661
# k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.36.1
16621662
# k8s.io/streaming => k8s.io/streaming v0.36.1
1663+
# github.com/kubernetes-csi/external-snapshotter/client/v8 => github.com/mdzraf/external-snapshotter/client/v8 v8.4.1-0.20260728152935-a33775e126a5

0 commit comments

Comments
 (0)