mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-25 04:33:26 +00:00
Merge pull request #47503 from chakri-nelluri/flexcap
Automatic merge from submit-queue (batch tested with PRs 47878, 47503, 47857) Remove controller node plugin driver dependency for non-attachable fl… …ex volume drivers (Ex: NFS). **What this PR does / why we need it**: Removes requirement to install flex volume drivers on master node for non-attachable drivers likes NFS. **Which issue this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close that issue when PR gets merged)*: fixes #47109 ```release-note Fixes issue w/Flex volume, introduced in 1.6.0, where drivers without an attacher would fail (node indefinitely waiting for attach). Drivers that don't implement attach should return `attach: false` on `init`. ```
This commit is contained in:
commit
d021db8204
@ -83,7 +83,7 @@ unmount() {
|
|||||||
op=$1
|
op=$1
|
||||||
|
|
||||||
if [ "$op" = "init" ]; then
|
if [ "$op" = "init" ]; then
|
||||||
log "{\"status\": \"Success\"}"
|
log "{\"status\": \"Success\", \"capabilities\": {\"attach\": false}}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -100,9 +100,6 @@ case "$op" in
|
|||||||
unmount)
|
unmount)
|
||||||
unmount $*
|
unmount $*
|
||||||
;;
|
;;
|
||||||
getvolumename)
|
|
||||||
getvolumename $*
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
log "{ \"status\": \"Not supported\" }"
|
log "{ \"status\": \"Not supported\" }"
|
||||||
exit 0
|
exit 0
|
||||||
|
@ -17,8 +17,6 @@ limitations under the License.
|
|||||||
package flexvolume
|
package flexvolume
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang/glog"
|
"github.com/golang/glog"
|
||||||
@ -33,30 +31,24 @@ type attacherDefaults flexVolumeAttacher
|
|||||||
|
|
||||||
// Attach is part of the volume.Attacher interface
|
// Attach is part of the volume.Attacher interface
|
||||||
func (a *attacherDefaults) Attach(spec *volume.Spec, hostName types.NodeName) (string, error) {
|
func (a *attacherDefaults) Attach(spec *volume.Spec, hostName types.NodeName) (string, error) {
|
||||||
glog.Warning(logPrefix(a.plugin), "using default Attach for volume ", spec.Name, ", host ", hostName)
|
glog.Warning(logPrefix(a.plugin.flexVolumePlugin), "using default Attach for volume ", spec.Name, ", host ", hostName)
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForAttach is part of the volume.Attacher interface
|
// WaitForAttach is part of the volume.Attacher interface
|
||||||
func (a *attacherDefaults) WaitForAttach(spec *volume.Spec, devicePath string, timeout time.Duration) (string, error) {
|
func (a *attacherDefaults) WaitForAttach(spec *volume.Spec, devicePath string, timeout time.Duration) (string, error) {
|
||||||
glog.Warning(logPrefix(a.plugin), "using default WaitForAttach for volume ", spec.Name, ", device ", devicePath)
|
glog.Warning(logPrefix(a.plugin.flexVolumePlugin), "using default WaitForAttach for volume ", spec.Name, ", device ", devicePath)
|
||||||
return devicePath, nil
|
return devicePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeviceMountPath is part of the volume.Attacher interface
|
// GetDeviceMountPath is part of the volume.Attacher interface
|
||||||
func (a *attacherDefaults) GetDeviceMountPath(spec *volume.Spec, mountsDir string) (string, error) {
|
func (a *attacherDefaults) GetDeviceMountPath(spec *volume.Spec, mountsDir string) (string, error) {
|
||||||
glog.Warning(logPrefix(a.plugin), "using default GetDeviceMountPath for volume ", spec.Name, ", mountsDir ", mountsDir)
|
return a.plugin.getDeviceMountPath(spec)
|
||||||
volumeName, err := a.plugin.GetVolumeName(spec)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("GetVolumeName failed from GetDeviceMountPath: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.Join(mountsDir, volumeName), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountDevice is part of the volume.Attacher interface
|
// MountDevice is part of the volume.Attacher interface
|
||||||
func (a *attacherDefaults) MountDevice(spec *volume.Spec, devicePath string, deviceMountPath string, mounter mount.Interface) error {
|
func (a *attacherDefaults) MountDevice(spec *volume.Spec, devicePath string, deviceMountPath string, mounter mount.Interface) error {
|
||||||
glog.Warning(logPrefix(a.plugin), "using default MountDevice for volume ", spec.Name, ", device ", devicePath, ", deviceMountPath ", deviceMountPath)
|
glog.Warning(logPrefix(a.plugin.flexVolumePlugin), "using default MountDevice for volume ", spec.Name, ", device ", devicePath, ", deviceMountPath ", deviceMountPath)
|
||||||
volSource, readOnly := getVolumeSource(spec)
|
volSource, readOnly := getVolumeSource(spec)
|
||||||
|
|
||||||
options := make([]string, 0)
|
options := make([]string, 0)
|
||||||
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||||||
package flexvolume
|
package flexvolume
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang/glog"
|
"github.com/golang/glog"
|
||||||
@ -26,7 +25,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type flexVolumeAttacher struct {
|
type flexVolumeAttacher struct {
|
||||||
plugin *flexVolumePlugin
|
plugin *flexVolumeAttachablePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ volume.Attacher = &flexVolumeAttacher{}
|
var _ volume.Attacher = &flexVolumeAttacher{}
|
||||||
@ -64,9 +63,7 @@ func (a *flexVolumeAttacher) WaitForAttach(spec *volume.Spec, devicePath string,
|
|||||||
|
|
||||||
// GetDeviceMountPath is part of the volume.Attacher interface
|
// GetDeviceMountPath is part of the volume.Attacher interface
|
||||||
func (a *flexVolumeAttacher) GetDeviceMountPath(spec *volume.Spec) (string, error) {
|
func (a *flexVolumeAttacher) GetDeviceMountPath(spec *volume.Spec) (string, error) {
|
||||||
mountsDir := path.Join(a.plugin.host.GetPluginDir(flexVolumePluginName), a.plugin.driverName, "mounts")
|
return a.plugin.getDeviceMountPath(spec)
|
||||||
|
|
||||||
return (*attacherDefaults)(a).GetDeviceMountPath(spec, mountsDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountDevice is part of the volume.Attacher interface
|
// MountDevice is part of the volume.Attacher interface
|
||||||
|
@ -28,16 +28,18 @@ import (
|
|||||||
volumetesting "k8s.io/kubernetes/pkg/volume/testing"
|
volumetesting "k8s.io/kubernetes/pkg/volume/testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testPlugin() (*flexVolumePlugin, string) {
|
func testPlugin() (*flexVolumeAttachablePlugin, string) {
|
||||||
rootDir, err := utiltesting.MkTmpdir("flexvolume_test")
|
rootDir, err := utiltesting.MkTmpdir("flexvolume_test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("error creating temp dir: " + err.Error())
|
panic("error creating temp dir: " + err.Error())
|
||||||
}
|
}
|
||||||
return &flexVolumePlugin{
|
return &flexVolumeAttachablePlugin{
|
||||||
driverName: "test",
|
flexVolumePlugin: &flexVolumePlugin{
|
||||||
execPath: "/plugin",
|
driverName: "test",
|
||||||
host: volumetesting.NewFakeVolumeHost(rootDir, nil, nil),
|
execPath: "/plugin",
|
||||||
unsupportedCommands: []string{},
|
host: volumetesting.NewFakeVolumeHost(rootDir, nil, nil),
|
||||||
|
unsupportedCommands: []string{},
|
||||||
|
},
|
||||||
}, rootDir
|
}, rootDir
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,11 +79,11 @@ func fakeResultOutput(result interface{}) exec.FakeCombinedOutputAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func successOutput() exec.FakeCombinedOutputAction {
|
func successOutput() exec.FakeCombinedOutputAction {
|
||||||
return fakeResultOutput(&DriverStatus{StatusSuccess, "", "", "", true})
|
return fakeResultOutput(&DriverStatus{StatusSuccess, "", "", "", true, nil})
|
||||||
}
|
}
|
||||||
|
|
||||||
func notSupportedOutput() exec.FakeCombinedOutputAction {
|
func notSupportedOutput() exec.FakeCombinedOutputAction {
|
||||||
return fakeResultOutput(&DriverStatus{StatusNotSupported, "", "", "", false})
|
return fakeResultOutput(&DriverStatus{StatusNotSupported, "", "", "", false, nil})
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameArgs(args, expectedArgs []string) bool {
|
func sameArgs(args, expectedArgs []string) bool {
|
||||||
@ -126,7 +128,7 @@ func fakePersistentVolumeSpec() *volume.Spec {
|
|||||||
return volume.NewSpecFromPersistentVolume(vol, false)
|
return volume.NewSpecFromPersistentVolume(vol, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func specJson(plugin *flexVolumePlugin, spec *volume.Spec, extraOptions map[string]string) string {
|
func specJson(plugin *flexVolumeAttachablePlugin, spec *volume.Spec, extraOptions map[string]string) string {
|
||||||
o, err := NewOptionsForDriver(spec, plugin.host, extraOptions)
|
o, err := NewOptionsForDriver(spec, plugin.host, extraOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Failed to convert spec: " + err.Error())
|
panic("Failed to convert spec: " + err.Error())
|
||||||
|
@ -28,18 +28,18 @@ type detacherDefaults flexVolumeDetacher
|
|||||||
|
|
||||||
// Detach is part of the volume.Detacher interface.
|
// Detach is part of the volume.Detacher interface.
|
||||||
func (d *detacherDefaults) Detach(deviceName string, hostName types.NodeName) error {
|
func (d *detacherDefaults) Detach(deviceName string, hostName types.NodeName) error {
|
||||||
glog.Warning(logPrefix(d.plugin), "using default Detach for device ", deviceName, ", host ", hostName)
|
glog.Warning(logPrefix(d.plugin.flexVolumePlugin), "using default Detach for device ", deviceName, ", host ", hostName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForDetach is part of the volume.Detacher interface.
|
// WaitForDetach is part of the volume.Detacher interface.
|
||||||
func (d *detacherDefaults) WaitForDetach(devicePath string, timeout time.Duration) error {
|
func (d *detacherDefaults) WaitForDetach(devicePath string, timeout time.Duration) error {
|
||||||
glog.Warning(logPrefix(d.plugin), "using default WaitForDetach for device ", devicePath)
|
glog.Warning(logPrefix(d.plugin.flexVolumePlugin), "using default WaitForDetach for device ", devicePath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmountDevice is part of the volume.Detacher interface.
|
// UnmountDevice is part of the volume.Detacher interface.
|
||||||
func (d *detacherDefaults) UnmountDevice(deviceMountPath string) error {
|
func (d *detacherDefaults) UnmountDevice(deviceMountPath string) error {
|
||||||
glog.Warning(logPrefix(d.plugin), "using default UnmountDevice for device mount path ", deviceMountPath)
|
glog.Warning(logPrefix(d.plugin.flexVolumePlugin), "using default UnmountDevice for device mount path ", deviceMountPath)
|
||||||
return util.UnmountPath(deviceMountPath, d.plugin.host.GetMounter())
|
return util.UnmountPath(deviceMountPath, d.plugin.host.GetMounter())
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type flexVolumeDetacher struct {
|
type flexVolumeDetacher struct {
|
||||||
plugin *flexVolumePlugin
|
plugin *flexVolumeAttachablePlugin
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ volume.Detacher = &flexVolumeDetacher{}
|
var _ volume.Detacher = &flexVolumeDetacher{}
|
||||||
|
@ -58,6 +58,8 @@ const (
|
|||||||
optionKeyPodUID = "kubernetes.io/pod.uid"
|
optionKeyPodUID = "kubernetes.io/pod.uid"
|
||||||
|
|
||||||
optionKeyServiceAccountName = "kubernetes.io/serviceAccount.name"
|
optionKeyServiceAccountName = "kubernetes.io/serviceAccount.name"
|
||||||
|
|
||||||
|
attachCapability = "attach"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -199,6 +201,10 @@ type DriverStatus struct {
|
|||||||
VolumeName string `json:"volumeName,omitempty"`
|
VolumeName string `json:"volumeName,omitempty"`
|
||||||
// Represents volume is attached on the node
|
// Represents volume is attached on the node
|
||||||
Attached bool `json:"attached,omitempty"`
|
Attached bool `json:"attached,omitempty"`
|
||||||
|
// Returns capabilities of the driver.
|
||||||
|
// By default we assume all the capabilities are supported.
|
||||||
|
// If the plugin does not support a capability, it can return false for that capability.
|
||||||
|
Capabilities map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCmdNotSupportedErr checks if the error corresponds to command not supported by
|
// isCmdNotSupportedErr checks if the error corresponds to command not supported by
|
||||||
|
@ -17,8 +17,6 @@ limitations under the License.
|
|||||||
package flexvolume
|
package flexvolume
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/golang/glog"
|
"github.com/golang/glog"
|
||||||
|
|
||||||
"k8s.io/kubernetes/pkg/volume"
|
"k8s.io/kubernetes/pkg/volume"
|
||||||
@ -31,13 +29,9 @@ type mounterDefaults flexVolumeMounter
|
|||||||
func (f *mounterDefaults) SetUpAt(dir string, fsGroup *int64) error {
|
func (f *mounterDefaults) SetUpAt(dir string, fsGroup *int64) error {
|
||||||
glog.Warning(logPrefix(f.plugin), "using default SetUpAt to ", dir)
|
glog.Warning(logPrefix(f.plugin), "using default SetUpAt to ", dir)
|
||||||
|
|
||||||
a, err := f.plugin.NewAttacher()
|
src, err := f.plugin.getDeviceMountPath(f.spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("NewAttacher failed: %v", err)
|
return err
|
||||||
}
|
|
||||||
src, err := a.GetDeviceMountPath(f.spec)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("GetDeviceMountPath failed: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := doMount(f.mounter, src, dir, "auto", []string{"bind"}); err != nil {
|
if err := doMount(f.mounter, src, dir, "auto", []string{"bind"}); err != nil {
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
package flexvolume
|
package flexvolume
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@ -27,6 +28,7 @@ import (
|
|||||||
api "k8s.io/kubernetes/pkg/api/v1"
|
api "k8s.io/kubernetes/pkg/api/v1"
|
||||||
"k8s.io/kubernetes/pkg/util/exec"
|
"k8s.io/kubernetes/pkg/util/exec"
|
||||||
"k8s.io/kubernetes/pkg/util/mount"
|
"k8s.io/kubernetes/pkg/util/mount"
|
||||||
|
utilstrings "k8s.io/kubernetes/pkg/util/strings"
|
||||||
"k8s.io/kubernetes/pkg/volume"
|
"k8s.io/kubernetes/pkg/volume"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -43,9 +45,56 @@ type flexVolumePlugin struct {
|
|||||||
unsupportedCommands []string
|
unsupportedCommands []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ volume.AttachableVolumePlugin = &flexVolumePlugin{}
|
type flexVolumeAttachablePlugin struct {
|
||||||
|
*flexVolumePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ volume.AttachableVolumePlugin = &flexVolumeAttachablePlugin{}
|
||||||
var _ volume.PersistentVolumePlugin = &flexVolumePlugin{}
|
var _ volume.PersistentVolumePlugin = &flexVolumePlugin{}
|
||||||
|
|
||||||
|
func NewFlexVolumePlugin(pluginDir, name string) (volume.VolumePlugin, error) {
|
||||||
|
execPath := path.Join(pluginDir, name)
|
||||||
|
|
||||||
|
driverName := utilstrings.UnescapePluginName(name)
|
||||||
|
|
||||||
|
flexPlugin := &flexVolumePlugin{
|
||||||
|
driverName: driverName,
|
||||||
|
execPath: execPath,
|
||||||
|
runner: exec.New(),
|
||||||
|
unsupportedCommands: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether the plugin is attachable.
|
||||||
|
ok, err := isAttachable(flexPlugin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
// Plugin supports attach/detach, so return flexVolumeAttachablePlugin
|
||||||
|
return &flexVolumeAttachablePlugin{flexVolumePlugin: flexPlugin}, nil
|
||||||
|
} else {
|
||||||
|
return flexPlugin, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAttachable(plugin *flexVolumePlugin) (bool, error) {
|
||||||
|
call := plugin.NewDriverCall(initCmd)
|
||||||
|
res, err := call.Run()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default all plugins are attachable, unless they report otherwise.
|
||||||
|
cap, ok := res.Capabilities[attachCapability]
|
||||||
|
if ok {
|
||||||
|
// cap is false, so plugin does not support attach/detach calls.
|
||||||
|
return cap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Init is part of the volume.VolumePlugin interface.
|
// Init is part of the volume.VolumePlugin interface.
|
||||||
func (plugin *flexVolumePlugin) Init(host volume.VolumeHost) error {
|
func (plugin *flexVolumePlugin) Init(host volume.VolumeHost) error {
|
||||||
plugin.host = host
|
plugin.host = host
|
||||||
@ -155,12 +204,12 @@ func (plugin *flexVolumePlugin) newUnmounterInternal(volName string, podUID type
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAttacher is part of the volume.AttachableVolumePlugin interface.
|
// NewAttacher is part of the volume.AttachableVolumePlugin interface.
|
||||||
func (plugin *flexVolumePlugin) NewAttacher() (volume.Attacher, error) {
|
func (plugin *flexVolumeAttachablePlugin) NewAttacher() (volume.Attacher, error) {
|
||||||
return &flexVolumeAttacher{plugin}, nil
|
return &flexVolumeAttacher{plugin}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDetacher is part of the volume.AttachableVolumePlugin interface.
|
// NewDetacher is part of the volume.AttachableVolumePlugin interface.
|
||||||
func (plugin *flexVolumePlugin) NewDetacher() (volume.Detacher, error) {
|
func (plugin *flexVolumeAttachablePlugin) NewDetacher() (volume.Detacher, error) {
|
||||||
return &flexVolumeDetacher{plugin}, nil
|
return &flexVolumeDetacher{plugin}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,3 +257,13 @@ func (plugin *flexVolumePlugin) GetDeviceMountRefs(deviceMountPath string) ([]st
|
|||||||
mounter := plugin.host.GetMounter()
|
mounter := plugin.host.GetMounter()
|
||||||
return mount.GetMountRefs(mounter, deviceMountPath)
|
return mount.GetMountRefs(mounter, deviceMountPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (plugin *flexVolumePlugin) getDeviceMountPath(spec *volume.Spec) (string, error) {
|
||||||
|
volumeName, err := plugin.GetVolumeName(spec)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GetVolumeName failed from getDeviceMountPath: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mountsDir := path.Join(plugin.host.GetPluginDir(flexVolumePluginName), plugin.driverName, "mounts")
|
||||||
|
return path.Join(mountsDir, volumeName), nil
|
||||||
|
}
|
||||||
|
@ -18,10 +18,7 @@ package flexvolume
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"path"
|
|
||||||
|
|
||||||
"k8s.io/kubernetes/pkg/util/exec"
|
|
||||||
utilstrings "k8s.io/kubernetes/pkg/util/strings"
|
|
||||||
"k8s.io/kubernetes/pkg/volume"
|
"k8s.io/kubernetes/pkg/volume"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,13 +34,12 @@ func ProbeVolumePlugins(pluginDir string) []volume.VolumePlugin {
|
|||||||
// e.g. dirname = vendor~cifs
|
// e.g. dirname = vendor~cifs
|
||||||
// then, executable will be pluginDir/dirname/cifs
|
// then, executable will be pluginDir/dirname/cifs
|
||||||
if f.IsDir() {
|
if f.IsDir() {
|
||||||
execPath := path.Join(pluginDir, f.Name())
|
plugin, err := NewFlexVolumePlugin(pluginDir, f.Name())
|
||||||
plugins = append(plugins, &flexVolumePlugin{
|
if err != nil {
|
||||||
driverName: utilstrings.UnescapePluginName(f.Name()),
|
continue
|
||||||
execPath: execPath,
|
}
|
||||||
runner: exec.New(),
|
|
||||||
unsupportedCommands: []string{},
|
plugins = append(plugins, plugin)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plugins
|
return plugins
|
||||||
|
Loading…
Reference in New Issue
Block a user