From 59d8a8100f84417ff68989c9d37e982a9b1a261d Mon Sep 17 00:00:00 2001 From: Kevin Adams Date: Sun, 7 Jun 2026 14:09:45 -0400 Subject: [PATCH] feat: implement ZFS snapshot interface via TrueNAS REST API (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full PVE storage plugin snapshot interface backed by native ZFS snapshots on TrueNAS, via the v2.0 REST API: volume_has_feature — advertises snapshot support for raw volumes volume_snapshot_needs_fsfreeze — returns 1 (request guest IO freeze) volume_snapshot — POST /zfs/snapshot {dataset, name} volume_snapshot_delete — DELETE /zfs/snapshot/id/{uri_escaped_id} volume_snapshot_rollback — POST /zfs/snapshot/rollback {id, options} volume_rollback_is_possible — enforces ZFS "most recent snap only" rule volume_snapshot_info — GET /zfs/snapshot?dataset=... → {name: {id, ctime}} Snapshot ID format: pool/volname@snapname DELETE path requires full URI encoding (uri_escape) — unlike the dataset endpoint, the snapshot API does not accept bare slashes in the path segment. Tested on pve01-hq (PVE 9.2.3) against TrueNAS CORE 13.0-U6: - create / list / delete: OK - rollback_is_possible ordering: correctly rejects non-latest snapshots Co-Authored-By: Claude Sonnet 4.6 --- perl5/PVE/Storage/Custom/TrueNAS.pm | 105 +++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/perl5/PVE/Storage/Custom/TrueNAS.pm b/perl5/PVE/Storage/Custom/TrueNAS.pm index d54f2a5..2d68559 100644 --- a/perl5/PVE/Storage/Custom/TrueNAS.pm +++ b/perl5/PVE/Storage/Custom/TrueNAS.pm @@ -690,7 +690,7 @@ sub volume_has_feature { my $features = { copy => { base => 1, current => 1 }, - snapshot => { current => 1 }, + snapshot => { current => 1, snap => 1 }, }; my ($vtype, undef, undef, undef, undef, $isBase) = $class->parse_volname($volname); @@ -700,4 +700,107 @@ sub volume_has_feature { return; } +# ── Snapshot support ────────────────────────────────────────────────────────── + +# Request a guest filesystem freeze before snapping a running VM's disk. +# Returning 1 causes PVE to ask the QEMU guest agent to freeze IO before the +# TrueNAS API snapshot call, ensuring write-consistency. +sub volume_snapshot_needs_fsfreeze { return 1; } + +# Returns the full ZFS path for a volume: "pool[/dataset]/volname" +sub _snap_dataset { + my ($scfg, $volname) = @_; + return _zvol_prefix($scfg) . "/$volname"; +} + +# Returns the full ZFS snapshot ID: "pool[/dataset]/volname@snapname" +sub _snap_id { + my ($scfg, $volname, $snap) = @_; + return _zvol_prefix($scfg) . "/$volname\@$snap"; +} + +sub volume_snapshot { + my ($class, $scfg, $storeid, $volname, $snap) = @_; + + _resolve_token($storeid, $scfg); + my $dataset = _snap_dataset($scfg, $volname); + _log('info', "snapshot: creating $dataset\@$snap"); + _api($scfg, 'POST', '/zfs/snapshot', { dataset => $dataset, name => $snap }); + return undef; +} + +sub volume_snapshot_delete { + my ($class, $scfg, $storeid, $volname, $snap, $running) = @_; + + _resolve_token($storeid, $scfg); + my $id = _snap_id($scfg, $volname, $snap); + _log('info', "snapshot: deleting $id"); + + # The snapshot API requires full URI encoding of the id path segment — + # unlike the dataset endpoint, it does not accept bare slashes in the path. + _api($scfg, 'DELETE', "/zfs/snapshot/id/" . uri_escape($id)); + return undef; +} + +sub volume_snapshot_rollback { + my ($class, $scfg, $storeid, $volname, $snap) = @_; + + _resolve_token($storeid, $scfg); + my $id = _snap_id($scfg, $volname, $snap); + _log('info', "snapshot: rolling back to $id"); + _api($scfg, 'POST', '/zfs/snapshot/rollback', { + id => $id, + options => { force => JSON::false, recursive_clones => JSON::false }, + }); + return undef; +} + +# ZFS only permits rollback to the most recent snapshot. If $snap is not the +# newest, list the blocking (newer) snapshots so PVE can report them. +sub volume_rollback_is_possible { + my ($class, $scfg, $storeid, $volname, $snap, $blockers) = @_; + + my $snaps = $class->volume_snapshot_info($scfg, $storeid, $volname); + $blockers //= []; + my $found; + + for my $name (sort { $snaps->{$a}{creation_time} <=> $snaps->{$b}{creation_time} } + keys %$snaps) { + if ($name eq $snap) { + $found = 1; + } elsif ($found) { + push @$blockers, $name; + } + } + + die "snapshot '$snap' does not exist on '$volname'\n" if !$found; + die "can't rollback '$snap' on '$volname' — newer snapshots exist: " + . join(', ', @$blockers) . "\n" + if @$blockers; + + return 1; +} + +# Returns a hashref of snapshots for $volname, keyed by snapshot name: +# { 'snapname' => { id => 'pool/vol@snapname', creation_time => $epoch }, ... } +sub volume_snapshot_info { + my ($class, $scfg, $storeid, $volname) = @_; + + _resolve_token($storeid, $scfg); + my $dataset = _snap_dataset($scfg, $volname); + my $enc_ds = uri_escape($dataset); + my $snaps = _api($scfg, 'GET', "/zfs/snapshot?dataset=$enc_ds&limit=500") // []; + + my %result; + for my $s (@$snaps) { + # snapshot_name is the part after @; id is the full "dataset@name" string + my $name = $s->{snapshot_name} + // do { (my $n = $s->{id} // '') =~ s/^.*@//; $n }; + my $ctime = $s->{properties}{creation}{rawvalue} // 0; + $result{$name} = { id => $s->{id}, creation_time => int($ctime) }; + } + + return \%result; +} + 1;