Skip to content

Commit

Permalink
NAS-132120 / 25.10 / Add API to enable ZFS snapdir access over NFS (b…
Browse files Browse the repository at this point in the history
…y anodos325) (#15712)

This commit adds support for the new exports "zfs_snapdir" line
for enterprise customers.
  • Loading branch information
bugclerk authored Feb 14, 2025
1 parent a992c09 commit 7e0a704
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Add zfs_snapdir to NFS exports
Revision ID: d908d564231d
Revises: 673dd6925aba
Create Date: 2025-02-14 15:13:59.521506+00:00
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'd908d564231d'
down_revision = '673dd6925aba'
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table('sharing_nfs_share', schema=None) as batch_op:
batch_op.add_column(sa.Column('nfs_expose_snapshots', sa.Boolean(), nullable=False, server_default='0'))
5 changes: 5 additions & 0 deletions src/middlewared/middlewared/api/v25_04_0/nfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ class NfsShareEntry(BaseModel):
""" Enable or disable the share. """
locked: bool | None
""" Lock state of the dataset (if encrypted). """
expose_snapshots: bool = False
"""
Enterprise feature to enable access to the ZFS snapshot directory for the export.
Export path must be the root directory of a ZFS dataset.
"""


class NfsShareCreate(NfsShareEntry):
Expand Down
3 changes: 3 additions & 0 deletions src/middlewared/middlewared/etc_files/exports.mako
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@
options = []
params += ",no_subtree_check" if p.is_mount() else ",subtree_check"
if share["expose_snapshots"]:
params += ",zfs_snapdir"
for host in share["hosts"]:
anonymous = False
export_host = parse_host(host, gaierrors)
Expand Down
27 changes: 27 additions & 0 deletions src/middlewared/middlewared/plugins/nfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ class NFSShareModel(sa.Model):
nfs_mapall_user = sa.Column(sa.String(120), nullable=True, default='')
nfs_mapall_group = sa.Column(sa.String(120), nullable=True, default='')
nfs_security = sa.Column(sa.MultiSelectField())
nfs_expose_snapshots = sa.Column(sa.Boolean(), default=False)
nfs_enabled = sa.Column(sa.Boolean(), default=True)


Expand Down Expand Up @@ -458,6 +459,9 @@ async def do_create(self, data):
`hosts` is a list of IP's/hostnames which are allowed to access the share. If empty, all IP's/hostnames are
allowed.
`expose_snapshots` enable TrueNAS Enterprise feature to allow access
to the ZFS snapshot directory over NFS. This feature requires a valid
enterprise license.
"""
verrors = ValidationErrors()

Expand Down Expand Up @@ -625,6 +629,29 @@ async def validate(self, data, schema_name, verrors, old=None):
f"The following security flavor(s) require NFSv4 to be enabled: {','.join(v4_sec)}."
)

if data["expose_snapshots"]:
if await self.middleware.call("system.is_enterprise"):
# check if mountpoint and whether snapdir is enabled
try:
# We're using statfs output because in future it should expose
# whether snapdir is enabled
sfs = await self.middleware.call("filesystem.statfs", data["path"])
if sfs["dest"] != data["path"]:
verrors.add(
f"{schema_name}.expose_snapshots",
f"{data['path']}: export path is not the root directory of a dataset."
)
except Exception:
# we can't get info on unmounted / locked datasets but this
# doesn't have to be perfect. We can improve in GE with newer pylibzfs
# that doesn't use a process pool
pass
else:
verrors.add(
f"{schema_name}.expose_snapshots",
"This is an enterprise feature and may not be enabled without a valid license."
)

@private
def sanitize_share_networks_and_hosts(self, data, schema_name, verrors):
"""
Expand Down
128 changes: 128 additions & 0 deletions tests/api2/test_nfs_snapdir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import pytest

from middlewared.test.integration.assets.filesystem import directory
from middlewared.test.integration.assets.pool import dataset
from middlewared.test.integration.assets.product import product_type
from middlewared.test.integration.utils import call, ssh, password
from middlewared.test.integration.utils.client import truenas_server
from middlewared.service_exception import ValidationErrors
from protocols import SSH_NFS

SNAPDIR_EXPORTS_ENTRY = 'zfs_snapdir'


@pytest.fixture(scope='module')
def start_nfs():
call('service.start', 'nfs')
yield
call('service.stop', 'nfs')


@pytest.fixture(scope='function')
def enterprise():
with product_type():
yield


@pytest.fixture(scope='module')
def nfs_dataset():
with dataset('nfs_snapdir') as ds:
ssh(f'echo -n Cats > /mnt/{ds}/canary')
call('zfs.snapshot.create', {'dataset': ds, 'name': 'now'})
yield ds


@pytest.fixture(scope='function')
def community():
with product_type('COMMUNITY_EDITION'):
yield


@pytest.fixture(scope='function')
def nfs_export(nfs_dataset):
share = call('sharing.nfs.create', {'path': f'/mnt/{nfs_dataset}'})
try:
yield share['id']
finally:
call('sharing.nfs.delete', share['id'])


def test__snapdir_community_fail_create(community, nfs_dataset):
with pytest.raises(ValidationErrors, match='This is an enterprise feature'):
share = call('sharing.nfs.create', {
'path': f'/mnt/{nfs_dataset}',
'expose_snapshots': True
})
# cleanup just in case test failed
call('sharing.nfs.delete', share['id'])


def test__snapdir_community_fail_update(community, nfs_export):
with pytest.raises(ValidationErrors, match='This is an enterprise feature'):
call('sharing.nfs.update', nfs_export, {'expose_snapshots': True})


def test__snapdir_enterprise_fail_subdir(enterprise, nfs_dataset):
with directory(f'/mnt/{nfs_dataset}/subdir') as d:
with pytest.raises(ValidationErrors, match='not the root directory of a dataset'):
share = call('sharing.nfs.create', {
'path': d,
'expose_snapshots': True
})
# cleanup just in case test failed
call('sharing.nfs.delete', share['id'])


def test__snapdir_enable_enterprise_create(start_nfs, enterprise, nfs_dataset):
""" check that create sets correct exports line """
share = call('sharing.nfs.create', {
'path': f'/mnt/{nfs_dataset}',
'expose_snapshots': True
})

try:
assert share['expose_snapshots'] is True
exports = ssh('cat /etc/exports')
assert SNAPDIR_EXPORTS_ENTRY in exports
finally:
call('sharing.nfs.delete', share['id'])


def test__snapdir_enable_enterprise_update(start_nfs, enterprise, nfs_export):
""" check that update sets correct exports line """
exports = ssh('cat /etc/exports')
assert SNAPDIR_EXPORTS_ENTRY not in exports

share = call('sharing.nfs.update', nfs_export, {'expose_snapshots': True})
assert share['expose_snapshots'] is True

exports = ssh('cat /etc/exports')
assert SNAPDIR_EXPORTS_ENTRY in exports

share = call('sharing.nfs.update', nfs_export, {'expose_snapshots': False})
assert share['expose_snapshots'] is False

exports = ssh('cat /etc/exports')
assert SNAPDIR_EXPORTS_ENTRY not in exports


@pytest.mark.parametrize('vers', [3, 4])
def test__snapdir_functional(start_nfs, enterprise, nfs_dataset, nfs_export, vers):
share = call('sharing.nfs.update', nfs_export, {'expose_snapshots': True})
assert share['expose_snapshots'] is True
call('service.stop', 'nfs')
call('service.start', 'nfs', {'silent': False})
with SSH_NFS(
hostname=truenas_server.ip,
path=f'/mnt/{nfs_dataset}',
vers=vers,
user='root',
password=password(),
ip=truenas_server.ip
) as n:

contents = n.ls('.')
assert 'canary' in contents

snapdir_contents = n.ls('.zfs/snapshot/now')
assert 'canary' in snapdir_contents
2 changes: 1 addition & 1 deletion tests/protocols/nfs_proto.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ def mount(self):
cmd = ['mount.nfs', '-o', mnt_opts, f'{self._hostname}:{self._path}', self._localpath]
do_mount = SSH_TEST(" ".join(cmd), self._mount_user, self._mount_password, self._ip)
if do_mount['result'] is False:
raise RuntimeError(do_mount['stderr'])
raise RuntimeError(f"{do_mount['stderr']}: {cmd}")

self._mounted = True

Expand Down

0 comments on commit 7e0a704

Please sign in to comment.