mirror of
https://github.com/vmware-tanzu/velero.git
synced 2026-01-03 11:45:20 +00:00
add restore item action to change PVC/PV storage class name (#1621)
* add restore item action to change PVC/PV storage class name Signed-off-by: Steve Kriss <krisss@vmware.com> * code review Signed-off-by: Steve Kriss <krisss@vmware.com> * change existing plugin names to lowercase/hyphenated Signed-off-by: Steve Kriss <krisss@vmware.com> * add validation for new storage class name Signed-off-by: Steve Kriss <krisss@vmware.com> * add test cases Signed-off-by: Steve Kriss <krisss@vmware.com> * changelog Signed-off-by: Steve Kriss <krisss@vmware.com> * fix imports Signed-off-by: Steve Kriss <krisss@vmware.com> * update plugin names to be more consistent Signed-off-by: Steve Kriss <krisss@vmware.com> * update unit tests to use pkg/test object constructors Signed-off-by: Steve Kriss <krisss@vmware.com>
This commit is contained in:
committed by
Adnan Abdulhussein
parent
63964fc6f9
commit
1bb167ef90
1
changelogs/unreleased/1621-skriss
Normal file
1
changelogs/unreleased/1621-skriss
Normal file
@@ -0,0 +1 @@
|
||||
add plugin for updating PV & PVC storage classes on restore based on a config map
|
||||
@@ -46,14 +46,15 @@ func NewCommand(f client.Factory) *cobra.Command {
|
||||
RegisterVolumeSnapshotter("velero.io/gcp", newGcpVolumeSnapshotter).
|
||||
RegisterBackupItemAction("velero.io/pv", newPVBackupItemAction).
|
||||
RegisterBackupItemAction("velero.io/pod", newPodBackupItemAction).
|
||||
RegisterBackupItemAction("velero.io/serviceaccount", newServiceAccountBackupItemAction(f)).
|
||||
RegisterBackupItemAction("velero.io/service-account", newServiceAccountBackupItemAction(f)).
|
||||
RegisterRestoreItemAction("velero.io/job", newJobRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/pod", newPodRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/restic", newResticRestoreItemAction(f)).
|
||||
RegisterRestoreItemAction("velero.io/service", newServiceRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/serviceaccount", newServiceAccountRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/addPVCFromPod", newAddPVCFromPodRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/addPVFromPVC", newAddPVFromPVCRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/service-account", newServiceAccountRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/add-pvc-from-pod", newAddPVCFromPodRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/add-pv-from-pvc", newAddPVFromPVCRestoreItemAction).
|
||||
RegisterRestoreItemAction("velero.io/change-storage-class", newChangeStorageClassRestoreItemAction(f)).
|
||||
Serve()
|
||||
},
|
||||
}
|
||||
@@ -154,3 +155,18 @@ func newAddPVCFromPodRestoreItemAction(logger logrus.FieldLogger) (interface{},
|
||||
func newAddPVFromPVCRestoreItemAction(logger logrus.FieldLogger) (interface{}, error) {
|
||||
return restore.NewAddPVFromPVCAction(logger), nil
|
||||
}
|
||||
|
||||
func newChangeStorageClassRestoreItemAction(f client.Factory) veleroplugin.HandlerInitializer {
|
||||
return func(logger logrus.FieldLogger) (interface{}, error) {
|
||||
client, err := f.KubeClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return restore.NewChangeStorageClassAction(
|
||||
logger,
|
||||
client.CoreV1().ConfigMaps(f.Namespace()),
|
||||
client.StorageV1().StorageClasses(),
|
||||
), nil
|
||||
}
|
||||
}
|
||||
|
||||
117
pkg/restore/change_storageclass_action.go
Normal file
117
pkg/restore/change_storageclass_action.go
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright 2019 the Velero contributors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package restore
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
storagev1client "k8s.io/client-go/kubernetes/typed/storage/v1"
|
||||
|
||||
"github.com/heptio/velero/pkg/plugin/framework"
|
||||
"github.com/heptio/velero/pkg/plugin/velero"
|
||||
)
|
||||
|
||||
// ChangeStorageClassAction updates a PV or PVC's storage class name
|
||||
// if a mapping is found in the plugin's config map.
|
||||
type ChangeStorageClassAction struct {
|
||||
logger logrus.FieldLogger
|
||||
configMapClient corev1client.ConfigMapInterface
|
||||
storageClassClient storagev1client.StorageClassInterface
|
||||
}
|
||||
|
||||
// NewChangeStorageClassAction is the constructor for ChangeStorageClassAction.
|
||||
func NewChangeStorageClassAction(
|
||||
logger logrus.FieldLogger,
|
||||
configMapClient corev1client.ConfigMapInterface,
|
||||
storageClassClient storagev1client.StorageClassInterface,
|
||||
) *ChangeStorageClassAction {
|
||||
return &ChangeStorageClassAction{
|
||||
logger: logger,
|
||||
configMapClient: configMapClient,
|
||||
storageClassClient: storageClassClient,
|
||||
}
|
||||
}
|
||||
|
||||
// AppliesTo returns the resources that ChangeStorageClassAction should
|
||||
// be run for.
|
||||
func (a *ChangeStorageClassAction) AppliesTo() (velero.ResourceSelector, error) {
|
||||
return velero.ResourceSelector{
|
||||
IncludedResources: []string{"persistentvolumeclaims", "persistentvolumes"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Execute updates the item's spec.storageClassName if a mapping is found
|
||||
// in the config map for the plugin.
|
||||
func (a *ChangeStorageClassAction) Execute(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
a.logger.Info("Executing ChangeStorageClassAction")
|
||||
defer a.logger.Info("Done executing ChangeStorageClassAction")
|
||||
|
||||
a.logger.Debug("Getting plugin config")
|
||||
config, err := getPluginConfig(framework.PluginKindRestoreItemAction, "velero.io/change-storage-class", a.configMapClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config == nil || len(config.Data) == 0 {
|
||||
a.logger.Debug("No storage class mappings found")
|
||||
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
|
||||
}
|
||||
|
||||
obj, ok := input.Item.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
return nil, errors.Errorf("object was of unexpected type %T", input.Item)
|
||||
}
|
||||
|
||||
log := a.logger.WithFields(map[string]interface{}{
|
||||
"kind": obj.GetKind(),
|
||||
"namespace": obj.GetNamespace(),
|
||||
"name": obj.GetName(),
|
||||
})
|
||||
|
||||
// use the unstructured helpers here since this code is for both PVs and PVCs, and the
|
||||
// field names are the same for both types.
|
||||
storageClass, _, err := unstructured.NestedString(obj.UnstructuredContent(), "spec", "storageClassName")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error getting item's spec.storageClassName")
|
||||
}
|
||||
if storageClass == "" {
|
||||
log.Debug("Item has no storage class specified")
|
||||
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
|
||||
}
|
||||
|
||||
newStorageClass, ok := config.Data[storageClass]
|
||||
if !ok {
|
||||
log.Debugf("No mapping found for storage class %s", storageClass)
|
||||
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
|
||||
}
|
||||
|
||||
// validate that new storage class exists
|
||||
if _, err := a.storageClassClient.Get(newStorageClass, metav1.GetOptions{}); err != nil {
|
||||
return nil, errors.Wrapf(err, "error getting storage class %s from API", newStorageClass)
|
||||
}
|
||||
|
||||
log.Infof("Updating item's storage class name to %s", newStorageClass)
|
||||
|
||||
if err := unstructured.SetNestedField(obj.UnstructuredContent(), newStorageClass, "spec", "storageClassName"); err != nil {
|
||||
return nil, errors.Wrap(err, "unable to set item's spec.storageClassName")
|
||||
}
|
||||
|
||||
return velero.NewRestoreItemActionExecuteOutput(obj), nil
|
||||
}
|
||||
188
pkg/restore/change_storageclass_action_test.go
Normal file
188
pkg/restore/change_storageclass_action_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
Copyright 2019 the Velero contributors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package restore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
storagev1api "k8s.io/api/storage/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
"github.com/heptio/velero/pkg/plugin/velero"
|
||||
"github.com/heptio/velero/pkg/test"
|
||||
)
|
||||
|
||||
// TestChangeStorageClassActionExecute runs the ChangeStorageClassAction's Execute
|
||||
// method and validates that the item's storage class is modified (or not) as expected.
|
||||
// Validation is done by comparing the result of the Execute method to the test case's
|
||||
// desired result.
|
||||
func TestChangeStorageClassActionExecute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pvOrPVC interface{}
|
||||
configMap *corev1api.ConfigMap
|
||||
storageClass *storagev1api.StorageClass
|
||||
want interface{}
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "a valid mapping for a persistent volume is applied correctly",
|
||||
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "storageclass-2"),
|
||||
),
|
||||
storageClass: test.NewStorageClass("storageclass-2"),
|
||||
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-2")),
|
||||
},
|
||||
{
|
||||
name: "a valid mapping for a persistent volume claim is applied correctly",
|
||||
pvOrPVC: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "storageclass-2"),
|
||||
),
|
||||
storageClass: test.NewStorageClass("storageclass-2"),
|
||||
want: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-2")),
|
||||
},
|
||||
{
|
||||
name: "when no config map exists for the plugin, the item is returned as-is",
|
||||
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/some-other-plugin", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "storageclass-2"),
|
||||
),
|
||||
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
},
|
||||
{
|
||||
name: "when no storage class mappings exist in the plugin config map, the item is returned as-is",
|
||||
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
),
|
||||
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
},
|
||||
{
|
||||
name: "when persistent volume has no storage class, the item is returned as-is",
|
||||
pvOrPVC: test.NewPV("pv-1"),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "storageclass-2"),
|
||||
),
|
||||
want: test.NewPV("pv-1"),
|
||||
},
|
||||
{
|
||||
name: "when persistent volume claim has no storage class, the item is returned as-is",
|
||||
pvOrPVC: test.NewPVC("velero", "pvc-1"),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "storageclass-2"),
|
||||
),
|
||||
want: test.NewPVC("velero", "pvc-1"),
|
||||
},
|
||||
{
|
||||
name: "when persistent volume's storage class has no mapping in the config map, the item is returned as-is",
|
||||
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-3", "storageclass-4"),
|
||||
),
|
||||
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
},
|
||||
{
|
||||
name: "when persistent volume claim's storage class has no mapping in the config map, the item is returned as-is",
|
||||
pvOrPVC: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-3", "storageclass-4"),
|
||||
),
|
||||
want: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
|
||||
},
|
||||
{
|
||||
name: "when persistent volume's storage class is mapped to a nonexistent storage class, an error is returned",
|
||||
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "nonexistent-storage-class"),
|
||||
),
|
||||
wantErr: errors.New("error getting storage class nonexistent-storage-class from API: storageclasses.storage.k8s.io \"nonexistent-storage-class\" not found"),
|
||||
},
|
||||
{
|
||||
name: "when persistent volume claim's storage class is mapped to a nonexistent storage class, an error is returned",
|
||||
pvOrPVC: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
|
||||
configMap: test.NewConfigMap("velero", "change-storage-class",
|
||||
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
|
||||
test.WithConfigMapData("storageclass-1", "nonexistent-storage-class"),
|
||||
),
|
||||
wantErr: errors.New("error getting storage class nonexistent-storage-class from API: storageclasses.storage.k8s.io \"nonexistent-storage-class\" not found"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
clientset := fake.NewSimpleClientset()
|
||||
a := NewChangeStorageClassAction(
|
||||
logrus.StandardLogger(),
|
||||
clientset.CoreV1().ConfigMaps("velero"),
|
||||
clientset.StorageV1().StorageClasses(),
|
||||
)
|
||||
|
||||
// set up test data
|
||||
if tc.configMap != nil {
|
||||
_, err := clientset.CoreV1().ConfigMaps(tc.configMap.Namespace).Create(tc.configMap)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tc.storageClass != nil {
|
||||
_, err := clientset.StorageV1().StorageClasses().Create(tc.storageClass)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.pvOrPVC)
|
||||
require.NoError(t, err)
|
||||
|
||||
input := &velero.RestoreItemActionExecuteInput{
|
||||
Item: &unstructured.Unstructured{
|
||||
Object: unstructuredMap,
|
||||
},
|
||||
}
|
||||
|
||||
// execute method under test
|
||||
res, err := a.Execute(input)
|
||||
|
||||
// validate for both error and non-error cases
|
||||
switch {
|
||||
case tc.wantErr != nil:
|
||||
assert.EqualError(t, err, tc.wantErr.Error())
|
||||
default:
|
||||
assert.NoError(t, err)
|
||||
|
||||
wantUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.want)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, &unstructured.Unstructured{Object: wantUnstructured}, res.UpdatedItem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
storagev1 "k8s.io/api/storage/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
@@ -249,6 +250,38 @@ func NewNamespace(name string, opts ...ObjectOpts) *corev1.Namespace {
|
||||
return obj
|
||||
}
|
||||
|
||||
func NewConfigMap(ns, name string, opts ...ObjectOpts) *corev1.ConfigMap {
|
||||
obj := &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "ConfigMap",
|
||||
APIVersion: "v1",
|
||||
},
|
||||
ObjectMeta: objectMeta(ns, name),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(obj)
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
func NewStorageClass(name string, opts ...ObjectOpts) *storagev1.StorageClass {
|
||||
obj := &storagev1.StorageClass{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: "StorageClass",
|
||||
APIVersion: "storage/v1",
|
||||
},
|
||||
ObjectMeta: objectMeta("", name),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(obj)
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// VolumeOpts exists because corev1.Volume does not implement metav1.Object
|
||||
type VolumeOpts func(*corev1.Volume)
|
||||
|
||||
@@ -418,6 +451,23 @@ func WithPVName(name string) func(obj metav1.Object) {
|
||||
}
|
||||
}
|
||||
|
||||
// WithStorageClassName is a functional option for persistent volumes or
|
||||
// persistent volume claims that sets the specified storage class name.
|
||||
// It panics if the object is not a persistent volume or persistent volume
|
||||
// claim.
|
||||
func WithStorageClassName(name string) func(obj metav1.Object) {
|
||||
return func(obj metav1.Object) {
|
||||
switch obj.(type) {
|
||||
case *corev1.PersistentVolume:
|
||||
obj.(*corev1.PersistentVolume).Spec.StorageClassName = name
|
||||
case *corev1.PersistentVolumeClaim:
|
||||
obj.(*corev1.PersistentVolumeClaim).Spec.StorageClassName = &name
|
||||
default:
|
||||
panic("WithStorageClassName is only valid for persistent volumes and persistent volume claims")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithVolume is a functional option for pods that sets the specified
|
||||
// volume on the pod's Spec. It panics if the object is not a pod.
|
||||
func WithVolume(volume *corev1.Volume) func(obj metav1.Object) {
|
||||
@@ -445,3 +495,26 @@ func WithCSISource(driverName string) func(vol *corev1.Volume) {
|
||||
vol.VolumeSource.CSI = &corev1.CSIVolumeSource{Driver: driverName}
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfigMapData is a functional option for config maps that puts the specified
|
||||
// values in the Data field. It panics if the object is not a config map.
|
||||
func WithConfigMapData(vals ...string) func(obj metav1.Object) {
|
||||
return func(obj metav1.Object) {
|
||||
cm, ok := obj.(*corev1.ConfigMap)
|
||||
if !ok {
|
||||
panic("WithConfigMapData is only valid for config maps")
|
||||
}
|
||||
|
||||
if cm.Data == nil {
|
||||
cm.Data = make(map[string]string)
|
||||
}
|
||||
|
||||
if len(vals)%2 != 0 {
|
||||
vals = append(vals, "")
|
||||
}
|
||||
|
||||
for i := 0; i < len(vals); i += 2 {
|
||||
cm.Data[vals[i]] = vals[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user