Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -658,10 +658,14 @@ public Answer copyVolumeFromPrimaryToSecondary(final CopyCommand cmd) {
final String secondaryStorageUrl = nfsStore.getUrl();
KVMStoragePool secondaryStoragePool = null;

boolean srcConnected = false;
try {
final String volumeName = UUID.randomUUID().toString();

final String destVolumeName = volumeName + "." + ImageFormat.QCOW2.getFileExtension();
// connect: a detached source volume may have no device yet on shared pools
srcConnected = storagePoolMgr.connectPhysicalDisk(
primaryStore.getPoolType(), primaryStore.getUuid(), srcVolumePath, null);
final KVMPhysicalDisk volume = storagePoolMgr.getPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), srcVolumePath);
volume.setFormat(PhysicalDiskFormat.valueOf(srcFormat.toString()));

Expand All @@ -680,6 +684,9 @@ public Answer copyVolumeFromPrimaryToSecondary(final CopyCommand cmd) {
} finally {
srcVol.clearPassphrase();
destVol.clearPassphrase();
if (srcConnected) {
storagePoolMgr.disconnectPhysicalDisk(primaryStore.getPoolType(), primaryStore.getUuid(), srcVolumePath);
}
if (secondaryStoragePool != null) {
storagePoolMgr.deleteStoragePool(secondaryStoragePool.getType(), secondaryStoragePool.getUuid());
}
Expand Down
14 changes: 14 additions & 0 deletions plugins/storage/volume/linstor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ All notable changes to Linstor CloudStack plugin will be documented in this file
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2026-07-31]

### Added

- Support for shared (thick LVM) storage pools: resources on a shared LUN are
activated on at most one node; live migration uses the LINSTOR 1.29
make-available/unmake-available API (with fallback to the previous handling
for older controllers), snapshots work on thick LVM pools, and resources of
stopped VMs are activated on demand for start, snapshot backup and revert

### Changed

- java-linstor client updated to 0.8.0 (LINSTOR REST API 1.29.0)

## [2026-06-24]

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ private String zfsSnapdev(boolean hide, String zfsUrl) {
return script.execute();
}

private String lvmSetActive(boolean activate, String devMapperPath) {
// lvm resolves /dev/mapper/vg-lv names textually, works also for inactive LVs
Script script = new Script("lvchange", Duration.millis(30000));
if (activate) {
script.add("-ay");
script.add("-K"); // snapshot LVs carry the skip-activation flag
} else {
script.add("-an");
}
// never talk to dmeventd: registering the snapshot for monitoring can block lvchange
// indefinitely (and with it the whole LVM lock on the node); a full-size thick COW
// snapshot cannot overflow, so monitoring is not needed for the backup window
script.add("--monitor");
script.add("n");
script.add(devMapperPath);
return script.execute();
}

