feat: truenas-proxmox-multipath plugin and packaging (#256)

Adds TrueNASMultipath.pm — a separate PVE storage type 'truenas-multipath'
that inherits all REST API and iSCSI resource management from TrueNAS.pm but
uses iscsiadm + dm-multipath for block device access:

  path()             → /dev/mapper/<wwid>  (NAA from TrueNAS extent)
  activate_volume()  → iscsiadm login on each portal in truenas_portals;
                       waits for /dev/mapper device to appear
  deactivate_volume()→ multipath flush + iscsiadm logout
  qemu_blockdev_options → returns undef (path() used instead)

Packaging:
  packaging/DEBIAN-multipath/ — control.j2, postinst, postrm,
  changelog.Debian, multipath.conf.example
  Distributed via the 'multipath' apt component (ADR-011)
  Depends: truenas-proxmox >= 3.1.0, open-iscsi, multipath-tools

CI (build.yml):
  - Third staging dir (dist-multipath) and .deb output
  - multipath component pool under pool/multipath/v<major>
  - Both stable and testing publish steps updated to generate
    main + multipath components; Release lists both
  - Lint checks TrueNASMultipath.pm and multipath packaging scripts

PoC confirmed 2026-06-07 on pve01-hq against TrueNAS SCALE 24.10:
  - Two sessions (172.31.69.91 + 192.168.69.91), both active
  - dm-multipath aggregated 3 disks × 2 paths each
  - /dev/mapper/mpath* devices appeared correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kevin Adams 2026-06-08 08:52:47 -04:00
parent 0ce11dc6ba
commit 0d195a9074
7 changed files with 419 additions and 39 deletions

View File

@ -46,12 +46,16 @@ jobs:
echo "==> Checking Perl syntax..."
perl -c -I/tmp/pve-stub perl5/PVE/Storage/Custom/TrueNAS.pm \
&& echo "TrueNAS.pm: OK"
perl -c -I/tmp/pve-stub -I/tmp/pve-stub/.. \
perl5/PVE/Storage/Custom/TrueNASMultipath.pm \
&& echo "TrueNASMultipath.pm: OK"
- name: Perl static analysis (perlcritic)
run: |
echo "==> Running perlcritic..."
perlcritic --profile .perlcriticrc \
perl5/PVE/Storage/Custom/TrueNAS.pm
perl5/PVE/Storage/Custom/TrueNAS.pm \
perl5/PVE/Storage/Custom/TrueNASMultipath.pm
- name: Shell script lint (shellcheck)
run: |
@ -59,7 +63,9 @@ jobs:
shellcheck --severity=warning \
packaging/DEBIAN/postinst \
packaging/DEBIAN/postrm \
packaging/DEBIAN-transitional/postinst
packaging/DEBIAN-transitional/postinst \
packaging/DEBIAN-multipath/postinst \
packaging/DEBIAN-multipath/postrm
echo "Shell scripts OK"
# ── Job 2: Build .deb ────────────────────────────────────────────────────────
@ -72,6 +78,7 @@ jobs:
version: ${{ steps.vars.outputs.version }}
deb_file: ${{ steps.vars.outputs.deb_file }}
transitional_deb_file: ${{ steps.vars.outputs.transitional_deb_file }}
multipath_deb_file: ${{ steps.vars.outputs.multipath_deb_file }}
channel: ${{ steps.vars.outputs.channel }}
cloudsmith_repo: ${{ steps.vars.outputs.cloudsmith_repo }}
is_release: ${{ steps.vars.outputs.is_release }}
@ -129,10 +136,12 @@ jobs:
DEB_FILE="${PACKAGE_NAME}_${VERSION}_all.deb"
TRANSITIONAL_DEB_FILE="${TRANSITIONAL_PACKAGE_NAME}_${VERSION}_all.deb"
MULTIPATH_DEB_FILE="truenas-proxmox-multipath_${VERSION}_all.deb"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "deb_file=${DEB_FILE}" >> "$GITHUB_OUTPUT"
echo "transitional_deb_file=${TRANSITIONAL_DEB_FILE}" >> "$GITHUB_OUTPUT"
echo "multipath_deb_file=${MULTIPATH_DEB_FILE}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
echo "cloudsmith_repo=${CLOUDSMITH_REPO}" >> "$GITHUB_OUTPUT"
echo "is_release=${IS_RELEASE}" >> "$GITHUB_OUTPUT"
@ -198,21 +207,49 @@ jobs:
echo "==> Transitional package contents:"
find "${STAGING}" | sort
- name: Assemble multipath package staging directory
run: |
VERSION="${{ steps.vars.outputs.version }}"
STAGING="dist-multipath"
mkdir -p "${STAGING}/DEBIAN"
mkdir -p "${STAGING}/usr/share/truenas-proxmox-multipath"
sed "s/\${VERSION}/${VERSION}/" packaging/DEBIAN-multipath/control.j2 \
> "${STAGING}/DEBIAN/control"
cp packaging/DEBIAN-multipath/postinst "${STAGING}/DEBIAN/postinst"
cp packaging/DEBIAN-multipath/postrm "${STAGING}/DEBIAN/postrm"
chmod 0755 "${STAGING}/DEBIAN/postinst" "${STAGING}/DEBIAN/postrm"
cp perl5/PVE/Storage/Custom/TrueNASMultipath.pm \
"${STAGING}/usr/share/truenas-proxmox-multipath/TrueNASMultipath.pm"
cp packaging/DEBIAN-multipath/multipath.conf.example \
"${STAGING}/usr/share/truenas-proxmox-multipath/multipath.conf.example"
mkdir -p "${STAGING}/usr/share/doc/truenas-proxmox-multipath"
gzip -9c packaging/DEBIAN-multipath/changelog.Debian \
> "${STAGING}/usr/share/doc/truenas-proxmox-multipath/changelog.Debian.gz"
echo "==> Multipath package contents:"
find "${STAGING}" | sort
- name: Build .deb packages
run: |
sudo dpkg-deb -Zgzip --build dist "${{ steps.vars.outputs.deb_file }}"
sudo dpkg-deb -Zgzip --build dist-transitional "${{ steps.vars.outputs.transitional_deb_file }}"
sudo dpkg-deb -Zgzip --build dist-multipath "${{ steps.vars.outputs.multipath_deb_file }}"
- name: Verify .deb packages
run: |
echo "==> Package info (truenas-proxmox):"
dpkg-deb --info "${{ steps.vars.outputs.deb_file }}"
echo ""
echo "==> Package contents (truenas-proxmox):"
dpkg-deb --contents "${{ steps.vars.outputs.deb_file }}"
echo ""
echo "==> Package info (freenas-proxmox transitional):"
dpkg-deb --info "${{ steps.vars.outputs.transitional_deb_file }}"
echo ""
echo "==> Package info (truenas-proxmox-multipath):"
dpkg-deb --info "${{ steps.vars.outputs.multipath_deb_file }}"
- name: Upload package artifacts
uses: actions/upload-artifact@v4
@ -221,6 +258,7 @@ jobs:
path: |
${{ steps.vars.outputs.deb_file }}
${{ steps.vars.outputs.transitional_deb_file }}
${{ steps.vars.outputs.multipath_deb_file }}
retention-days: 30
# ── Job 3: Security scan ─────────────────────────────────────────────────────
@ -340,54 +378,55 @@ jobs:
"https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \
"$PAGES_DIR"
# Add .deb files to the versioned pool directory
# Add .deb files to the versioned pool directories
mkdir -p "${PAGES_DIR}/pool/v${MAJOR}"
mkdir -p "${PAGES_DIR}/pool/multipath/v${MAJOR}"
cp "${{ needs.build.outputs.deb_file }}" "${PAGES_DIR}/pool/v${MAJOR}/"
cp "${{ needs.build.outputs.transitional_deb_file }}" "${PAGES_DIR}/pool/v${MAJOR}/"
cp "${{ needs.build.outputs.multipath_deb_file }}" "${PAGES_DIR}/pool/multipath/v${MAJOR}/"
# Install apt tooling
sudo apt-get install -y --no-install-recommends dpkg-dev apt-utils
# Determine which dists to update
# v2.x: update v2, main (alias), and v2 (explicit)
# v3.x and above: update only the versioned dist
if [[ "$MAJOR" == "2" ]]; then
DISTS="v2 main"
else
DISTS="v${MAJOR}"
fi
for DIST in $DISTS; do
mkdir -p "${PAGES_DIR}/dists/${DIST}/main/binary-all"
mkdir -p "${PAGES_DIR}/dists/${DIST}/main/binary-amd64"
mkdir -p "${PAGES_DIR}/dists/${DIST}/main/binary-arm64"
# Generate Packages file (paths relative to repo root)
dpkg-scanpackages --multiversion "${PAGES_DIR}/pool/v${MAJOR}" \
_gen_component() {
local PAGES_DIR="$1" DIST="$2" COMP="$3" POOL="$4"
mkdir -p "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-all"
mkdir -p "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-amd64"
mkdir -p "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-arm64"
dpkg-scanpackages --multiversion "$POOL" \
| sed "s|^Filename: ${PAGES_DIR}/|Filename: |" \
> "${PAGES_DIR}/dists/${DIST}/main/binary-all/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/main/binary-all/Packages"
> "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-all/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-all/Packages"
touch "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-amd64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-amd64/Packages"
touch "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-arm64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-arm64/Packages"
}
# Empty arch-specific Packages — all our packages are Architecture: all;
# these files silence the apt "doesn't support architecture" warning on
# amd64 and arm64 hosts without duplicating package entries.
touch "${PAGES_DIR}/dists/${DIST}/main/binary-amd64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/main/binary-amd64/Packages"
touch "${PAGES_DIR}/dists/${DIST}/main/binary-arm64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/main/binary-arm64/Packages"
for DIST in $DISTS; do
_gen_component "$PAGES_DIR" "$DIST" "main" \
"${PAGES_DIR}/pool/v${MAJOR}"
_gen_component "$PAGES_DIR" "$DIST" "multipath" \
"${PAGES_DIR}/pool/multipath/v${MAJOR}"
# Generate Release file
# Generate Release file listing both components
apt-ftparchive \
-o "APT::FTPArchive::Release::Origin=truenas-proxmox" \
-o "APT::FTPArchive::Release::Label=truenas-proxmox" \
-o "APT::FTPArchive::Release::Suite=${DIST}" \
-o "APT::FTPArchive::Release::Codename=${DIST}" \
-o "APT::FTPArchive::Release::Components=main" \
-o "APT::FTPArchive::Release::Components=main multipath" \
-o "APT::FTPArchive::Release::Architectures=all amd64 arm64" \
release "${PAGES_DIR}/dists/${DIST}" \
> "${PAGES_DIR}/dists/${DIST}/Release"
# Sign → InRelease
gpg --batch --yes \
--passphrase "${APT_SIGNING_KEY_PASSPHRASE}" \
--default-key "${GPG_KEY_ID}" \
@ -433,32 +472,39 @@ jobs:
# Replace pool/testing/ contents with the latest build only
rm -rf "${PAGES_DIR}/pool/testing"
mkdir -p "${PAGES_DIR}/pool/testing"
mkdir -p "${PAGES_DIR}/pool/multipath/testing"
cp "${{ needs.build.outputs.deb_file }}" "${PAGES_DIR}/pool/testing/"
cp "${{ needs.build.outputs.transitional_deb_file }}" "${PAGES_DIR}/pool/testing/"
cp "${{ needs.build.outputs.multipath_deb_file }}" "${PAGES_DIR}/pool/multipath/testing/"
sudo apt-get install -y --no-install-recommends dpkg-dev apt-utils
DIST="testing"
mkdir -p "${PAGES_DIR}/dists/${DIST}/main/binary-all"
mkdir -p "${PAGES_DIR}/dists/${DIST}/main/binary-amd64"
mkdir -p "${PAGES_DIR}/dists/${DIST}/main/binary-arm64"
dpkg-scanpackages --multiversion "${PAGES_DIR}/pool/testing" \
| sed "s|^Filename: ${PAGES_DIR}/|Filename: |" \
> "${PAGES_DIR}/dists/${DIST}/main/binary-all/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/main/binary-all/Packages"
_gen_component() {
local PAGES_DIR="$1" DIST="$2" COMP="$3" POOL="$4"
mkdir -p "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-all"
mkdir -p "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-amd64"
mkdir -p "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-arm64"
dpkg-scanpackages --multiversion "$POOL" \
| sed "s|^Filename: ${PAGES_DIR}/|Filename: |" \
> "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-all/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-all/Packages"
touch "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-amd64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-amd64/Packages"
touch "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-arm64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/${COMP}/binary-arm64/Packages"
}
touch "${PAGES_DIR}/dists/${DIST}/main/binary-amd64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/main/binary-amd64/Packages"
touch "${PAGES_DIR}/dists/${DIST}/main/binary-arm64/Packages"
gzip -kf "${PAGES_DIR}/dists/${DIST}/main/binary-arm64/Packages"
_gen_component "$PAGES_DIR" "$DIST" "main" "${PAGES_DIR}/pool/testing"
_gen_component "$PAGES_DIR" "$DIST" "multipath" "${PAGES_DIR}/pool/multipath/testing"
apt-ftparchive \
-o "APT::FTPArchive::Release::Origin=truenas-proxmox" \
-o "APT::FTPArchive::Release::Label=truenas-proxmox" \
-o "APT::FTPArchive::Release::Suite=${DIST}" \
-o "APT::FTPArchive::Release::Codename=${DIST}" \
-o "APT::FTPArchive::Release::Components=main" \
-o "APT::FTPArchive::Release::Components=main multipath" \
-o "APT::FTPArchive::Release::Architectures=all amd64 arm64" \
release "${PAGES_DIR}/dists/${DIST}" \
> "${PAGES_DIR}/dists/${DIST}/Release"
@ -487,6 +533,7 @@ jobs:
files: |
${{ needs.build.outputs.deb_file }}
${{ needs.build.outputs.transitional_deb_file }}
${{ needs.build.outputs.multipath_deb_file }}
generate_release_notes: false
body: |
## truenas-proxmox v${{ needs.build.outputs.version }}

View File

@ -0,0 +1,10 @@
truenas-proxmox-multipath (3.1.0-1) bookworm; urgency=low
* Initial release — kernel iSCSI multipath via iscsiadm + dm-multipath
* Registers 'truenas-multipath' PVE storage type
* Inherits all REST API and iSCSI resource management from truenas-proxmox
* path() returns /dev/mapper/<wwid> for QEMU block device access
* activate_volume() logs in via all configured portals
* Distributed via the 'multipath' apt component
-- KSA Technologies, LLC <theprofessor@ksatechnologies.com> Sun, 07 Jun 2026 00:00:00 +0000

View File

@ -0,0 +1,19 @@
Package: truenas-proxmox-multipath
Version: ${VERSION}
Architecture: all
Maintainer: KSA Technologies, LLC <theprofessor@ksatechnologies.com>
Depends: truenas-proxmox (>= 3.1.0), open-iscsi, multipath-tools
Section: perl
Priority: optional
Homepage: https://github.com/TheGrandWazoo/truenas-proxmox
Description: TrueNAS Multipath Storage Plugin for Proxmox VE
Extends truenas-proxmox with kernel iSCSI multipath support via iscsiadm
and dm-multipath. Registers a separate 'truenas-multipath' storage type
that presents /dev/mapper/<wwid> block devices to QEMU instead of
iscsi:// URIs, enabling active/active multipath with failover.
.
Requires two or more network paths between Proxmox and TrueNAS and
TrueNAS iSCSI portals configured on each path.
.
Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
Copyright (c) 2020 KSA Technologies, LLC.

View File

@ -0,0 +1,20 @@
# /etc/multipath.conf — installed by truenas-proxmox-multipath
# This file is only created if /etc/multipath.conf does not already exist.
# If you have an existing multipath.conf, merge the 'devices' section below
# into it manually.
defaults {
find_multipaths yes
}
devices {
device {
vendor "TrueNAS"
product "iSCSI Disk"
path_grouping_policy multibus
path_checker tur
failback immediate
rr_weight uniform
no_path_retry 5
}
}

View File

@ -0,0 +1,56 @@
#!/bin/bash
# postinst: truenas-proxmox-multipath install/upgrade script
set -e
INSTALL_DIR="/usr/share/truenas-proxmox-multipath"
PLUGIN_DST="/usr/share/perl5/PVE/Storage/Custom/TrueNASMultipath.pm"
MULTIPATH_CONF="/etc/multipath.conf"
LOG_FILE="/var/log/truenas-proxmox-install.log"
log() {
echo "[truenas-proxmox-multipath] $*" | tee -a "$LOG_FILE"
}
install_plugin() {
log "Installing ${PLUGIN_DST}"
mkdir -p "$(dirname "$PLUGIN_DST")"
cp "${INSTALL_DIR}/TrueNASMultipath.pm" "$PLUGIN_DST"
}
install_multipath_conf() {
if [ -f "$MULTIPATH_CONF" ]; then
log "${MULTIPATH_CONF} already exists — not overwriting"
log "Ensure it contains a 'devices' section for TrueNAS iSCSI Disk"
log "See ${INSTALL_DIR}/multipath.conf.example for reference"
return 0
fi
log "Installing ${MULTIPATH_CONF}"
cp "${INSTALL_DIR}/multipath.conf.example" "$MULTIPATH_CONF"
systemctl enable multipathd 2>/dev/null || true
systemctl start multipathd 2>/dev/null || true
log "multipathd enabled and started"
}
restart_pve_services() {
log "Restarting Proxmox VE services ..."
pvedaemon restart && log "pvedaemon restarted"
pveproxy restart && log "pveproxy restarted"
log "Done. Refresh your Proxmox browser tab."
}
case "$1" in
configure)
log "Configuring truenas-proxmox-multipath (previous version: ${2:-none})"
install_plugin
install_multipath_conf
restart_pve_services
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "$0: called with unknown argument '$1'" >&2
exit 0
;;
esac
exit 0

