Ability to specify NFS mount options while adding a primary storage and modify them on a pre-existing primary storage (#8947)

* Ability to specify NFS mount options while adding a primary storage and modify it later

* Pull 8947: Rename all occurrence of nfsopt to nfsMountOpt and added nfsMountOpts to ApiConstants

* Pull 8947: Refactor code - move into separate methods

* Pull 8947: CollectionsUtils.isNotEmpty and switch statement in LibvirtStoragePoolDef.java

* Pull 8947: UI - cancel maintainenace will remount the storage pool and apply the options

* Pull 8947: UI - moved edit NFS mount options to edit Primary Storage form

* Pull 8947: UI - moved 'NFS Mount Options' to below 'Type' in dataview

* Pull 8947: Fixed message in AddPrimaryStorage.vue

* Pull 8947: Convert _nfsmountOpts to Set in libvirtStoragePoolDef

* Pull 8947: Throw exception and log error if mount fails due to incorrect mount option

* Pull 8947: Added UT and moved integration test to component/maint

* Pull 8947: Review comments

* Pull 8947: Removed password from integration test

* Pull 8947: move details allocation to inside the if loop in getStoragePoolNFSMountOpts

* Pull 8947: Fixed a bug in AddPrimaryStorage.vue

* Pull 8947: Pool should remain in maintenance mode if mount fails

* Pull 8947: Removed password from integration test

* Pull 8947: Added UT

* Pull 8875: Fixed a bug in CloudStackPrimaryDataStoreLifeCycleImplTest

* Pull 8875: Fixed a bug in LibvirtStoragePoolDefTest

* Pull 8947: minor code restructuring

* Pull 8947 : added some ut for coverage

* Fix LibvirtStorageAdapterTest UT
This commit is contained in:
Abhisar Sinha 2024-06-25 23:45:35 +05:30 committed by GitHub
parent 620ed164d8
commit 4eb43651e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1037 additions and 66 deletions

View File

@ -1099,6 +1099,8 @@ public class ApiConstants {
public static final String PARAMETER_DESCRIPTION_IS_TAG_A_RULE = "Whether the informed tag is a JS interpretable rule or not.";
public static final String NFS_MOUNT_OPTIONS = "nfsmountopts";
/**
* This enum specifies IO Drivers, each option controls specific policies on I/O.
* Qemu guests support "threads" and "native" options Since 0.8.8 ; "io_uring" is supported Since 6.3.0 (QEMU 5.0).

View File

@ -101,6 +101,10 @@ public class StoragePoolResponse extends BaseResponseWithAnnotations {
@Param(description = "the tags for the storage pool")
private String tags;
@SerializedName(ApiConstants.NFS_MOUNT_OPTIONS)
@Param(description = "the nfs mount options for the storage pool", since = "4.19.1")
private String nfsMountOpts;
@SerializedName(ApiConstants.IS_TAG_A_RULE)
@Param(description = ApiConstants.PARAMETER_DESCRIPTION_IS_TAG_A_RULE)
private Boolean isTagARule;
@ -347,4 +351,12 @@ public class StoragePoolResponse extends BaseResponseWithAnnotations {
public void setProvider(String provider) {
this.provider = provider;
}
public String getNfsMountOpts() {
return nfsMountOpts;
}
public void setNfsMountOpts(String nfsMountOpts) {
this.nfsMountOpts = nfsMountOpts;
}
}

View File

@ -46,6 +46,10 @@ public class ModifyStoragePoolCommand extends Command {
this.details = details;
}
public ModifyStoragePoolCommand(boolean add, StoragePool pool, Map<String, String> details) {
this(add, pool, LOCAL_PATH_PREFIX + File.separator + UUID.nameUUIDFromBytes((pool.getHostAddress() + pool.getPath()).getBytes()), details);
}
public ModifyStoragePoolCommand(boolean add, StoragePool pool) {
this(add, pool, LOCAL_PATH_PREFIX + File.separator + UUID.nameUUIDFromBytes((pool.getHostAddress() + pool.getPath()).getBytes()));
}

View File

@ -18,6 +18,7 @@ package com.cloud.storage;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener;
@ -341,6 +342,10 @@ public interface StorageManager extends StorageService {
boolean registerHostListener(String providerUuid, HypervisorHostListener listener);
Pair<Map<String, String>, Boolean> getStoragePoolNFSMountOpts(StoragePool pool, Map<String, String> details);
String getStoragePoolMountFailureReason(String error);
boolean connectHostToSharedPool(long hostId, long poolId) throws StorageUnavailableException, StorageConflictException;
void disconnectHostFromSharedPool(long hostId, long poolId) throws StorageUnavailableException, StorageConflictException;

View File

@ -42,7 +42,9 @@ import com.cloud.storage.StoragePool;
import com.cloud.storage.StoragePoolHostVO;
import com.cloud.storage.StorageService;
import com.cloud.storage.dao.StoragePoolHostDao;
import com.cloud.utils.Pair;
import com.cloud.utils.exception.CloudRuntimeException;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
import org.apache.cloudstack.engine.subsystem.api.storage.HypervisorHostListener;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
@ -53,7 +55,9 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.Logger;
import javax.inject.Inject;
import java.util.List;
import java.util.Map;
public class DefaultHostListener implements HypervisorHostListener {
private static final Logger s_logger = Logger.getLogger(DefaultHostListener.class);
@ -125,7 +129,9 @@ public class DefaultHostListener implements HypervisorHostListener {
@Override
public boolean hostConnect(long hostId, long poolId) throws StorageConflictException {
StoragePool pool = (StoragePool) this.dataStoreMgr.getDataStore(poolId, DataStoreRole.Primary);
ModifyStoragePoolCommand cmd = new ModifyStoragePoolCommand(true, pool);
Pair<Map<String, String>, Boolean> nfsMountOpts = storageManager.getStoragePoolNFSMountOpts(pool, null);
ModifyStoragePoolCommand cmd = new ModifyStoragePoolCommand(true, pool, nfsMountOpts.first());
cmd.setWait(modifyStoragePoolCommandWait);
s_logger.debug(String.format("Sending modify storage pool command to agent: %d for storage pool: %d with timeout %d seconds",
hostId, poolId, cmd.getWait()));
@ -138,7 +144,7 @@ public class DefaultHostListener implements HypervisorHostListener {
if (!answer.getResult()) {
String msg = "Unable to attach storage pool" + poolId + " to the host" + hostId;
alertMgr.sendAlert(AlertManager.AlertType.ALERT_TYPE_HOST, pool.getDataCenterId(), pool.getPodId(), msg, msg);
throw new CloudRuntimeException("Unable establish connection from storage head to storage pool " + pool.getId() + " due to " + answer.getDetails() +
throw new CloudRuntimeException("Unable to establish connection from storage head to storage pool " + pool.getId() + " due to " + answer.getDetails() +
pool.getId());
}

View File

@ -16,6 +16,12 @@
// under the License.
package com.cloud.hypervisor.kvm.resource;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.collections.CollectionUtils;
public class LibvirtStoragePoolDef {
public enum PoolType {
ISCSI("iscsi"), NETFS("netfs"), LOGICAL("logical"), DIR("dir"), RBD("rbd"), GLUSTERFS("glusterfs"), POWERFLEX("powerflex");
@ -55,6 +61,7 @@ public class LibvirtStoragePoolDef {
private String _authUsername;
private AuthenticationType _authType;
private String _secretUuid;
private Set<String> _nfsMountOpts = new HashSet<>();
public LibvirtStoragePoolDef(PoolType type, String poolName, String uuid, String host, int port, String dir, String targetPath) {
_poolType = type;
@ -75,6 +82,15 @@ public class LibvirtStoragePoolDef {
_targetPath = targetPath;
}
public LibvirtStoragePoolDef(PoolType type, String poolName, String uuid, String host, String dir, String targetPath, List<String> nfsMountOpts) {
this(type, poolName, uuid, host, dir, targetPath);
if (CollectionUtils.isNotEmpty(nfsMountOpts)) {
for (String nfsMountOpt : nfsMountOpts) {
this._nfsMountOpts.add(nfsMountOpt);
}
}
}
public LibvirtStoragePoolDef(PoolType type, String poolName, String uuid, String sourceHost, int sourcePort, String dir, String authUsername, AuthenticationType authType,
String secretUuid) {
_poolType = type;
@ -124,69 +140,98 @@ public class LibvirtStoragePoolDef {
return _authType;
}
public Set<String> getNfsMountOpts() {
return _nfsMountOpts;
}
@Override
public String toString() {
StringBuilder storagePoolBuilder = new StringBuilder();
if (_poolType == PoolType.GLUSTERFS) {
/* libvirt mounts a Gluster volume, similar to NFS */
storagePoolBuilder.append("<pool type='netfs'>\n");
} else {
storagePoolBuilder.append("<pool type='");
storagePoolBuilder.append(_poolType);
storagePoolBuilder.append("'>\n");
String poolTypeXML;
switch (_poolType) {
case NETFS:
if (_nfsMountOpts != null) {
poolTypeXML = "netfs' xmlns:fs='http://libvirt.org/schemas/storagepool/fs/1.0";
} else {
poolTypeXML = _poolType.toString();
}
break;
case GLUSTERFS:
/* libvirt mounts a Gluster volume, similar to NFS */
poolTypeXML = "netfs";
break;
default:
poolTypeXML = _poolType.toString();
}
storagePoolBuilder.append("<pool type='");
storagePoolBuilder.append(poolTypeXML);
storagePoolBuilder.append("'>\n");
storagePoolBuilder.append("<name>" + _poolName + "</name>\n");
if (_uuid != null)
storagePoolBuilder.append("<uuid>" + _uuid + "</uuid>\n");
if (_poolType == PoolType.NETFS) {
storagePoolBuilder.append("<source>\n");
storagePoolBuilder.append("<host name='" + _sourceHost + "'/>\n");
storagePoolBuilder.append("<dir path='" + _sourceDir + "'/>\n");
storagePoolBuilder.append("</source>\n");
}
if (_poolType == PoolType.RBD) {
storagePoolBuilder.append("<source>\n");
for (String sourceHost : _sourceHost.split(",")) {
switch (_poolType) {
case NETFS:
storagePoolBuilder.append("<source>\n");
storagePoolBuilder.append("<host name='" + _sourceHost + "'/>\n");
storagePoolBuilder.append("<dir path='" + _sourceDir + "'/>\n");
storagePoolBuilder.append("</source>\n");
break;
case RBD:
storagePoolBuilder.append("<source>\n");
for (String sourceHost : _sourceHost.split(",")) {
storagePoolBuilder.append("<host name='");
storagePoolBuilder.append(sourceHost);
if (_sourcePort != 0) {
storagePoolBuilder.append("' port='");
storagePoolBuilder.append(_sourcePort);
}
storagePoolBuilder.append("'/>\n");
}
storagePoolBuilder.append("<name>" + _sourceDir + "</name>\n");
if (_authUsername != null) {
storagePoolBuilder.append("<auth username='" + _authUsername + "' type='" + _authType + "'>\n");
storagePoolBuilder.append("<secret uuid='" + _secretUuid + "'/>\n");
storagePoolBuilder.append("</auth>\n");
}
storagePoolBuilder.append("</source>\n");
break;
case GLUSTERFS:
storagePoolBuilder.append("<source>\n");
storagePoolBuilder.append("<host name='");
storagePoolBuilder.append(sourceHost);
storagePoolBuilder.append(_sourceHost);
if (_sourcePort != 0) {
storagePoolBuilder.append("' port='");
storagePoolBuilder.append(_sourcePort);
}
storagePoolBuilder.append("'/>\n");
}
storagePoolBuilder.append("<dir path='");
storagePoolBuilder.append(_sourceDir);
storagePoolBuilder.append("'/>\n");
storagePoolBuilder.append("<format type='");
storagePoolBuilder.append(_poolType);
storagePoolBuilder.append("'/>\n");
storagePoolBuilder.append("</source>\n");
break;
}
storagePoolBuilder.append("<name>" + _sourceDir + "</name>\n");
if (_authUsername != null) {
storagePoolBuilder.append("<auth username='" + _authUsername + "' type='" + _authType + "'>\n");
storagePoolBuilder.append("<secret uuid='" + _secretUuid + "'/>\n");
storagePoolBuilder.append("</auth>\n");
}
storagePoolBuilder.append("</source>\n");
}
if (_poolType == PoolType.GLUSTERFS) {
storagePoolBuilder.append("<source>\n");
storagePoolBuilder.append("<host name='");
storagePoolBuilder.append(_sourceHost);
if (_sourcePort != 0) {
storagePoolBuilder.append("' port='");
storagePoolBuilder.append(_sourcePort);
}
storagePoolBuilder.append("'/>\n");
storagePoolBuilder.append("<dir path='");
storagePoolBuilder.append(_sourceDir);
storagePoolBuilder.append("'/>\n");
storagePoolBuilder.append("<format type='");
storagePoolBuilder.append(_poolType);
storagePoolBuilder.append("'/>\n");
storagePoolBuilder.append("</source>\n");
}
if (_poolType != PoolType.RBD && _poolType != PoolType.POWERFLEX) {
storagePoolBuilder.append("<target>\n");
storagePoolBuilder.append("<path>" + _targetPath + "</path>\n");
storagePoolBuilder.append("</target>\n");
}
if (_poolType == PoolType.NETFS && _nfsMountOpts != null) {
storagePoolBuilder.append("<fs:mount_opts>\n");
for (String options : _nfsMountOpts) {
storagePoolBuilder.append("<fs:option name='" + options + "'/>\n");
}
storagePoolBuilder.append("</fs:mount_opts>\n");
}
storagePoolBuilder.append("</pool>\n");
return storagePoolBuilder.toString();
}

View File

@ -37,6 +37,19 @@ import org.xml.sax.SAXException;
public class LibvirtStoragePoolXMLParser {
private static final Logger s_logger = Logger.getLogger(LibvirtStoragePoolXMLParser.class);
private List<String> getNFSMountOptsFromRootElement(Element rootElement) {
List<String> nfsMountOpts = new ArrayList<>();
Element mountOpts = (Element) rootElement.getElementsByTagName("fs:mount_opts").item(0);
if (mountOpts != null) {
NodeList options = mountOpts.getElementsByTagName("fs:option");
for (int i = 0; i < options.getLength(); i++) {
Element option = (Element) options.item(i);
nfsMountOpts.add(option.getAttribute("name"));
}
}
return nfsMountOpts;
}
public LibvirtStoragePoolDef parseStoragePoolXML(String poolXML) {
DocumentBuilder builder;
try {
@ -94,11 +107,15 @@ public class LibvirtStoragePoolXMLParser {
poolName, uuid, host, port, path, targetPath);
} else {
String path = getAttrValue("dir", "path", source);
Element target = (Element)rootElement.getElementsByTagName("target").item(0);
String targetPath = getTagValue("path", target);
return new LibvirtStoragePoolDef(LibvirtStoragePoolDef.PoolType.valueOf(type.toUpperCase()), poolName, uuid, host, path, targetPath);
if (type.equalsIgnoreCase("netfs")) {
List<String> nfsMountOpts = getNFSMountOptsFromRootElement(rootElement);
return new LibvirtStoragePoolDef(LibvirtStoragePoolDef.PoolType.valueOf(type.toUpperCase()), poolName, uuid, host, path, targetPath, nfsMountOpts);
} else {
return new LibvirtStoragePoolDef(LibvirtStoragePoolDef.PoolType.valueOf(type.toUpperCase()), poolName, uuid, host, path, targetPath);
}
}
} catch (ParserConfigurationException e) {
s_logger.debug(e.toString());

View File

@ -25,6 +25,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.utils.cryptsetup.KeyFile;
import org.apache.cloudstack.utils.qemu.QemuImg;
import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat;
@ -32,6 +33,7 @@ import org.apache.cloudstack.utils.qemu.QemuImgException;
import org.apache.cloudstack.utils.qemu.QemuImgFile;
import org.apache.cloudstack.utils.qemu.QemuObject;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.collections.CollectionUtils;
import org.apache.log4j.Logger;
import org.libvirt.Connect;
import org.libvirt.LibvirtException;
@ -282,9 +284,9 @@ public class LibvirtStorageAdaptor implements StorageAdaptor {
}
}
private StoragePool createNetfsStoragePool(PoolType fsType, Connect conn, String uuid, String host, String path) throws LibvirtException {
private StoragePool createNetfsStoragePool(PoolType fsType, Connect conn, String uuid, String host, String path, List<String> nfsMountOpts) throws LibvirtException {
String targetPath = _mountPoint + File.separator + uuid;
LibvirtStoragePoolDef spd = new LibvirtStoragePoolDef(fsType, uuid, uuid, host, path, targetPath);
LibvirtStoragePoolDef spd = new LibvirtStoragePoolDef(fsType, uuid, uuid, host, path, targetPath, nfsMountOpts);
_storageLayer.mkdir(targetPath);
StoragePool sp = null;
try {
@ -373,6 +375,42 @@ public class LibvirtStorageAdaptor implements StorageAdaptor {
}
private List<String> getNFSMountOptsFromDetails(StoragePoolType type, Map<String, String> details) {
List<String> nfsMountOpts = null;
if (!type.equals(StoragePoolType.NetworkFilesystem) || details == null) {
return nfsMountOpts;
}
if (details.containsKey(ApiConstants.NFS_MOUNT_OPTIONS)) {
nfsMountOpts = Arrays.asList(details.get(ApiConstants.NFS_MOUNT_OPTIONS).replaceAll("\\s", "").split(","));
}
return nfsMountOpts;
}
private boolean destroyStoragePoolOnNFSMountOptionsChange(StoragePool sp, Connect conn, List<String> nfsMountOpts) {
try {
LibvirtStoragePoolDef poolDef = getStoragePoolDef(conn, sp);
Set poolNfsMountOpts = poolDef.getNfsMountOpts();
boolean mountOptsDiffer = false;
if (poolNfsMountOpts.size() != nfsMountOpts.size()) {
mountOptsDiffer = true;
} else {
for (String nfsMountOpt : nfsMountOpts) {
if (!poolNfsMountOpts.contains(nfsMountOpt)) {
mountOptsDiffer = true;
break;
}
}
}
if (mountOptsDiffer) {
sp.destroy();
return true;
}
} catch (LibvirtException e) {
s_logger.error("Failure in destroying the pre-existing storage pool for changing the NFS mount options" + e);
}
return false;
}
private StoragePool createRBDStoragePool(Connect conn, String uuid, String host, int port, String userInfo, String path) {
LibvirtStoragePoolDef spd;
@ -664,12 +702,21 @@ public class LibvirtStorageAdaptor implements StorageAdaptor {
} catch (LibvirtException e) {
s_logger.error("Failure in attempting to see if an existing storage pool might be using the path of the pool to be created:" + e);
}
}
List<String> nfsMountOpts = getNFSMountOptsFromDetails(type, details);
if (sp != null && CollectionUtils.isNotEmpty(nfsMountOpts) &&
destroyStoragePoolOnNFSMountOptionsChange(sp, conn, nfsMountOpts)) {
sp = null;
}
if (sp == null) {
s_logger.debug("Attempting to create storage pool " + name);
if (type == StoragePoolType.NetworkFilesystem) {
try {
sp = createNetfsStoragePool(PoolType.NETFS, conn, name, host, path);
sp = createNetfsStoragePool(PoolType.NETFS, conn, name, host, path, nfsMountOpts);
} catch (LibvirtException e) {
s_logger.error("Failed to create netfs mount: " + host + ":" + path , e);
s_logger.error(e.getStackTrace());
@ -677,7 +724,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor {
}
} else if (type == StoragePoolType.Gluster) {
try {
sp = createNetfsStoragePool(PoolType.GLUSTERFS, conn, name, host, path);
sp = createNetfsStoragePool(PoolType.GLUSTERFS, conn, name, host, path, null);
} catch (LibvirtException e) {
s_logger.error("Failed to create glusterfs mount: " + host + ":" + path , e);
s_logger.error(e.getStackTrace());

View File

@ -19,6 +19,9 @@
package com.cloud.hypervisor.kvm.resource;
import java.util.ArrayList;
import java.util.List;
import junit.framework.TestCase;
import com.cloud.hypervisor.kvm.resource.LibvirtStoragePoolDef.PoolType;
import com.cloud.hypervisor.kvm.resource.LibvirtStoragePoolDef.AuthenticationType;
@ -47,6 +50,14 @@ public class LibvirtStoragePoolDefTest extends TestCase {
assertEquals(port, pool.getSourcePort());
assertEquals(dir, pool.getSourceDir());
assertEquals(targetPath, pool.getTargetPath());
List<String> nfsMountOpts = new ArrayList<>();
nfsMountOpts.add("vers=4.1");
nfsMountOpts.add("nconnect=4");
pool = new LibvirtStoragePoolDef(type, name, uuid, host, dir, targetPath, nfsMountOpts);
assertTrue(pool.getNfsMountOpts().contains("vers=4.1"));
assertTrue(pool.getNfsMountOpts().contains("nconnect=4"));
assertEquals(pool.getNfsMountOpts().size(), 2);
}
@Test
@ -57,12 +68,38 @@ public class LibvirtStoragePoolDefTest extends TestCase {
String host = "127.0.0.1";
String dir = "/export/primary";
String targetPath = "/mnt/" + uuid;
List<String> nfsMountOpts = new ArrayList<>();
nfsMountOpts.add("vers=4.1");
nfsMountOpts.add("nconnect=4");
LibvirtStoragePoolDef pool = new LibvirtStoragePoolDef(type, name, uuid, host, dir, targetPath);
LibvirtStoragePoolDef pool = new LibvirtStoragePoolDef(type, name, uuid, host, dir, targetPath, nfsMountOpts);
String expectedXml = "<pool type='" + type.toString() + "'>\n<name>" + name + "</name>\n<uuid>" + uuid + "</uuid>\n" +
String expectedXml = "<pool type='" + type.toString() + "' xmlns:fs='http://libvirt.org/schemas/storagepool/fs/1.0'>\n" +
"<name>" +name + "</name>\n<uuid>" + uuid + "</uuid>\n" +
"<source>\n<host name='" + host + "'/>\n<dir path='" + dir + "'/>\n</source>\n<target>\n" +
"<path>" + targetPath + "</path>\n</target>\n</pool>\n";
"<path>" + targetPath + "</path>\n</target>\n" +
"<fs:mount_opts>\n<fs:option name='vers=4.1'/>\n<fs:option name='nconnect=4'/>\n</fs:mount_opts>\n</pool>\n";
assertEquals(expectedXml, pool.toString());
}
@Test
public void testGlusterFSStoragePool() {
PoolType type = PoolType.GLUSTERFS;
String name = "myGFSPool";
String uuid = "89a605bc-d470-4637-b3df-27388be452f5";
String host = "127.0.0.1";
String dir = "/export/primary";
String targetPath = "/mnt/" + uuid;
List<String> nfsMountOpts = new ArrayList<>();
LibvirtStoragePoolDef pool = new LibvirtStoragePoolDef(type, name, uuid, host, dir, targetPath, nfsMountOpts);
String expectedXml = "<pool type='netfs'>\n" +
"<name>" +name + "</name>\n<uuid>" + uuid + "</uuid>\n" +
"<source>\n<host name='" + host + "'/>\n<dir path='" + dir + "'/>\n" +
"<format type='glusterfs'/>\n</source>\n<target>\n" +
"<path>" + targetPath + "</path>\n</target>\n</pool>\n";
assertEquals(expectedXml, pool.toString());
}

View File

@ -30,7 +30,7 @@ public class LibvirtStoragePoolXMLParserTest extends TestCase {
@Test
public void testParseNfsStoragePoolXML() {
String poolXML = "<pool type='netfs'>\n" +
String poolXML = "<pool type='netfs' xmlns:fs='http://libvirt.org/schemas/storagepool/fs/1.0'>\n" +
" <name>feff06b5-84b2-3258-b5f9-1953217295de</name>\n" +
" <uuid>feff06b5-84b2-3258-b5f9-1953217295de</uuid>\n" +
" <capacity unit='bytes'>111111111</capacity>\n" +
@ -49,12 +49,18 @@ public class LibvirtStoragePoolXMLParserTest extends TestCase {
" <group>0</group>\n" +
" </permissions>\n" +
" </target>\n" +
" <fs:mount_opts>\n" +
" <fs:option name='nconnect=8'/>\n" +
" <fs:option name='vers=4.1'/>\n" +
" </fs:mount_opts>\n" +
"</pool>";
LibvirtStoragePoolXMLParser parser = new LibvirtStoragePoolXMLParser();
LibvirtStoragePoolDef pool = parser.parseStoragePoolXML(poolXML);
Assert.assertEquals("10.11.12.13", pool.getSourceHost());
assertEquals("10.11.12.13", pool.getSourceHost());
assertTrue(pool.getNfsMountOpts().contains("vers=4.1"));
assertTrue(pool.getNfsMountOpts().contains("nconnect=8"));
}
@Test

View File

@ -0,0 +1,91 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 com.cloud.hypervisor.kvm.storage;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import com.cloud.utils.exception.CloudRuntimeException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.libvirt.Connect;
import org.libvirt.StoragePool;
import org.libvirt.StoragePoolInfo;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.mockito.junit.MockitoJUnitRunner;
import com.cloud.hypervisor.kvm.resource.LibvirtConnection;
import com.cloud.hypervisor.kvm.resource.LibvirtStoragePoolDef;
import com.cloud.storage.Storage;
@RunWith(MockitoJUnitRunner.class)
public class LibvirtStorageAdaptorTest {
private MockedStatic<LibvirtConnection> libvirtConnectionMockedStatic;
private AutoCloseable closeable;
@Spy
static LibvirtStorageAdaptor libvirtStorageAdaptor = new LibvirtStorageAdaptor(null);
@Before
public void initMocks() {
closeable = MockitoAnnotations.openMocks(this);
libvirtConnectionMockedStatic = Mockito.mockStatic(LibvirtConnection.class);
}
@After
public void tearDown() throws Exception {
libvirtConnectionMockedStatic.close();
closeable.close();
}
@Test(expected = CloudRuntimeException.class)
public void testCreateStoragePoolWithNFSMountOpts() throws Exception {
LibvirtStoragePoolDef.PoolType type = LibvirtStoragePoolDef.PoolType.NETFS;
String name = "Primary1";
String uuid = String.valueOf(UUID.randomUUID());
String host = "127.0.0.1";
String dir = "/export/primary";
String targetPath = "/mnt/" + uuid;
String poolXml = "<pool type='" + type.toString() + "' xmlns:fs='http://libvirt.org/schemas/storagepool/fs/1.0'>\n" +
"<name>" +name + "</name>\n<uuid>" + uuid + "</uuid>\n" +
"<source>\n<host name='" + host + "'/>\n<dir path='" + dir + "'/>\n</source>\n<target>\n" +
"<path>" + targetPath + "</path>\n</target>\n" +
"<fs:mount_opts>\n<fs:option name='vers=4.1'/>\n<fs:option name='nconnect=4'/>\n</fs:mount_opts>\n</pool>\n";
Connect conn = Mockito.mock(Connect.class);
StoragePool sp = Mockito.mock(StoragePool.class);
StoragePoolInfo spinfo = Mockito.mock(StoragePoolInfo.class);
Mockito.when(LibvirtConnection.getConnection()).thenReturn(conn);
Mockito.when(conn.storagePoolLookupByUUIDString(uuid)).thenReturn(sp);
Mockito.when(sp.isActive()).thenReturn(1);
Mockito.when(sp.getXMLDesc(0)).thenReturn(poolXml);
Map<String, String> details = new HashMap<>();
details.put("nfsmountopts", "vers=4.1, nconnect=4");
KVMStoragePool pool = libvirtStorageAdaptor.createStoragePool(uuid, null, 0, dir, null, Storage.StoragePoolType.NetworkFilesystem, details);
}
}

View File

@ -411,6 +411,10 @@ public class CloudStackPrimaryDataStoreLifeCycleImpl implements PrimaryDataStore
throw new CloudRuntimeException("Storage has already been added as local storage");
} catch (Exception e) {
s_logger.warn("Unable to establish a connection between " + h + " and " + primarystore, e);
String reason = storageMgr.getStoragePoolMountFailureReason(e.getMessage());
if (reason != null) {
throw new CloudRuntimeException(reason);
}
}
}
@ -438,6 +442,10 @@ public class CloudStackPrimaryDataStoreLifeCycleImpl implements PrimaryDataStore
throw new CloudRuntimeException("Storage has already been added as local storage to host: " + host.getName());
} catch (Exception e) {
s_logger.warn("Unable to establish a connection between " + host + " and " + dataStore, e);
String reason = storageMgr.getStoragePoolMountFailureReason(e.getMessage());
if (reason != null) {
throw new CloudRuntimeException(reason);
}
}
}
if (poolHosts.isEmpty()) {
@ -458,8 +466,8 @@ public class CloudStackPrimaryDataStoreLifeCycleImpl implements PrimaryDataStore
@Override
public boolean cancelMaintain(DataStore store) {
dataStoreHelper.cancelMaintain(store);
storagePoolAutmation.cancelMaintain(store);
dataStoreHelper.cancelMaintain(store);
return true;
}

View File

@ -34,6 +34,7 @@ import com.cloud.storage.Storage;
import com.cloud.storage.StorageManager;
import com.cloud.storage.StorageManagerImpl;
import com.cloud.storage.dao.StoragePoolHostDao;
import com.cloud.utils.exception.CloudRuntimeException;
import junit.framework.TestCase;
import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
@ -57,7 +58,6 @@ import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@ -171,4 +171,23 @@ public class CloudStackPrimaryDataStoreLifeCycleImplTest extends TestCase {
public void testAttachCluster() throws Exception {
Assert.assertTrue(_cloudStackPrimaryDataStoreLifeCycle.attachCluster(store, new ClusterScope(1L, 1L, 1L)));
}
@Test
public void testAttachClusterException() throws Exception {
String exceptionString = "Mount failed due to incorrect mount options.";
String mountFailureReason = "Incorrect mount option specified.";
CloudRuntimeException exception = new CloudRuntimeException(exceptionString);
StorageManager storageManager = Mockito.mock(StorageManager.class);
Mockito.when(storageManager.connectHostToSharedPool(Mockito.anyLong(), Mockito.anyLong())).thenThrow(exception);
Mockito.when(storageManager.getStoragePoolMountFailureReason(exceptionString)).thenReturn(mountFailureReason);
ReflectionTestUtils.setField(_cloudStackPrimaryDataStoreLifeCycle, "storageMgr", storageManager);
try {
_cloudStackPrimaryDataStoreLifeCycle.attachCluster(store, new ClusterScope(1L, 1L, 1L));
Assert.fail();
} catch (Exception e) {
Assert.assertEquals(e.getMessage(), mountFailureReason);
}
}
}

View File

@ -2988,6 +2988,16 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
return new Pair<>(pools, pools.size());
}
private void setPoolResponseNFSMountOptions(StoragePoolResponse poolResponse, Long poolId) {
if (Storage.StoragePoolType.NetworkFilesystem.toString().equals(poolResponse.getType()) &&
HypervisorType.KVM.toString().equals(poolResponse.getHypervisor())) {
StoragePoolDetailVO detail = _storagePoolDetailsDao.findDetail(poolId, ApiConstants.NFS_MOUNT_OPTIONS);
if (detail != null) {
poolResponse.setNfsMountOpts(detail.getValue());
}
}
}
private ListResponse<StoragePoolResponse> createStoragesPoolResponse(Pair<List<StoragePoolJoinVO>, Integer> storagePools) {
ListResponse<StoragePoolResponse> response = new ListResponse<>();
@ -3009,6 +3019,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
poolResponse.setCaps(caps);
}
}
setPoolResponseNFSMountOptions(poolResponse, poolUuidToIdMap.get(poolResponse.getId()));
}
response.setResponses(poolResponses, storagePools.second());

View File

@ -412,6 +412,8 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C
private final Map<String, HypervisorHostListener> hostListeners = new HashMap<String, HypervisorHostListener>();
private static final String NFS_MOUNT_OPTIONS_INCORRECT = "An incorrect mount option was specified";
public boolean share(VMInstanceVO vm, List<VolumeVO> vols, HostVO host, boolean cancelPreviousShare) throws StorageUnavailableException {
// if pool is in maintenance and it is the ONLY pool available; reject
@ -840,6 +842,53 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C
return String.format("%s-%s-%s", StringUtils.trim(host.getName()), "local", storagePoolInformation.getUuid().split("-")[0]);
}
protected void checkNfsMountOptions(String nfsMountOpts) throws InvalidParameterValueException {
String[] options = nfsMountOpts.replaceAll("\\s", "").split(",");
Map<String, String> optionsMap = new HashMap<>();
for (String option : options) {
String[] keyValue = option.split("=");
if (keyValue.length > 2) {
throw new InvalidParameterValueException("Invalid value for NFS option " + keyValue[0]);
}
if (optionsMap.containsKey(keyValue[0])) {
throw new InvalidParameterValueException("Duplicate NFS option values found for option " + keyValue[0]);
}
optionsMap.put(keyValue[0], null);
}
}
protected void checkNFSMountOptionsForCreate(Map<String, String> details, HypervisorType hypervisorType, String scheme) throws InvalidParameterValueException {
if (!details.containsKey(ApiConstants.NFS_MOUNT_OPTIONS)) {
return;
}
if (!hypervisorType.equals(HypervisorType.KVM) && !hypervisorType.equals(HypervisorType.Simulator)) {
throw new InvalidParameterValueException("NFS options can not be set for the hypervisor type " + hypervisorType);
}
if (!"nfs".equals(scheme)) {
throw new InvalidParameterValueException("NFS options can only be set on pool type " + StoragePoolType.NetworkFilesystem);
}
checkNfsMountOptions(details.get(ApiConstants.NFS_MOUNT_OPTIONS));
}
protected void checkNFSMountOptionsForUpdate(Map<String, String> details, StoragePoolVO pool, Long accountId) throws InvalidParameterValueException {
if (!details.containsKey(ApiConstants.NFS_MOUNT_OPTIONS)) {
return;
}
if (!_accountMgr.isRootAdmin(accountId)) {
throw new PermissionDeniedException("Only root admin can modify nfs options");
}
if (!pool.getHypervisor().equals(HypervisorType.KVM) && !pool.getHypervisor().equals((HypervisorType.Simulator))) {
throw new InvalidParameterValueException("NFS options can only be set for the hypervisor type " + HypervisorType.KVM);
}
if (!pool.getPoolType().equals(StoragePoolType.NetworkFilesystem)) {
throw new InvalidParameterValueException("NFS options can only be set on pool type " + StoragePoolType.NetworkFilesystem);
}
if (!pool.isInMaintenance()) {
throw new InvalidParameterValueException("The storage pool should be in maintenance mode to edit nfs options");
}
checkNfsMountOptions(details.get(ApiConstants.NFS_MOUNT_OPTIONS));
}
@Override
public PrimaryDataStoreInfo createPool(CreateStoragePoolCmd cmd) throws ResourceInUseException, IllegalArgumentException, UnknownHostException, ResourceUnavailableException {
String providerName = cmd.getStorageProviderName();
@ -904,6 +953,8 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C
}
Map<String, String> details = extractApiParamAsMap(cmd.getDetails());
checkNFSMountOptionsForCreate(details, hypervisorType, uriParams.get("scheme"));
DataCenterVO zone = _dcDao.findById(cmd.getZoneId());
if (zone == null) {
throw new InvalidParameterValueException("unable to find zone by id " + zoneId);
@ -1086,6 +1137,9 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C
throw new IllegalArgumentException("Unable to find storage pool with ID: " + id);
}
Map<String, String> inputDetails = extractApiParamAsMap(cmd.getDetails());
checkNFSMountOptionsForUpdate(inputDetails, pool, cmd.getEntityOwnerId());
String name = cmd.getName();
if(StringUtils.isNotBlank(name)) {
s_logger.debug("Updating Storage Pool name to: " + name);
@ -1129,12 +1183,9 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C
}
// retrieve current details and merge/overlay input to capture changes
Map<String, String> inputDetails = extractApiParamAsMap(cmd.getDetails());
Map<String, String> details = null;
if (inputDetails == null) {
details = _storagePoolDetailsDao.listDetailsKeyPairs(id);
} else {
details = _storagePoolDetailsDao.listDetailsKeyPairs(id);
details = _storagePoolDetailsDao.listDetailsKeyPairs(id);
if (inputDetails != null) {
details.putAll(inputDetails);
changes = true;
}
@ -1233,6 +1284,32 @@ public class StorageManagerImpl extends ManagerBase implements StorageManager, C
return deleteDataStoreInternal(sPool, forced);
}
@Override
public Pair<Map<String, String>, Boolean> getStoragePoolNFSMountOpts(StoragePool pool, Map<String, String> details) {
boolean details_added = false;
if (!pool.getPoolType().equals(Storage.StoragePoolType.NetworkFilesystem)) {
return new Pair<>(details, details_added);
}
StoragePoolDetailVO nfsMountOpts = _storagePoolDetailsDao.findDetail(pool.getId(), ApiConstants.NFS_MOUNT_OPTIONS);
if (nfsMountOpts != null) {
if (details == null) {
details = new HashMap<>();
}
details.put(ApiConstants.NFS_MOUNT_OPTIONS, nfsMountOpts.getValue());
details_added = true;
}
return new Pair<>(details, details_added);
}
public String getStoragePoolMountFailureReason(String reason) {
if (reason.toLowerCase().contains(NFS_MOUNT_OPTIONS_INCORRECT.toLowerCase())) {
return NFS_MOUNT_OPTIONS_INCORRECT;
} else {
return null;
}
}
private boolean checkIfDataStoreClusterCanbeDeleted(StoragePoolVO sPool, boolean forced) {
List<StoragePoolVO> childStoragePools = _storagePoolDao.listChildStoragePoolsInDatastoreCluster(sPool.getId());
boolean canDelete = true;

View File

@ -20,6 +20,7 @@ package com.cloud.storage;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
@ -28,6 +29,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.DataStore;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager;
import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProviderManager;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.log4j.Logger;
import org.springframework.stereotype.Component;
@ -48,6 +50,7 @@ import com.cloud.storage.dao.VolumeDao;
import com.cloud.user.Account;
import com.cloud.user.User;
import com.cloud.user.dao.UserDao;
import com.cloud.utils.Pair;
import com.cloud.utils.exception.CloudRuntimeException;
import com.cloud.vm.ConsoleProxyVO;
import com.cloud.vm.DomainRouterVO;
@ -88,6 +91,8 @@ public class StoragePoolAutomationImpl implements StoragePoolAutomation {
@Inject
PrimaryDataStoreDao primaryDataStoreDao;
@Inject
StoragePoolDetailsDao storagePoolDetailsDao;
@Inject
DataStoreManager dataStoreMgr;
@Inject
protected ResourceManager _resourceMgr;
@ -318,14 +323,25 @@ public class StoragePoolAutomationImpl implements StoragePoolAutomation {
if (hosts == null || hosts.size() == 0) {
return true;
}
Pair<Map<String, String>, Boolean> nfsMountOpts = storageManager.getStoragePoolNFSMountOpts(pool, null);
// add heartbeat
for (HostVO host : hosts) {
ModifyStoragePoolCommand msPoolCmd = new ModifyStoragePoolCommand(true, pool);
ModifyStoragePoolCommand msPoolCmd = new ModifyStoragePoolCommand(true, pool, nfsMountOpts.first());
final Answer answer = agentMgr.easySend(host.getId(), msPoolCmd);
if (answer == null || !answer.getResult()) {
if (s_logger.isDebugEnabled()) {
s_logger.debug("ModifyStoragePool add failed due to " + ((answer == null) ? "answer null" : answer.getDetails()));
}
if (answer != null && nfsMountOpts.second()) {
s_logger.error(String.format("Unable to attach storage pool to the host %s due to %s", host, answer.getDetails()));
StringBuilder exceptionSB = new StringBuilder("Unable to attach storage pool to the host ").append(host.getName());
String reason = storageManager.getStoragePoolMountFailureReason(answer.getDetails());
if (reason!= null) {
exceptionSB.append(". ").append(reason).append(".");
}
throw new CloudRuntimeException(exceptionSB.toString());
}
} else {
if (s_logger.isDebugEnabled()) {
s_logger.debug("ModifyStoragePool add succeeded");

View File

@ -21,14 +21,21 @@ import com.cloud.dc.DataCenterVO;
import com.cloud.dc.dao.DataCenterDao;
import com.cloud.exception.ConnectionException;
import com.cloud.exception.InvalidParameterValueException;
import com.cloud.exception.PermissionDeniedException;
import com.cloud.host.Host;
import com.cloud.hypervisor.Hypervisor;
import com.cloud.storage.dao.VolumeDao;
import com.cloud.user.AccountManager;
import com.cloud.utils.Pair;
import com.cloud.vm.VMInstanceVO;
import com.cloud.vm.dao.VMInstanceDao;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.framework.config.ConfigDepot;
import org.apache.cloudstack.framework.config.dao.ConfigurationDao;
import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailVO;
import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao;
import org.apache.cloudstack.storage.datastore.db.StoragePoolVO;
import org.apache.commons.collections.MapUtils;
import org.junit.Assert;
@ -59,6 +66,10 @@ public class StorageManagerImplTest {
ConfigurationDao configurationDao;
@Mock
DataCenterDao dataCenterDao;
@Mock
AccountManager accountManager;
@Mock
StoragePoolDetailsDao storagePoolDetailsDao;
@Spy
@InjectMocks
@ -249,4 +260,152 @@ public class StorageManagerImplTest {
.update(StorageManager.DataStoreDownloadFollowRedirects.key(),StorageManager.DataStoreDownloadFollowRedirects.defaultValue());
}
@Test
public void testCheckNFSMountOptionsForCreateNoNFSMountOptions() {
Map<String, String> details = new HashMap<>();
try {
storageManagerImpl.checkNFSMountOptionsForCreate(details, Hypervisor.HypervisorType.XenServer, "");
} catch (Exception e) {
Assert.fail();
}
}
@Test
public void testCheckNFSMountOptionsForCreateNotKVM() {
Map<String, String> details = new HashMap<>();
details.put(ApiConstants.NFS_MOUNT_OPTIONS, "vers=4.1");
InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class,
() -> storageManagerImpl.checkNFSMountOptionsForCreate(details, Hypervisor.HypervisorType.XenServer, ""));
Assert.assertEquals(exception.getMessage(), "NFS options can not be set for the hypervisor type " + Hypervisor.HypervisorType.XenServer);
}
@Test
public void testCheckNFSMountOptionsForCreateNotNFS() {
Map<String, String> details = new HashMap<>();
details.put(ApiConstants.NFS_MOUNT_OPTIONS, "vers=4.1");
InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class,
() -> storageManagerImpl.checkNFSMountOptionsForCreate(details, Hypervisor.HypervisorType.KVM, ""));
Assert.assertEquals(exception.getMessage(), "NFS options can only be set on pool type " + Storage.StoragePoolType.NetworkFilesystem);
}
@Test
public void testCheckNFSMountOptionsForUpdateNoNFSMountOptions() {
Map<String, String> details = new HashMap<>();
StoragePoolVO pool = new StoragePoolVO();
Long accountId = 1L;
try {
storageManagerImpl.checkNFSMountOptionsForUpdate(details, pool, accountId);
} catch (Exception e) {
Assert.fail();
}
}
@Test
public void testCheckNFSMountOptionsForUpdateNotRootAdmin() {
Map<String, String> details = new HashMap<>();
StoragePoolVO pool = new StoragePoolVO();
Long accountId = 1L;
details.put(ApiConstants.NFS_MOUNT_OPTIONS, "vers=4.1");
Mockito.when(accountManager.isRootAdmin(accountId)).thenReturn(false);
PermissionDeniedException exception = Assert.assertThrows(PermissionDeniedException.class,
() -> storageManagerImpl.checkNFSMountOptionsForUpdate(details, pool, accountId));
Assert.assertEquals(exception.getMessage(), "Only root admin can modify nfs options");
}
@Test
public void testCheckNFSMountOptionsForUpdateNotKVM() {
Map<String, String> details = new HashMap<>();
StoragePoolVO pool = new StoragePoolVO();
Long accountId = 1L;
details.put(ApiConstants.NFS_MOUNT_OPTIONS, "vers=4.1");
Mockito.when(accountManager.isRootAdmin(accountId)).thenReturn(true);
pool.setHypervisor(Hypervisor.HypervisorType.XenServer);
InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class,
() -> storageManagerImpl.checkNFSMountOptionsForUpdate(details, pool, accountId));
Assert.assertEquals(exception.getMessage(), "NFS options can only be set for the hypervisor type " + Hypervisor.HypervisorType.KVM);
}
@Test
public void testCheckNFSMountOptionsForUpdateNotNFS() {
Map<String, String> details = new HashMap<>();
StoragePoolVO pool = new StoragePoolVO();
Long accountId = 1L;
details.put(ApiConstants.NFS_MOUNT_OPTIONS, "vers=4.1");
Mockito.when(accountManager.isRootAdmin(accountId)).thenReturn(true);
pool.setHypervisor(Hypervisor.HypervisorType.KVM);
pool.setPoolType(Storage.StoragePoolType.FiberChannel);
InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class,
() -> storageManagerImpl.checkNFSMountOptionsForUpdate(details, pool, accountId));
Assert.assertEquals(exception.getMessage(), "NFS options can only be set on pool type " + Storage.StoragePoolType.NetworkFilesystem);
}
@Test
public void testCheckNFSMountOptionsForUpdateNotMaintenance() {
Map<String, String> details = new HashMap<>();
StoragePoolVO pool = new StoragePoolVO();
Long accountId = 1L;
details.put(ApiConstants.NFS_MOUNT_OPTIONS, "vers=4.1");
Mockito.when(accountManager.isRootAdmin(accountId)).thenReturn(true);
pool.setHypervisor(Hypervisor.HypervisorType.KVM);
pool.setPoolType(Storage.StoragePoolType.NetworkFilesystem);
pool.setStatus(StoragePoolStatus.Up);
InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class,
() -> storageManagerImpl.checkNFSMountOptionsForUpdate(details, pool, accountId));
Assert.assertEquals(exception.getMessage(), "The storage pool should be in maintenance mode to edit nfs options");
}
@Test(expected = InvalidParameterValueException.class)
public void testDuplicateNFSMountOptions() {
String nfsMountOpts = "vers=4.1, nconnect=4,vers=4.2";
Map<String, String> details = new HashMap<>();
details.put(ApiConstants.NFS_MOUNT_OPTIONS, nfsMountOpts);
storageManagerImpl.checkNFSMountOptionsForCreate(details, Hypervisor.HypervisorType.KVM, "nfs");
}
@Test(expected = InvalidParameterValueException.class)
public void testInvalidNFSMountOptions() {
String nfsMountOpts = "vers=4.1=2,";
Map<String, String> details = new HashMap<>();
details.put(ApiConstants.NFS_MOUNT_OPTIONS, nfsMountOpts);
StoragePoolVO pool = new StoragePoolVO();
pool.setHypervisor(Hypervisor.HypervisorType.KVM);
pool.setPoolType(Storage.StoragePoolType.NetworkFilesystem);
pool.setStatus(StoragePoolStatus.Maintenance);
Long accountId = 1L;
Mockito.when(accountManager.isRootAdmin(accountId)).thenReturn(true);
storageManagerImpl.checkNFSMountOptionsForUpdate(details, pool, accountId);
}
@Test
public void testGetStoragePoolMountOptionsNotNFS() {
StoragePoolVO pool = new StoragePoolVO();
pool.setPoolType(Storage.StoragePoolType.FiberChannel);
Pair<Map<String, String>, Boolean> details = storageManagerImpl.getStoragePoolNFSMountOpts(pool, null);
Assert.assertEquals(details.second(), false);
Assert.assertEquals(details.first(), null);
}
@Test
public void testGetStoragePoolMountOptions() {
Long poolId = 1L;
String key = "nfsmountopts";
String value = "vers=4.1,nconnect=2";
StoragePoolDetailVO nfsMountOpts = new StoragePoolDetailVO(poolId, key, value, true);
StoragePoolVO pool = new StoragePoolVO();
pool.setId(poolId);
pool.setPoolType(Storage.StoragePoolType.NetworkFilesystem);
Mockito.when(storagePoolDetailsDao.findDetail(poolId, ApiConstants.NFS_MOUNT_OPTIONS)).thenReturn(nfsMountOpts);
Pair<Map<String, String>, Boolean> details = storageManagerImpl.getStoragePoolNFSMountOpts(pool, null);
Assert.assertEquals(details.second(), true);
Assert.assertEquals(details.first().get(key), value);
}
@Test
public void testGetStoragePoolMountFailureReason() {
String error = "Mount failed on kvm host. An incorrect mount option was specified.\nIncorrect mount option.";
String failureReason = storageManagerImpl.getStoragePoolMountFailureReason(error);
Assert.assertEquals(failureReason, "An incorrect mount option was specified");
}
}

View File

@ -0,0 +1,184 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
from marvin.cloudstackTestCase import cloudstackTestCase
from marvin.cloudstackAPI import *
from marvin.lib.utils import *
from marvin.lib.base import *
from marvin.lib.common import (list_clusters, list_hosts, list_storage_pools)
import xml.etree.ElementTree as ET
from lxml import etree
from nose.plugins.attrib import attr
class TestNFSMountOptsKVM(cloudstackTestCase):
""" Test cases for host HA using KVM host(s)
"""
@classmethod
def setUpClass(cls):
testClient = super(TestNFSMountOptsKVM, cls).getClsTestClient()
cls.apiclient = testClient.getApiClient()
cls.cluster = list_clusters(cls.apiclient)[0]
cls.host = cls.getHost(cls)
cls.storage_pool = cls.getPrimaryStorage(cls, cls.cluster.id)
cls.hostConfig = cls.config.__dict__["zones"][0].__dict__["pods"][0].__dict__["clusters"][0].__dict__["hosts"][0].__dict__
cls.cluster_id = cls.host.clusterid
cls.sshClient = SshClient(
host=cls.host.ipaddress,
port=22,
user=cls.hostConfig['username'],
passwd=cls.hostConfig['password'])
cls.version = cls.getNFSMountOptionForPool(cls, "vers", cls.storage_pool.id)
if (cls.version == None):
raise cls.skipTest("Storage pool not associated with the host")
def tearDown(self):
nfsMountOpts = "vers=" + self.version
details = [{'nfsmountopts': nfsMountOpts}]
self.changeNFSOptions(details)
pass
def getHost(self):
response = list_hosts(
self.apiclient,
type='Routing',
hypervisor='kvm',
state='Up',
id=None
)
if response and len(response) > 0:
self.host = response[0]
return self.host
raise self.skipTest("Not enough KVM hosts found, skipping NFS options test")
def getPrimaryStorage(self, clusterId):
response = list_storage_pools(
self.apiclient,
clusterid=clusterId,
type='NetworkFilesystem',
state='Up',
id=None,
)
if response and len(response) > 0:
return response[0]
raise self.skipTest("Not enough KVM hosts found, skipping NFS options test")
def getNFSMountOptionsFromVirsh(self, poolId):
virsh_cmd = "virsh pool-dumpxml %s" % poolId
xml_res = self.sshClient.execute(virsh_cmd)
xml_as_str = ''.join(xml_res)
self.debug(xml_as_str)
parser = etree.XMLParser(remove_blank_text=True)
root = ET.fromstring(xml_as_str, parser=parser)
mount_opts = root.findall("{http://libvirt.org/schemas/storagepool/fs/1.0}mount_opts")[0]
options_map = {}
for child in mount_opts:
option = child.get('name').split("=")
options_map[option[0]] = option[1]
return options_map
def getUnusedNFSVersions(self, filter):
nfsstat_cmd = "nfsstat -m | sed -n '/%s/{ n; p }'" % filter
nfsstats = self.sshClient.execute(nfsstat_cmd)
versions = {"4.1": 0, "4.2": 0, "3": 0}
for stat in nfsstats:
vers = stat[stat.find("vers"):].split("=")[1].split(",")[0]
versions[vers] += 1
for key in versions:
if versions[key] == 0:
return key
return None
def getNFSMountOptionForPool(self, option, poolId):
nfsstat_cmd = "nfsstat -m | sed -n '/%s/{ n; p }'" % poolId
nfsstat = self.sshClient.execute(nfsstat_cmd)
if (nfsstat == None):
return None
stat = nfsstat[0]
vers = stat[stat.find(option):].split("=")[1].split(",")[0]
return vers
def changeNFSOptions(self, details):
maint_cmd = enableStorageMaintenance.enableStorageMaintenanceCmd()
maint_cmd.id = self.storage_pool.id
storage = StoragePool.list(self.apiclient,
id=self.storage_pool.id
)[0]
if storage.state != "Maintenance":
self.apiclient.enableStorageMaintenance(maint_cmd)
StoragePool.update(self.apiclient,
id=self.storage_pool.id,
details=details
)
store_maint_cmd = cancelStorageMaintenance.cancelStorageMaintenanceCmd()
store_maint_cmd.id = self.storage_pool.id
resp = self.apiclient.cancelStorageMaintenance(store_maint_cmd)
return resp
@attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], required_hardware="true")
def test_primary_storage_nfs_options_kvm(self):
"""
Tests that NFS mount options configured on the primary storage are set correctly on the KVM hypervisor host
"""
version = self.getUnusedNFSVersions(self.storage_pool.ipaddress)
nconnect = None
if version == None:
self.debug("skipping nconnect mount option as there are multiple mounts already present from the nfs server for all versions")
version = self.getUnusedNFSVersions(self.storage_pool.id)
nfsMountOpts = "vers=" + version
else:
nconnect='4'
nfsMountOpts = "vers=" + version + ",nconnect=" + nconnect
details = [{'nfsmountopts': nfsMountOpts}]
resp = self.changeNFSOptions(details)
storage = StoragePool.list(self.apiclient,
id=self.storage_pool.id
)[0]
self.assertEqual(storage.nfsmountopts, nfsMountOpts)
options = self.getNFSMountOptionsFromVirsh(self.storage_pool.id)
self.assertEqual(options["vers"], version)
if (nconnect != None):
self.assertEqual(options["nconnect"], nconnect)
@attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], required_hardware="true")
def test_primary_storage_incorrect_nfs_options_kvm(self):
"""
Tests that incorrect NFS mount options leads to exception when maintenance mode is cancelled
"""
nfsMountOpts = "version=4.1"
details = [{'nfsmountopts': nfsMountOpts}]
try:
resp = self.changeNFSOptions(details)
except Exception:
storage = StoragePool.list(self.apiclient,
id=self.storage_pool.id
)[0]
self.assertEqual(storage.state, "Maintenance")
else:
self.fail("Incorrect NFS mount option should throw error while mounting")

View File

@ -118,6 +118,7 @@
"label.action.edit.domain": "Edit domain",
"label.action.edit.instance": "Edit Instance",
"label.action.edit.iso": "Edit ISO",
"label.action.edit.nfs.options": "Edit NFS mount options",
"label.action.edit.template": "Edit Template",
"label.action.edit.zone": "Edit zone",
"label.action.enable.two.factor.authentication": "Enabled Two factor authentication",
@ -1427,6 +1428,7 @@
"label.newname": "New name",
"label.next": "Next",
"label.nfs": "NFS",
"label.nfsmountopts": "NFS mount options",
"label.nfsserver": "NFS server",
"label.nic": "NIC",
"label.nicadaptertype": "NIC adapter type",
@ -2457,6 +2459,7 @@
"message.action.disable.zone": "Please confirm that you want to disable this zone.",
"message.action.download.iso": "Please confirm that you want to download this ISO.",
"message.action.download.template": "Please confirm that you want to download this Template.",
"message.action.edit.nfs.mount.options": "Changes to NFS mount options will only take affect on cancelling maintenance mode which will cause the storage pool to be remounted on all KVM hosts with the new mount options.",
"message.action.enable.cluster": "Please confirm that you want to enable this cluster.",
"message.action.enable.disk.offering": "Please confirm that you want to enable this disk offering.",
"message.action.enable.service.offering": "Please confirm that you want to enable this service offering.",
@ -3035,6 +3038,7 @@
"message.network.updateip": "Please confirm that you would like to change the IP address for this NIC on the Instance.",
"message.network.usage.info.data.points": "Each data point represents the difference in data traffic since the last data point.",
"message.network.usage.info.sum.of.vnics": "The Network usage shown is made up of the sum of data traffic from all the vNICs in the Instance.",
"message.nfs.mount.options.description": "Comma separated list of NFS mount options for KVM hosts. Supported options : vers=[3,4.0,4.1,4.2], nconnect=[1...16]",
"message.no.data.to.show.for.period": "No data to show for the selected period.",
"message.no.description": "No description entered.",
"message.offering.internet.protocol.warning": "WARNING: IPv6 supported Networks use static routing and will require upstream routes to be configured manually.",
@ -3206,6 +3210,7 @@
"message.success.disable.saml.auth": "Successfully disabled SAML authorization",
"message.success.disable.vpn": "Successfully disabled VPN",
"message.success.edit.acl": "Successfully edited ACL rule",
"message.success.edit.primary.storage": "Successfully edited Primary Storage",
"message.success.edit.rule": "Successfully edited rule",
"message.success.enable.saml.auth": "Successfully enabled SAML Authorization",
"message.success.import.instance": "Successfully imported Instance",

View File

@ -35,7 +35,7 @@ export default {
fields.push('zonename')
return fields
},
details: ['name', 'id', 'ipaddress', 'type', 'scope', 'tags', 'path', 'provider', 'hypervisor', 'overprovisionfactor', 'disksizetotal', 'disksizeallocated', 'disksizeused', 'clustername', 'podname', 'zonename', 'created'],
details: ['name', 'id', 'ipaddress', 'type', 'nfsmountopts', 'scope', 'tags', 'path', 'provider', 'hypervisor', 'overprovisionfactor', 'disksizetotal', 'disksizeallocated', 'disksizeused', 'clustername', 'podname', 'zonename', 'created'],
related: [{
name: 'volume',
title: 'label.volumes',
@ -90,7 +90,8 @@ export default {
icon: 'edit-outlined',
label: 'label.edit',
dataView: true,
args: ['name', 'tags', 'istagarule', 'capacitybytes', 'capacityiops']
popup: true,
component: shallowRef(defineAsyncComponent(() => import('@/views/infra/UpdatePrimaryStorage.vue')))
},
{
api: 'updateStoragePool',

View File

@ -178,6 +178,17 @@
<a-input v-model:value="form.path" :placeholder="$t('message.path.description')"/>
</a-form-item>
</div>
<div
v-if="form.protocol === 'nfs' &&
((form.scope === 'zone' && (form.hypervisor === 'KVM' || form.hypervisor === 'Simulator')) ||
(form.scope === 'cluster' && (hypervisorType === 'KVM' || hypervisorType === 'Simulator')))">
<a-form-item name="nfsMountOpts" ref="nfsMountOpts">
<template #label>
<tooltip-label :title="$t('label.nfsmountopts')" :tooltip="$t('message.nfs.mount.options.description')"/>
</template>
<a-input v-model:value="form.nfsMountOpts" :placeholder="$t('message.nfs.mount.options.description')" />
</a-form-item>
</div>
<div v-if="form.protocol === 'SMB'">
<a-form-item :label="$t('label.smbusername')" name="smbUsername" ref="smbUsername">
<a-input v-model:value="form.smbUsername"/>
@ -794,6 +805,9 @@ export default {
var url = ''
if (values.protocol === 'nfs') {
url = this.nfsURL(server, path)
if (values.nfsMountOpts) {
params['details[0].nfsmountopts'] = values.nfsMountOpts
}
} else if (values.protocol === 'SMB') {
url = this.smbURL(server, path)
const smbParams = {

View File

@ -0,0 +1,195 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
<template>
<div class="form-layout" v-ctrl-enter="handleSubmit">
<a-spin :spinning="loading">
<a-form
:ref="formRef"
:model="form"
:rules="rules"
@finish="handleSubmit"
layout="vertical"
>
<a-form-item name="name" ref="name">
<template #label>
<tooltip-label :title="$t('label.name')" :tooltip="apiParams.name.description"/>
</template>
<a-input
v-model:value="form.name"
:placeholder="apiParams.name.description"
v-focus="true" />
</a-form-item>
<a-form-item name="tags" ref="tags">
<template #label>
<tooltip-label :title="$t('label.tags')" :tooltip="apiParams.tags.description"/>
</template>
<a-input
v-model:value="form.tags"
:placeholder="apiParams.tags.description"
v-focus="true" />
</a-form-item>
<a-form-item name="isTagARule" ref="isTagARule">
<template #label>
<tooltip-label :title="$t('label.istagarule')" :tooltip="apiParams.istagarule.description"/>
</template>
<a-switch v-model:checked="form.isTagARule" />
</a-form-item>
<a-form-item name="capacityBytes" ref="capacityBytes">
<template #label>
<tooltip-label :title="$t('label.capacitybytes')" :tooltip="apiParams.capacitybytes.description"/>
</template>
<a-input
v-model:value="form.capacityBytes"
:placeholder="apiParams.capacitybytes.description"
v-focus="true" />
</a-form-item>
<a-form-item name="capacityIOPS" ref="capacityIOPS">
<template #label>
<tooltip-label :title="$t('label.capacityiops')" :tooltip="apiParams.capacityiops.description"/>
</template>
<a-input
v-model:value="form.capacityIOPS"
:placeholder="apiParams.capacityiops.description"
v-focus="true" />
</a-form-item>
<br>
<a-form-item name="nfsMountOpts" ref="nfsMountOpts" v-if="canUpdateNFSMountOpts">
<template #label>
<tooltip-label :title="$t('label.nfsmountopts')" :tooltip="$t('message.nfs.mount.options.description')"/>
</template>
<a-alert type="warning">
<template #message>
<span v-html="$t('message.action.edit.nfs.mount.options')"></span>
</template>
</a-alert>
<br>
<a-input
v-model:value="form.nfsMountOpts"
:placeholder="$t('message.nfs.mount.options.description')"
v-focus="true" />
</a-form-item>
<div :span="24" class="action-button">
<a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
<a-button :loading="loading" ref="submit" type="primary" @click="handleSubmit">{{ $t('label.ok') }}</a-button>
</div>
</a-form>
</a-spin>
</div>
</template>
<script>
import { ref, reactive, toRaw } from 'vue'
import { api } from '@/api'
import { isAdmin } from '@/role'
import { mixinForm } from '@/utils/mixin'
import TooltipLabel from '@/components/widgets/TooltipLabel'
export default {
name: 'UpdateStoragePool',
mixins: [mixinForm],
components: {
TooltipLabel
},
props: {
resource: {
type: Object,
required: true
}
},
data () {
return {
loading: false
}
},
beforeCreate () {
this.apiParams = this.$getApiParams('updateStoragePool')
},
created () {
this.initForm()
this.form.name = this.resource.name
},
computed: {
canUpdateNFSMountOpts () {
if (isAdmin() === false) return false
if (this.resource.type === 'NetworkFilesystem' && this.resource.state === 'Maintenance' &&
(this.resource.hypervisor === 'KVM' || this.resource.hypervisor === 'Simulator')) {
return true
}
return false
}
},
methods: {
initForm () {
this.formRef = ref()
this.form = reactive({ })
this.rules = reactive({ })
},
isAdmin () {
return isAdmin()
},
handleSubmit (e) {
if (this.loading) return
this.formRef.value.validate().then(() => {
const formRaw = toRaw(this.form)
const values = this.handleRemoveFields(formRaw)
var params = {
id: this.resource.id,
name: values.name,
tags: values.tags,
istagarule: values.isTagARule,
capacitybytes: values.capacityBytes,
capacityiops: values.capacityIOPS
}
if (values.nfsMountOpts) {
params['details[0].nfsmountopts'] = values.nfsMountOpts
}
this.updateStoragePool(params)
}).catch(error => {
this.formRef.value.scrollToField(error.errorFields[0].name)
})
},
closeAction () {
this.$emit('close-action')
},
updateStoragePool (args) {
api('updateStoragePool', args).then(json => {
this.$message.success(`${this.$t('message.success.edit.primary.storage')}: ${this.resource.name}`)
this.$emit('refresh-data')
this.closeAction()
}).catch(error => {
this.$notifyError(error)
}).finally(() => {
this.loading = false
})
}
}
}
</script>
<style scoped lang="less">
.form-layout {
width: 60vw;
@media (min-width: 500px) {
width: 450px;
}
}
</style>

View File

@ -530,6 +530,15 @@ export default {
primaryStorageProtocol: ['vmfs', 'datastorecluster']
}
},
{
title: 'label.nfsmountopts',
key: 'primaryStorageNFSMountOptions',
required: false,
display: {
primaryStorageProtocol: 'nfs',
hypervisor: ['KVM', 'Simulator']
}
},
{
title: 'label.resourcegroup',
key: 'primaryStorageLinstorResourceGroup',

View File

@ -1387,6 +1387,7 @@ export default {
path = '/' + path
}
url = this.nfsURL(server, path)
params['details[0].nfsmountopts'] = this.prefillContent.primaryStorageNFSMountOptions
} else if (protocol === 'SMB') {
let path = this.prefillContent?.primaryStoragePath || ''
if (path.substring(0, 1) !== '/') {