Skip to content

Commit 0d1429c

Browse files
committed
cinder csi: validate volume status before attach
Check that a volume is in an acceptable state before calling the Nova attach API. For regular volumes the status must be 'available'. For multi-attach capable volumes, both 'available' and 'in-use' are valid. Previously the driver would blindly call volumeattach.Create regardless of volume status, relying on Nova/Cinder to reject the request. This produced opaque errors that made troubleshooting difficult and generated unnecessary API calls for volumes in transitional states (creating, downloading, detaching, etc.). With this change the driver returns an immediate, descriptive error when the volume is not attachable, allowing the CO (external-attacher) to retry with exponential backoff via standard CSI error handling.
1 parent d1cc4cb commit 0d1429c

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

pkg/csi/cinder/openstack/openstack_volumes.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,16 @@ func (os *OpenStack) AttachVolume(ctx context.Context, instanceID, volumeID stri
218218
}
219219
}
220220

221+
if volume.Multiattach {
222+
if volume.Status != VolumeAvailableStatus && volume.Status != VolumeInUseStatus {
223+
return "", fmt.Errorf("volume %s is in %s status, volume must be available or in-use for multi-attach capable volumes", volumeID, volume.Status)
224+
}
225+
} else {
226+
if volume.Status != VolumeAvailableStatus {
227+
return "", fmt.Errorf("volume %s is in %s status, volume must be available", volumeID, volume.Status)
228+
}
229+
}
230+
221231
if volume.Multiattach {
222232
// For multiattach volumes, supported compute api version is 2.60
223233
// Init a local thread safe copy of the compute ServiceClient
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/*
2+
Copyright 2024 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 openstack
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"net/http"
24+
"net/http/httptest"
25+
"testing"
26+
27+
"github.com/gophercloud/gophercloud/v2"
28+
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes"
29+
)
30+
31+
// fakeVolumeResponse returns a JSON response body for a Cinder volume GET.
32+
func fakeVolumeResponse(id, status string, multiattach bool, attachments []volumes.Attachment) string {
33+
atts := "[]"
34+
if len(attachments) > 0 {
35+
b, _ := json.Marshal(attachments)
36+
atts = string(b)
37+
}
38+
return fmt.Sprintf(`{
39+
"volume": {
40+
"id": %q,
41+
"status": %q,
42+
"multiattach": %t,
43+
"attachments": %s,
44+
"size": 1,
45+
"availability_zone": "nova"
46+
}
47+
}`, id, status, multiattach, atts)
48+
}
49+
50+
// fakeAttachResponse returns a JSON response for a successful Nova volume attach.
51+
func fakeAttachResponse(serverID, volumeID, device string) string {
52+
return fmt.Sprintf(`{
53+
"volumeAttachment": {
54+
"serverId": %q,
55+
"volumeId": %q,
56+
"device": %q
57+
}
58+
}`, serverID, volumeID, device)
59+
}
60+
61+
// newFakeOpenStack creates an OpenStack instance backed by httptest servers.
62+
// cinderHandler handles /volumes/{id} requests.
63+
// novaHandler handles /servers/{id}/os-volume_attachments requests.
64+
func newFakeOpenStack(cinderHandler http.HandlerFunc, novaHandler http.HandlerFunc) (*OpenStack, *httptest.Server, *httptest.Server) {
65+
cinderServer := httptest.NewServer(cinderHandler)
66+
novaServer := httptest.NewServer(novaHandler)
67+
68+
novaEndpoint := novaServer.URL + "/"
69+
70+
providerClient := &gophercloud.ProviderClient{
71+
// EndpointLocator is required for openstack.NewComputeV2 used in multiattach path
72+
EndpointLocator: func(opts gophercloud.EndpointOpts) (string, error) {
73+
return novaEndpoint, nil
74+
},
75+
}
76+
77+
blockstorageClient := &gophercloud.ServiceClient{
78+
ProviderClient: &gophercloud.ProviderClient{},
79+
Endpoint: cinderServer.URL + "/",
80+
}
81+
82+
computeClient := &gophercloud.ServiceClient{
83+
ProviderClient: providerClient,
84+
Endpoint: novaEndpoint,
85+
}
86+
87+
os := &OpenStack{
88+
compute: computeClient,
89+
blockstorage: blockstorageClient,
90+
epOpts: gophercloud.EndpointOpts{},
91+
}
92+
93+
return os, cinderServer, novaServer
94+
}
95+
96+
func TestAttachVolume_AvailableStatus(t *testing.T) {
97+
volumeID := "vol-123"
98+
instanceID := "instance-456"
99+
100+
cinderHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+
w.Header().Set("Content-Type", "application/json")
102+
w.WriteHeader(http.StatusOK)
103+
fmt.Fprint(w, fakeVolumeResponse(volumeID, VolumeAvailableStatus, false, nil))
104+
})
105+
106+
novaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+
w.Header().Set("Content-Type", "application/json")
108+
w.WriteHeader(http.StatusOK)
109+
fmt.Fprint(w, fakeAttachResponse(instanceID, volumeID, "/dev/vdb"))
110+
})
111+
112+
os, cinderSrv, novaSrv := newFakeOpenStack(cinderHandler, novaHandler)
113+
defer cinderSrv.Close()
114+
defer novaSrv.Close()
115+
116+
result, err := os.AttachVolume(context.Background(), instanceID, volumeID)
117+
if err != nil {
118+
t.Fatalf("expected no error for available volume, got: %v", err)
119+
}
120+
if result != volumeID {
121+
t.Errorf("expected volume ID %q, got %q", volumeID, result)
122+
}
123+
}
124+
125+
func TestAttachVolume_NonAvailableStatus_SingleAttach(t *testing.T) {
126+
tests := []struct {
127+
name string
128+
status string
129+
}{
130+
{"creating", "creating"},
131+
{"error", "error"},
132+
{"in-use", VolumeInUseStatus},
133+
{"detaching", VolumeDetachingStatus},
134+
{"attaching", "attaching"},
135+
{"downloading", "downloading"},
136+
}
137+
138+
for _, tc := range tests {
139+
t.Run(tc.name, func(t *testing.T) {
140+
volumeID := "vol-123"
141+
instanceID := "instance-456"
142+
143+
cinderHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144+
w.Header().Set("Content-Type", "application/json")
145+
w.WriteHeader(http.StatusOK)
146+
fmt.Fprint(w, fakeVolumeResponse(volumeID, tc.status, false, nil))
147+
})
148+
149+
novaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150+
t.Error("Nova attach should not be called for non-available volume")
151+
w.WriteHeader(http.StatusInternalServerError)
152+
})
153+
154+
os, cinderSrv, novaSrv := newFakeOpenStack(cinderHandler, novaHandler)
155+
defer cinderSrv.Close()
156+
defer novaSrv.Close()
157+
158+
_, err := os.AttachVolume(context.Background(), instanceID, volumeID)
159+
if err == nil {
160+
t.Fatalf("expected error for volume in %q status, got nil", tc.status)
161+
}
162+
163+
expectedMsg := fmt.Sprintf("volume %s is in %s status, volume must be available", volumeID, tc.status)
164+
if err.Error() != expectedMsg {
165+
t.Errorf("expected error message %q, got %q", expectedMsg, err.Error())
166+
}
167+
})
168+
}
169+
}
170+
171+
func TestAttachVolume_MultiAttach_AvailableStatus(t *testing.T) {
172+
volumeID := "vol-123"
173+
instanceID := "instance-456"
174+
175+
cinderHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176+
w.Header().Set("Content-Type", "application/json")
177+
w.WriteHeader(http.StatusOK)
178+
fmt.Fprint(w, fakeVolumeResponse(volumeID, VolumeAvailableStatus, true, nil))
179+
})
180+
181+
novaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
182+
w.Header().Set("Content-Type", "application/json")
183+
w.WriteHeader(http.StatusOK)
184+
fmt.Fprint(w, fakeAttachResponse(instanceID, volumeID, "/dev/vdb"))
185+
})
186+
187+
os, cinderSrv, novaSrv := newFakeOpenStack(cinderHandler, novaHandler)
188+
defer cinderSrv.Close()
189+
defer novaSrv.Close()
190+
191+
result, err := os.AttachVolume(context.Background(), instanceID, volumeID)
192+
if err != nil {
193+
t.Fatalf("expected no error for multiattach available volume, got: %v", err)
194+
}
195+
if result != volumeID {
196+
t.Errorf("expected volume ID %q, got %q", volumeID, result)
197+
}
198+
}
199+
200+
func TestAttachVolume_MultiAttach_InUseStatus(t *testing.T) {
201+
volumeID := "vol-123"
202+
instanceID := "instance-456"
203+
otherInstanceID := "instance-789"
204+
205+
// Volume is already attached to another instance
206+
attachments := []volumes.Attachment{
207+
{ServerID: otherInstanceID, VolumeID: volumeID},
208+
}
209+
210+
cinderHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
211+
w.Header().Set("Content-Type", "application/json")
212+
w.WriteHeader(http.StatusOK)
213+
fmt.Fprint(w, fakeVolumeResponse(volumeID, VolumeInUseStatus, true, attachments))
214+
})
215+
216+
novaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
217+
w.Header().Set("Content-Type", "application/json")
218+
w.WriteHeader(http.StatusOK)
219+
fmt.Fprint(w, fakeAttachResponse(instanceID, volumeID, "/dev/vdc"))
220+
})
221+
222+
os, cinderSrv, novaSrv := newFakeOpenStack(cinderHandler, novaHandler)
223+
defer cinderSrv.Close()
224+
defer novaSrv.Close()
225+
226+
result, err := os.AttachVolume(context.Background(), instanceID, volumeID)
227+
if err != nil {
228+
t.Fatalf("expected no error for multiattach in-use volume, got: %v", err)
229+
}
230+
if result != volumeID {
231+
t.Errorf("expected volume ID %q, got %q", volumeID, result)
232+
}
233+
}
234+
235+
func TestAttachVolume_MultiAttach_InvalidStatus(t *testing.T) {
236+
tests := []struct {
237+
name string
238+
status string
239+
}{
240+
{"creating", "creating"},
241+
{"error", "error"},
242+
{"detaching", VolumeDetachingStatus},
243+
{"attaching", "attaching"},
244+
{"downloading", "downloading"},
245+
}
246+
247+
for _, tc := range tests {
248+
t.Run(tc.name, func(t *testing.T) {
249+
volumeID := "vol-123"
250+
instanceID := "instance-456"
251+
252+
cinderHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
253+
w.Header().Set("Content-Type", "application/json")
254+
w.WriteHeader(http.StatusOK)
255+
fmt.Fprint(w, fakeVolumeResponse(volumeID, tc.status, true, nil))
256+
})
257+
258+
novaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
259+
t.Error("Nova attach should not be called for volume in invalid status")
260+
w.WriteHeader(http.StatusInternalServerError)
261+
})
262+
263+
os, cinderSrv, novaSrv := newFakeOpenStack(cinderHandler, novaHandler)
264+
defer cinderSrv.Close()
265+
defer novaSrv.Close()
266+
267+
_, err := os.AttachVolume(context.Background(), instanceID, volumeID)
268+
if err == nil {
269+
t.Fatalf("expected error for multiattach volume in %q status, got nil", tc.status)
270+
}
271+
272+
expectedMsg := fmt.Sprintf("volume %s is in %s status, volume must be available or in-use for multi-attach capable volumes", volumeID, tc.status)
273+
if err.Error() != expectedMsg {
274+
t.Errorf("expected error message %q, got %q", expectedMsg, err.Error())
275+
}
276+
})
277+
}
278+
}
279+
280+
func TestAttachVolume_AlreadyAttachedToSameInstance(t *testing.T) {
281+
volumeID := "vol-123"
282+
instanceID := "instance-456"
283+
284+
// Volume is already attached to the same instance we're trying to attach to
285+
attachments := []volumes.Attachment{
286+
{ServerID: instanceID, VolumeID: volumeID},
287+
}
288+
289+
cinderHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
290+
w.Header().Set("Content-Type", "application/json")
291+
w.WriteHeader(http.StatusOK)
292+
// Status is in-use because it's already attached
293+
fmt.Fprint(w, fakeVolumeResponse(volumeID, VolumeInUseStatus, false, attachments))
294+
})
295+
296+
novaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
297+
t.Error("Nova attach should not be called when already attached to same instance")
298+
w.WriteHeader(http.StatusInternalServerError)
299+
})
300+
301+
os, cinderSrv, novaSrv := newFakeOpenStack(cinderHandler, novaHandler)
302+
defer cinderSrv.Close()
303+
defer novaSrv.Close()
304+
305+
// Should return early with the volume ID (idempotent)
306+
result, err := os.AttachVolume(context.Background(), instanceID, volumeID)
307+
if err != nil {
308+
t.Fatalf("expected no error for already-attached volume, got: %v", err)
309+
}
310+
if result != volumeID {
311+
t.Errorf("expected volume ID %q, got %q", volumeID, result)
312+
}
313+
}

0 commit comments

Comments
 (0)