feat: implement ZFS snapshot interface via TrueNAS REST API (#234)

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adams 2026-06-07 14:09:45 -04:00
parent 58a648e1a3
commit 59d8a8100f
1 changed files with 104 additions and 1 deletions

View File

@ -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;