private String qemuShrink(String path, long sizeByte, long timeout) {
Script qemuImg = new Script("qemu-img", Duration.millis(timeout));
qemuImg.add("resize");
Expand Down Expand Up @@ -146,6 +164,7 @@ public CopyCmdAnswer execute(LinstorBackupSnapshotCommand cmd, LibvirtComputingR
final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr();
KVMStoragePool linstorPool = storagePoolMgr.getStoragePool(Storage.StoragePoolType.Linstor, src.getDataStore().getUuid());
boolean zfsHidden = false;
String lvmSnapDev = null; // vg/lv we activated for the copy, deactivated on cleanup
String srcPath = src.getPath();

if (linstorPool == null) {
Expand All @@ -169,6 +188,12 @@ public CopyCmdAnswer execute(LinstorBackupSnapshotCommand cmd, LibvirtComputingR
return new CopyCmdAnswer("Unable to unhide zfs snapshot device.");
}
srcPath = "/dev/zvol/" + srcPath.substring(6);
} else if (srcPath.startsWith("/dev/mapper/") && !new File(srcPath).exists()) {
// thick LVM snapshot LVs are created inactive with the skip-activation flag
if (lvmSetActive(true, srcPath) != null) {
return new CopyCmdAnswer("Unable to activate LVM snapshot device " + srcPath);
}
lvmSnapDev = srcPath;
}

secondaryPool = storagePoolMgr.getStoragePoolByURI(dstDataStore.getUrl());
Expand Down Expand Up @@ -205,6 +230,9 @@ public CopyCmdAnswer execute(LinstorBackupSnapshotCommand cmd, LibvirtComputingR
if (zfsHidden) {
zfsSnapdev(true, src.getPath());
}
if (lvmSnapDev != null) {
lvmSetActive(false, lvmSnapDev);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,14 @@
import com.linbit.linstor.api.model.ApiCallRcList;
import com.linbit.linstor.api.model.Node;
import com.linbit.linstor.api.model.Properties;
import com.linbit.linstor.api.model.ProviderKind;
import com.linbit.linstor.api.model.Resource;
import com.linbit.linstor.api.model.ResourceConnectionModify;
import com.linbit.linstor.api.model.ResourceCreate;
import com.linbit.linstor.api.model.ResourceDefinition;
import com.linbit.linstor.api.model.ResourceDefinitionModify;
import com.linbit.linstor.api.model.ResourceGroupSpawn;
import com.linbit.linstor.api.model.ResourceMakeAvailable;
import com.linbit.linstor.api.model.ResourceWithVolumes;
import com.linbit.linstor.api.model.StoragePool;
import com.linbit.linstor.api.model.VolumeDefinition;

import java.io.File;
Expand Down Expand Up @@ -239,21 +238,22 @@ public KVMPhysicalDisk createPhysicalDisk(String name, KVMStoragePool pool, Qemu

String foundRscName = resourceDefinition != null ? resourceDefinition.getName() : rscName;

makeResourceAvailable(api, foundRscName, false);

// query linstor for the device path
List<ResourceWithVolumes> resources = api.viewResources(
Collections.emptyList(),
Collections.singletonList(localNodeName),
Collections.singletonList(foundRscName),
Collections.emptyList(),
null,
null,
null);

makeResourceAvailable(api, foundRscName, false);

if (!resources.isEmpty() && !resources.get(0).getVolumes().isEmpty()) {
final String devPath = LinstorUtil.getDevicePathFromResource(resources.get(0));
logger.info("Linstor: Created drbd device: " + devPath);
final KVMPhysicalDisk kvmDisk = new KVMPhysicalDisk(devPath, name, pool);
final String diskName = foundRscName.substring(LinstorUtil.RSC_PREFIX.length());
final KVMPhysicalDisk kvmDisk = new KVMPhysicalDisk(devPath, diskName, pool);
kvmDisk.setFormat(QemuImg.PhysicalDiskFormat.RAW);
long allocatedKib = resources.get(0).getVolumes().get(0).getAllocatedSizeKib() != null ?
resources.get(0).getVolumes().get(0).getAllocatedSizeKib() : 0;
Expand Down Expand Up @@ -321,6 +321,37 @@ private void allow2PrimariesIfInUse(DevelopersApi api, String rscName) throws Ap
}
}

private boolean usesDrbd(DevelopersApi api, String rscName) throws ApiException {
List<ResourceWithVolumes> rscs = api.viewResources(
Collections.emptyList(),
Collections.singletonList(rscName),
null,
null,
null,
null);

for (ResourceWithVolumes rsc : rscs) {
if (rsc.getLayerObject().getDrbd() != null) {
return true;
}
}
return false;
}


private String findCorrectTemplateName(DevelopersApi api, String rscName, KVMStoragePool pool ) {
String templateName = rscName;
LinstorStoragePool lpool = (LinstorStoragePool) pool;
try {
ResourceDefinition resourceDefinition = LinstorUtil.findResourceDefinition(
api, rscName, lpool.getResourceGroup());
templateName = resourceDefinition != null ? resourceDefinition.getName() : rscName;
} catch (ApiException e) {
logger.error("Error finding resource definition for {}", rscName);
}
return templateName;
}

@Override
public boolean connectPhysicalDisk(
String volumePath, KVMStoragePool pool, Map<String, String> details, boolean isVMMigration)
Expand All @@ -332,21 +363,51 @@ public boolean connectPhysicalDisk(
}

final DevelopersApi api = getLinstorAPI(pool);
final boolean liveMigrateApi = LinstorUtil.supportsLiveMigrateApi(api);
String rscName;
boolean usesDrbd = false;
try
{
rscName = getLinstorRscName(volumePath);

ResourceMakeAvailable rma = new ResourceMakeAvailable();
ApiCallRcList answers = api.resourceMakeAvailableOnNode(rscName, localNodeName, rma);
checkLinstorAnswersThrow(answers);
rscName = findCorrectTemplateName(api, getLinstorRscName(volumePath), pool);

if (liveMigrateApi) {
// controller >= 1.29 prepares the live migration itself:
// dual-primary for DRBD, activation on both nodes for shared storage pools
ResourceMakeAvailable rma = new ResourceMakeAvailable();
if (isVMMigration) {
// only set when needed: older controllers reject the unknown property
rma.autoManageDualPrimary(true);
}
ApiCallRcList answers = api.resourceMakeAvailableOnNode(rscName, localNodeName, rma);
checkLinstorAnswersThrow(answers);
} else {
usesDrbd = usesDrbd(api, rscName);

if (isVMMigration && !usesDrbd) {
// create the resource on the new node, noop if already exists
ResourceCreate rc = new ResourceCreate();
Resource rsc = new Resource();
rsc.setNodeName(localNodeName);
rc.setResource(rsc);
ApiCallRcList answers = api.resourceCreateOnNode(rscName, localNodeName, rc);
checkLinstorAnswersThrow(answers);
// activate resource if there already was an inactive resource here
answers = api.activateRsc(rscName, localNodeName);
checkLinstorAnswersThrow(answers);
} else {
// DRBD resources just work with a diskless and don't have the activate/deactivate concept
ResourceMakeAvailable rma = new ResourceMakeAvailable();
ApiCallRcList answers = api.resourceMakeAvailableOnNode(rscName, localNodeName, rma);
checkLinstorAnswersThrow(answers);
}
}

} catch (ApiException apiEx) {
logger.error(apiEx);
throw new CloudRuntimeException(apiEx.getBestMessage(), apiEx);
}

if (isVMMigration) {
if (!liveMigrateApi && isVMMigration && usesDrbd) {
try {
allow2PrimariesIfInUse(api, rscName);
} catch (ApiException apiEx) {
Expand Down Expand Up @@ -393,13 +454,48 @@ private void removeTwoPrimariesProps(DevelopersApi api, String inUseNode, String
removeTwoPrimariesRcProps(api, rscName, inUseNode, deleteProps);
}

/**
* Revert a make-available after e.g. a live migration: removes dual-primary settings and
* deletes the resource on this node if that is possible without losing data
* (diskless or a redundant shared storage pool copy; tiebreaker and diskful resources are kept).
* No-op if the resource isn't deployed here, so it is safe to call on every disconnect.
*/
private void unmakeAvailable(DevelopersApi api, String rscName) {
try {
ApiCallRcList answers = api.resourceUnmakeAvailableOnNode(rscName, localNodeName);
if (answers.hasError()) {
// e.g. FAIL_IN_USE: the resource is still in use on this node and is kept
logger.warn("Linstor: unmake-available {} on {}: {}",
rscName, localNodeName, LinstorUtil.getBestErrorMessage(answers));
} else {
logLinstorAnswers(answers);
}
} catch (ApiException apiEx) {
logger.error(apiEx.getBestMessage());
// do not fail here, cleaning up after a disconnect isn't fatal
}
}

private void deleteUnusedSharedResources(DevelopersApi api, Resource nodeRsc) {
try {
List<Resource> rscs = api.resourceList(nodeRsc.getName(), null, null);
if (rscs != null && rscs.stream()
.filter(rsc -> rsc.getFlags() == null || !rsc.getFlags().contains(ApiConsts.FLAG_RSC_INACTIVE))
.count() > 1) {
api.resourceDelete(nodeRsc.getName(), localNodeName, true);
}
} catch (ApiException apiEx) {
logger.error(apiEx.getBestMessage());
}
}

private boolean tryDisconnectLinstor(String volumePath, KVMStoragePool pool)
{
if (volumePath == null) {
return false;
}

logger.debug("Linstor: Using storage pool: " + pool.getUuid());
logger.debug("Linstor: Using storage pool: {}", pool.getUuid());
final DevelopersApi api = getLinstorAPI(pool);

Optional<ResourceWithVolumes> optRsc;
Expand All @@ -423,33 +519,42 @@ private boolean tryDisconnectLinstor(String volumePath, KVMStoragePool pool)

if (optRsc.isPresent()) {
Resource rsc = optRsc.get();
try {
String inUseNode = LinstorUtil.isResourceInUse(api, rsc.getName());
if (inUseNode != null && !inUseNode.equalsIgnoreCase(localNodeName)) {
removeTwoPrimariesProps(api, inUseNode, rsc.getName());

if (LinstorUtil.supportsLiveMigrateApi(api)) {
unmakeAvailable(api, rsc.getName());
// if it is a non DRBD resource(shared) we need to check if we are the last active one
// on live migrate, we can have 2 active resources at the same time
} else if (rsc.getLayerObject().getDrbd() == null) {
deleteUnusedSharedResources(api, rsc);
} else {
try {
String inUseNode = LinstorUtil.isResourceInUse(api, rsc.getName());
if (inUseNode != null && !inUseNode.equalsIgnoreCase(localNodeName)) {
removeTwoPrimariesProps(api, inUseNode, rsc.getName());
}
} catch (ApiException apiEx) {
logger.error(apiEx.getBestMessage());
// do not fail here as removing allow-two-primaries property or deleting diskless isn't fatal
}
} catch (ApiException apiEx) {
logger.error(apiEx.getBestMessage());
// do not fail here as removing allow-two-primaries property or deleting diskless isn't fatal
}

try {
// if diskless resource remove it, in the worst case it will be transformed to a tiebreaker
if (rsc.getFlags() != null &&
rsc.getFlags().contains(ApiConsts.FLAG_DRBD_DISKLESS) &&
!rsc.getFlags().contains(ApiConsts.FLAG_TIE_BREAKER)) {
ApiCallRcList delAnswers = api.resourceDelete(rsc.getName(), localNodeName, true);
logLinstorAnswers(delAnswers);
try {
// if diskless resource remove it, in the worst case it will be transformed to a tiebreaker
if (rsc.getFlags() != null &&
rsc.getFlags().contains(ApiConsts.FLAG_DRBD_DISKLESS) &&
!rsc.getFlags().contains(ApiConsts.FLAG_TIE_BREAKER)) {
ApiCallRcList delAnswers = api.resourceDelete(rsc.getName(), localNodeName, true);
logLinstorAnswers(delAnswers);
}
} catch (ApiException apiEx) {
logger.error(apiEx.getBestMessage());
// do not fail here as removing allow-two-primaries property or deleting diskless isn't fatal
}
} catch (ApiException apiEx) {
logger.error(apiEx.getBestMessage());
// do not fail here as removing allow-two-primaries property or deleting diskless isn't fatal
}

return true;
}

logger.warn("Linstor: Couldn't find resource for this path: " + volumePath);
logger.warn("Linstor: Couldn't find resource for this path: {}", volumePath);
return false;
}

Expand Down Expand Up @@ -654,14 +759,14 @@ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk disk, String name, KVMSt
name, QemuImg.PhysicalDiskFormat.RAW, provisioningType, disk.getVirtualSize(), null);

final DevelopersApi api = getLinstorAPI(destPools);
setRscDfnAuxProperties(api, disk, destPools, name);
setRscDfnAuxProperties(api, disk, destPools, dstDisk.getName());

logger.debug("Linstor.copyPhysicalDisk: dstPath: {}", dstDisk.getPath());
final QemuImgFile destFile = new QemuImgFile(dstDisk.getPath());
destFile.setFormat(dstDisk.getFormat());
destFile.setSize(disk.getVirtualSize());

boolean zeroedDevice = LinstorUtil.resourceSupportZeroBlocks(destPools, getLinstorRscName(name));
boolean zeroedDevice = LinstorUtil.resourceSupportZeroBlocks(destPools, getLinstorRscName(dstDisk.getName()));
try {
final QemuImg qemu = new QemuImg(timeout, zeroedDevice, true);
qemu.convert(srcFile, destFile);
Expand Down Expand Up @@ -768,12 +873,7 @@ public long getAvailable(LinstorStoragePool pool) {
DevelopersApi linstorApi = getLinstorAPI(pool);
final String rscGroupName = pool.getResourceGroup();
try {
List<StoragePool> storagePools = LinstorUtil.getRscGroupStoragePools(linstorApi, rscGroupName);

final long free = storagePools.stream()
.filter(sp -> sp.getProviderKind() != ProviderKind.DISKLESS)
.mapToLong(sp -> sp.getFreeCapacity() != null ? sp.getFreeCapacity() : 0L).sum() * 1024; // linstor uses KiB

final long free = LinstorUtil.getFreeCapacityBytes(linstorApi, rscGroupName);
logger.debug("Linstor: getAvailable() -> " + free);
return free;
} catch (ApiException apiEx) {
Expand All @@ -786,13 +886,7 @@ public long getUsed(LinstorStoragePool pool) {
DevelopersApi linstorApi = getLinstorAPI(pool);
final String rscGroupName = pool.getResourceGroup();
try {
List<StoragePool> storagePools = LinstorUtil.getRscGroupStoragePools(linstorApi, rscGroupName);

final long used = storagePools.stream()
.filter(sp -> sp.getProviderKind() != ProviderKind.DISKLESS)
.mapToLong(sp -> sp.getTotalCapacity() != null && sp.getFreeCapacity() != null ?
sp.getTotalCapacity() - sp.getFreeCapacity() : 0L)
.sum() * 1024; // linstor uses Kib
final long used = LinstorUtil.getUsedCapacityBytes(linstorApi, rscGroupName);
logger.debug("Linstor: getUsed() -> " + used);
return used;
} catch (ApiException apiEx) {
Expand Down
Loading
Loading