Use a back-compat struct to handle listing volumes for volumes we know
about (because, presumably, they are being used by a container) for
volume drivers which don't yet support `List`.
Adds a fall-back for the volume driver `Get` call, which will use
`Create` when the driver returns a `404` for `Get`. The old behavior was
to always use `Create` to get a volume reference.
Signed-off-by: Brian Goff <cpuguy83@gmail.com>
1 | 1 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,215 @@ |
0 |
+// +build !windows |
|
1 |
+ |
|
2 |
+package main |
|
3 |
+ |
|
4 |
+import ( |
|
5 |
+ "encoding/json" |
|
6 |
+ "fmt" |
|
7 |
+ "io" |
|
8 |
+ "io/ioutil" |
|
9 |
+ "net/http" |
|
10 |
+ "net/http/httptest" |
|
11 |
+ "os" |
|
12 |
+ "path/filepath" |
|
13 |
+ "strings" |
|
14 |
+ |
|
15 |
+ "github.com/docker/docker/pkg/integration/checker" |
|
16 |
+ |
|
17 |
+ "github.com/go-check/check" |
|
18 |
+) |
|
19 |
+ |
|
20 |
+func init() { |
|
21 |
+ check.Suite(&DockerExternalVolumeSuiteCompatV1_1{ |
|
22 |
+ ds: &DockerSuite{}, |
|
23 |
+ }) |
|
24 |
+} |
|
25 |
+ |
|
26 |
+type DockerExternalVolumeSuiteCompatV1_1 struct { |
|
27 |
+ server *httptest.Server |
|
28 |
+ ds *DockerSuite |
|
29 |
+ d *Daemon |
|
30 |
+ ec *eventCounter |
|
31 |
+} |
|
32 |
+ |
|
33 |
+func (s *DockerExternalVolumeSuiteCompatV1_1) SetUpTest(c *check.C) { |
|
34 |
+ s.d = NewDaemon(c) |
|
35 |
+ s.ec = &eventCounter{} |
|
36 |
+} |
|
37 |
+ |
|
38 |
+func (s *DockerExternalVolumeSuiteCompatV1_1) TearDownTest(c *check.C) { |
|
39 |
+ s.d.Stop() |
|
40 |
+ s.ds.TearDownTest(c) |
|
41 |
+} |
|
42 |
+ |
|
43 |
+func (s *DockerExternalVolumeSuiteCompatV1_1) SetUpSuite(c *check.C) { |
|
44 |
+ mux := http.NewServeMux() |
|
45 |
+ s.server = httptest.NewServer(mux) |
|
46 |
+ |
|
47 |
+ type pluginRequest struct { |
|
48 |
+ Name string |
|
49 |
+ } |
|
50 |
+ |
|
51 |
+ type pluginResp struct { |
|
52 |
+ Mountpoint string `json:",omitempty"` |
|
53 |
+ Err string `json:",omitempty"` |
|
54 |
+ } |
|
55 |
+ |
|
56 |
+ type vol struct { |
|
57 |
+ Name string |
|
58 |
+ Mountpoint string |
|
59 |
+ } |
|
60 |
+ var volList []vol |
|
61 |
+ |
|
62 |
+ read := func(b io.ReadCloser) (pluginRequest, error) { |
|
63 |
+ defer b.Close() |
|
64 |
+ var pr pluginRequest |
|
65 |
+ if err := json.NewDecoder(b).Decode(&pr); err != nil { |
|
66 |
+ return pr, err |
|
67 |
+ } |
|
68 |
+ return pr, nil |
|
69 |
+ } |
|
70 |
+ |
|
71 |
+ send := func(w http.ResponseWriter, data interface{}) { |
|
72 |
+ switch t := data.(type) { |
|
73 |
+ case error: |
|
74 |
+ http.Error(w, t.Error(), 500) |
|
75 |
+ case string: |
|
76 |
+ w.Header().Set("Content-Type", "application/vnd.docker.plugins.v1+json") |
|
77 |
+ fmt.Fprintln(w, t) |
|
78 |
+ default: |
|
79 |
+ w.Header().Set("Content-Type", "application/vnd.docker.plugins.v1+json") |
|
80 |
+ json.NewEncoder(w).Encode(&data) |
|
81 |
+ } |
|
82 |
+ } |
|
83 |
+ |
|
84 |
+ mux.HandleFunc("/Plugin.Activate", func(w http.ResponseWriter, r *http.Request) { |
|
85 |
+ s.ec.activations++ |
|
86 |
+ send(w, `{"Implements": ["VolumeDriver"]}`) |
|
87 |
+ }) |
|
88 |
+ |
|
89 |
+ mux.HandleFunc("/VolumeDriver.Create", func(w http.ResponseWriter, r *http.Request) { |
|
90 |
+ s.ec.creations++ |
|
91 |
+ pr, err := read(r.Body) |
|
92 |
+ if err != nil { |
|
93 |
+ send(w, err) |
|
94 |
+ return |
|
95 |
+ } |
|
96 |
+ volList = append(volList, vol{Name: pr.Name}) |
|
97 |
+ send(w, nil) |
|
98 |
+ }) |
|
99 |
+ |
|
100 |
+ mux.HandleFunc("/VolumeDriver.Remove", func(w http.ResponseWriter, r *http.Request) { |
|
101 |
+ s.ec.removals++ |
|
102 |
+ pr, err := read(r.Body) |
|
103 |
+ if err != nil { |
|
104 |
+ send(w, err) |
|
105 |
+ return |
|
106 |
+ } |
|
107 |
+ |
|
108 |
+ if err := os.RemoveAll(hostVolumePath(pr.Name)); err != nil { |
|
109 |
+ send(w, &pluginResp{Err: err.Error()}) |
|
110 |
+ return |
|
111 |
+ } |
|
112 |
+ |
|
113 |
+ for i, v := range volList { |
|
114 |
+ if v.Name == pr.Name { |
|
115 |
+ if err := os.RemoveAll(hostVolumePath(v.Name)); err != nil { |
|
116 |
+ send(w, fmt.Sprintf(`{"Err": "%v"}`, err)) |
|
117 |
+ return |
|
118 |
+ } |
|
119 |
+ volList = append(volList[:i], volList[i+1:]...) |
|
120 |
+ break |
|
121 |
+ } |
|
122 |
+ } |
|
123 |
+ send(w, nil) |
|
124 |
+ }) |
|
125 |
+ |
|
126 |
+ mux.HandleFunc("/VolumeDriver.Path", func(w http.ResponseWriter, r *http.Request) { |
|
127 |
+ s.ec.paths++ |
|
128 |
+ |
|
129 |
+ pr, err := read(r.Body) |
|
130 |
+ if err != nil { |
|
131 |
+ send(w, err) |
|
132 |
+ return |
|
133 |
+ } |
|
134 |
+ p := hostVolumePath(pr.Name) |
|
135 |
+ send(w, &pluginResp{Mountpoint: p}) |
|
136 |
+ }) |
|
137 |
+ |
|
138 |
+ mux.HandleFunc("/VolumeDriver.Mount", func(w http.ResponseWriter, r *http.Request) { |
|
139 |
+ s.ec.mounts++ |
|
140 |
+ |
|
141 |
+ pr, err := read(r.Body) |
|
142 |
+ if err != nil { |
|
143 |
+ send(w, err) |
|
144 |
+ return |
|
145 |
+ } |
|
146 |
+ |
|
147 |
+ p := hostVolumePath(pr.Name) |
|
148 |
+ if err := os.MkdirAll(p, 0755); err != nil { |
|
149 |
+ send(w, &pluginResp{Err: err.Error()}) |
|
150 |
+ return |
|
151 |
+ } |
|
152 |
+ |
|
153 |
+ if err := ioutil.WriteFile(filepath.Join(p, "test"), []byte(s.server.URL), 0644); err != nil { |
|
154 |
+ send(w, err) |
|
155 |
+ return |
|
156 |
+ } |
|
157 |
+ |
|
158 |
+ send(w, &pluginResp{Mountpoint: p}) |
|
159 |
+ }) |
|
160 |
+ |
|
161 |
+ mux.HandleFunc("/VolumeDriver.Unmount", func(w http.ResponseWriter, r *http.Request) { |
|
162 |
+ s.ec.unmounts++ |
|
163 |
+ |
|
164 |
+ _, err := read(r.Body) |
|
165 |
+ if err != nil { |
|
166 |
+ send(w, err) |
|
167 |
+ return |
|
168 |
+ } |
|
169 |
+ |
|
170 |
+ send(w, nil) |
|
171 |
+ }) |
|
172 |
+ |
|
173 |
+ err := os.MkdirAll("/etc/docker/plugins", 0755) |
|
174 |
+ c.Assert(err, checker.IsNil) |
|
175 |
+ |
|
176 |
+ err = ioutil.WriteFile("/etc/docker/plugins/test-external-volume-driver.spec", []byte(s.server.URL), 0644) |
|
177 |
+ c.Assert(err, checker.IsNil) |
|
178 |
+} |
|
179 |
+ |
|
180 |
+func (s *DockerExternalVolumeSuiteCompatV1_1) TearDownSuite(c *check.C) { |
|
181 |
+ s.server.Close() |
|
182 |
+ |
|
183 |
+ err := os.RemoveAll("/etc/docker/plugins") |
|
184 |
+ c.Assert(err, checker.IsNil) |
|
185 |
+} |
|
186 |
+ |
|
187 |
+func (s *DockerExternalVolumeSuiteCompatV1_1) TestExternalVolumeDriverCompatV1_1(c *check.C) { |
|
188 |
+ err := s.d.StartWithBusybox() |
|
189 |
+ c.Assert(err, checker.IsNil) |
|
190 |
+ |
|
191 |
+ out, err := s.d.Cmd("run", "--name=test", "-v", "foo:/bar", "--volume-driver", "test-external-volume-driver", "busybox", "sh", "-c", "echo hello > /bar/hello") |
|
192 |
+ c.Assert(err, checker.IsNil, check.Commentf(out)) |
|
193 |
+ out, err = s.d.Cmd("rm", "test") |
|
194 |
+ c.Assert(err, checker.IsNil, check.Commentf(out)) |
|
195 |
+ |
|
196 |
+ out, err = s.d.Cmd("run", "--name=test2", "-v", "foo:/bar", "busybox", "cat", "/bar/hello") |
|
197 |
+ c.Assert(err, checker.IsNil, check.Commentf(out)) |
|
198 |
+ c.Assert(strings.TrimSpace(out), checker.Equals, "hello") |
|
199 |
+ |
|
200 |
+ err = s.d.Restart() |
|
201 |
+ c.Assert(err, checker.IsNil) |
|
202 |
+ |
|
203 |
+ out, err = s.d.Cmd("start", "-a", "test2") |
|
204 |
+ c.Assert(strings.TrimSpace(out), checker.Equals, "hello") |
|
205 |
+ |
|
206 |
+ out, err = s.d.Cmd("rm", "test2") |
|
207 |
+ c.Assert(err, checker.IsNil, check.Commentf(out)) |
|
208 |
+ |
|
209 |
+ out, err = s.d.Cmd("volume", "inspect", "foo") |
|
210 |
+ c.Assert(err, checker.IsNil, check.Commentf(out)) |
|
211 |
+ |
|
212 |
+ out, err = s.d.Cmd("volume", "rm", "foo") |
|
213 |
+ c.Assert(err, checker.IsNil, check.Commentf(out)) |
|
214 |
+} |
... | ... |
@@ -3,7 +3,6 @@ package plugins |
3 | 3 |
import ( |
4 | 4 |
"bytes" |
5 | 5 |
"encoding/json" |
6 |
- "fmt" |
|
7 | 6 |
"io" |
8 | 7 |
"io/ioutil" |
9 | 8 |
"net/http" |
... | ... |
@@ -124,7 +123,7 @@ func (c *Client) callWithRetry(serviceMethod string, data io.Reader, retry bool) |
124 | 124 |
if resp.StatusCode != http.StatusOK { |
125 | 125 |
b, err := ioutil.ReadAll(resp.Body) |
126 | 126 |
if err != nil { |
127 |
- return nil, fmt.Errorf("%s: %s", serviceMethod, err) |
|
127 |
+ return nil, &statusError{resp.StatusCode, serviceMethod, err.Error()} |
|
128 | 128 |
} |
129 | 129 |
|
130 | 130 |
// Plugins' Response(s) should have an Err field indicating what went |
... | ... |
@@ -136,11 +135,11 @@ func (c *Client) callWithRetry(serviceMethod string, data io.Reader, retry bool) |
136 | 136 |
remoteErr := responseErr{} |
137 | 137 |
if err := json.Unmarshal(b, &remoteErr); err == nil { |
138 | 138 |
if remoteErr.Err != "" { |
139 |
- return nil, fmt.Errorf("%s: %s", serviceMethod, remoteErr.Err) |
|
139 |
+ return nil, &statusError{resp.StatusCode, serviceMethod, remoteErr.Err} |
|
140 | 140 |
} |
141 | 141 |
} |
142 | 142 |
// old way... |
143 |
- return nil, fmt.Errorf("%s: %s", serviceMethod, string(b)) |
|
143 |
+ return nil, &statusError{resp.StatusCode, serviceMethod, string(b)} |
|
144 | 144 |
} |
145 | 145 |
return resp.Body, nil |
146 | 146 |
} |
147 | 147 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,33 @@ |
0 |
+package plugins |
|
1 |
+ |
|
2 |
+import ( |
|
3 |
+ "fmt" |
|
4 |
+ "net/http" |
|
5 |
+) |
|
6 |
+ |
|
7 |
+type statusError struct { |
|
8 |
+ status int |
|
9 |
+ method string |
|
10 |
+ err string |
|
11 |
+} |
|
12 |
+ |
|
13 |
+// Error returns a formated string for this error type |
|
14 |
+func (e *statusError) Error() string { |
|
15 |
+ return fmt.Sprintf("%s: %v", e.method, e.err) |
|
16 |
+} |
|
17 |
+ |
|
18 |
+// IsNotFound indicates if the passed in error is from an http.StatusNotFound from the plugin |
|
19 |
+func IsNotFound(err error) bool { |
|
20 |
+ return isStatusError(err, http.StatusNotFound) |
|
21 |
+} |
|
22 |
+ |
|
23 |
+func isStatusError(err error, status int) bool { |
|
24 |
+ if err == nil { |
|
25 |
+ return false |
|
26 |
+ } |
|
27 |
+ e, ok := err.(*statusError) |
|
28 |
+ if !ok { |
|
29 |
+ return false |
|
30 |
+ } |
|
31 |
+ return e.status == status |
|
32 |
+} |
... | ... |
@@ -1,6 +1,9 @@ |
1 | 1 |
package volumedrivers |
2 | 2 |
|
3 |
-import "github.com/docker/docker/volume" |
|
3 |
+import ( |
|
4 |
+ "github.com/docker/docker/pkg/plugins" |
|
5 |
+ "github.com/docker/docker/volume" |
|
6 |
+) |
|
4 | 7 |
|
5 | 8 |
type volumeDriverAdapter struct { |
6 | 9 |
name string |
... | ... |
@@ -47,7 +50,11 @@ func (a *volumeDriverAdapter) List() ([]volume.Volume, error) { |
47 | 47 |
func (a *volumeDriverAdapter) Get(name string) (volume.Volume, error) { |
48 | 48 |
v, err := a.proxy.Get(name) |
49 | 49 |
if err != nil { |
50 |
- return nil, err |
|
50 |
+ // TODO: remove this hack. Allows back compat with volume drivers that don't support this call |
|
51 |
+ if !plugins.IsNotFound(err) { |
|
52 |
+ return nil, err |
|
53 |
+ } |
|
54 |
+ return a.Create(name, nil) |
|
51 | 55 |
} |
52 | 56 |
|
53 | 57 |
return &volumeAdapter{ |
... | ... |
@@ -14,23 +14,23 @@ import ( |
14 | 14 |
func New() *VolumeStore { |
15 | 15 |
return &VolumeStore{ |
16 | 16 |
locks: &locker.Locker{}, |
17 |
- names: make(map[string]string), |
|
17 |
+ names: make(map[string]volume.Volume), |
|
18 | 18 |
refs: make(map[string][]string), |
19 | 19 |
} |
20 | 20 |
} |
21 | 21 |
|
22 |
-func (s *VolumeStore) getNamed(name string) (string, bool) { |
|
22 |
+func (s *VolumeStore) getNamed(name string) (volume.Volume, bool) { |
|
23 | 23 |
s.globalLock.Lock() |
24 |
- driverName, exists := s.names[name] |
|
24 |
+ v, exists := s.names[name] |
|
25 | 25 |
s.globalLock.Unlock() |
26 |
- return driverName, exists |
|
26 |
+ return v, exists |
|
27 | 27 |
} |
28 | 28 |
|
29 |
-func (s *VolumeStore) setNamed(name, driver, ref string) { |
|
29 |
+func (s *VolumeStore) setNamed(v volume.Volume, ref string) { |
|
30 | 30 |
s.globalLock.Lock() |
31 |
- s.names[name] = driver |
|
31 |
+ s.names[v.Name()] = v |
|
32 | 32 |
if len(ref) > 0 { |
33 |
- s.refs[name] = append(s.refs[name], ref) |
|
33 |
+ s.refs[v.Name()] = append(s.refs[v.Name()], ref) |
|
34 | 34 |
} |
35 | 35 |
s.globalLock.Unlock() |
36 | 36 |
} |
... | ... |
@@ -48,7 +48,7 @@ type VolumeStore struct { |
48 | 48 |
globalLock sync.Mutex |
49 | 49 |
// names stores the volume name -> driver name relationship. |
50 | 50 |
// This is used for making lookups faster so we don't have to probe all drivers |
51 |
- names map[string]string |
|
51 |
+ names map[string]volume.Volume |
|
52 | 52 |
// refs stores the volume name and the list of things referencing it |
53 | 53 |
refs map[string][]string |
54 | 54 |
} |
... | ... |
@@ -67,12 +67,12 @@ func (s *VolumeStore) List() ([]volume.Volume, []string, error) { |
67 | 67 |
name := normaliseVolumeName(v.Name()) |
68 | 68 |
|
69 | 69 |
s.locks.Lock(name) |
70 |
- driverName, exists := s.getNamed(name) |
|
70 |
+ storedV, exists := s.getNamed(name) |
|
71 | 71 |
if !exists { |
72 |
- s.setNamed(name, v.DriverName(), "") |
|
72 |
+ s.setNamed(v, "") |
|
73 | 73 |
} |
74 |
- if exists && driverName != v.DriverName() { |
|
75 |
- logrus.Warnf("Volume name %s already exists for driver %s, not including volume returned by %s", v.Name(), driverName, v.DriverName()) |
|
74 |
+ if exists && storedV.DriverName() != v.DriverName() { |
|
75 |
+ logrus.Warnf("Volume name %s already exists for driver %s, not including volume returned by %s", v.Name(), storedV.DriverName(), v.DriverName()) |
|
76 | 76 |
s.locks.Unlock(v.Name()) |
77 | 77 |
continue |
78 | 78 |
} |
... | ... |
@@ -95,8 +95,9 @@ func (s *VolumeStore) list() ([]volume.Volume, []string, error) { |
95 | 95 |
) |
96 | 96 |
|
97 | 97 |
type vols struct { |
98 |
- vols []volume.Volume |
|
99 |
- err error |
|
98 |
+ vols []volume.Volume |
|
99 |
+ err error |
|
100 |
+ driverName string |
|
100 | 101 |
} |
101 | 102 |
chVols := make(chan vols, len(drivers)) |
102 | 103 |
|
... | ... |
@@ -104,23 +105,32 @@ func (s *VolumeStore) list() ([]volume.Volume, []string, error) { |
104 | 104 |
go func(d volume.Driver) { |
105 | 105 |
vs, err := d.List() |
106 | 106 |
if err != nil { |
107 |
- chVols <- vols{err: &OpErr{Err: err, Name: d.Name(), Op: "list"}} |
|
107 |
+ chVols <- vols{driverName: d.Name(), err: &OpErr{Err: err, Name: d.Name(), Op: "list"}} |
|
108 | 108 |
return |
109 | 109 |
} |
110 | 110 |
chVols <- vols{vols: vs} |
111 | 111 |
}(vd) |
112 | 112 |
} |
113 | 113 |
|
114 |
+ badDrivers := make(map[string]struct{}) |
|
114 | 115 |
for i := 0; i < len(drivers); i++ { |
115 | 116 |
vs := <-chVols |
116 | 117 |
|
117 | 118 |
if vs.err != nil { |
118 | 119 |
warnings = append(warnings, vs.err.Error()) |
120 |
+ badDrivers[vs.driverName] = struct{}{} |
|
119 | 121 |
logrus.Warn(vs.err) |
120 |
- continue |
|
121 | 122 |
} |
122 | 123 |
ls = append(ls, vs.vols...) |
123 | 124 |
} |
125 |
+ |
|
126 |
+ if len(badDrivers) > 0 { |
|
127 |
+ for _, v := range s.names { |
|
128 |
+ if _, exists := badDrivers[v.DriverName()]; exists { |
|
129 |
+ ls = append(ls, v) |
|
130 |
+ } |
|
131 |
+ } |
|
132 |
+ } |
|
124 | 133 |
return ls, warnings, nil |
125 | 134 |
} |
126 | 135 |
|
... | ... |
@@ -137,7 +147,7 @@ func (s *VolumeStore) CreateWithRef(name, driverName, ref string, opts map[strin |
137 | 137 |
return nil, &OpErr{Err: err, Name: name, Op: "create"} |
138 | 138 |
} |
139 | 139 |
|
140 |
- s.setNamed(name, v.DriverName(), ref) |
|
140 |
+ s.setNamed(v, ref) |
|
141 | 141 |
return v, nil |
142 | 142 |
} |
143 | 143 |
|
... | ... |
@@ -151,7 +161,7 @@ func (s *VolumeStore) Create(name, driverName string, opts map[string]string) (v |
151 | 151 |
if err != nil { |
152 | 152 |
return nil, &OpErr{Err: err, Name: name, Op: "create"} |
153 | 153 |
} |
154 |
- s.setNamed(name, v.DriverName(), "") |
|
154 |
+ s.setNamed(v, "") |
|
155 | 155 |
return v, nil |
156 | 156 |
} |
157 | 157 |
|
... | ... |
@@ -169,12 +179,11 @@ func (s *VolumeStore) create(name, driverName string, opts map[string]string) (v |
169 | 169 |
return nil, &OpErr{Err: errInvalidName, Name: name, Op: "create"} |
170 | 170 |
} |
171 | 171 |
|
172 |
- vdName, exists := s.getNamed(name) |
|
173 |
- if exists { |
|
174 |
- if vdName != driverName && driverName != "" && driverName != volume.DefaultDriverName { |
|
172 |
+ if v, exists := s.getNamed(name); exists { |
|
173 |
+ if v.DriverName() != driverName && driverName != "" && driverName != volume.DefaultDriverName { |
|
175 | 174 |
return nil, errNameConflict |
176 | 175 |
} |
177 |
- driverName = vdName |
|
176 |
+ return v, nil |
|
178 | 177 |
} |
179 | 178 |
|
180 | 179 |
logrus.Debugf("Registering new volume reference: driver %s, name %s", driverName, name) |
... | ... |
@@ -207,7 +216,7 @@ func (s *VolumeStore) GetWithRef(name, driverName, ref string) (volume.Volume, e |
207 | 207 |
return nil, &OpErr{Err: err, Name: name, Op: "get"} |
208 | 208 |
} |
209 | 209 |
|
210 |
- s.setNamed(name, v.DriverName(), ref) |
|
210 |
+ s.setNamed(v, ref) |
|
211 | 211 |
return v, nil |
212 | 212 |
} |
213 | 213 |
|
... | ... |
@@ -221,6 +230,7 @@ func (s *VolumeStore) Get(name string) (volume.Volume, error) { |
221 | 221 |
if err != nil { |
222 | 222 |
return nil, &OpErr{Err: err, Name: name, Op: "get"} |
223 | 223 |
} |
224 |
+ s.setNamed(v, "") |
|
224 | 225 |
return v, nil |
225 | 226 |
} |
226 | 227 |
|
... | ... |
@@ -229,8 +239,8 @@ func (s *VolumeStore) Get(name string) (volume.Volume, error) { |
229 | 229 |
// it is expected that callers of this function hold any neccessary locks |
230 | 230 |
func (s *VolumeStore) getVolume(name string) (volume.Volume, error) { |
231 | 231 |
logrus.Debugf("Getting volume reference for name: %s", name) |
232 |
- if vdName, exists := s.names[name]; exists { |
|
233 |
- vd, err := volumedrivers.GetDriver(vdName) |
|
232 |
+ if v, exists := s.names[name]; exists { |
|
233 |
+ vd, err := volumedrivers.GetDriver(v.DriverName()) |
|
234 | 234 |
if err != nil { |
235 | 235 |
return nil, err |
236 | 236 |
} |