Files
Kevin AdamsandClaude Opus 5.5 312cfa8c19 feat: implement volume_resize for truenas and truenas-multipath (#294)
Without an override the base Plugin.pm resize dies "can't resize this
image format", which blocked qm disk resize, the GUI and Terraform's
cloud-image provisioning. Grows the zvol via pool/dataset update
(1 MiB-aligned, integer volsize for SCALE's Pydantic). The WebSocket
transport gains the PUT -> pool.dataset.update mapping it needs.

Multipath additionally rescans the target's kernel sessions and runs
multipathd resize map when the device is active on this node, since
QMP block_resize on a host_device can't grow past the stale dm size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:17:26 -04:00

261 lines
10 KiB
Perl

package PVE::Storage::Custom::TrueNASMultipath;
# TrueNAS Multipath Storage Plugin for Proxmox VE
#
# Extends TrueNAS.pm with kernel iSCSI multipath support (iscsiadm + dm-multipath).
# Registers as PVE storage type 'truenas-multipath'.
#
# Requires: truenas-proxmox >= 3.1.0, open-iscsi, multipath-tools
# Auth: Same Bearer token model as TrueNAS.pm
# Path: /dev/mapper/<wwid> instead of iscsi:// — QEMU uses the block device directly
use strict;
use warnings;
use PVE::Storage::Custom::TrueNAS;
use base qw(PVE::Storage::Custom::TrueNAS);
# ── Plugin identity ───────────────────────────────────────────────────────────
sub type { return 'truenas-multipath'; }
sub plugindata {
return {
content => [ { images => 1 }, { images => 1 } ],
format => [ { raw => 1 }, 'raw' ],
};
}
# ── Config properties (extends parent) ───────────────────────────────────────
# Only declare NEW properties here. Inherited TrueNAS.pm properties (truenas_host,
# truenas_pool, etc.) are already registered globally by TrueNAS.pm — re-declaring
# them would cause a "duplicate property" error in PVE::SectionConfig.
sub properties {
return {
truenas_portals => {
description => "Comma-separated iSCSI portal IP addresses for multipath "
. "(e.g. '172.31.69.91,192.168.69.91'). Each portal is logged "
. "into separately; dm-multipath aggregates the sessions.",
type => 'string',
},
};
}
sub options {
my $opts = PVE::Storage::Custom::TrueNAS::options();
$opts->{truenas_portals} = { optional => 0 };
return $opts;
}
# ── Private helpers ───────────────────────────────────────────────────────────
# Parse truenas_portals into a list of IP strings.
sub _portals {
my ($scfg) = @_;
return grep { length } split /\s*,\s*/, ($scfg->{truenas_portals} // '');
}
# Convert TrueNAS NAA string (e.g. "0x6589cfc...") to the dm-multipath WWID
# used in /dev/mapper/ (e.g. "36589cfc..."). Returns undef for a missing/empty
# NAA — a bare "3" would be truthy and send callers to /dev/mapper/3.
sub _naa_to_wwid {
my ($naa) = @_;
return unless defined $naa && length $naa;
$naa =~ s/^0x//i;
return "3$naa";
}
# Run an iscsiadm command, returning (exit_code, output).
# Suppresses errors that indicate a session already exists.
sub _iscsiadm {
my (@args) = @_;
my $out = '';
open(my $fh, '-|', 'iscsiadm', @args, '2>&1')
or return (1, "failed to exec iscsiadm: $!");
$out = do { local $/; <$fh> };
close $fh;
my $rc = $? >> 8;
return ($rc, $out);
}
# Wait up to $timeout seconds for a block device path to appear.
sub _wait_for_device {
my ($path, $timeout) = @_;
$timeout //= 30;
while ($timeout-- > 0) {
return 1 if -b $path;
sleep 1;
}
return 0;
}
# ── Path — block device via dm-multipath ─────────────────────────────────────
sub path {
my ($class, $scfg, $volname, $storeid, $snapname) = @_;
PVE::Storage::Custom::TrueNAS::_resolve_token($storeid, $scfg);
# PVE's contract is ($path, $owner_vmid, $vtype). qm destroy skips any
# disk whose owner != vmid, so returning the LUN id here silently
# orphaned every multipath disk on destroy (#291).
my ($vtype, undef, $ds_vmid) = $class->parse_volname($volname);
# Orphaned zvol (no extent / no NAA): return a placeholder with the real
# owner and vtype so pvesm free / GUI Remove can still delete it (#292).
# activate_volume dies on the same condition, so it is never opened.
my $ext = PVE::Storage::Custom::TrueNAS::_find_extent($scfg, $volname);
my $wwid = $ext ? _naa_to_wwid($ext->{naa}) : undef;
unless ($wwid) {
PVE::Storage::Custom::TrueNAS::_log('warning',
"multipath path: $volname has no iSCSI extent/WWID — returning placeholder path");
return ("/dev/mapper/truenas-unmapped-$volname", $ds_vmid, $vtype);
}
return ("/dev/mapper/$wwid", $ds_vmid, $vtype);
}
# ── volume_resize — grow zvol, then refresh kernel view of the LUN ────────────
# The base class grows the zvol. If the volume is active on this node, the
# kernel iSCSI sessions and the dm map still report the old size, and
# qemu-server's QMP block_resize on the host_device would fail. Rescan every
# session to the target, then have multipathd resize the map.
sub volume_resize {
my ($class, $scfg, $storeid, $volname, $size, $running, $snapname) = @_;
my $new_kb = $class->SUPER::volume_resize($scfg, $storeid, $volname, $size, $running, $snapname);
my $ext = PVE::Storage::Custom::TrueNAS::_find_extent($scfg, $volname);
my $wwid = $ext ? _naa_to_wwid($ext->{naa}) : undef;
return $new_kb unless $wwid && -b "/dev/mapper/$wwid" && defined $ext->{target_id};
my $t = PVE::Storage::Custom::TrueNAS::_api($scfg, 'GET',
"/iscsi/target/id/$ext->{target_id}") // {};
my $iqn = PVE::Storage::Custom::TrueNAS::_basename($scfg) . ":$t->{name}";
my ($rc, $out) = _iscsiadm('-m', 'node', '-T', $iqn, '-R');
die "iscsiadm rescan of $iqn failed (rc=$rc): $out\n" if $rc;
my $multipathd = -x '/usr/sbin/multipathd' ? '/usr/sbin/multipathd' : '/sbin/multipathd';
system($multipathd, 'resize', 'map', $wwid) == 0
or die "multipathd resize map $wwid failed (rc=" . ($? >> 8) . ")\n";
PVE::Storage::Custom::TrueNAS::_log('info', "multipath resize: $wwid rescanned and resized");
return $new_kb;
}
# ── qemu_blockdev_options — delegate to Plugin.pm (host_device for /dev/mapper) ─
# TrueNAS.pm overrides qemu_blockdev_options to return an iscsi:// blockdev —
# that's wrong for multipath where path() returns /dev/mapper/<wwid>.
# We skip the parent and call the grandparent (PVE::Storage::Plugin) directly.
# Plugin.pm calls path(), sees /dev/mapper/<wwid> starts with '/', stats it as a
# block device (S_ISBLK), and returns { driver => 'host_device', filename => ... }.
# QEMU then accesses the dm-multipath block device directly.
sub qemu_blockdev_options {
my ($class, $scfg, $storeid, $volname, $machine_version, $options) = @_;
return PVE::Storage::Plugin::qemu_blockdev_options(
$class, $scfg, $storeid, $volname, $machine_version, $options // {});
}
# ── activate_volume — login via all portals, wait for mapper device ───────────
sub activate_volume {
my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
PVE::Storage::Custom::TrueNAS::_resolve_token($storeid, $scfg);
my $ext = PVE::Storage::Custom::TrueNAS::_find_extent($scfg, $volname);
die "Volume '$volname' has no iSCSI extent on $scfg->{truenas_host}.\n" unless $ext;
die "Volume '$volname' is not mapped to any iSCSI target.\n"
unless defined $ext->{target_id};
my $t = PVE::Storage::Custom::TrueNAS::_api($scfg, 'GET',
"/iscsi/target/id/$ext->{target_id}") // {};
my $iqn = PVE::Storage::Custom::TrueNAS::_basename($scfg) . ":$t->{name}";
my $wwid = _naa_to_wwid($ext->{naa})
or die "Volume '$volname' has no NAA/WWID.\n";
my $mapper = "/dev/mapper/$wwid";
return if -b $mapper; # already active
my @portals = _portals($scfg);
die "No portals configured — set truenas_portals in storage config.\n" unless @portals;
PVE::Storage::Custom::TrueNAS::_log('info',
"multipath activate: $volname iqn=$iqn wwid=$wwid portals=" . join(',', @portals));
for my $ip (@portals) {
my $portal = "$ip:3260";
# Ensure node entry exists
_iscsiadm('-m', 'node', '-T', $iqn, '-p', $portal, '-o', 'new');
# Login — ISCSI_ERR_SESS_EXISTS (15) is fine
my ($rc, $out) = _iscsiadm('-m', 'node', '-T', $iqn, '-p', $portal, '--login');
if ($rc && $rc != 15) {
die "iscsiadm login to $portal failed (rc=$rc): $out\n";
}
}
# Signal multipathd to pick up the new sessions. Don't call multipath(8)
# directly — that conflicts with a running multipathd daemon.
if (-x '/usr/sbin/multipathd') {
system('multipathd', 'reconfigure');
} elsif (-x '/sbin/multipathd') {
system('/sbin/multipathd', 'reconfigure');
}
# multipathd may take a moment to create the dm device after sessions appear
unless (_wait_for_device($mapper, 30)) {
# Last resort: force a manual scan
system('multipath', '-v0') if !-b $mapper;
unless (_wait_for_device($mapper, 10)) {
my $found = `multipath -l 2>/dev/null` // '';
die "Multipath device $mapper did not appear after 40s. "
. "Check that multipathd is running and multipath.conf includes TrueNAS devices.\n"
. "Current multipath map:\n$found\n";
}
}
PVE::Storage::Custom::TrueNAS::_log('info', "multipath activate: $mapper ready");
return;
}
# ── deactivate_volume — logout from all portals, flush mapper device ──────────
sub deactivate_volume {
my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
PVE::Storage::Custom::TrueNAS::_resolve_token($storeid, $scfg);
my $ext = PVE::Storage::Custom::TrueNAS::_find_extent($scfg, $volname);
return unless $ext && defined $ext->{target_id};
my $t = PVE::Storage::Custom::TrueNAS::_api($scfg, 'GET',
"/iscsi/target/id/$ext->{target_id}") // {};
my $iqn = PVE::Storage::Custom::TrueNAS::_basename($scfg) . ":$t->{name}";
my $wwid = _naa_to_wwid($ext->{naa} // '');
my $mapper = $wwid ? "/dev/mapper/$wwid" : undef;
# Flush multipath device first
if ($mapper && -b $mapper) {
system('multipath', '-f', $wwid);
}
for my $ip (_portals($scfg)) {
my $portal = "$ip:3260";
_iscsiadm('-m', 'node', '-T', $iqn, '-p', $portal, '--logout');
_iscsiadm('-m', 'node', '-T', $iqn, '-p', $portal, '-o', 'delete');
}
PVE::Storage::Custom::TrueNAS::_log('info', "multipath deactivate: $volname done");
return;
}
1;