View File

@ -0,0 +1,32 @@
#!/bin/bash
# postrm: truenas-proxmox-multipath remove/purge script
set -e
PLUGIN_DST="/usr/share/perl5/PVE/Storage/Custom/TrueNASMultipath.pm"
LOG_FILE="/var/log/truenas-proxmox-install.log"
log() {
echo "[truenas-proxmox-multipath] $*" | tee -a "$LOG_FILE"
}
case "$1" in
remove|purge)
if [ -f "$PLUGIN_DST" ]; then
log "Removing ${PLUGIN_DST}"
rm -f "$PLUGIN_DST"
fi
log "Restarting Proxmox VE services ..."
pvedaemon restart 2>/dev/null && log "pvedaemon restarted" || true
pveproxy restart 2>/dev/null && log "pveproxy restarted" || true
log "Done."
;;
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
*)
echo "$0: called with unknown argument '$1'" >&2
exit 0
;;
esac
exit 0

View File

@ -0,0 +1,196 @@
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) ───────────────────────────────────────
sub properties {
my $props = PVE::Storage::Custom::TrueNAS::properties();
$props->{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',
};
return $props;
}
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...").
sub _naa_to_wwid {
my ($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);
my $ext = PVE::Storage::Custom::TrueNAS::_find_extent($scfg, $volname);
die "Volume '$volname' has no iSCSI extent on $scfg->{truenas_host}.\n" unless $ext;
my $wwid = _naa_to_wwid($ext->{naa})
or die "Volume '$volname' has no NAA/WWID on $scfg->{truenas_host}.\n";
return ("/dev/mapper/$wwid", $ext->{lun_id}, $storeid);
}
# ── qemu_blockdev_options — not used (path returns a block device) ────────────
# The base class qemu_blockdev_options builds an iscsi:// blockdev.
# For multipath we return undef so PVE uses path() instead.
sub qemu_blockdev_options { return undef; }
# ── 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";
}
}
# Tell multipathd to rescan now
system('multipathd', 'reconfigure') if -x '/usr/sbin/multipathd';
system('multipath') if !-b $mapper;
unless (_wait_for_device($mapper, 30)) {
die "Multipath device $mapper did not appear after 30s. "
. "Check that multipathd is running and multipath.conf includes TrueNAS devices.\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;