diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ceec335..082f118 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/packaging/DEBIAN-multipath/changelog.Debian b/packaging/DEBIAN-multipath/changelog.Debian new file mode 100644 index 0000000..5599546 --- /dev/null +++ b/packaging/DEBIAN-multipath/changelog.Debian @@ -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/ for QEMU block device access + * activate_volume() logs in via all configured portals + * Distributed via the 'multipath' apt component + + -- KSA Technologies, LLC Sun, 07 Jun 2026 00:00:00 +0000 diff --git a/packaging/DEBIAN-multipath/control.j2 b/packaging/DEBIAN-multipath/control.j2 new file mode 100644 index 0000000..8d65a77 --- /dev/null +++ b/packaging/DEBIAN-multipath/control.j2 @@ -0,0 +1,19 @@ +Package: truenas-proxmox-multipath +Version: ${VERSION} +Architecture: all +Maintainer: KSA Technologies, LLC +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/ 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. diff --git a/packaging/DEBIAN-multipath/multipath.conf.example b/packaging/DEBIAN-multipath/multipath.conf.example new file mode 100644 index 0000000..320131d --- /dev/null +++ b/packaging/DEBIAN-multipath/multipath.conf.example @@ -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 + } +} diff --git a/packaging/DEBIAN-multipath/postinst b/packaging/DEBIAN-multipath/postinst new file mode 100644 index 0000000..ea8c35b --- /dev/null +++ b/packaging/DEBIAN-multipath/postinst @@ -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 diff --git a/packaging/DEBIAN-multipath/postrm b/packaging/DEBIAN-multipath/postrm new file mode 100644 index 0000000..ea065b1 --- /dev/null +++ b/packaging/DEBIAN-multipath/postrm @@ -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 diff --git a/perl5/PVE/Storage/Custom/TrueNASMultipath.pm b/perl5/PVE/Storage/Custom/TrueNASMultipath.pm new file mode 100644 index 0000000..3fd0b42 --- /dev/null +++ b/perl5/PVE/Storage/Custom/TrueNASMultipath.pm @@ -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/ 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;