mirror of
https://github.com/TECHNOFAB11/zfs-localpv.git
synced 2025-12-12 06:20:11 +01:00
feat(zfspv): pvc should be bound only if volume has been created.
The controller does not check whether the volume has been created or not and return successful. Which in turn binds the pvc to the pv. The PVC should not bound until corresponding zfs volume has been created. Now controller will check the ZFSVolume CR state to be "Ready" before returning successful. The CSI will retry the CreateVolume request when it will get a error reply and when the ZFS node agent creates the ZFS volume and sets the ZFSVolume CR state to be "Ready", the controller will return success for the CreateVolume Request and then PVC will be bound. Signed-off-by: Pawan <pawan@mayadata.io>
This commit is contained in:
parent
9118f56600
commit
25d1f1a413
13 changed files with 150 additions and 5 deletions
|
|
@ -314,6 +314,8 @@ Spec:
|
||||||
Pool Name: zfspv-pool
|
Pool Name: zfspv-pool
|
||||||
Recordsize: 4k
|
Recordsize: 4k
|
||||||
Volume Type: DATASET
|
Volume Type: DATASET
|
||||||
|
Status:
|
||||||
|
State: Ready
|
||||||
Events: <none>
|
Events: <none>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -488,7 +490,7 @@ Name: pvc-e1230d2c-b32a-48f7-8b76-ca335b253dcd
|
||||||
Namespace: openebs
|
Namespace: openebs
|
||||||
Labels: kubernetes.io/nodename=zfspv-node1
|
Labels: kubernetes.io/nodename=zfspv-node1
|
||||||
Annotations: <none>
|
Annotations: <none>
|
||||||
API Version: openebs.io/v1alpha1
|
API Version: zfs.openebs.io/v1alpha1
|
||||||
Kind: ZFSVolume
|
Kind: ZFSVolume
|
||||||
Metadata:
|
Metadata:
|
||||||
Creation Timestamp: 2019-11-22T09:49:29Z
|
Creation Timestamp: 2019-11-22T09:49:29Z
|
||||||
|
|
@ -505,6 +507,8 @@ Spec:
|
||||||
Pool Name: zfspv-pool
|
Pool Name: zfspv-pool
|
||||||
Snap Name: pvc-34133838-0d0d-11ea-96e3-42010a800114@snapshot-3cbd5e59-4c6f-4bd6-95ba-7f72c9f12fcd
|
Snap Name: pvc-34133838-0d0d-11ea-96e3-42010a800114@snapshot-3cbd5e59-4c6f-4bd6-95ba-7f72c9f12fcd
|
||||||
Volume Type: DATASET
|
Volume Type: DATASET
|
||||||
|
Status:
|
||||||
|
State: Ready
|
||||||
Events: <none>
|
Events: <none>
|
||||||
|
|
||||||
Here you can note that this resource has Snapname field which tells that this volume is created from that snapshot.
|
Here you can note that this resource has Snapname field which tells that this volume is created from that snapshot.
|
||||||
|
|
|
||||||
1
changelogs/unreleased/121-pawanpraka1
Normal file
1
changelogs/unreleased/121-pawanpraka1
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Fixes an issue where PVC was bound to unusable PV created using incorrect values provided in PVC/Storageclass
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
apiVersion: openebs.io/v1alpha1
|
apiVersion: zfs.openebs.io/v1alpha1
|
||||||
kind: ZFSVolume
|
kind: ZFSVolume
|
||||||
metadata:
|
metadata:
|
||||||
name: pvc-34133838-0d0d-11ea-96e3-42010a800114
|
name: pvc-34133838-0d0d-11ea-96e3-42010a800114
|
||||||
|
|
@ -16,3 +16,5 @@ spec:
|
||||||
recordsize: 8k
|
recordsize: 8k
|
||||||
thinProvision: "off"
|
thinProvision: "off"
|
||||||
volumeType: DATASET
|
volumeType: DATASET
|
||||||
|
status:
|
||||||
|
state: Ready
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ spec:
|
||||||
description: Size of the volume
|
description: Size of the volume
|
||||||
name: Size
|
name: Size
|
||||||
type: string
|
type: string
|
||||||
|
- JSONPath: .status.state
|
||||||
|
description: Status of the volume
|
||||||
|
name: Status
|
||||||
|
type: string
|
||||||
- JSONPath: .spec.volblocksize
|
- JSONPath: .spec.volblocksize
|
||||||
description: volblocksize of volume
|
description: volblocksize of volume
|
||||||
name: volblocksize
|
name: volblocksize
|
||||||
|
|
@ -216,6 +220,18 @@ spec:
|
||||||
- poolName
|
- poolName
|
||||||
- volumeType
|
- volumeType
|
||||||
type: object
|
type: object
|
||||||
|
status:
|
||||||
|
properties:
|
||||||
|
state:
|
||||||
|
description: State specifies the current state of the volume provisioning
|
||||||
|
request. The state "Pending" means that the volume creation request
|
||||||
|
has not processed yet. The state "Ready" means that the volume has
|
||||||
|
been created and it is ready for the use.
|
||||||
|
enum:
|
||||||
|
- Pending
|
||||||
|
- Ready
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
required:
|
required:
|
||||||
- spec
|
- spec
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ spec:
|
||||||
description: Size of the volume
|
description: Size of the volume
|
||||||
name: Size
|
name: Size
|
||||||
type: string
|
type: string
|
||||||
|
- JSONPath: .status.state
|
||||||
|
description: Status of the volume
|
||||||
|
name: Status
|
||||||
|
type: string
|
||||||
- JSONPath: .spec.volblocksize
|
- JSONPath: .spec.volblocksize
|
||||||
description: volblocksize of volume
|
description: volblocksize of volume
|
||||||
name: volblocksize
|
name: volblocksize
|
||||||
|
|
@ -237,6 +241,18 @@ spec:
|
||||||
- poolName
|
- poolName
|
||||||
- volumeType
|
- volumeType
|
||||||
type: object
|
type: object
|
||||||
|
status:
|
||||||
|
properties:
|
||||||
|
state:
|
||||||
|
description: State specifies the current state of the volume provisioning
|
||||||
|
request. The state "Pending" means that the volume creation request
|
||||||
|
has not processed yet. The state "Ready" means that the volume has
|
||||||
|
been created and it is ready for the use.
|
||||||
|
enum:
|
||||||
|
- Pending
|
||||||
|
- Ready
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
required:
|
required:
|
||||||
- spec
|
- spec
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ spec:
|
||||||
ownerNodeID: pawan-3 # should be the nodename where ZPOOL is running
|
ownerNodeID: pawan-3 # should be the nodename where ZPOOL is running
|
||||||
poolName: zfspv-pool # poolname where the volume is present
|
poolName: zfspv-pool # poolname where the volume is present
|
||||||
volumeType: DATASET # whether it is a DATASET or ZVOL
|
volumeType: DATASET # whether it is a DATASET or ZVOL
|
||||||
|
Status:
|
||||||
|
State: Ready
|
||||||
```
|
```
|
||||||
|
|
||||||
Modify the parameters :-
|
Modify the parameters :-
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import (
|
||||||
// +kubebuilder:printcolumn:name="ZPool",type=string,JSONPath=`.spec.poolName`,description="ZFS Pool where the volume is created"
|
// +kubebuilder:printcolumn:name="ZPool",type=string,JSONPath=`.spec.poolName`,description="ZFS Pool where the volume is created"
|
||||||
// +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.spec.ownerNodeID`,description="Node where the volume is created"
|
// +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.spec.ownerNodeID`,description="Node where the volume is created"
|
||||||
// +kubebuilder:printcolumn:name="Size",type=string,JSONPath=`.spec.capacity`,description="Size of the volume"
|
// +kubebuilder:printcolumn:name="Size",type=string,JSONPath=`.spec.capacity`,description="Size of the volume"
|
||||||
|
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.state`,description="Status of the volume"
|
||||||
// +kubebuilder:printcolumn:name="volblocksize",type=string,JSONPath=`.spec.volblocksize`,description="volblocksize of volume"
|
// +kubebuilder:printcolumn:name="volblocksize",type=string,JSONPath=`.spec.volblocksize`,description="volblocksize of volume"
|
||||||
// +kubebuilder:printcolumn:name="recordsize",type=string,JSONPath=`.spec.recordsize`,description="recordsize of created zfs dataset"
|
// +kubebuilder:printcolumn:name="recordsize",type=string,JSONPath=`.spec.recordsize`,description="recordsize of created zfs dataset"
|
||||||
// +kubebuilder:printcolumn:name="Filesystem",type=string,JSONPath=`.spec.fsType`,description="filesystem created on the volume"
|
// +kubebuilder:printcolumn:name="Filesystem",type=string,JSONPath=`.spec.fsType`,description="filesystem created on the volume"
|
||||||
|
|
@ -39,7 +40,8 @@ type ZFSVolume struct {
|
||||||
metav1.TypeMeta `json:",inline"`
|
metav1.TypeMeta `json:",inline"`
|
||||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||||
|
|
||||||
Spec VolumeInfo `json:"spec"`
|
Spec VolumeInfo `json:"spec"`
|
||||||
|
Status VolStatus `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountInfo contains the volume related info
|
// MountInfo contains the volume related info
|
||||||
|
|
@ -198,3 +200,12 @@ type VolumeInfo struct {
|
||||||
// Default Value: ext4.
|
// Default Value: ext4.
|
||||||
FsType string `json:"fsType,omitempty"`
|
FsType string `json:"fsType,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VolStatus struct {
|
||||||
|
// State specifies the current state of the volume provisioning request.
|
||||||
|
// The state "Pending" means that the volume creation request has not
|
||||||
|
// processed yet. The state "Ready" means that the volume has been created
|
||||||
|
// and it is ready for the use.
|
||||||
|
// +kubebuilder:validation:Enum=Pending;Ready
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,22 @@ func (in *SnapStatus) DeepCopy() *SnapStatus {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *VolStatus) DeepCopyInto(out *VolStatus) {
|
||||||
|
*out = *in
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolStatus.
|
||||||
|
func (in *VolStatus) DeepCopy() *VolStatus {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(VolStatus)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *VolumeInfo) DeepCopyInto(out *VolumeInfo) {
|
func (in *VolumeInfo) DeepCopyInto(out *VolumeInfo) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
|
@ -149,6 +165,7 @@ func (in *ZFSVolume) DeepCopyInto(out *ZFSVolume) {
|
||||||
out.TypeMeta = in.TypeMeta
|
out.TypeMeta = in.TypeMeta
|
||||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||||
out.Spec = in.Spec
|
out.Spec = in.Spec
|
||||||
|
out.Status = in.Status
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,12 @@ func (b *Builder) WithVolumeType(vtype string) *Builder {
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithVolumeStatus sets ZFSVolume status
|
||||||
|
func (b *Builder) WithVolumeStatus(status string) *Builder {
|
||||||
|
b.volume.Object.Status.State = status
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
// WithFsType sets filesystem for the ZFSVolume
|
// WithFsType sets filesystem for the ZFSVolume
|
||||||
func (b *Builder) WithFsType(fstype string) *Builder {
|
func (b *Builder) WithFsType(fstype string) *Builder {
|
||||||
b.volume.Object.Spec.FsType = fstype
|
b.volume.Object.Spec.FsType = fstype
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ func CreateZFSVolume(req *csi.CreateVolumeRequest) (string, error) {
|
||||||
WithThinProv(tp).
|
WithThinProv(tp).
|
||||||
WithOwnerNode(selected).
|
WithOwnerNode(selected).
|
||||||
WithVolumeType(vtype).
|
WithVolumeType(vtype).
|
||||||
|
WithVolumeStatus(zfs.ZFSStatusPending).
|
||||||
WithFsType(fstype).
|
WithFsType(fstype).
|
||||||
WithCompression(compression).Build()
|
WithCompression(compression).Build()
|
||||||
|
|
||||||
|
|
@ -161,7 +162,9 @@ func CreateZFSClone(req *csi.CreateVolumeRequest, snapshot string) (string, erro
|
||||||
selected := snap.Spec.OwnerNodeID
|
selected := snap.Spec.OwnerNodeID
|
||||||
|
|
||||||
volObj, err := volbuilder.NewBuilder().
|
volObj, err := volbuilder.NewBuilder().
|
||||||
WithName(volName).Build()
|
WithName(volName).
|
||||||
|
WithVolumeStatus(zfs.ZFSStatusPending).
|
||||||
|
Build()
|
||||||
|
|
||||||
volObj.Spec = snap.Spec
|
volObj.Spec = snap.Spec
|
||||||
volObj.Spec.SnapName = snapshot
|
volObj.Spec.SnapName = snapshot
|
||||||
|
|
@ -187,12 +190,22 @@ func (cs *controller) CreateVolume(
|
||||||
volName := req.GetName()
|
volName := req.GetName()
|
||||||
pool := req.GetParameters()["poolname"]
|
pool := req.GetParameters()["poolname"]
|
||||||
size := req.GetCapacityRange().RequiredBytes
|
size := req.GetCapacityRange().RequiredBytes
|
||||||
|
contentSource := req.GetVolumeContentSource()
|
||||||
|
|
||||||
if err = cs.validateVolumeCreateReq(req); err != nil {
|
if err = cs.validateVolumeCreateReq(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
contentSource := req.GetVolumeContentSource()
|
selected, state, err := zfs.GetZFSVolumeState(req.Name)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// ZFSVolume CR has been created, check if it is in Ready state
|
||||||
|
if state == zfs.ZFSStatusReady {
|
||||||
|
goto CreateVolumeResponse
|
||||||
|
}
|
||||||
|
return nil, status.Errorf(codes.Internal, "volume %s creation is Pending", volName)
|
||||||
|
}
|
||||||
|
|
||||||
if contentSource != nil && contentSource.GetSnapshot() != nil {
|
if contentSource != nil && contentSource.GetSnapshot() != nil {
|
||||||
snapshotID := contentSource.GetSnapshot().GetSnapshotId()
|
snapshotID := contentSource.GetSnapshot().GetSnapshotId()
|
||||||
|
|
||||||
|
|
@ -205,6 +218,18 @@ func (cs *controller) CreateVolume(
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, state, err = zfs.GetZFSVolumeState(req.Name)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "createvolume: failed to fetch the volume %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == zfs.ZFSStatusPending {
|
||||||
|
return nil, status.Errorf(codes.Internal, "volume %s is being created", volName)
|
||||||
|
}
|
||||||
|
|
||||||
|
CreateVolumeResponse:
|
||||||
|
|
||||||
sendEventOrIgnore(volName, strconv.FormatInt(int64(size), 10), "zfs-localpv", analytics.VolumeProvision)
|
sendEventOrIgnore(volName, strconv.FormatInt(int64(size), 10), "zfs-localpv", analytics.VolumeProvision)
|
||||||
|
|
||||||
topology := map[string]string{zfs.ZFSTopologyKey: selected}
|
topology := map[string]string{zfs.ZFSTopologyKey: selected}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,18 @@ func (c *FakeZFSVolumes) Update(zFSVolume *v1alpha1.ZFSVolume) (result *v1alpha1
|
||||||
return obj.(*v1alpha1.ZFSVolume), err
|
return obj.(*v1alpha1.ZFSVolume), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateStatus was generated because the type contains a Status member.
|
||||||
|
// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
|
||||||
|
func (c *FakeZFSVolumes) UpdateStatus(zFSVolume *v1alpha1.ZFSVolume) (*v1alpha1.ZFSVolume, error) {
|
||||||
|
obj, err := c.Fake.
|
||||||
|
Invokes(testing.NewUpdateSubresourceAction(zfsvolumesResource, "status", c.ns, zFSVolume), &v1alpha1.ZFSVolume{})
|
||||||
|
|
||||||
|
if obj == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return obj.(*v1alpha1.ZFSVolume), err
|
||||||
|
}
|
||||||
|
|
||||||
// Delete takes name of the zFSVolume and deletes it. Returns an error if one occurs.
|
// Delete takes name of the zFSVolume and deletes it. Returns an error if one occurs.
|
||||||
func (c *FakeZFSVolumes) Delete(name string, options *v1.DeleteOptions) error {
|
func (c *FakeZFSVolumes) Delete(name string, options *v1.DeleteOptions) error {
|
||||||
_, err := c.Fake.
|
_, err := c.Fake.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ type ZFSVolumesGetter interface {
|
||||||
type ZFSVolumeInterface interface {
|
type ZFSVolumeInterface interface {
|
||||||
Create(*v1alpha1.ZFSVolume) (*v1alpha1.ZFSVolume, error)
|
Create(*v1alpha1.ZFSVolume) (*v1alpha1.ZFSVolume, error)
|
||||||
Update(*v1alpha1.ZFSVolume) (*v1alpha1.ZFSVolume, error)
|
Update(*v1alpha1.ZFSVolume) (*v1alpha1.ZFSVolume, error)
|
||||||
|
UpdateStatus(*v1alpha1.ZFSVolume) (*v1alpha1.ZFSVolume, error)
|
||||||
Delete(name string, options *v1.DeleteOptions) error
|
Delete(name string, options *v1.DeleteOptions) error
|
||||||
DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
|
DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
|
||||||
Get(name string, options v1.GetOptions) (*v1alpha1.ZFSVolume, error)
|
Get(name string, options v1.GetOptions) (*v1alpha1.ZFSVolume, error)
|
||||||
|
|
@ -132,6 +133,22 @@ func (c *zFSVolumes) Update(zFSVolume *v1alpha1.ZFSVolume) (result *v1alpha1.ZFS
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateStatus was generated because the type contains a Status member.
|
||||||
|
// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
|
||||||
|
|
||||||
|
func (c *zFSVolumes) UpdateStatus(zFSVolume *v1alpha1.ZFSVolume) (result *v1alpha1.ZFSVolume, err error) {
|
||||||
|
result = &v1alpha1.ZFSVolume{}
|
||||||
|
err = c.client.Put().
|
||||||
|
Namespace(c.ns).
|
||||||
|
Resource("zfsvolumes").
|
||||||
|
Name(zFSVolume.Name).
|
||||||
|
SubResource("status").
|
||||||
|
Body(zFSVolume).
|
||||||
|
Do().
|
||||||
|
Into(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Delete takes name of the zFSVolume and deletes it. Returns an error if one occurs.
|
// Delete takes name of the zFSVolume and deletes it. Returns an error if one occurs.
|
||||||
func (c *zFSVolumes) Delete(name string, options *v1.DeleteOptions) error {
|
func (c *zFSVolumes) Delete(name string, options *v1.DeleteOptions) error {
|
||||||
return c.client.Delete().
|
return c.client.Delete().
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,21 @@ func GetZFSVolume(volumeID string) (*apis.ZFSVolume, error) {
|
||||||
return vol, err
|
return vol, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetZFSVolumeState returns ZFSVolume OwnerNode and State for
|
||||||
|
// the given volume. CreateVolume request may call it again and
|
||||||
|
// again until volume is "Ready".
|
||||||
|
func GetZFSVolumeState(volID string) (string, string, error) {
|
||||||
|
getOptions := metav1.GetOptions{}
|
||||||
|
vol, err := volbuilder.NewKubeclient().
|
||||||
|
WithNamespace(OpenEBSNamespace).Get(volID, getOptions)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return vol.Spec.OwnerNodeID, vol.Status.State, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateZvolInfo updates ZFSVolume CR with node id and finalizer
|
// UpdateZvolInfo updates ZFSVolume CR with node id and finalizer
|
||||||
func UpdateZvolInfo(vol *apis.ZFSVolume) error {
|
func UpdateZvolInfo(vol *apis.ZFSVolume) error {
|
||||||
finalizers := []string{ZFSFinalizer}
|
finalizers := []string{ZFSFinalizer}
|
||||||
|
|
@ -168,6 +183,7 @@ func UpdateZvolInfo(vol *apis.ZFSVolume) error {
|
||||||
|
|
||||||
newVol, err := volbuilder.BuildFrom(vol).
|
newVol, err := volbuilder.BuildFrom(vol).
|
||||||
WithFinalizer(finalizers).
|
WithFinalizer(finalizers).
|
||||||
|
WithVolumeStatus(ZFSStatusReady).
|
||||||
WithLabels(labels).Build()
|
WithLabels(labels).Build()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue