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:
parent
58a648e1a3
commit
59d8a8100f
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue