Merge pull request #297 from TheGrandWazoo/fix/294-resize-sizeinfo-cloudinit

fix: volume_resize, list-context volume_size_info, cloud-init volnames (#294)
This commit is contained in:
Kevin Scott Adams
2026-10-13 08:34:57 -04:00
committed by GitHub
7 changed files with 266 additions and 3 deletions
+13
View File
@@ -35,6 +35,7 @@ This plugin is a native `PVE::Storage::Custom` type — Proxmox discovers it aut
- Bearer Token (API key) authentication — no username/password, no SSH keys
- Per-VM iSCSI targets (`proxmox-vm-<vmid>`) — removing one disk never affects other VMs
- Automatic rollback when operations fail (no dangling iSCSI extents)
- Disk resize, including on a running VM. Cloud-init drives are supported (v4.0.2+).
- Supports TrueNAS CORE 13.x and TrueNAS SCALE 24.10–25.10
---
@@ -607,6 +608,18 @@ If you see iSCSI extents in TrueNAS that are not associated with any target, the
perl -MPVE::Storage -e 'PVE::Storage::vdisk_free(PVE::Storage::config(), "STORAGE:vm-101-disk-0")'
```
### Resize, disk info or cloud-init operations fail on 4.0.1 and earlier
These errors come from plugin gaps that are **fixed in v4.0.2** ([#294](https://github.com/TheGrandWazoo/freenas-proxmox/issues/294)):
| Error | Cause | Workaround before upgrading |
|---|---|---|
| `can't resize this image format` (`qm disk resize`, GUI Resize, Terraform) | No `volume_resize` | Grow the zvol in TrueNAS (Datasets → zvol → Edit → Size). Then run `qm rescan --vmid <id>` so Proxmox picks up the new size. |
| `volume_size_info on '<volid>' failed - no format` (content API, `bpg/proxmox` Terraform refresh) | `volume_size_info` returned only a size | None. Upgrade. |
| `unable to parse TrueNAS volume name 'vm-<id>-cloudinit'` (`qm destroy`, cloud-init regenerate) | `parse_volname` rejected cloud-init names | Remove the cloud-init drive from the VM. Then delete `vm-<id>-cloudinit` in TrueNAS along with its iSCSI extent (and the `proxmox-vm-<id>` target if it's now empty). |
Resizing only grows the disk. You still need to grow the partition and filesystem inside the guest yourself (cloud-init's `growpart` does this on next boot).
### `qm destroy` left multipath disks behind on TrueNAS
**Symptom:** you destroy a VM whose disks are on a `truenas-multipath` storage. The task reports `OK`, but the zvols, extents and the `proxmox-vm-<id>` target are still on TrueNAS. The journal has no `free_image` line for those disks.
+48 -2
View File
@@ -534,6 +534,9 @@ sub _api_ws {
if ($method eq 'DELETE') {
$rpc_method = 'pool.dataset.delete';
$params = [$id, $data // {}];
} elsif ($method eq 'PUT') {
$rpc_method = 'pool.dataset.update';
$params = [$id, $data];
} else {
$rpc_method = 'pool.dataset.query';
$params = [[[ 'id', '=', $id ]], {}];
@@ -791,6 +794,12 @@ sub parse_volname {
return ('images', $volname, $vmid, undef, undef, $isBase, 'raw');
}
# Cloud-init drive: vm-<vmid>-cloudinit, or base-<vmid>-cloudinit once the
# VM is converted to a template. alloc_image already creates these (#294).
if ($volname =~ /^(vm|base)-(\d+)-cloudinit$/) {
return ('images', $volname, $2, undef, undef, ($1 eq 'base' ? 1 : 0), 'raw');
}
# RAM snapshot state volume: vm-<vmid>-state-<snapname>
# PVE allocates one of these per snapshot when "Include RAM" is selected.
if ($volname =~ /^vm-(\d+)-state-.+$/) {
@@ -979,10 +988,47 @@ sub volume_size_info {
my ($class, $scfg, $storeid, $volname, $timeout) = @_;
_resolve_token($storeid, $scfg);
my (undef, undef, undef, $parent, undef, undef, $format) = $class->parse_volname($volname);
my $zvol = _zvol_prefix($scfg) . "/$volname";
my $enc = uri_escape($zvol, "^A-Za-z0-9\\-_.~");
my $ds = _api($scfg, 'GET', "/pool/dataset/id/$enc") // {};
return $ds->{volsize}{parsed} // 0;
my $ds = _api($scfg, 'GET', "/pool/dataset/id/$enc");
die "volume_size_info: zvol '$zvol' not found on $scfg->{truenas_host}\n"
unless ref $ds eq 'HASH' && defined $ds->{volsize}{parsed};
my $size = int($ds->{volsize}{parsed});
my $used = int($ds->{usedbydataset}{parsed} // $ds->{used}{parsed} // 0);
# List context is (size, format, used, parent), as in ZFSPoolPlugin. A bare
# scalar here made the content API die with "no format" (#294).
return wantarray ? ($size, $format, $used, $parent) : $size;
}
# Grows the zvol. Without this override the base Plugin.pm version dies with
# "can't resize this image format" (#294). For a running VM, qemu-server then
# issues QMP block_resize, which makes libiscsi re-read the LUN capacity —
# TrueNAS resizes the LUN with the zvol, so no iSCSI-side call is needed here.
sub volume_resize {
my ($class, $scfg, $storeid, $volname, $size, $running, $snapname) = @_;
_resolve_token($storeid, $scfg);
die "resizing a snapshot is not supported\n" if $snapname;
$class->parse_volname($volname); # dies on names we don't own
# Align to 1 MiB like ZFSPoolPlugin, so volsize is always a multiple of
# the zvol's volblocksize (TrueNAS rejects anything else).
my $new_kb = int($size / 1024);
$new_kb += (1024 - $new_kb % 1024) % 1024;
my $zvol = _zvol_prefix($scfg) . "/$volname";
my $enc = uri_escape($zvol, "^A-Za-z0-9\\-_.~");
_log('info', "volume_resize: $zvol to ${new_kb}K");
# int() for a fresh IV: SCALE 25.10 Pydantic rejects string-typed integers.
_api($scfg, 'PUT', "/pool/dataset/id/$enc", { volsize => int($new_kb * 1024) });
return $new_kb;
}
sub path {
@@ -116,6 +116,36 @@ sub path {
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 —
+1 -1
View File
@@ -66,7 +66,7 @@ sub _route {
}
}
return if $method eq 'DELETE' || $method eq 'POST';
return if $method eq 'DELETE' || $method eq 'POST' || $method eq 'PUT';
die "MockTrueNAS: unhandled $method $path\n";
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/perl
# volume_resize (#294): grows the zvol via PUT /pool/dataset/id/<zvol> with a
# 1 MiB-aligned integer volsize, returns the new size in KiB (ZFSPoolPlugin
# convention), and refuses snapshots / foreign volume names.
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib", "$FindBin::Bin/../perl5";
use Test::More;
use JSON ();
use MockTrueNAS;
use PVE::Storage::Custom::TrueNAS;
use PVE::Storage::Custom::TrueNASMultipath;
my $TN = 'PVE::Storage::Custom::TrueNAS';
my $MP = 'PVE::Storage::Custom::TrueNASMultipath';
my $GiB = 1024 ** 3;
sub puts { my ($api) = @_; return grep { $_->[0] eq 'PUT' } @{ $api->calls }; }
subtest 'grows zvol to the requested size' => sub {
my $api = MockTrueNAS->install();
my $kb = $TN->volume_resize(MockTrueNAS::scfg(), 'store', 'vm-100-disk-0', 40 * $GiB, 0);
is($kb, 40 * 1024 * 1024, 'returns new size in KiB');
my @p = puts($api);
is(scalar @p, 1, 'one PUT');
is($p[0][1], '/pool/dataset/id/tank%2Fvm%2Fvm-100-disk-0', 'targets the zvol');
is($p[0][2]{volsize}, 40 * $GiB, 'volsize in bytes');
is(JSON->new->canonical->encode($p[0][2]), '{"volsize":' . (40 * $GiB) . '}',
'volsize encodes as a JSON integer, not a string');
};
subtest 'rounds up to a 1 MiB boundary' => sub {
my $api = MockTrueNAS->install();
my $kb = $TN->volume_resize(MockTrueNAS::scfg(), 'store', 'vm-100-disk-0', 10 * $GiB + 1536, 0);
is($kb, 10 * 1024 * 1024 + 1024, 'next whole MiB');
is((puts($api))[0][2]{volsize} % (1024 * 1024), 0, 'volsize is MiB-aligned');
};
subtest 'cloud-init drive can be resized' => sub {
my $api = MockTrueNAS->install();
$TN->volume_resize(MockTrueNAS::scfg(), 'store', 'vm-100-cloudinit', 8 * 1024 * 1024, 0);
is(scalar(puts($api)), 1, 'PUT issued');
};
subtest 'snapshot resize refused' => sub {
my $api = MockTrueNAS->install();
eval { $TN->volume_resize(MockTrueNAS::scfg(), 'store', 'vm-100-disk-0', $GiB, 0, 'snap1') };
like($@, qr/snapshot/, 'dies');
is(scalar(puts($api)), 0, 'no API write');
};
subtest 'foreign volume name refused before any API write' => sub {
my $api = MockTrueNAS->install();
eval { $TN->volume_resize(MockTrueNAS::scfg(), 'store', '../other-zvol', $GiB, 0) };
like($@, qr/unable to parse/, 'dies');
is(scalar(puts($api)), 0, 'no API write');
};
subtest 'multipath: inactive volume only grows the zvol' => sub {
my $api = MockTrueNAS->install(
extents => [ { id => 3, name => 'vm-100-disk-0', naa => '0x6589cfc000000abc' } ],
targetextents => [ { id => 5, extent => 3, target => 7, lunid => 0 } ],
targets => [ { id => 7, name => 'proxmox-vm-100' } ],
);
# /dev/mapper/36589cfc000000abc does not exist here, so no rescan is attempted.
my $kb = $MP->volume_resize(MockTrueNAS::scfg(), 'store', 'vm-100-disk-0', 20 * $GiB, 0);
is($kb, 20 * 1024 * 1024, 'returns new size in KiB');
is(scalar(puts($api)), 1, 'zvol PUT issued');
};
done_testing();
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/perl
# volume_size_info contract: scalar context → size; list context →
# (size, format, used, parent). PVE's content API dies "no format" otherwise.
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib", "$FindBin::Bin/../perl5";
use Test::More;
use MockTrueNAS;
use PVE::Storage::Custom::TrueNAS;
my $TN = 'PVE::Storage::Custom::TrueNAS';
MockTrueNAS->install(datasets => {
'tank/vm/vm-100-disk-0' => {
volsize => { parsed => 34359738368 },
usedbydataset => { parsed => 1073741824 },
used => { parsed => 2147483648 },
},
'tank/vm/vm-100-cloudinit' => {
volsize => { parsed => 4194304 },
used => { parsed => 65536 },
},
});
subtest 'scalar context returns size' => sub {
my $size = $TN->volume_size_info(MockTrueNAS::scfg(), 'store', 'vm-100-disk-0');
is($size, 34359738368, 'size');
};
subtest 'list context returns (size, format, used, parent) (#294)' => sub {
my @r = $TN->volume_size_info(MockTrueNAS::scfg(), 'store', 'vm-100-disk-0');
is(scalar @r, 4, 'four values');
is($r[0], 34359738368, 'size');
is($r[1], 'raw', 'format');
is($r[2], 1073741824, 'used prefers usedbydataset');
ok(!defined $r[3], 'no parent');
};
subtest 'used falls back to used when usedbydataset is absent' => sub {
my (undef, $fmt, $used) = $TN->volume_size_info(MockTrueNAS::scfg(), 'store', 'vm-100-cloudinit');
is($fmt, 'raw', 'cloud-init drive has a format');
is($used, 65536, 'used');
};
subtest 'missing zvol dies instead of reporting size 0' => sub {
eval { $TN->volume_size_info(MockTrueNAS::scfg(), 'store', 'vm-100-disk-9') };
like($@, qr/not found/, 'dies');
};
done_testing();
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/perl
# parse_volname: every volume name the plugin (or PVE on its behalf) creates
# must parse, or later operations on it (destroy, resize, regenerate) fail.
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib", "$FindBin::Bin/../perl5";
use Test::More;
use PVE::Storage::Custom::TrueNAS;
my $TN = 'PVE::Storage::Custom::TrueNAS';
my @ok = (
# volname vmid isBase
[ 'vm-100-disk-0', 100, 0 ],
[ 'base-9000-disk-1', 9000, 1 ],
[ 'vm-100-state-pre_upg', 100, 0 ],
[ 'vm-9100-cloudinit', 9100, 0 ], # #294
[ 'base-9000-cloudinit', 9000, 1 ], # #294
);
for my $case (@ok) {
my ($volname, $vmid, $isBase) = @$case;
my @r = eval { $TN->parse_volname($volname) };
is($@, '', "$volname parses");
is($r[0], 'images', "$volname vtype");
is($r[1], $volname, "$volname name");
is($r[2], $vmid, "$volname owner");
is($r[5], $isBase, "$volname isBase");
is($r[6], 'raw', "$volname format");
}
for my $bad (qw(vm-100 vm-abc-disk-0 vm-100-cloudinit.qcow2 subvol-100-disk-0 ../vm-100-disk-0)) {
eval { $TN->parse_volname($bad) };
like($@, qr/unable to parse/, "$bad rejected");
}
done_testing();