This is an automated email from the ASF dual-hosted git repository.
nvazquez pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/main by this push:
new e9af62c4ad5 flasharray/kvm/adaptive: NVMe-TCP transport for FlashArray
primary storage (#13061)
e9af62c4ad5 is described below
commit e9af62c4ad5d1b6af40dc059360821e367257ea8
Author: Eugenio Grosso <[email protected]>
AuthorDate: Mon Oct 26 12:32:19 2026 +0100
flasharray/kvm/adaptive: NVMe-TCP transport for FlashArray primary storage
(#13061)
* adapter: add NVMETCP address type and FlashArrayConnection.nsid
Preparatory data-model changes for NVMe-TCP support on the adaptive
storage framework. No behaviour change for existing Fibre Channel
users - the extra enum value, field, and getter/setter are only
exercised by callers that explicitly use them.
ProviderVolume.AddressType gains a NVMETCP value alongside FIBERWWN,
so adapters can declare that a volume is addressed by an NVMe EUI-128
(NGUID) rather than a SCSI WWN.
FlashArrayVolume.getAddress() produces the NGUID layout expected by
the Linux kernel for a FlashArray NVMe namespace:
00 + serial[0:14] + 24a937 (Pure 6-hex OUI) + serial[14:24]
which matches the /dev/disk/by-id/nvme-eui.<id> symlink emitted by
udev. Fibre Channel callers (addressType != NVMETCP) still get the
existing 6 + 24a9370 + serial form.
FlashArrayConnection gains a nsid field to carry the namespace id the
FlashArray REST API attaches to host-group-scoped NVMe connections,
when it is present.
* flasharray: support NVMe-TCP transport
Teach FlashArrayAdapter to talk to a pool over NVMe over TCP instead of
Fibre Channel.
The transport is selected from a new transport= option on the storage
pool URL (or the equivalent storage_pool_details entry), e.g.
https://user:pass@fa:443/api?pod=cs&transport=nvme-tcp&hostgroup=cluster1
Defaults remain Fibre Channel / WWN addressing when transport is absent
or anything other than nvme-tcp, so existing FC pools are unaffected.
Beyond the transport parsing itself the adapter now:
* Tracks a per-pool volumeAddressType (AddressType.NVMETCP or
FIBERWWN) and stamps every volume it hands back to the framework
with it (withAddressType), so the adaptive driver path stores the
correct type=... field in the CloudStack volume path (used later
by the KVM driver to locate the device).
* Attaches pod-backed NVMe-TCP volumes at the host-group level
(POST /connections?host_group_names=...) instead of per-host, so
the array assigns a consistent NSID to every member host; falls
back to per-host attach for FC or when no hostgroup is configured.
* Tolerates a missing nsid in the FlashArray connections response
for NVMe-TCP - Purity does not return one for host-group NVMe
connections; the namespace is identified on the host by EUI-128
from FlashArrayVolume.getAddress(), so a placeholder value is
returned to the caller purely for informational tracking.
* Resolves NVMETCP addresses back to volumes in getVolumeByAddress
by reversing the EUI-128 layout (strip optional eui. prefix, drop
leading 00 and the embedded Pure OUI).
* Indexes NVMe connections in getConnectionIdMap by host name (the
array returns one entry per host inside a host-group connection),
so connid.<hostname> tokens in the path still match in
parseAndValidatePath on the KVM side.
Followed by a matching adaptive/KVM driver change (separate commit).
* storage: add NVMeTCP storage pool type
NVMe-oF over TCP (NVMe-TCP) is conceptually a separate storage fabric
from Fibre Channel / iSCSI: it speaks the NVMe command set rather than
SCSI, identifies namespaces by EUI-128 NGUIDs rather than WWNs, and on
Linux is multipathed natively by the nvme driver rather than by
device-mapper multipath. Giving it its own StoragePoolType lets the
KVM agent dispatch the adaptive driver to a dedicated NVMe-oF adapter
(added in the next commit) without polluting the existing Fibre Channel
code path.
The new value is wired into the same format-routing and derivePath
fall-through paths that already special-case FiberChannel in
KVMStorageProcessor: NVMe-TCP volumes are also RAW and carry their
device path in DataObjectTO.path rather than in a managedStoreTarget
detail.
* kvm: add MultipathNVMeOFAdapterBase and NVMeTCPAdapter
Introduce an NVMe-over-Fabrics counterpart to the existing
MultipathSCSIAdapterBase / FiberChannelAdapter pair.
NVMe-oF is conceptually distinct from SCSI - it speaks the NVMe command
set, identifies namespaces by EUI-128 NGUIDs, and is multipathed by the
kernel natively rather than by device-mapper - so keeping it out of the
SCSI code path avoids special-casing inside every method that handles
volume paths, connect, disconnect, or size lookup.
MultipathNVMeOFAdapterBase (abstract)
* Parses volume paths of the form
type=NVMETCP; address=<eui>; connid.<host>=<nsid>; ...
into an AddressInfo whose path is
/dev/disk/by-id/nvme-eui.<eui>
which is the udev symlink the kernel emits for every NVMe namespace.
* connectPhysicalDisk polls the udev path and, on every iteration,
triggers nvme ns-rescan on all local NVMe controllers, to cover
target/firmware combinations that do not send an asynchronous event
notification when a new namespace is mapped.
* disconnectPhysicalDisk is a no-op; the kernel drops the namespace
when the target removes the host-group connection. The
ByPath variant only claims paths starting with
/dev/disk/by-id/nvme-eui. so foreign paths still fall through to
other adapters.
* Delegates getPhysicalDisk, isConnected, and getPhysicalDiskSize to
plain test -b / blockdev --getsize64 calls - no SCSI rescan, no dm
multipath, no multipath-map cleanup timer.
* createPhysicalDisk / createTemplateFromDisk / listPhysicalDisks /
copyPhysicalDisk all throw UnsupportedOperationException - these
are the responsibility of the storage provider, not the KVM
adapter, same as the SCSI base.
MultipathNVMeOFPool
* KVMStoragePool mirror of MultipathSCSIPool. Defaults to
Storage.StoragePoolType.NVMeTCP in the parameterless-fallback
constructor.
NVMeTCPAdapter
* Concrete adapter that registers itself for
Storage.StoragePoolType.NVMeTCP via the reflection-based scan in
KVMStoragePoolManager. Carries no logic of its own beyond binding
the base to the pool type.
A similar MultipathNVMeOFAdapterBase-derived NVMeRoCEAdapter (or
NVMeFCAdapter) can later be added by adding one concrete subclass and a
new pool-type value; the base does not assume any particular
fabric-level transport.
* adaptive: pick NVMeTCP pool type when transport=nvme-tcp
The adaptive storage framework hard-coded FiberChannel as the KVM-side
pool type for every provider it fronts. With a separate NVMeTCP pool
type now available (and a dedicated NVMe-oF adapter on the KVM side),
teach the lifecycle to route a pool to the right adapter based on a
transport= URL parameter:
https://user:pass@host/api?...&transport=nvme-tcp
-> StoragePoolType.NVMeTCP -> NVMeTCPAdapter on the KVM host
When the query parameter is absent the default stays FiberChannel, so
existing FC deployments on Primera or FlashArray continue to work
unchanged.
The choice is made in the shared AdaptiveDataStoreLifeCycleImpl rather
than inside each vendor plugin so every adaptive provider (FlashArray,
Primera, any future one) speaks the same configuration vocabulary.
* docs: note NVMe-TCP support on the FlashArray adaptive plugin in
PendingReleaseNotes
* kvm: implement copyPhysicalDisk on MultipathNVMeOFAdapterBase
The NVMe-oF KVM adapter refused every template copy request from the
adaptive storage orchestrator with UnsupportedOperationException, which
made it impossible to use an NVMe-TCP pool as primary storage for a VM
root disk: every deploy that landed a root volume on the pool failed
as soon as CloudStack tried to lay down the template.
Implement it the same way FiberChannel (SCSI) does: the storage provider
creates and connects a raw namespace ahead of time, then the adapter
resolves the host-side /dev/disk/by-id/nvme-eui.<NGUID> path via the
existing getPhysicalDisk plumbing (which will nvme ns-rescan and wait
for the symlink if the kernel has not yet picked it up) and qemu-img
converts the source image into the raw block device.
User-space encrypted source or destination volumes are rejected: the
FlashArray already encrypts at rest and layering qemu-img LUKS on top
of a hostgroup-scoped namespace shared between hosts is not a sensible
layering. Source encryption would also break on migration because the
passphrase does not travel.
With this change a CloudStack KVM VM can have its ROOT volume on an
NVMe-TCP pool (tested end-to-end on 4.23-SNAPSHOT against Purity 6.7.7:
template copy, first boot, live migrate with data disk, VM snapshot
with quiesce, and revert all work).
Signed-off-by: Eugenio Grosso <[email protected]>
* kvm/flasharray: address review feedback on NVMe-TCP PR
Apply the review comments from the first round on #13061:
* FlashArrayAdapter.snapshot() and both getSnapshot() entry points now
wrap the returned FlashArrayVolume in withAddressType(). Without this,
snapshots taken against an NVMe-TCP pool had the constructor-default
AddressType.FIBERWWN and ProviderSnapshot.getAddress() emitted an FC
style WWN instead of the NVMe EUI-128, which the adaptive driver then
persisted as the snapshot path. Verified end-to-end against Purity 6.7.7:
a fresh NVMe-TCP snapshot now lands with install_path starting 006c... ,
matching the source volume's EUI (previously it was 6-24a9370...).
* FlashArrayAdapter.attach() - retry path after 'Connection already
exists' no longer requires a hostgroup-scoped match for NVMe-TCP. If
hostgroup is not configured, or the existing connection is host-scoped,
fall back to matching by host name, same as the Fibre Channel branch.
Also normalize the 'volume lun is not found' message when no
connection list is returned.
* FlashArrayAdapter.attach() - initial 'Volume attach did not return lun
information' exception message now mentions both lun (FC) and nsid
(NVMe-TCP) so the error is not misleading on NVMe deployments.
* FlashArrayAdapter.getVolumeByAddress() - validate the EUI-128 length
before slicing. A short/malformed address used to throw
StringIndexOutOfBoundsException deep inside getFlashArrayItem and be
swallowed as 'not found'; now a clear RuntimeException is raised with
the expected vs actual length.
* FlashArrayVolume.getAddress() - same defensive check when building an
EUI-128 from the FlashArray volume serial; if the serial is shorter
than 24 hex chars, fail with a clear message instead of SIOOBE.
* MultipathNVMeOFAdapterBase.connectPhysicalDisk() - Integer.parseInt of
the STORAGE_POOL_DISK_WAIT detail is now guarded; a non-numeric value
falls back to the default rather than aborting the connect.
* MultipathNVMeOFAdapterBase.rescanAllControllers() - honour the boolean
return from Process.waitFor(). If an nvme ns-rescan invocation does
not complete in NS_RESCAN_TIMEOUT_SECS we destroyForcibly() it, so
hung nvme-cli processes do not accumulate while the namespace poll
loop retries.
* NVMeTCPAdapter - rename LOGGER_NVMETCP to LOGGER to match the naming
convention used in the other KVM adapters.
Signed-off-by: Eugenio Grosso <[email protected]>
* ui: expose NVMe-TCP transport for FlashArray primary storage
AddPrimaryStorage previously pinned the protocol to FiberChannel whenever
the operator picked the FlashArray provider, leaving NVMe-TCP backends only
reachable by hand-crafting the URL with ?transport=nvme-tcp. Surface the choice
in the form:
- protocols dropdown for FlashArray now offers FiberChannel and NVMeTCP
(Primera stays FC-only).
- when NVMeTCP is selected, the submit handler appends transport=nvme-tcp
to the FlashArray URL so the adaptive lifecycle pivot in pickPoolType()
resolves StoragePoolType.NVMeTCP server-side.
- the generic Path field, already hidden for FiberChannel, is also hidden
for NVMeTCP for parity.
Signed-off-by: Eugenio Grosso <[email protected]>
* kvm: align MultipathNVMeOFPool with KVMStoragePool API change
Reported by @slavkap: building this branch against current main produces a
compile error in MultipathNVMeOFPool because the KVMStoragePool interface
drifted between when this PR was last rebased (2026-05-22) and main today
(2026-06-09):
- checkingHeartBeat(HAStoragePool, HostTO) was renamed to
hasHeartBeat(HAStoragePool, HostTO)
- vmActivityCheck(...) was renamed to hasVmActivity(...) with the same
signature
Update the two overrides accordingly (both still return null as before —
MultipathNVMeOFPool does not participate in the KVM-side HA heartbeat). Mirrors
how MultipathSCSIPool implements them.
Verified compile via mvn -pl plugins/hypervisors/kvm -am -DskipTests
compile.
Signed-off-by: Eugenio Grosso <[email protected]>
* kvm: guard parseAndValidatePath against null inPath
@slavkap noted that parseAndValidatePath has no internal null guard. The
two callers in this base class (getPhysicalDisk and connectPhysicalDisk)
already pre-check via StringUtils.isEmpty(volumePath), so the current code path
is safe, but as a public method it deserves defense-in-depth.
Throw CloudRuntimeException with a clear message instead of letting it NPE
on inPath.split(...). Mirrors how the FC sibling FiberChannelAdapter throws on
an invalid address type.
Signed-off-by: Eugenio Grosso <[email protected]>
* kvm/flasharray/ui: address Copilot review round 3 on NVMe-TCP PR
Address the 2026-07-27 Copilot review comments:
- AddPrimaryStorage.vue: set (or replace) the transport= query parameter
instead of blindly appending it, so a FlashArray URL that already carries
a hand-entered transport= is not left with two conflicting values.
- FlashArrayVolume: slice the EUI-128 serial with explicit substring(14, 24)
rather than substring(14). The length guard rejected short serials but a
longer-than-expected serial would previously yield an EUI over 32 hex
characters, which cannot match /dev/disk/by-id/nvme-eui.<eui>.
- FlashArrayAdapter: guard the Fibre Channel connection match against
hostnames with no dot. hostname.substring(0, hostname.indexOf('.')) would
throw StringIndexOutOfBoundsException when indexOf('.') == -1. Mirrors the
guard already present on the NVMe-TCP branch.
- MultipathNVMeOFAdapterBase: MapStorageUuidToStoragePool is now a
ConcurrentHashMap populated via computeIfAbsent, so concurrent callers
cannot race to create duplicate pool objects or corrupt the map.
- MultipathNVMeOFAdapterBase: split key=value tokens with a limit of 2 so a
value containing '=' is no longer silently discarded.
- MultipathNVMeOFAdapterBase: throttle rescanAllControllers() during
namespace discovery to once per 10s instead of on every 2s poll. Spawning
one nvme ns-rescan per controller every 2s is bursty on hosts with many
controllers; the first iteration still rescans immediately.
No functional change to the happy path.
Signed-off-by: Eugenio Grosso <[email protected]>
* flasharray: tighten NVMe-TCP EUI-128 validation
Address the 2026-08-04 Copilot follow-up:
- FlashArrayVolume.getAddress() now requires the serial to match exactly 24
hexadecimal characters instead of merely rejecting shorter ones. The
previous
form sliced serial[0:24], so two distinct serials sharing a 24-character
prefix would have produced the same EUI-128 and therefore the same volume
identity. Non-hex serials are now rejected as well.
- FlashArrayAdapter.getVolumeByAddress() validates the FlashArray EUI-128
layout before reversing it into a serial: 32 hexadecimal characters, a 00
prefix, and the Pure Storage OUI at offset 16. Previously any 32-character
string was accepted and deterministically mapped onto a volume serial, so
a
malformed or tampered address could resolve to an unintended volume.
Verified against a real FlashArray namespace: EUI
006c1b16ce1c034d24a9371c05ab334a passes both checks and round-trips to
serial
6C1B16CE1C034D1C05AB334A and back unchanged.
Signed-off-by: Eugenio Grosso <[email protected]>
* kvm: implement volume resize for NVMe-TCP pools
Resize of a volume on an NVMe-TCP pool previously failed. The provider half
worked -- FlashArrayAdapter.resize() PATCHes the array to grow the
namespace --
but the KVM half was never wired up:
* LibvirtResizeVolumeCommandWrapper short-circuits MultipathSCSIPool via
handleMultipathSCSIResize(), but MultipathNVMeOFPool implements
KVMStoragePool directly rather than extending MultipathSCSIPool, so it
fell through.
* It then reached LibvirtComputingResource.getResizeScriptType(), which
has
no NVMeTCP case, and threw
"Cannot determine resize type from pool type NVMeTCP".
* MultipathNVMeOFAdapterBase.resize() threw UnsupportedOperationException.
Wire up the host-side half, mirroring how the Fibre Channel path is handled
(FC shipped resize in its own initial PR, #7889):
* LibvirtResizeVolumeCommandWrapper: dispatch MultipathNVMeOFPool to a new
handleMultipathNVMeOFResize(), alongside the existing SCSI branch.
* MultipathNVMeOFPool.resize(): delegate to the adaptor, mirroring
MultipathSCSIPool.resize().
* MultipathNVMeOFAdapterBase.resize(): issue nvme ns-rescan so the kernel
observes the grown namespace without waiting for an asynchronous event
notification from the target, poll until the new size is visible, then
virsh blockresize so a running guest sees the capacity without a reboot.
Unlike the SCSI path there is no device-mapper map to grow, so no helper
script is needed; the existing rescanAllControllers() is reused.
If the namespace does not report the new size within the settle timeout we
log a warning rather than throwing: the array has already been grown at that
point, and failing here would make the management server roll back a resize
that actually happened.
The guest-notification lookup matches the domain disk source against the
/dev/disk/by-id symlink, its canonicalised /dev/nvmeXnY target, or the bare
EUI, because libvirt may report any of those forms.
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
* server: allow online volume resize on NVMe-TCP pools
validateVolumeResizeWithSize() refuses to resize a volume on a managed pool
while its VM is running, unless the pool type is on an allowlist. That list
held PowerFlex and FiberChannel but not NVMeTCP, so resizing an attached
NVMe-TCP volume on a running KVM instance failed at the API layer with
"This kind of KVM disk cannot be resized while it is connected to a VM
that's not in the Stopped state." before any host-side work was attempted.
Add NVMeTCP alongside FiberChannel. The two transports are equivalent here:
in both cases the provider grows the volume on the array and the KVM agent
then makes the new capacity visible to the guest, which the preceding commit
implements for NVMe-oF.
Found by running the test matrix @abh1sar requested on
apache/cloudstack#13061:
offline resize (VM Stopped) already worked end to end -- CloudStack, the
array
and the guest all agreed on the new size -- and this guard was the only
thing
blocking the online case.
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
* storage: recognise NVMeTCP in the managed-storage code paths
When Fibre Channel managed storage was added (#7889) the FiberChannel pool
type was added to a number of allowlists in shared managed-storage code. The
NVMe-TCP work added the pool type, the KVM adaptor and the lifecycle pivot
but
not those allowlists, so several managed-storage operations silently did not
work on an NVMe-TCP pool even though the equivalent Fibre Channel operation
did.
StorageSystemDataMotionStrategy:
* verifyFormatWithPoolType(): RAW was accepted only on PowerFlex and
FiberChannel, so any data-motion of a RAW NVMe-TCP volume was rejected
outright. Message updated to match.
* handleCreateTemplateFromManagedVolume(): same format check, which made
createTemplate from an NVMe-TCP volume fail with the (misleading)
"When using managed storage, you can only create a template from a
volume
on KVM currently."
* the grantAccess() / revokeAccess() pair around the template copy only
ran
for a detached volume or for PowerFlex/FiberChannel, so an attached
NVMe-TCP volume was never granted host access for the copy. Both sites
are
updated together so access is still revoked in the finally block.
* getSnapshotDetails(): PowerFlex and FiberChannel pass the snapshot path
straight through as the device address, which is also correct for
array-native NVMe-TCP snapshots.
ApiDBUtils.getHypervisorTypeFromZone(): NVMeTCP is a KVM-only pool type, so
its presence in a zone should imply KVM in the same way the other non-QCOW2
pool types already do.
StatsCollector's volume-stats format check deliberately not touched: RAW is
already in its accepted-format list, so the guard never fires for NVMe-TCP
volumes and adding the pool type there would be dead weight.
Found by working through the test matrix @abh1sar asked for on
apache/cloudstack#13061, which surfaced these as a family rather than one at
a time.
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
* flasharray: connect NVMe-TCP volumes per host, not per host group
Access to a managed volume is granted and revoked one host at a time:
AdaptiveDataStoreDriverImpl.grantAccess() calls attach(volume, hostname) and
revokeAccess() calls detach(volume, hostname). Neither the adapter context
nor
the data object carries the VM or its power state, so the adapter cannot
tell
why access is being revoked.
attach() did not follow that model for NVMe-TCP. When a hostgroup was
configured it created a single host-group scoped connection shared by every
member host, while detach() still ran per host and deleted that shared
connection. The two are irreconcilable, and live migration shows why:
grantAccess(V, dst) -> POST /connections?host_group_names=cluster1
("already exists")
... guest migrates and is now running on dst, doing I/O to V ...
revokeAccess(V, src) -> DELETE /connections?host_group_names=cluster1
The DELETE removes the only connection there is, so V is revoked from the
whole
group including dst, the namespace disappears under the running guest, and
with
werror=stop the VM freezes.
Group scoping was justified in a comment as giving "a consistent NSID
visible to
every member host", but the NSID is not used to find the namespace: it is
located by its EUI-128 address (FlashArrayVolume.getAddress()), as the
comment
20 lines further down already says. So the justification does not hold.
Connect and disconnect per host instead, which is what Fibre Channel has
always
done in this same adapter and what the interface can express. The hostgroup
parameter remains accepted and is now used only where it was documented to
be
used -- removing leftover group connections when a volume is deleted --
matching
the "retrieve for legacy purposes" comment on the field. In the "Connection
already exists" path a group-scoped match is demoted to a fallback so
volumes
still carrying a group connection from an earlier release keep resolving.
Found while working through the test matrix @abh1sar asked for on
apache/cloudstack#13061, as the cause of the live-migration failure.
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
* flasharray: always stamp the pool address type on returned volumes
FlashArrayVolume defaults addressType to FIBERWWN in its constructor, so a
volume deserialised from the array reports an FC-style NAA WWN from
getAddress()
until the pool's real address type is stamped onto it. Most accessors do
that
via withAddressType(), but two paths returned an unstamped volume:
* revert() returned the private getVolume() result directly, so reverting
a
snapshot on an NVMe-TCP pool produced a volume carrying an FC-style WWN.
* copy() returned the newly created volume unstamped. The adaptive driver
persists this one -- copyAsync() stores generatePathInfo(outVolume,
...) --
so the volume was recorded in the database with an address the host
cannot
resolve, since NVMe namespaces are located by EUI-128.
Rather than add a third withAddressType() call at each site, stamp inside
the
private getVolume() accessor so every path is correct by construction. That
matches the private getSnapshot() accessor, which already did this, and
makes
the wrap at the public getVolume() call site redundant. copy() now reuses
the
accessor for its source lookup instead of repeating the same GET inline, and
stamps the volume it hands back.
No behavioural change for Fibre Channel: FIBERWWN is what the constructor
already defaulted to, so stamping it is a no-op there.
Also drops a comment in getConnectionIdMap() describing connections as
host-group scoped, which is no longer how NVMe-TCP volumes are connected
after
the preceding commit. The surrounding logic was already keyed on host name
and
is unchanged; only nsid vs lun differs between the transports.
Found by auditing every NVMe-TCP divergence from the Fibre Channel path, on
the
principle that NVMe-TCP is only a different carrier: the array operations
are
identical and the host-side difference is an NQN instead of a WWN, which
lives
on the array and is never touched by this adapter (hosts are resolved by
name).
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
* kvm: align NVMe-oF adaptor stubs with the Fibre Channel adaptor
NVMe-TCP is a different carrier for the same array operations, so the
NVMe-oF
storage adaptor should only diverge from MultipathSCSIAdapterBase where the
transport genuinely differs. Several unimplemented methods threw
UnsupportedOperationException where the Fibre Channel adaptor returns a
benign
value, which means a caller that degrades gracefully on Fibre Channel
aborts on
NVMe-TCP:
* deletePhysicalDisk(): FC returns false. Namespaces are created and
destroyed
by the storage provider, so a host-side delete is simply not handled
here;
throwing from a cleanup path is worse than reporting that.
* createTemplateFromDisk(), listPhysicalDisks(): FC returns null.
* createFolder(): FC logs and returns true. Block storage has no directory
structure, so this is a no-op rather than an error.
createPhysicalDisk(), createDiskFromTemplate() and
createDiskFromTemplateBacking()
still throw, because the Fibre Channel adaptor throws for those too.
createTemplateFromDirectDownloadFile() also still throws, but the message
now
states the limitation instead of reading as an oversight. The Fibre Channel
implementation resolves the downloaded file with destPool.getPhysicalDisk(),
which on this adaptor requires a "type=NVMETCP;address=..." path and
therefore
cannot describe a plain local file. Supporting it needs a separate way to
present
the local file as the copy source, which is left for follow-up rather than
guessed at here.
No functional change to the connect/disconnect paths. The comment on
disconnectPhysicalDisk() is updated because it described connections as
host-group scoped, which is no longer how NVMe-TCP volumes are connected,
and to
record why the host-side no-op is correct: the kernel drops the namespace
when
the target removes the connection, whereas Fibre Channel must flush its
device-mapper entry.
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
* kvm: support direct download templates on NVMe-oF pools
createTemplateFromDirectDownloadFile() threw UnsupportedOperationException,
so a
template registered with directdownload=true could not be deployed onto an
NVMe-TCP pool: the download itself succeeded and the deployment then failed
on
the host.
The two path arguments are different kinds of thing, and conflating them is
what
made this look harder than it is. templateFilePath is a plain local file
produced
by the direct-download helper, while destTemplatePath is a managed volume
path of
the form "type=NVMETCP;address=...". Only the destination may be resolved
through
KVMStoragePool.getPhysicalDisk(); handing the local file to that would pass
a file
name to parseAndValidatePath() and fail. KVMStorageProcessor has already
issued
connectPhysicalDisk() for the destination by the time we are called, so the
namespace is present.
Worth recording that the Fibre Channel implementation does conflate them.
It calls
destPool.getPhysicalDisk(templateFilePath), and FiberChannelAdapter's
parseAndValidatePath() treats any string without a ";" as a bare WWN, so a
local
file path becomes "/dev/mapper/3" + that path and the lookup cannot
succeed. This
implementation follows ScaleIOStorageAdaptor instead, which is the working
precedent for block-backed managed storage.
The template is written as QCOW2 onto the raw namespace rather than as RAW.
Three
reasons: it matches ScaleIO; it agrees with the QCOW2 format the template is
registered with, so stored content and declared format do not contradict
each
other; and KVMStorageProcessor runs Qcow2Inspector.validateQcow2File() on
the path
we return, which for a block device that exists would fail on RAW content
and then
try to delete the device.
The disk returned to the caller is constructed rather than being the one
getPhysicalDisk() handed back, because its *name* matters and not just its
path.
KVMStorageProcessor puts disk.getName() into the DirectDownloadAnswer, which
becomes the template's install path in template_spool_ref and later its
external
name, and the provider interpolates that external name into array REST
calls.
getPhysicalDisk() names a disk AddressInfo.toString(), e.g.
AddressInfo NVMETCP [address=006c..., connectionId=2172,
path=/dev/disk/by-id/...]
Recording that as the install path makes the first deployment from a freshly
registered template fail: revokeAccess() -> detach() builds
"/connections?host_names=kvm01&volume_names=cloudstack2::<external name>"
and
URI.create() rejects the spaces and brackets with "Illegal character in
query".
The surfaced error is a misleading "Failed to update state", because the
resulting
CloudRuntimeException is followed by applying OperationFailed to a template
that
has already reached Ready. Naming the returned disk with the managed volume
path
gives the same install path shape a volume records:
type=NVMETCP; address=006c...; providerName=cloudstack2::tpl-9-0-2-210;
providerID=...
Also handles compressed templates via TemplateDownloaderUtil, since
direct-download templates are commonly published that way, and refuses up
front if
the template will not fit the namespace rather than failing part way
through the
convert.
Tested on a two-host KVM cluster against a FlashArray over NVMe-TCP:
registered a
QCOW2 template with directdownload=true and deployed a VM from it, on the
first
attempt, with a storage offering tagged for the NVMe-TCP pool:
ROOT-32 Everpure X90 NVMe-TCP pool2 Ready
type=NVMETCP; address=006c1b16ce1c034d24a9371c0623b125
Signed-off-by: Eugenio Grosso <[email protected]>
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Signed-off-by: Eugenio Grosso <[email protected]>
Co-authored-by: Eugenio Grosso <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
---
PendingReleaseNotes | 10 +
api/src/main/java/com/cloud/storage/Storage.java | 3 +-
.../motion/StorageSystemDataMotionStrategy.java | 17 +-
.../wrapper/LibvirtResizeVolumeCommandWrapper.java | 10 +
.../kvm/storage/KVMStorageProcessor.java | 9 +-
.../kvm/storage/MultipathNVMeOFAdapterBase.java | 722 +++++++++++++++++++++
.../kvm/storage/MultipathNVMeOFPool.java | 165 +++++
.../hypervisor/kvm/storage/NVMeTCPAdapter.java | 51 ++
.../storage/datastore/adapter/ProviderVolume.java | 3 +-
.../lifecycle/AdaptiveDataStoreLifeCycleImpl.java | 24 +-
.../adapter/flasharray/FlashArrayAdapter.java | 160 ++++-
.../adapter/flasharray/FlashArrayConnection.java | 9 +
.../adapter/flasharray/FlashArrayVolume.java | 26 +
server/src/main/java/com/cloud/api/ApiDBUtils.java | 3 +-
.../com/cloud/storage/VolumeApiServiceImpl.java | 3 +-
ui/src/views/infra/AddPrimaryStorage.vue | 17 +-
16 files changed, 1181 insertions(+), 51 deletions(-)
diff --git a/PendingReleaseNotes b/PendingReleaseNotes
index bc6068fa331..c9afb25f7ed 100644
--- a/PendingReleaseNotes
+++ b/PendingReleaseNotes
@@ -81,3 +81,13 @@ example.ver.1 > example.ver.2:
additional key store is required. Note: attaching an encrypted RBD volume
to a running Instance requires libvirt >= 10.1.0; booting an Instance from
an encrypted RBD root disk works on older libvirt.
+
+ * Added NVMe-over-Fabrics (TCP) support to the adaptive storage framework
+ and the Pure Storage FlashArray plugin. Volumes on a FlashArray primary
+ pool can now be delivered to KVM hypervisors over NVMe-TCP instead of
+ Fibre Channel by setting transport=nvme-tcp on the pool's provider URL.
+ Volumes are identified on the host via EUI-128 NGUIDs and attached to
+ guests as plain block devices through the native NVMe multipath layer;
+ no device-mapper multipath configuration is required. A new
+ Storage.StoragePoolType.NVMeTCP + MultipathNVMeOFAdapterBase /
+ NVMeTCPAdapter on the KVM side back the new pool type.
diff --git a/api/src/main/java/com/cloud/storage/Storage.java
b/api/src/main/java/com/cloud/storage/Storage.java
index 275f6d25268..42a18b9c83c 100644
--- a/api/src/main/java/com/cloud/storage/Storage.java
+++ b/api/src/main/java/com/cloud/storage/Storage.java
@@ -185,7 +185,8 @@ public class Storage {
Linstor(true, true, EncryptionSupport.Storage),
DatastoreCluster(true, true, EncryptionSupport.Unsupported), // for
VMware, to abstract pool of clusters
StorPool(true, true, EncryptionSupport.Hypervisor),
- FiberChannel(true, true, EncryptionSupport.Unsupported); // Fiber
Channel Pool for KVM hypervisors is used to find the volume by WWN value
(/dev/disk/by-id/wwn-<wwnvalue>)
+ FiberChannel(true, true, EncryptionSupport.Unsupported), // Fiber
Channel Pool for KVM hypervisors is used to find the volume by WWN value
(/dev/disk/by-id/wwn-<wwnvalue>)
+ NVMeTCP(true, true, EncryptionSupport.Unsupported); // NVMe over TCP
(NVMe-oF/TCP) Pool for KVM hypervisors; volumes are identified by EUI-128 NGUID
(/dev/disk/by-id/nvme-eui.<eui>)
private final boolean shared;
private final boolean overProvisioning;
diff --git
a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
index 7674f1ce25a..2a348cb058d 100644
---
a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
+++
b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java
@@ -616,8 +616,9 @@ public class StorageSystemDataMotionStrategy implements
DataMotionStrategy {
private void verifyFormatWithPoolType(ImageFormat imageFormat,
StoragePoolType poolType) {
if (imageFormat != ImageFormat.VHD && imageFormat != ImageFormat.OVA
&& imageFormat != ImageFormat.QCOW2 &&
!(imageFormat == ImageFormat.RAW && (StoragePoolType.PowerFlex
== poolType ||
- StoragePoolType.FiberChannel == poolType))) {
- throw new CloudRuntimeException(String.format("Only the following
image types are currently supported: %s, %s, %s, %s (for PowerFlex and
FiberChannel)",
+ StoragePoolType.FiberChannel == poolType ||
+ StoragePoolType.NVMeTCP == poolType))) {
+ throw new CloudRuntimeException(String.format("Only the following
image types are currently supported: %s, %s, %s, %s (for PowerFlex,
FiberChannel and NVMeTCP)",
ImageFormat.VHD.toString(), ImageFormat.OVA.toString(),
ImageFormat.QCOW2.toString(), ImageFormat.RAW.toString()));
}
}
@@ -2796,7 +2797,8 @@ public class StorageSystemDataMotionStrategy implements
DataMotionStrategy {
if (!ImageFormat.QCOW2.equals(volumeInfo.getFormat()) &&
!(ImageFormat.RAW.equals(volumeInfo.getFormat()) && (
StoragePoolType.PowerFlex == storagePoolVO.getPoolType() ||
- StoragePoolType.FiberChannel ==
storagePoolVO.getPoolType()))) {
+ StoragePoolType.FiberChannel ==
storagePoolVO.getPoolType() ||
+ StoragePoolType.NVMeTCP == storagePoolVO.getPoolType()))) {
throw new CloudRuntimeException("When using managed storage,
you can only create a template from a volume on KVM currently.");
}
@@ -2810,7 +2812,8 @@ public class StorageSystemDataMotionStrategy implements
DataMotionStrategy {
try {
handleQualityOfServiceForVolumeMigration(volumeInfo,
PrimaryDataStoreDriver.QualityOfServiceState.MIGRATION);
- if (srcVolumeDetached || StoragePoolType.PowerFlex ==
storagePoolVO.getPoolType() || StoragePoolType.FiberChannel ==
storagePoolVO.getPoolType()) {
+ if (srcVolumeDetached || StoragePoolType.PowerFlex ==
storagePoolVO.getPoolType() || StoragePoolType.FiberChannel ==
storagePoolVO.getPoolType()
+ || StoragePoolType.NVMeTCP ==
storagePoolVO.getPoolType()) {
_volumeService.grantAccess(volumeInfo, hostVO,
srcDataStore);
}
@@ -2844,7 +2847,8 @@ public class StorageSystemDataMotionStrategy implements
DataMotionStrategy {
throw new CloudRuntimeException(msg + ex.getMessage(), ex);
}
finally {
- if (srcVolumeDetached || StoragePoolType.PowerFlex ==
storagePoolVO.getPoolType() || StoragePoolType.FiberChannel ==
storagePoolVO.getPoolType()) {
+ if (srcVolumeDetached || StoragePoolType.PowerFlex ==
storagePoolVO.getPoolType() || StoragePoolType.FiberChannel ==
storagePoolVO.getPoolType()
+ || StoragePoolType.NVMeTCP ==
storagePoolVO.getPoolType()) {
try {
_volumeService.revokeAccess(volumeInfo, hostVO,
srcDataStore);
}
@@ -2939,7 +2943,8 @@ public class StorageSystemDataMotionStrategy implements
DataMotionStrategy {
long snapshotId = snapshotInfo.getId();
- if (storagePoolVO.getPoolType() == StoragePoolType.PowerFlex ||
storagePoolVO.getPoolType() == StoragePoolType.FiberChannel) {
+ if (storagePoolVO.getPoolType() == StoragePoolType.PowerFlex ||
storagePoolVO.getPoolType() == StoragePoolType.FiberChannel
+ || storagePoolVO.getPoolType() == StoragePoolType.NVMeTCP) {
snapshotDetails.put(DiskTO.IQN, snapshotInfo.getPath());
} else {
snapshotDetails.put(DiskTO.IQN, getSnapshotProperty(snapshotId,
DiskTO.IQN));
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java
index 20e3891478b..32e756607e5 100644
---
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtResizeVolumeCommandWrapper.java
@@ -48,6 +48,7 @@ import
com.cloud.hypervisor.kvm.resource.LibvirtComputingResource;
import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk;
import com.cloud.hypervisor.kvm.storage.KVMStoragePool;
import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager;
+import com.cloud.hypervisor.kvm.storage.MultipathNVMeOFPool;
import com.cloud.hypervisor.kvm.storage.MultipathSCSIPool;
import com.cloud.resource.CommandWrapper;
import com.cloud.resource.ResourceWrapper;
@@ -86,6 +87,10 @@ public final class LibvirtResizeVolumeCommandWrapper extends
CommandWrapper<Resi
return handleMultipathSCSIResize(command, pool);
}
+ if (pool instanceof MultipathNVMeOFPool) {
+ return handleMultipathNVMeOFResize(command, pool);
+ }
+
if (spool.getType().equals(StoragePoolType.PowerFlex)) {
pool.connectPhysicalDisk(volumeId, null);
}
@@ -239,4 +244,9 @@ public final class LibvirtResizeVolumeCommandWrapper
extends CommandWrapper<Resi
((MultipathSCSIPool)pool).resize(command.getPath(),
command.getInstanceName(), command.getNewSize());
return new ResizeVolumeAnswer(command, true, "");
}
+
+ private Answer handleMultipathNVMeOFResize(ResizeVolumeCommand command,
KVMStoragePool pool) {
+ ((MultipathNVMeOFPool)pool).resize(command.getPath(),
command.getInstanceName(), command.getNewSize());
+ return new ResizeVolumeAnswer(command, true, "");
+ }
}
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
index 0cb32da318e..29b23e6c645 100644
---
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java
@@ -421,7 +421,8 @@ public class KVMStorageProcessor implements
StorageProcessor {
StoragePoolType.PowerFlex,
StoragePoolType.Linstor,
StoragePoolType.FiberChannel,
- StoragePoolType.CLVM).contains(primaryPool.getType())) {
+ StoragePoolType.CLVM,
+ StoragePoolType.NVMeTCP).contains(primaryPool.getType())) {
newTemplate.setFormat(ImageFormat.RAW);
} else {
newTemplate.setFormat(ImageFormat.QCOW2);
@@ -454,7 +455,8 @@ public class KVMStorageProcessor implements
StorageProcessor {
public static String derivePath(PrimaryDataStoreTO primaryStore, DataTO
destData, Map<String, String> details) {
String path = null;
- if (primaryStore.getPoolType() == StoragePoolType.FiberChannel) {
+ if (primaryStore.getPoolType() == StoragePoolType.FiberChannel
+ || primaryStore.getPoolType() == StoragePoolType.NVMeTCP) {
path = destData.getPath();
} else {
path = details != null ? details.get("managedStoreTarget") : null;
@@ -3513,7 +3515,8 @@ public class KVMStorageProcessor implements
StorageProcessor {
StoragePoolType.PowerFlex,
StoragePoolType.Linstor,
StoragePoolType.FiberChannel,
- StoragePoolType.CLVM).contains(poolType)) {
+ StoragePoolType.CLVM,
+ StoragePoolType.NVMeTCP).contains(poolType)) {
return ImageFormat.RAW;
} else {
return ImageFormat.QCOW2;
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/MultipathNVMeOFAdapterBase.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/MultipathNVMeOFAdapterBase.java
new file mode 100644
index 00000000000..2128d96ccf1
--- /dev/null
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/MultipathNVMeOFAdapterBase.java
@@ -0,0 +1,722 @@
+// 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.io.File;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.cloudstack.utils.qemu.QemuImg;
+import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat;
+import org.apache.cloudstack.utils.qemu.QemuImgException;
+import org.apache.cloudstack.utils.qemu.QemuImgFile;
+import org.libvirt.LibvirtException;
+
+import com.cloud.storage.Storage;
+import com.cloud.utils.exception.CloudRuntimeException;
+import com.cloud.utils.script.OutputInterpreter;
+import com.cloud.utils.script.Script;
+import com.cloud.utils.storage.TemplateDownloaderUtil;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * Base class for KVM storage adapters that surface remote block volumes over
+ * NVMe-over-Fabrics (NVMe-oF). It is the NVMe-oF counterpart of
+ * {@link MultipathSCSIAdapterBase}: it does not drive device-mapper multipath
+ * and does not rescan the SCSI bus, because NVMe-oF has its own multipath
+ * (the kernel's native NVMe multipath) and namespaces show up via
+ * asynchronous event notifications as soon as the target grants access.
+ *
+ * Volumes are identified on the host by their EUI-128 NGUID, which udev
+ * exposes as {@code /dev/disk/by-id/nvme-eui.<eui>}.
+ */
+public abstract class MultipathNVMeOFAdapterBase implements StorageAdaptor {
+ protected static Logger LOGGER =
LogManager.getLogger(MultipathNVMeOFAdapterBase.class);
+ static final Map<String, KVMStoragePool> MapStorageUuidToStoragePool = new
ConcurrentHashMap<>();
+
+ static final int DEFAULT_DISK_WAIT_SECS = 240;
+ static final long NS_RESCAN_TIMEOUT_SECS = 5;
+ private static final long POLL_INTERVAL_MS = 2000;
+ private static final long RESCAN_INTERVAL_MS = 10_000;
+ private static final long RESIZE_SETTLE_TIMEOUT_MS = 30_000;
+
+ @Override
+ public KVMStoragePool getStoragePool(String uuid) {
+ // Dummy pool - adapters that dispatch per-volume don't need
+ // connectivity information on the pool itself. Use computeIfAbsent
+ // so concurrent callers do not race to create duplicate pool objects.
+ return MapStorageUuidToStoragePool.computeIfAbsent(uuid, u -> new
MultipathNVMeOFPool(u, this));
+ }
+
+ @Override
+ public KVMStoragePool getStoragePool(String uuid, boolean refreshInfo) {
+ return getStoragePool(uuid);
+ }
+
+ public abstract String getName();
+
+ @Override
+ public abstract Storage.StoragePoolType getStoragePoolType();
+
+ public abstract boolean isStoragePoolTypeSupported(Storage.StoragePoolType
type);
+
+ /**
+ * Parse a {@code type=NVMETCP; address=<eui>; connid.<host>=<nsid>; ...}
+ * volume path and produce an {@link AddressInfo} with the host-side device
+ * path set to {@code /dev/disk/by-id/nvme-eui.<eui>}.
+ */
+ public AddressInfo parseAndValidatePath(String inPath) {
+ if (inPath == null) {
+ throw new CloudRuntimeException("Cannot parse null volume path");
+ }
+ String type = null;
+ String address = null;
+ String connectionId = null;
+ String path = null;
+ String hostname = resolveHostnameShort();
+ String hostnameFq = resolveHostnameFq();
+ String[] parts = inPath.split(";");
+ for (String part : parts) {
+ // Cap the split at 2 so values containing '=' (e.g. base64) are
not silently discarded.
+ String[] pair = part.split("=", 2);
+ if (pair.length != 2) {
+ continue;
+ }
+ String key = pair[0].trim();
+ String value = pair[1].trim();
+ if (key.equals("type")) {
+ type = value.toUpperCase();
+ } else if (key.equals("address")) {
+ address = value;
+ } else if (key.equals("connid")) {
+ connectionId = value;
+ } else if (key.startsWith("connid.")) {
+ String inHostname = key.substring("connid.".length());
+ if (inHostname.equals(hostname) ||
inHostname.equals(hostnameFq)) {
+ connectionId = value;
+ }
+ }
+ }
+
+ if (!"NVMETCP".equals(type)) {
+ throw new CloudRuntimeException("Invalid address type provided for
NVMe-oF target disk: " + type);
+ }
+ if (address == null) {
+ throw new CloudRuntimeException("NVMe-oF volume path is missing
the required address field");
+ }
+ path = "/dev/disk/by-id/nvme-eui." + address.toLowerCase();
+ return new AddressInfo(type, address, connectionId, path);
+ }
+
+ @Override
+ public KVMPhysicalDisk getPhysicalDisk(String volumePath, KVMStoragePool
pool) {
+ if (StringUtils.isEmpty(volumePath) || pool == null) {
+ LOGGER.error("Unable to get physical disk, volume path or pool not
specified");
+ return null;
+ }
+ return getPhysicalDisk(parseAndValidatePath(volumePath), pool);
+ }
+
+ private KVMPhysicalDisk getPhysicalDisk(AddressInfo address,
KVMStoragePool pool) {
+ KVMPhysicalDisk disk = new KVMPhysicalDisk(address.getPath(),
address.toString(), pool);
+ disk.setFormat(QemuImg.PhysicalDiskFormat.RAW);
+
+ if (!isConnected(address.getPath())) {
+ if (!connectPhysicalDisk(address, pool, null)) {
+ throw new CloudRuntimeException("Unable to connect to NVMe
namespace at " + address.getPath());
+ }
+ }
+ long diskSize = getPhysicalDiskSize(address.getPath());
+ disk.setSize(diskSize);
+ disk.setVirtualSize(diskSize);
+ return disk;
+ }
+
+ @Override
+ public KVMStoragePool createStoragePool(String uuid, String host, int
port, String path, String userInfo, Storage.StoragePoolType type, Map<String,
String> details, boolean isPrimaryStorage) {
+ LOGGER.info(String.format("createStoragePool(uuid,host,port,path,type)
called with args (%s, %s, %d, %s, %s)", uuid, host, port, path, type));
+ MultipathNVMeOFPool pool = new MultipathNVMeOFPool(uuid, host, port,
path, type, details, this);
+ MapStorageUuidToStoragePool.put(uuid, pool);
+ return pool;
+ }
+
+ @Override
+ public boolean deleteStoragePool(String uuid) {
+ MapStorageUuidToStoragePool.remove(uuid);
+ return true;
+ }
+
+ @Override
+ public boolean deleteStoragePool(KVMStoragePool pool) {
+ return deleteStoragePool(pool.getUuid());
+ }
+
+ @Override
+ public boolean connectPhysicalDisk(String volumePath, KVMStoragePool pool,
Map<String, String> details, boolean isVMMigrate) {
+ if (StringUtils.isEmpty(volumePath) || pool == null) {
+ LOGGER.error("Unable to connect NVMe-oF physical disk:
insufficient arguments");
+ return false;
+ }
+ return connectPhysicalDisk(parseAndValidatePath(volumePath), pool,
details);
+ }
+
+ private boolean connectPhysicalDisk(AddressInfo address, KVMStoragePool
pool, Map<String, String> details) {
+ if (address.getConnectionId() == null) {
+ LOGGER.error("NVMe-oF volume " + address.getPath() + " on pool " +
pool.getUuid() + " is missing a connid.<host> token in its path");
+ return false;
+ }
+ long waitSecs = DEFAULT_DISK_WAIT_SECS;
+ if (details != null &&
details.containsKey(com.cloud.storage.StorageManager.STORAGE_POOL_DISK_WAIT.toString()))
{
+ String waitTime =
details.get(com.cloud.storage.StorageManager.STORAGE_POOL_DISK_WAIT.toString());
+ if (StringUtils.isNotEmpty(waitTime)) {
+ try {
+ waitSecs = Integer.parseInt(waitTime);
+ } catch (NumberFormatException e) {
+ LOGGER.warn("Ignoring non-numeric " +
com.cloud.storage.StorageManager.STORAGE_POOL_DISK_WAIT.toString()
+ + "=[" + waitTime + "] on pool " + pool.getUuid()
+ ", falling back to default "
+ + DEFAULT_DISK_WAIT_SECS + "s");
+ }
+ }
+ }
+ return waitForNamespace(address, pool, waitSecs);
+ }
+
+ /**
+ * Poll for the EUI-keyed udev symlink to show up. On every iteration also
+ * nudge the kernel with {@code nvme ns-rescan} on every local NVMe
+ * controller, to cover arrays / firmware combinations that do not emit a
+ * reliable asynchronous event notification when a new namespace is
+ * mapped.
+ */
+ private boolean waitForNamespace(AddressInfo address, KVMStoragePool pool,
long waitSecs) {
+ if (waitSecs < 60) {
+ waitSecs = 60;
+ }
+ long deadline = System.currentTimeMillis() + (waitSecs * 1000);
+ File dev = new File(address.getPath());
+ long lastRescan = 0;
+ while (System.currentTimeMillis() < deadline) {
+ if (dev.exists() && isConnected(address.getPath())) {
+ long size = getPhysicalDiskSize(address.getPath());
+ if (size > 0) {
+ LOGGER.debug("Found NVMe namespace at " +
address.getPath());
+ return true;
+ }
+ }
+ // Throttle rescanAllControllers(): spawning one nvme ns-rescan
per controller every
+ // 2s can be expensive on hosts with many controllers.
RESCAN_INTERVAL_MS caps the
+ // rate; the first iteration still rescans immediately because
lastRescan starts at 0.
+ long now = System.currentTimeMillis();
+ if (now - lastRescan >= RESCAN_INTERVAL_MS) {
+ rescanAllControllers();
+ lastRescan = now;
+ }
+ try {
+ Thread.sleep(POLL_INTERVAL_MS);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ LOGGER.debug("NVMe namespace did not appear at " + address.getPath() +
" within " + waitSecs + "s");
+ return false;
+ }
+
+ private void rescanAllControllers() {
+ try {
+ File sysClass = new File("/sys/class/nvme");
+ File[] ctrls = sysClass.listFiles();
+ if (ctrls == null) {
+ return;
+ }
+ for (File ctrl : ctrls) {
+ Process p = new ProcessBuilder("nvme", "ns-rescan", "/dev/" +
ctrl.getName())
+ .redirectErrorStream(true).start();
+ if (!p.waitFor(NS_RESCAN_TIMEOUT_SECS, TimeUnit.SECONDS)) {
+ // Kill runaway nvme-cli invocations so they do not pile
+ // up under the JVM on every poll iteration while we
+ // are still waiting for the namespace to appear.
+ LOGGER.debug("nvme ns-rescan /dev/" + ctrl.getName()
+ + " did not complete within " +
NS_RESCAN_TIMEOUT_SECS
+ + "s; terminating");
+ p.destroyForcibly();
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.debug("nvme ns-rescan attempt failed: " + e.getMessage());
+ }
+ }
+
+ @Override
+ public boolean disconnectPhysicalDisk(String volumePath, KVMStoragePool
pool) {
+ // NVMe-oF: the kernel drops the namespace as soon as the target
removes
+ // this host's connection, so there is no host-side map to tear down
the way
+ // Fibre Channel must flush its device-mapper entry.
+ return true;
+ }
+
+ @Override
+ public boolean disconnectPhysicalDisk(Map<String, String>
volumeToDisconnect) {
+ return true;
+ }
+
+ @Override
+ public boolean disconnectPhysicalDiskByPath(String localPath) {
+ // Same rationale as disconnectPhysicalDisk above. Only claim paths
+ // that look like NVMe EUI symlinks so we don't swallow foreign paths.
+ return localPath != null &&
localPath.startsWith("/dev/disk/by-id/nvme-eui.");
+ }
+
+ @Override
+ public boolean deletePhysicalDisk(String uuid, KVMStoragePool pool,
Storage.ImageFormat format) {
+ // Namespaces are created and destroyed by the storage provider, never
from the
+ // host. Report "not handled here" instead of throwing, so a caller on
a cleanup
+ // path behaves the same as it does on the Fibre Channel adapter.
+ LOGGER.info("deletePhysicalDisk({}) not handled by the NVMe-oF
adapter; the storage provider owns namespace deletion", uuid);
+ return false;
+ }
+
+ @Override
+ public KVMPhysicalDisk createPhysicalDisk(String name, KVMStoragePool
pool, PhysicalDiskFormat format,
+ Storage.ProvisioningType provisioningType, long size, byte[]
passphrase) {
+ throw new UnsupportedOperationException("Unimplemented method
'createPhysicalDisk'");
+ }
+
+ @Override
+ public KVMPhysicalDisk createTemplateFromDisk(KVMPhysicalDisk disk, String
name, QemuImg.PhysicalDiskFormat format, long size, KVMStoragePool destPool) {
+ LOGGER.info("createTemplateFromDisk not supported on NVMe-oF pools");
+ return null;
+ }
+
+ @Override
+ public List<KVMPhysicalDisk> listPhysicalDisks(String storagePoolUuid,
KVMStoragePool pool) {
+ // The array owns the namespace inventory; it is not enumerable from
the host.
+ LOGGER.info("listPhysicalDisks not supported on NVMe-oF pool {}",
storagePoolUuid);
+ return null;
+ }
+
+ @Override
+ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk disk, String name,
KVMStoragePool destPool, int timeout) {
+ return copyPhysicalDisk(disk, name, destPool, timeout, null, null,
null);
+ }
+
+ /**
+ * Copy a template or source disk into a pre-provisioned NVMe namespace on
+ * this pool, so it can be consumed by a VM as a root or data volume.
+ *
+ * The destination namespace is expected to have already been created on
+ * the storage provider and connected to this host's hostgroup (that is
+ * the storage orchestrator's responsibility, not the KVM adapter's). All
+ * this method does is resolve the destination device path via
+ * {@link #getPhysicalDisk} - which will nvme ns-rescan and wait for the
+ * by-id/nvme-eui.<NGUID> symlink to show up if the kernel has not
+ * picked it up yet - and {@code qemu-img convert} the source image into
+ * the raw block device.
+ *
+ * User-space encryption passphrases are not supported: the provider
+ * already encrypts at rest and qemu-img LUKS on top of a shared
+ * hostgroup-scoped namespace is not a sensible layering.
+ */
+ @Override
+ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk disk, String name,
KVMStoragePool destPool, int timeout,
+ byte[] srcPassphrase, byte[] destPassphrase,
Storage.ProvisioningType provisioningType) {
+ if (disk == null || StringUtils.isEmpty(name) || destPool == null) {
+ throw new CloudRuntimeException("Unable to copy disk to NVMe-oF
pool: source disk, destination volume name or destination pool not specified");
+ }
+ if (srcPassphrase != null || destPassphrase != null) {
+ throw new CloudRuntimeException("NVMe-oF adapter does not support
user-space encrypted source or destination volumes");
+ }
+
+ KVMPhysicalDisk destDisk = destPool.getPhysicalDisk(name);
+ if (destDisk == null || StringUtils.isEmpty(destDisk.getPath())) {
+ throw new CloudRuntimeException("Unable to resolve NVMe namespace
for destination volume [" + name + "] on pool [" + destPool.getUuid() + "]");
+ }
+
+ destDisk.setFormat(QemuImg.PhysicalDiskFormat.RAW);
+ destDisk.setVirtualSize(disk.getVirtualSize());
+ destDisk.setSize(disk.getSize());
+
+ LOGGER.info(String.format("Copying source disk [path=%s, format=%s,
virtualSize=%d] to NVMe-oF namespace [path=%s] on pool [%s]",
+ disk.getPath(), disk.getFormat(), disk.getVirtualSize(),
destDisk.getPath(), destPool.getUuid()));
+
+ QemuImgFile srcFile = new QemuImgFile(disk.getPath(),
disk.getFormat());
+ QemuImgFile destFile = new QemuImgFile(destDisk.getPath(),
destDisk.getFormat());
+
+ try {
+ QemuImg qemu = new QemuImg(timeout);
+ qemu.convert(srcFile, destFile, true);
+ } catch (QemuImgException | LibvirtException e) {
+ throw new CloudRuntimeException("Failed to copy source disk [" +
disk.getPath() + "] to NVMe-oF namespace ["
+ + destDisk.getPath() + "] on pool [" + destPool.getUuid()
+ "]: " + e.getMessage(), e);
+ }
+
+ LOGGER.info("Successfully copied source disk to NVMe-oF namespace [" +
destDisk.getPath() + "] on pool [" + destPool.getUuid() + "]");
+ return destDisk;
+ }
+
+ @Override
+ public KVMPhysicalDisk createDiskFromTemplate(KVMPhysicalDisk template,
String name, PhysicalDiskFormat format, Storage.ProvisioningType
provisioningType, long size, KVMStoragePool destPool, int timeout, byte[]
passphrase) {
+ throw new UnsupportedOperationException("Unimplemented method
'createDiskFromTemplate'");
+ }
+
+ @Override
+ public KVMPhysicalDisk createDiskFromTemplateBacking(KVMPhysicalDisk
template, String name, PhysicalDiskFormat format, long size, KVMStoragePool
destPool, int timeout, byte[] passphrase) {
+ throw new UnsupportedOperationException("Unimplemented method
'createDiskFromTemplateBacking'");
+ }
+
+ /**
+ * Write a directly-downloaded template onto a namespace on this pool.
+ *
+ * The two path arguments are different kinds of thing, which is worth
being explicit
+ * about: {@code templateFilePath} is a plain local file produced by the
direct-download
+ * helper, while {@code destTemplatePath} is a managed volume path of the
form
+ * {@code type=NVMETCP;address=...}. Only the destination may be resolved
through
+ * {@link KVMStoragePool#getPhysicalDisk(String)}; passing the local file
through it
+ * would hand a file name to {@link #parseAndValidatePath(String)} and
fail. The caller
+ * has already issued connectPhysicalDisk() for the destination, so the
namespace is
+ * expected to be present.
+ *
+ * The template is written as QCOW2 onto the raw namespace rather than as
RAW. That
+ * matches the ScaleIO adaptor, is consistent with the QCOW2 format the
template is
+ * registered with, and is what lets the Qcow2Inspector check the caller
runs on the
+ * returned path succeed.
+ *
+ * Note what the caller does with the returned disk: KVMStorageProcessor
puts
+ * {@code disk.getName()} into the DirectDownloadAnswer, and that becomes
the template's
+ * install path and later its external name, which the provider
interpolates into array
+ * REST calls. {@link #getPhysicalDisk(String, KVMStoragePool)} names a
disk
+ * {@code AddressInfo.toString()}, which contains spaces and brackets and
would produce
+ * a name that cannot be placed in a URI. So the disk handed back here is
named with the
+ * managed volume path we were given, matching what a volume records.
+ */
+ @Override
+ public KVMPhysicalDisk createTemplateFromDirectDownloadFile(String
templateFilePath, String destTemplatePath, KVMStoragePool destPool,
Storage.ImageFormat format, int timeout) {
+ if (StringUtils.isAnyEmpty(templateFilePath, destTemplatePath) ||
destPool == null) {
+ throw new CloudRuntimeException("Unable to create a template from
a direct download file on an NVMe-oF pool: "
+ + "template file path, destination template path or
destination pool not specified");
+ }
+
+ if (!Storage.ImageFormat.QCOW2.equals(format) &&
!Storage.ImageFormat.RAW.equals(format)) {
+ throw new CloudRuntimeException("Unsupported direct download
template format for NVMe-oF pools: " + format
+ + "; expected " + Storage.ImageFormat.QCOW2 + " or " +
Storage.ImageFormat.RAW);
+ }
+
+ File sourceFile = new File(templateFilePath);
+ if (!sourceFile.exists()) {
+ throw new CloudRuntimeException("Direct download template file " +
templateFilePath + " does not exist on this host");
+ }
+
+ LOGGER.debug("Creating a template on NVMe-oF pool [{}] from direct
download file [{}] into [{}], format [{}]",
+ destPool.getUuid(), templateFilePath, destTemplatePath,
format);
+
+ String srcTemplateFilePath = templateFilePath;
+ KVMPhysicalDisk destDisk;
+ try {
+ destDisk = destPool.getPhysicalDisk(destTemplatePath);
+ if (destDisk == null || StringUtils.isEmpty(destDisk.getPath())) {
+ throw new CloudRuntimeException("Unable to resolve the NVMe
namespace for destination template path ["
+ + destTemplatePath + "] on pool [" +
destPool.getUuid() + "]");
+ }
+
+ // Direct-download templates are commonly published compressed.
+ if
(TemplateDownloaderUtil.isTemplateExtractable(templateFilePath)) {
+ srcTemplateFilePath = sourceFile.getParent() + "/" +
UUID.randomUUID().toString();
+ LOGGER.debug("Extracting downloaded template [{}] to [{}]",
templateFilePath, srcTemplateFilePath);
+
Script.runSimpleBashScript(TemplateDownloaderUtil.getExtractCommandForDownloadedFile(templateFilePath,
srcTemplateFilePath));
+ Script.runSimpleBashScript("rm -f " + templateFilePath);
+ }
+
+ QemuImg.PhysicalDiskFormat srcFormat =
Storage.ImageFormat.RAW.equals(format)
+ ? QemuImg.PhysicalDiskFormat.RAW :
QemuImg.PhysicalDiskFormat.QCOW2;
+
+ QemuImg qemu = new QemuImg(timeout);
+ QemuImgFile srcFile = new QemuImgFile(srcTemplateFilePath,
srcFormat);
+ // Populates the virtual size, and fails early if the file is
unreadable or is
+ // not in the format the template claims to be.
+ qemu.info(srcFile);
+
+ long namespaceSize = getPhysicalDiskSize(destDisk.getPath());
+ if (namespaceSize > 0 && srcFile.getSize() > namespaceSize) {
+ throw new CloudRuntimeException("Direct download template
needs " + srcFile.getSize()
+ + " bytes but the NVMe namespace at " +
destDisk.getPath() + " is only " + namespaceSize + " bytes");
+ }
+
+ QemuImgFile destFile = new QemuImgFile(destDisk.getPath(),
QemuImg.PhysicalDiskFormat.QCOW2);
+ destFile.setSize(srcFile.getSize());
+
+ LOGGER.debug("Converting [{}] onto NVMe namespace [{}]",
srcFile.getFileName(), destDisk.getPath());
+ qemu.create(destFile);
+ qemu.convert(srcFile, destFile);
+
+ KVMPhysicalDisk template = new KVMPhysicalDisk(destDisk.getPath(),
destTemplatePath, destPool);
+ template.setFormat(QemuImg.PhysicalDiskFormat.QCOW2);
+ template.setVirtualSize(srcFile.getSize());
+ template.setSize(srcFile.getSize());
+ destDisk = template;
+ LOGGER.info("Wrote direct download template onto NVMe namespace
[{}] on pool [{}]",
+ destDisk.getPath(), destPool.getUuid());
+ } catch (QemuImgException | LibvirtException e) {
+ throw new CloudRuntimeException("Failed to write the direct
download template [" + templateFilePath
+ + "] onto the NVMe namespace for [" + destTemplatePath +
"] on pool [" + destPool.getUuid()
+ + "]: " + e.getMessage(), e);
+ } finally {
+ // Only remove what we extracted; the original download belongs to
the caller.
+ if (!srcTemplateFilePath.equals(templateFilePath)) {
+ Script.runSimpleBashScript("rm -f " + srcTemplateFilePath);
+ }
+ }
+
+ return destDisk;
+ }
+
+ @Override
+ public boolean refresh(KVMStoragePool pool) {
+ return true;
+ }
+
+ @Override
+ public boolean createFolder(String uuid, String path) {
+ return createFolder(uuid, path, null);
+ }
+
+ @Override
+ public boolean createFolder(String uuid, String path, String localPath) {
+ // Block storage has no directory structure to create. Succeed rather
than
+ // throw, matching the Fibre Channel adapter.
+ LOGGER.info("createFolder({}, {}, {}) is a no-op on NVMe-oF pools",
uuid, path, localPath);
+ return true;
+ }
+
+ /**
+ * Host-side half of a volume resize. The storage provider has already
grown the
+ * namespace on the array by the time we get here, so all that remains is
to make
+ * the new capacity visible locally and tell a running guest about it.
+ *
+ * Unlike the SCSI/FC path there is no device-mapper map to grow: the
kernel picks
+ * up the new namespace size either from the target's asynchronous event
+ * notification or from an explicit {@code nvme ns-rescan}, which we issue
here
+ * rather than waiting for the AEN.
+ */
+ public void resize(String path, String vmName, long newSize) {
+ AddressInfo address = parseAndValidatePath(path);
+ if (address == null || address.getPath() == null) {
+ throw new CloudRuntimeException("Unable to resize NVMe-oF volume,
could not derive a device path from [" + path + "]");
+ }
+
+ LOGGER.debug("Resizing NVMe-oF volume " + address.getPath() + " to " +
newSize + " bytes for VM " + vmName);
+
+ rescanAllControllers();
+
+ long observed = waitForNamespaceSize(address.getPath(), newSize);
+ if (observed < newSize) {
+ // Not fatal: the array has already been grown, and the kernel may
still
+ // catch up via an AEN. Surface it rather than failing the
operation, so
+ // the management server does not roll back a resize that did
happen.
+ LOGGER.warn("NVMe namespace " + address.getPath() + " still
reports " + observed
+ + " bytes after rescan, expected at least " + newSize
+ + "; the guest may not observe the new size until the next
rescan");
+ }
+
+ notifyGuestOfResize(address.getPath(), vmName, newSize,
address.getAddress());
+ }
+
+ /**
+ * Poll the block device until it reports at least {@code expectedSize},
since
+ * ns-rescan and AEN processing are asynchronous.
+ *
+ * @return the last size observed, which may be smaller than expected on
timeout.
+ */
+ private long waitForNamespaceSize(String devicePath, long expectedSize) {
+ long deadline = System.currentTimeMillis() + RESIZE_SETTLE_TIMEOUT_MS;
+ long observed = getPhysicalDiskSize(devicePath);
+ while (observed < expectedSize && System.currentTimeMillis() <
deadline) {
+ try {
+ Thread.sleep(POLL_INTERVAL_MS);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ rescanAllControllers();
+ observed = getPhysicalDiskSize(devicePath);
+ }
+ return observed;
+ }
+
+ /**
+ * Ask libvirt to re-read the size of the guest's block device, so a
running VM
+ * sees the extra capacity without a reboot. A stopped VM needs nothing
here: it
+ * picks up the new size when the disk is next attached.
+ */
+ private void notifyGuestOfResize(String devicePath, String vmName, long
newSize, String eui) {
+ if (StringUtils.isEmpty(vmName)) {
+ LOGGER.debug("No VM name supplied for resize of " + devicePath +
"; skipping guest notification");
+ return;
+ }
+
+ if (!isVmRunning(vmName)) {
+ LOGGER.debug("VM " + vmName + " is not running; skipping guest
notification for " + devicePath);
+ return;
+ }
+
+ String target = findDomainDiskTarget(vmName, devicePath, eui);
+ if (target == null) {
+ LOGGER.warn("Could not find a disk target for " + devicePath + "
in domain " + vmName
+ + "; skipping guest notification");
+ return;
+ }
+
+ // virsh blockresize takes the new size in KiB.
+ Script cmd = new Script("virsh", LOGGER);
+ cmd.add("blockresize");
+ cmd.add("--path", target);
+ cmd.add("--size", String.valueOf(newSize / 1024L));
+ cmd.add(vmName);
+ String result = cmd.execute();
+ if (result != null) {
+ LOGGER.warn("virsh blockresize of " + target + " on " + vmName + "
failed: " + result);
+ } else {
+ LOGGER.info("Notified " + vmName + " of new size " + newSize + "
bytes for " + target);
+ }
+ }
+
+ private boolean isVmRunning(String vmName) {
+ Script cmd = new Script("virsh", LOGGER);
+ cmd.add("domstate", vmName);
+ OutputInterpreter.OneLineParser parser = new
OutputInterpreter.OneLineParser();
+ String result = cmd.execute(parser);
+ return result == null && parser.getLine() != null &&
parser.getLine().trim().startsWith("running");
+ }
+
+ /**
+ * Resolve the domain-local disk target (vda, vdb, ...) backing {@code
devicePath}.
+ *
+ * libvirt reports the source as it was configured, but may instead
surface a
+ * canonicalised /dev/nvmeXnY in place of the /dev/disk/by-id symlink we
attached,
+ * so accept either form, falling back to matching the bare EUI.
+ */
+ private String findDomainDiskTarget(String vmName, String devicePath,
String eui) {
+ Script cmd = new Script("virsh", LOGGER);
+ cmd.add("domblklist", vmName);
+ OutputInterpreter.AllLinesParser parser = new
OutputInterpreter.AllLinesParser();
+ String result = cmd.execute(parser);
+ if (result != null || parser.getLines() == null) {
+ return null;
+ }
+ String canonical = resolveCanonicalPath(devicePath);
+ for (String line : parser.getLines().split("\\R")) {
+ String[] cols = line.trim().split("\\s+");
+ if (cols.length < 2) {
+ continue;
+ }
+ String source = cols[1];
+ if (source.equals(devicePath)
+ || (canonical != null && source.equals(canonical))
+ || (StringUtils.isNotEmpty(eui) &&
source.toLowerCase().contains(eui.toLowerCase()))) {
+ return cols[0];
+ }
+ }
+ return null;
+ }
+
+ /** Resolve a /dev/disk/by-id symlink to its /dev/nvmeXnY target, or null.
*/
+ private String resolveCanonicalPath(String devicePath) {
+ try {
+ return new File(devicePath).getCanonicalPath();
+ } catch (Exception e) {
+ LOGGER.debug("Could not canonicalise " + devicePath + ": " +
e.getMessage());
+ return null;
+ }
+ }
+
+ boolean isConnected(String path) {
+ Script test = new Script("/bin/test", LOGGER);
+ test.add("-b", path);
+ test.execute();
+ return test.getExitValue() == 0;
+ }
+
+ long getPhysicalDiskSize(String diskPath) {
+ if (StringUtils.isEmpty(diskPath)) {
+ return 0;
+ }
+ Script cmd = new Script("blockdev", LOGGER);
+ cmd.add("--getsize64", diskPath);
+ OutputInterpreter.OneLineParser parser = new
OutputInterpreter.OneLineParser();
+ String result = cmd.execute(parser);
+ if (result != null) {
+ LOGGER.debug("Unable to get the disk size at path: " + diskPath);
+ return 0;
+ }
+ try {
+ return Long.parseLong(parser.getLine());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ private static String resolveHostnameShort() {
+ try {
+ String h = java.net.InetAddress.getLocalHost().getHostName();
+ int dot = h.indexOf('.');
+ return dot > 0 ? h.substring(0, dot) : h;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String resolveHostnameFq() {
+ try {
+ return java.net.InetAddress.getLocalHost().getCanonicalHostName();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * Same shape as {@link MultipathSCSIAdapterBase.AddressInfo}. Kept
+ * separate so this class can be consumed by adapters that don't share the
+ * SCSI base.
+ */
+ public static final class AddressInfo {
+ String type;
+ String address;
+ String connectionId;
+ String path;
+
+ public AddressInfo(String type, String address, String connectionId,
String path) {
+ this.type = type;
+ this.address = address;
+ this.connectionId = connectionId;
+ this.path = path;
+ }
+
+ public String getType() { return type; }
+ public String getAddress() { return address; }
+ public String getConnectionId() { return connectionId; }
+ public String getPath() { return path; }
+
+ public String toString() {
+ return String.format("AddressInfo %s [address=%s, connectionId=%s,
path=%s]", type, address, connectionId, path);
+ }
+ }
+}
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/MultipathNVMeOFPool.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/MultipathNVMeOFPool.java
new file mode 100644
index 00000000000..fec1e28f404
--- /dev/null
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/MultipathNVMeOFPool.java
@@ -0,0 +1,165 @@
+// 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.List;
+import java.util.Map;
+
+import org.apache.cloudstack.utils.qemu.QemuImg;
+import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat;
+import org.joda.time.Duration;
+
+import com.cloud.agent.api.to.HostTO;
+import com.cloud.hypervisor.kvm.resource.KVMHABase.HAStoragePool;
+import com.cloud.storage.Storage;
+import com.cloud.storage.Storage.ProvisioningType;
+
+/**
+ * KVMStoragePool for NVMe-over-Fabrics pools. Mirror of
+ * {@link MultipathSCSIPool} for adapters based on
+ * {@link MultipathNVMeOFAdapterBase}. Every data operation is delegated
+ * back to the adapter; the pool itself only tracks addressing/identity.
+ */
+public class MultipathNVMeOFPool implements KVMStoragePool {
+ private final String uuid;
+ private final String sourceHost;
+ private final int sourcePort;
+ private final String sourceDir;
+ private final Storage.StoragePoolType storagePoolType;
+ private final StorageAdaptor storageAdaptor;
+ private final Map<String, String> details;
+ private long capacity;
+ private long used;
+ private long available;
+
+ public MultipathNVMeOFPool(String uuid, String host, int port, String path,
+ Storage.StoragePoolType poolType, Map<String, String> poolDetails,
StorageAdaptor adaptor) {
+ this.uuid = uuid;
+ this.sourceHost = host;
+ this.sourcePort = port;
+ this.sourceDir = path;
+ this.storagePoolType = poolType;
+ this.storageAdaptor = adaptor;
+ this.details = poolDetails;
+ this.capacity = 0;
+ this.used = 0;
+ this.available = 0;
+ }
+
+ public MultipathNVMeOFPool(String uuid, StorageAdaptor adaptor) {
+ this.uuid = uuid;
+ this.sourceHost = null;
+ this.sourcePort = -1;
+ this.sourceDir = null;
+ this.storagePoolType = Storage.StoragePoolType.NVMeTCP;
+ this.storageAdaptor = adaptor;
+ this.details = new HashMap<>();
+ this.capacity = 0;
+ this.used = 0;
+ this.available = 0;
+ }
+
+ @Override
+ public KVMPhysicalDisk createPhysicalDisk(String volumeUuid,
ProvisioningType provisioningType, long size, byte[] passphrase) {
+ return null;
+ }
+
+ @Override
+ public KVMPhysicalDisk createPhysicalDisk(String volumeUuid,
PhysicalDiskFormat format, ProvisioningType provisioningType, long size, byte[]
passphrase) {
+ return null;
+ }
+
+ @Override
+ public boolean connectPhysicalDisk(String volumeUuid, Map<String, String>
details) {
+ return storageAdaptor.connectPhysicalDisk(volumeUuid, this, details,
false);
+ }
+
+ @Override
+ public KVMPhysicalDisk getPhysicalDisk(String volumeId) {
+ return storageAdaptor.getPhysicalDisk(volumeId, this);
+ }
+
+ @Override
+ public boolean disconnectPhysicalDisk(String volumeUuid) {
+ return storageAdaptor.disconnectPhysicalDisk(volumeUuid, this);
+ }
+
+ @Override
+ public boolean deletePhysicalDisk(String volumeUuid, Storage.ImageFormat
format) {
+ return true;
+ }
+
+ /**
+ * Host-side resize, mirroring {@link MultipathSCSIPool#resize}. The
provider has
+ * already grown the volume on the array; the adaptor makes it visible
here.
+ */
+ public void resize(String path, String vmName, long newSize) {
+ ((MultipathNVMeOFAdapterBase)storageAdaptor).resize(path, vmName,
newSize);
+ }
+
+ @Override
+ public List<KVMPhysicalDisk> listPhysicalDisks() {
+ return null;
+ }
+
+ @Override
+ public String getUuid() {
+ return uuid;
+ }
+
+ public void setCapacity(long capacity) { this.capacity = capacity; }
+ @Override public long getCapacity() { return this.capacity; }
+ public void setUsed(long used) { this.used = used; }
+ @Override public long getUsed() { return this.used; }
+ public void setAvailable(long available) { this.available = available; }
+ @Override public long getAvailable() { return this.available; }
+
+ @Override public boolean refresh() { return false; }
+ @Override public boolean isExternalSnapshot() { return true; }
+ @Override public String getLocalPath() { return null; }
+ @Override public String getSourceHost() { return this.sourceHost; }
+ @Override public String getSourceDir() { return this.sourceDir; }
+ @Override public int getSourcePort() { return this.sourcePort; }
+ @Override public String getAuthUserName() { return null; }
+ @Override public String getAuthSecret() { return null; }
+ @Override public Storage.StoragePoolType getType() { return
storagePoolType; }
+ @Override public boolean delete() { return false; }
+ @Override public QemuImg.PhysicalDiskFormat getDefaultFormat() { return
QemuImg.PhysicalDiskFormat.RAW; }
+ @Override public boolean createFolder(String path) { return false; }
+ @Override public boolean supportsConfigDriveIso() { return false; }
+ @Override public Map<String, String> getDetails() { return this.details; }
+ @Override public boolean isPoolSupportHA() { return false; }
+ @Override public String getHearthBeatPath() { return null; }
+
+ @Override
+ public String createHeartBeatCommand(HAStoragePool primaryStoragePool,
String hostPrivateIp, boolean hostValidation) {
+ return null;
+ }
+
+ @Override public String getStorageNodeId() { return null; }
+
+ @Override
+ public Boolean hasHeartBeat(HAStoragePool pool, HostTO host) { return
null; }
+
+ @Override
+ public Boolean hasVmActivity(HAStoragePool pool, HostTO host, Duration
activityScriptTimeout,
+ String volumeUUIDListString, String vmActivityCheckPath, long
duration) {
+ return null;
+ }
+}
diff --git
a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/NVMeTCPAdapter.java
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/NVMeTCPAdapter.java
new file mode 100644
index 00000000000..596f5d7bb9c
--- /dev/null
+++
b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/NVMeTCPAdapter.java
@@ -0,0 +1,51 @@
+// 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 com.cloud.storage.Storage;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+/**
+ * StorageAdaptor for the {@link Storage.StoragePoolType#NVMeTCP} pool type.
+ * All operational logic lives in {@link MultipathNVMeOFAdapterBase}; this
+ * class just binds that logic to a pool type so
+ * {@link KVMStoragePoolManager} can find it via reflection.
+ */
+public class NVMeTCPAdapter extends MultipathNVMeOFAdapterBase {
+ private static final Logger LOGGER =
LogManager.getLogger(NVMeTCPAdapter.class);
+
+ public NVMeTCPAdapter() {
+ LOGGER.info("Loaded NVMeTCPAdapter for StorageLayer");
+ }
+
+ @Override
+ public String getName() {
+ return "NVMeTCPAdapter";
+ }
+
+ @Override
+ public Storage.StoragePoolType getStoragePoolType() {
+ return Storage.StoragePoolType.NVMeTCP;
+ }
+
+ @Override
+ public boolean isStoragePoolTypeSupported(Storage.StoragePoolType type) {
+ return Storage.StoragePoolType.NVMeTCP.equals(type);
+ }
+}
diff --git
a/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/adapter/ProviderVolume.java
b/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/adapter/ProviderVolume.java
index 25577903e3d..a954560508c 100644
---
a/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/adapter/ProviderVolume.java
+++
b/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/adapter/ProviderVolume.java
@@ -35,6 +35,7 @@ public interface ProviderVolume {
public String getExternalName();
public String getExternalConnectionId();
public enum AddressType {
- FIBERWWN
+ FIBERWWN,
+ NVMETCP
}
}
diff --git
a/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/AdaptiveDataStoreLifeCycleImpl.java
b/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/AdaptiveDataStoreLifeCycleImpl.java
index c8efc08c289..4714576498a 100644
---
a/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/AdaptiveDataStoreLifeCycleImpl.java
+++
b/plugins/storage/volume/adaptive/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/AdaptiveDataStoreLifeCycleImpl.java
@@ -179,7 +179,7 @@ public class AdaptiveDataStoreLifeCycleImpl extends
BasePrimaryDataStoreLifeCycl
parameters.setHost(uri.getHost());
parameters.setPort(uri.getPort());
parameters.setPath(uri.getPath() + "?" + uri.getQuery());
- parameters.setType(StoragePoolType.FiberChannel);
+ parameters.setType(pickPoolType(uri));
parameters.setZoneId(zoneId);
parameters.setPodId(podId);
parameters.setClusterId(clusterId);
@@ -402,4 +402,26 @@ public class AdaptiveDataStoreLifeCycleImpl extends
BasePrimaryDataStoreLifeCycl
logger.info("Disabling storage pool {}", store);
_dataStoreHelper.disable(store);
}
+
+ /**
+ * Resolve the CloudStack StoragePoolType from the provider URL. Adaptive
+ * plugins advertise the underlying fabric via a {@code transport=} query
+ * parameter on the URL; when absent we keep the legacy FiberChannel
+ * default for backwards compatibility with adapters that still assume it.
+ */
+ private static StoragePoolType pickPoolType(java.net.URL uri) {
+ String query = uri.getQuery();
+ if (query != null) {
+ for (String tok : query.split("&")) {
+ int i = tok.indexOf('=');
+ if (i > 0 && "transport".equalsIgnoreCase(tok.substring(0,
i))) {
+ String value = tok.substring(i + 1);
+ if ("nvme-tcp".equalsIgnoreCase(value)) {
+ return StoragePoolType.NVMeTCP;
+ }
+ }
+ }
+ }
+ return StoragePoolType.FiberChannel;
+ }
}
diff --git
a/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayAdapter.java
b/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayAdapter.java
index fae94bb1bea..4e12e3eb18d 100644
---
a/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayAdapter.java
+++
b/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayAdapter.java
@@ -30,6 +30,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
@@ -78,6 +79,9 @@ public class FlashArrayAdapter implements ProviderAdapter {
public static final String HOSTGROUP = "hostgroup";
public static final String STORAGE_POD = "pod";
+ public static final String TRANSPORT = "transport";
+ public static final String TRANSPORT_FC = "fc";
+ public static final String TRANSPORT_NVME_TCP = "nvme-tcp";
public static final String KEY_TTL = "keyttl";
public static final String CONNECT_TIMEOUT_MS = "connectTimeoutMs";
public static final String POST_COPY_WAIT_MS = "postCopyWaitMs";
@@ -90,6 +94,9 @@ public class FlashArrayAdapter implements ProviderAdapter {
private static final String API_LOGIN_VERSION_DEFAULT = "1.19";
private static final String API_VERSION_DEFAULT = "2.23";
+ /** A FlashArray NVMe namespace EUI-128 is exactly 32 hexadecimal
characters. */
+ private static final Pattern EUI128_PATTERN =
Pattern.compile("[0-9a-fA-F]{32}");
+
// URLs for which the legacy-auth deprecation WARN has already been
emitted,
// so we don't spam the logs once per refresh per pool while it's still
configured.
private static final Set<String> WARNED_LEGACY_URLS =
ConcurrentHashMap.newKeySet();
@@ -99,7 +106,7 @@ public class FlashArrayAdapter implements ProviderAdapter {
public String hostgroup = null;
private static final DateTimeFormatter DELETION_TIMESTAMP_FORMAT =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.UTC);
-
+ private AddressType volumeAddressType = AddressType.FIBERWWN;
private String username;
private String password;
private String accessToken;
@@ -133,13 +140,14 @@ public class FlashArrayAdapter implements ProviderAdapter
{
request, new TypeReference<FlashArrayList<FlashArrayVolume>>()
{
});
- return (ProviderVolume) getFlashArrayItem(list);
+ return withAddressType((FlashArrayVolume) getFlashArrayItem(list));
}
/**
- * Volumes must be added to a host set to be visable to the hosts.
- * the Hostset should contain all the hosts that are membrers of the zone
or
- * cluster (depending on Cloudstack Storage Pool configuration)
+ * Connect the volume to the host that was granted access. The storage
framework
+ * grants and revokes access one host at a time, so each host is given its
own
+ * connection: a host-group scoped connection is shared by every member
host and
+ * therefore cannot express the removal of a single host's access.
*/
@Override
public String attach(ProviderAdapterContext context,
ProviderAdapterDataObject dataObject, String hostname) {
@@ -160,14 +168,21 @@ public class FlashArrayAdapter implements ProviderAdapter
{
}
if (list == null || list.getItems() == null ||
list.getItems().size() == 0) {
- throw new RuntimeException("Volume attach did not return lun
information");
+ throw new RuntimeException("Volume attach did not return
connection information "
+ + "(expected lun for Fibre Channel or nsid for
NVMe-TCP)");
}
FlashArrayConnection connection = (FlashArrayConnection)
this.getFlashArrayItem(list);
+ if (AddressType.NVMETCP.equals(volumeAddressType)) {
+ // The FlashArray REST API does not return nsid in the
connections
+ // payload for NVMe-TCP. The namespace is identified on the
host by
+ // EUI-128 (see FlashArrayVolume.getAddress()); the value
returned
+ // here is stored by the driver only for informational
purposes.
+ return connection.getNsid() != null ? "" +
connection.getNsid() : "1";
+ }
if (connection.getLun() == null) {
throw new RuntimeException("Volume attach missing lun field");
}
-
return "" + connection.getLun();
} catch (Throwable e) {
@@ -179,15 +194,35 @@ public class FlashArrayAdapter implements ProviderAdapter
{
});
if (list != null && list.getItems() != null) {
for (FlashArrayConnection conn : list.getItems()) {
- if (conn.getHost() != null && conn.getHost().getName()
!= null &&
- (conn.getHost().getName().equals(hostname) ||
conn.getHost().getName().equals(hostname.substring(0, hostname.indexOf('.'))))
&&
- conn.getLun() != null) {
+ if (AddressType.NVMETCP.equals(volumeAddressType)) {
+ // Match the host-scoped connection this adapter
creates. A
+ // host-group scoped match is kept only as a
fallback, so that
+ // volumes still carrying a group connection
created by an
+ // earlier release continue to resolve here.
+ if (conn.getHost() != null &&
conn.getHost().getName() != null
+ &&
(conn.getHost().getName().equals(hostname)
+ || (hostname.indexOf('.') > 0
+ && conn.getHost().getName()
+ .equals(hostname.substring(0,
hostname.indexOf('.')))))) {
+ return conn.getNsid() != null ? "" +
conn.getNsid() : "1";
+ }
+ if (hostgroup != null && conn.getHostGroup() !=
null
+ && conn.getHostGroup().getName() != null
+ &&
conn.getHostGroup().getName().equals(hostgroup)) {
+ return conn.getNsid() != null ? "" +
conn.getNsid() : "1";
+ }
+ } else if (conn.getHost() != null &&
conn.getHost().getName() != null
+ && (conn.getHost().getName().equals(hostname)
+ || (hostname.indexOf('.') > 0
+ && conn.getHost().getName()
+ .equals(hostname.substring(0,
hostname.indexOf('.')))))
+ && conn.getLun() != null) {
return "" + conn.getLun();
}
}
- throw new RuntimeException("Volume lun is not found in
existing connection");
+ throw new RuntimeException("Volume connection identifier
(lun/nsid) not found in existing connection");
} else {
- throw new RuntimeException("Volume lun is not found in
existing connection");
+ throw new RuntimeException("Volume connection is not found
in existing connection list");
}
} else {
throw e;
@@ -198,12 +233,10 @@ public class FlashArrayAdapter implements ProviderAdapter
{
@Override
public void detach(ProviderAdapterContext context,
ProviderAdapterDataObject dataObject, String hostname) {
String volumeName = normalizeName(pod, dataObject.getExternalName());
- // hostname is always provided by cloudstack, but we will detach from
hostgroup
- // if this pool is configured to use hostgroup for attachments
- if (hostgroup != null) {
- DELETE("/connections?host_group_names=" + hostgroup +
"&volume_names=" + volumeName);
- }
-
+ // Only the connection for this host may be removed. A host-group
scoped
+ // connection is shared by every member host, so deleting it here
would revoke
+ // the volume from all of them -- including the host a live-migrating
VM has
+ // just moved to, which pulls the namespace out from under the running
guest.
FlashArrayHost host = getHost(hostname);
if (host != null) {
DELETE("/connections?host_names=" + host.getName() +
"&volume_names=" + volumeName);
@@ -307,14 +340,37 @@ public class FlashArrayAdapter implements ProviderAdapter
{
throw new RuntimeException("Invalid search criteria provided for
getVolumeByAddress");
}
- // only support WWN type addresses at this time.
- if (!ProviderVolume.AddressType.FIBERWWN.equals(addressType)) {
+ String serial;
+ if (ProviderVolume.AddressType.FIBERWWN.equals(addressType)) {
+ // Strip the NAA prefix (1 char) + Pure OUI to recover the volume
serial.
+ serial = address.substring(FlashArrayVolume.PURE_OUI.length() +
1).toUpperCase();
+ } else if (ProviderVolume.AddressType.NVMETCP.equals(addressType)) {
+ // Reverse the EUI-128 layout: serial = eui[2:16] + eui[22:32],
after
+ // stripping the optional "eui." prefix that appears in udev paths.
+ String eui = address.startsWith("eui.") ? address.substring(4) :
address;
+ if (eui == null || !EUI128_PATTERN.matcher(eui).matches()) {
+ throw new RuntimeException("Invalid NVMe-TCP EUI-128 address ["
+ + address + "]: expected 32 hexadecimal characters,
got "
+ + (eui == null ? "null" :
String.valueOf(eui.length())));
+ }
+ // Validate the FlashArray EUI-128 layout before deriving a serial
from it, so a
+ // malformed or tampered address cannot be mapped onto an
unintended volume:
+ // 00 + serial[0:14] + <Pure OUI> + serial[14:24]
+ if (!eui.startsWith("00")) {
+ throw new RuntimeException("Invalid NVMe-TCP EUI-128 address
[" + address
+ + "]: expected a \"00\" prefix for a FlashArray
namespace");
+ }
+ if (!eui.regionMatches(true, 16, FlashArrayVolume.PURE_OUI_EUI, 0,
+ FlashArrayVolume.PURE_OUI_EUI.length())) {
+ throw new RuntimeException("Invalid NVMe-TCP EUI-128 address
[" + address
+ + "]: expected the Pure Storage OUI [" +
FlashArrayVolume.PURE_OUI_EUI
+ + "] at offset 16");
+ }
+ serial = (eui.substring(2, 16) + eui.substring(22)).toUpperCase();
+ } else {
throw new RuntimeException(
"Invalid volume address type [" + addressType + "]
requested for volume search");
}
-
- // convert WWN to serial to search on. strip out WWN type # + Flash
OUI value
- String serial = address.substring(FlashArrayVolume.PURE_OUI.length() +
1).toUpperCase();
String query = "serial='" + serial + "'";
FlashArrayVolume volume = null;
@@ -328,7 +384,7 @@ public class FlashArrayAdapter implements ProviderAdapter {
return null;
}
- volume = (FlashArrayVolume) this.getFlashArrayItem(list);
+ volume = withAddressType((FlashArrayVolume)
this.getFlashArrayItem(list));
if (volume != null && volume.getAddress() == null) {
return null;
}
@@ -365,8 +421,11 @@ public class FlashArrayAdapter implements ProviderAdapter {
"/volume-snapshots?source_names=" +
sourceDataObject.getExternalName(), null,
new TypeReference<FlashArrayList<FlashArrayVolume>>() {
});
-
- return (FlashArrayVolume) getFlashArrayItem(list);
+ // Stamp the pool's volume address type so
ProviderSnapshot.getAddress()
+ // emits an NVMe EUI-128 on NVMe-TCP pools. Without this, the adaptive
+ // driver persists the snapshot with an FC-style WWN and subsequent
+ // revert/list operations cannot locate the namespace.
+ return withAddressType((FlashArrayVolume) getFlashArrayItem(list));
}
/**
@@ -409,7 +468,12 @@ public class FlashArrayAdapter implements ProviderAdapter {
"/volume-snapshots?names=" + dataObject.getExternalName(),
new TypeReference<FlashArrayList<FlashArrayVolume>>() {
});
- return (FlashArrayVolume) getFlashArrayItem(list);
+ // Stamp the pool's volume address type so
ProviderSnapshot.getAddress()
+ // emits an NVMe EUI-128 on NVMe-TCP pools instead of the FIBERWWN
+ // default. Without this, the adaptive driver persists the snapshot
+ // path with an FC-style WWN and revert/list fails to locate the
+ // namespace on the host.
+ return withAddressType((FlashArrayVolume) getFlashArrayItem(list));
}
@Override
@@ -437,10 +501,7 @@ public class FlashArrayAdapter implements ProviderAdapter {
if
(sourceDataObject.getType().equals(ProviderAdapterDataObject.Type.SNAPSHOT)) {
currentVol = getSnapshot(sourceDataObject.getExternalName());
} else {
- currentVol = (FlashArrayVolume) this
- .getFlashArrayItem(GET("/volumes?names=" +
sourceDataObject.getExternalName(),
- new
TypeReference<FlashArrayList<FlashArrayVolume>>() {
- }));
+ currentVol = getVolume(sourceDataObject.getExternalName());
}
if (currentVol == null) {
@@ -459,7 +520,7 @@ public class FlashArrayAdapter implements ProviderAdapter {
"/volumes?names=" + payload.getExternalName() +
"&overwrite=true", payload,
new TypeReference<FlashArrayList<FlashArrayVolume>>() {
});
- FlashArrayVolume outVolume = (FlashArrayVolume)
getFlashArrayItem(list);
+ FlashArrayVolume outVolume = withAddressType((FlashArrayVolume)
getFlashArrayItem(list));
pause(postCopyWait);
return outVolume;
}
@@ -764,6 +825,13 @@ public class FlashArrayAdapter implements ProviderAdapter {
}
}
+ String transport = connectionDetails.get(FlashArrayAdapter.TRANSPORT);
+ if (transport == null) {
+ transport = queryParms.get(FlashArrayAdapter.TRANSPORT);
+ }
+ volumeAddressType = TRANSPORT_NVME_TCP.equalsIgnoreCase(transport)
+ ? AddressType.NVMETCP : AddressType.FIBERWWN;
+
// retrieve for legacy purposes. if set, we'll remove any connections
to hostgroup we find and use the host
hostgroup = connectionDetails.get(FlashArrayAdapter.HOSTGROUP);
if (hostgroup == null) {
@@ -919,11 +987,17 @@ public class FlashArrayAdapter implements ProviderAdapter
{
}
}
+ /**
+ * Look up a volume by name. The pool's address type is always stamped
onto the
+ * result, so no caller can emit an FC-style WWN for a volume that lives
on an
+ * NVMe-TCP pool (see {@link FlashArrayVolume#getAddress()}). The snapshot
+ * accessor below holds the same invariant.
+ */
private FlashArrayVolume getVolume(String volumeName) {
FlashArrayList<FlashArrayVolume> list = GET("/volumes?names=" +
volumeName,
new TypeReference<FlashArrayList<FlashArrayVolume>>() {
});
- return (FlashArrayVolume) getFlashArrayItem(list);
+ return withAddressType((FlashArrayVolume) getFlashArrayItem(list));
}
private FlashArrayPod getVolumeNamespace(String name) {
@@ -937,7 +1011,14 @@ public class FlashArrayAdapter implements ProviderAdapter
{
FlashArrayList<FlashArrayVolume> list = GET("/volume-snapshots?names="
+ snapshotName,
new TypeReference<FlashArrayList<FlashArrayVolume>>() {
});
- return (FlashArrayVolume) getFlashArrayItem(list);
+ return withAddressType((FlashArrayVolume) getFlashArrayItem(list));
+ }
+
+ private FlashArrayVolume withAddressType(FlashArrayVolume vol) {
+ if (vol != null) {
+ vol.setAddressType(volumeAddressType);
+ }
+ return vol;
}
private Object getFlashArrayItem(FlashArrayList<?> list) {
@@ -1246,7 +1327,16 @@ public class FlashArrayAdapter implements
ProviderAdapter {
if (list != null && list.getItems() != null) {
for (FlashArrayConnection conn : list.getItems()) {
- if (conn.getHost() != null) {
+ if (AddressType.NVMETCP.equals(volumeAddressType)) {
+ // Key on the host name so connid.<hostname> is
matched by
+ // parseAndValidatePath. NVMe-TCP reports an nsid
where Fibre
+ // Channel reports a lun; the connection itself is
host-scoped
+ // on both transports.
+ if (conn.getHost() != null && conn.getHost().getName()
!= null) {
+ String id = conn.getNsid() != null ? "" +
conn.getNsid() : "1";
+ map.put(conn.getHost().getName(), id);
+ }
+ } else if (conn.getHost() != null) {
map.put(conn.getHost().getName(), "" + conn.getLun());
}
}
diff --git
a/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayConnection.java
b/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayConnection.java
index 76cec9f70c4..b115035fda2 100644
---
a/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayConnection.java
+++
b/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayConnection.java
@@ -31,6 +31,8 @@ public class FlashArrayConnection {
private FlashArrayVolume volume;
@JsonProperty("lun")
private Integer lun;
+ @JsonProperty("nsid")
+ private Integer nsid;
public FlashArrayConnectionHostgroup getHostGroup() {
return hostGroup;
@@ -64,5 +66,12 @@ public class FlashArrayConnection {
this.lun = lun;
}
+ public Integer getNsid() {
+ return nsid;
+ }
+
+ public void setNsid(Integer nsid) {
+ this.nsid = nsid;
+ }
}
diff --git
a/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayVolume.java
b/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayVolume.java
index a3201a753a7..8ae232cbc68 100644
---
a/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayVolume.java
+++
b/plugins/storage/volume/flasharray/src/main/java/org/apache/cloudstack/storage/datastore/adapter/flasharray/FlashArrayVolume.java
@@ -16,6 +16,8 @@
// under the License.
package org.apache.cloudstack.storage.datastore.adapter.flasharray;
+import java.util.regex.Pattern;
+
import org.apache.cloudstack.storage.datastore.adapter.ProviderSnapshot;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -27,6 +29,13 @@ import com.fasterxml.jackson.annotation.JsonProperty;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class FlashArrayVolume implements ProviderSnapshot {
public static final String PURE_OUI = "24a9370";
+ // The 3-byte OUI as it appears inside an NVMe EUI-128 (no trailing
nibble).
+ // FC WWNs use a 7-hex-digit Pure OUI; NVMe NGUIDs embed the same vendor
+ // prefix in its raw 6-hex-digit form.
+ public static final String PURE_OUI_EUI = "24a937";
+
+ /** FlashArray volume serials are exactly 24 hexadecimal characters. */
+ private static final Pattern SERIAL_PATTERN =
Pattern.compile("[0-9a-fA-F]{24}");
@JsonProperty("destroyed")
private Boolean destroyed;
@@ -107,6 +116,23 @@ public class FlashArrayVolume implements ProviderSnapshot {
@JsonIgnore
public String getAddress() {
if (serial == null) return null;
+ if (AddressType.NVMETCP.equals(addressType)) {
+ // EUI-128 layout for FlashArray NVMe namespaces:
+ // 00 + serial[0:14] + <Pure OUI (24a937)> + serial[14:24]
+ // This is the value the Linux kernel exposes as
+ // /dev/disk/by-id/nvme-eui.<result>
+ // Require an exact 24-hex-character serial. Accepting anything
longer and slicing
+ // the first 24 characters would silently map two distinct serials
sharing a prefix
+ // onto the same EUI, breaking volume identity; accepting anything
shorter cannot
+ // produce a valid 32-character EUI at all.
+ if (!SERIAL_PATTERN.matcher(serial).matches()) {
+ throw new RuntimeException("FlashArray serial [" + serial
+ + "] cannot be used to build an NVMe EUI-128 address "
+ + "(expected exactly 24 hexadecimal characters, got "
+ + serial.length() + ")");
+ }
+ return ("00" + serial.substring(0, 14) + PURE_OUI_EUI +
serial.substring(14, 24)).toLowerCase();
+ }
return ("6" + PURE_OUI + serial).toLowerCase();
}
@Override
diff --git a/server/src/main/java/com/cloud/api/ApiDBUtils.java
b/server/src/main/java/com/cloud/api/ApiDBUtils.java
index 44f29040513..bb9867fa622 100644
--- a/server/src/main/java/com/cloud/api/ApiDBUtils.java
+++ b/server/src/main/java/com/cloud/api/ApiDBUtils.java
@@ -1355,7 +1355,8 @@ public class ApiDBUtils {
StoragePoolType.PowerFlex,
StoragePoolType.CLVM,
StoragePoolType.Linstor,
-
StoragePoolType.FiberChannel).contains(pool.getPoolType())) {
+ StoragePoolType.FiberChannel,
+
StoragePoolType.NVMeTCP).contains(pool.getPoolType())) {
// This case will note the presence of non-qcow2 primary
stores, suggesting KVM without NFS. Otherwse,
// If this check is not passed, the hypervisor type will
remain OVM.
type = HypervisorType.KVM;
diff --git a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java
b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java
index 8674014addd..e81cc00564e 100644
--- a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java
+++ b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java
@@ -2601,7 +2601,8 @@ public class VolumeApiServiceImpl extends ManagerBase
implements VolumeApiServic
if (storagePoolVO.isManaged() && !List.of(
Storage.StoragePoolType.PowerFlex,
-
Storage.StoragePoolType.FiberChannel).contains(storagePoolVO.getPoolType())) {
+ Storage.StoragePoolType.FiberChannel,
+
Storage.StoragePoolType.NVMeTCP).contains(storagePoolVO.getPoolType())) {
Long instanceId = volume.getInstanceId();
if (instanceId != null) {
diff --git a/ui/src/views/infra/AddPrimaryStorage.vue
b/ui/src/views/infra/AddPrimaryStorage.vue
index 7d189032f09..eeb3ebdd22c 100644
--- a/ui/src/views/infra/AddPrimaryStorage.vue
+++ b/ui/src/views/infra/AddPrimaryStorage.vue
@@ -242,7 +242,7 @@
</a-select>
</a-form-item>
</div>
- <div v-if="form.provider !== 'DefaultPrimary' && form.provider !==
'PowerFlex' && form.provider !== 'Linstor' && form.provider !== 'NetApp ONTAP'
&& form.protocol !== 'FiberChannel'">
+ <div v-if="form.provider !== 'DefaultPrimary' && form.provider !==
'PowerFlex' && form.provider !== 'Linstor' && form.provider !== 'NetApp ONTAP'
&& form.protocol !== 'FiberChannel' && form.protocol !== 'NVMeTCP'">
<a-form-item name="managed" ref="managed">
<template #label>
<tooltip-label :title="$t('label.ismanaged')"
:tooltip="apiParams.managed.description"/>
@@ -829,7 +829,10 @@ export default {
if (value === 'PowerFlex') {
this.protocols = ['custom']
this.form.protocol = 'custom'
- } else if (value === 'Flash Array' || value === 'Primera') {
+ } else if (value === 'Flash Array') {
+ this.protocols = ['FiberChannel', 'NVMeTCP']
+ this.form.protocol = 'FiberChannel'
+ } else if (value === 'Primera') {
this.protocols = ['FiberChannel']
this.form.protocol = 'FiberChannel'
} else if (value === 'NetApp ONTAP') {
@@ -960,6 +963,16 @@ export default {
params['details[0].api_username'] = values.flashArrayUsername
params['details[0].api_password'] = values.flashArrayPassword
url = values.flashArrayURL
+ if (values.protocol === 'NVMeTCP') {
+ // Set (or replace) the transport= query parameter rather than
blindly appending,
+ // so a URL already carrying transport= from a hand-crafted
operator entry is not
+ // left with two conflicting values.
+ if (/[?&]transport=/.test(url)) {
+ url = url.replace(/([?&])transport=[^&]*/,
'$1transport=nvme-tcp')
+ } else {
+ url = url + (url.indexOf('?') === -1 ? '?' : '&') +
'transport=nvme-tcp'
+ }
+ }
} else if (values.provider === 'NetApp ONTAP') {
params['details[0].storageIP'] = values.ontapIP
params['details[0].username'] = values.ontapUsername