Skip to content

Commit f79551c

Browse files
committed
Merge remote-tracking branch 'origin/pr/690'
* origin/pr/690: use dirty file instead of clean file storage: prune reflinks files when snapshots are disabled storage: prevent cloning when volumes without snapshot is running lint don't allow to enable or disable snapshots if dispvm base on current vm is running prevent startup of vm having snapshots disabled and running DispVM prevent startup if source volume snapshot disabled and is running storage: add a running state file for volumes storage: add a running state file for volumes storage: prevent setting revisions_to_keep < -1 backup: prevent backup of running VM when private volume snapshots are disabled prevent re-enabling snapshots when running VM storage: prevent setting disable snapshots when VM is running storage: snapshots_disabled for zfs storage storage: snapshots_disabled for reflink storage storage: snapshots_disabled for lvm storage storage: snapshots_disabled for file storage storage: add a new volume property snapshots_disabled api: update validate_size method to allow negative numbers Pull request description: This PR is related to issue QubesOS/qubes-issues#8767 A new option **disable snapshots** can be set on volumes (lvm, file, reflink and zfs supported). When enabled on a private volume, the volume is used directly instead of doing a snapshot and use it. The option can be enabled by setting ``revisions_to_keep = -1`` Using the volume directly saves space but requires features like backup, dispvm or cloning to be disabled when the volume is in use.
2 parents 52df11d + f0b01f6 commit f79551c

14 files changed

Lines changed: 629 additions & 35 deletions

File tree

qubes/api/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,15 +245,21 @@ def enforce(predicate):
245245
if not predicate:
246246
raise PermissionDenied()
247247

248-
def validate_size(self, untrusted_size: bytes) -> int:
248+
def validate_size(
249+
self, untrusted_size: bytes, allow_negative: bool = False
250+
) -> int:
249251
self.enforce(isinstance(untrusted_size, bytes))
252+
coefficient = 1
253+
if allow_negative and untrusted_size.startswith(b"-"):
254+
coefficient = -1
255+
untrusted_size = untrusted_size[1:]
250256
if not untrusted_size.isdigit():
251257
raise qubes.exc.ProtocolError("Size must be ASCII digits (only)")
252258
if len(untrusted_size) >= 20:
253259
raise qubes.exc.ProtocolError("Sizes limited to 19 decimal digits")
254260
if untrusted_size[0] == 48 and untrusted_size != b"0":
255261
raise qubes.exc.ProtocolError("Spurious leading zeros not allowed")
256-
return int(untrusted_size)
262+
return int(untrusted_size) * coefficient
257263

258264

259265
class QubesDaemonProtocol(asyncio.Protocol):

qubes/api/admin.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ async def vm_volume_clone_to(self, untrusted_payload):
502502
src_volume=src_volume, dst_volume=dst_volume
503503
)
504504
self.dest.volumes[self.arg] = await qubes.utils.coro_maybe(
505-
dst_volume.import_volume(src_volume)
505+
self.dest.storage.import_volume(dst_volume, src_volume)
506506
)
507507
self.app.save()
508508

@@ -550,11 +550,9 @@ async def vm_volume_clear(self):
550550
)
551551
async def vm_volume_set_revisions_to_keep(self, untrusted_payload):
552552
self.enforce(self.arg in self.dest.volumes.keys())
553-
newvalue = self.validate_size(untrusted_payload)
554-
553+
newvalue = self.validate_size(untrusted_payload, allow_negative=True)
555554
self.fire_event_for_permission(newvalue=newvalue)
556-
557-
self.dest.volumes[self.arg].revisions_to_keep = newvalue
555+
self.dest.storage.set_revisions_to_keep(self.arg, newvalue)
558556
self.app.save()
559557

560558
@qubes.api.method("admin.vm.volume.Set.rw", scope="local", write=True)
@@ -820,7 +818,10 @@ async def pool_set_revisions_to_keep(self, untrusted_payload):
820818
self.enforce(self.dest.name == "dom0")
821819
self.enforce(self.arg in self.app.pools.keys())
822820
pool = self.app.pools[self.arg]
823-
newvalue = self.validate_size(untrusted_payload)
821+
newvalue = self.validate_size(untrusted_payload, allow_negative=True)
822+
823+
if newvalue < -1:
824+
raise qubes.api.ProtocolError("Invalid value for revisions_to_keep")
824825

825826
self.fire_event_for_permission(newvalue=newvalue)
826827

qubes/backup.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,10 +517,20 @@ def get_backup_summary(self):
517517
summary_line += fmt.format(size_to_human(vm_size))
518518

519519
if qid != 0 and vm_info.vm.is_running():
520-
summary_line += (
521-
" <-- The VM is running, backup will contain "
522-
"its state from before its start!"
523-
)
520+
if [
521+
volume
522+
for volume in vm_info.vm.volumes.values()
523+
if volume.snapshots_disabled
524+
]:
525+
summary_line += (
526+
" <-- The VM is running and private volume snapshots "
527+
"are disabled. Backup will fail!"
528+
)
529+
else:
530+
summary_line += (
531+
" <-- The VM is running, backup will "
532+
"contain its state from before its start!"
533+
)
524534

525535
summary += summary_line + "\n"
526536

@@ -823,6 +833,15 @@ async def backup_do(self):
823833
backup_app.domains[qid].features["backup-content"] = True
824834
backup_app.domains[qid].features["backup-path"] = vm_info.subdir
825835
backup_app.domains[qid].features["backup-size"] = vm_info.size
836+
837+
# VM running private volumes without snapshoting them
838+
# (revision_to_keep = -1) must be powered off to be backup
839+
if backup_app.domains[qid].is_running:
840+
for volume in backup_app.domains[qid].volumes.values():
841+
if volume.snapshots_disabled:
842+
raise qubes.exc.QubesVMNotHaltedError(
843+
backup_app.domains[qid]
844+
)
826845
backup_app.save()
827846
del backup_app
828847

qubes/storage/__init__.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
from qubes.exc import StoragePoolException
4242

4343
STORAGE_ENTRY_POINT = "qubes.storage"
44+
VOLUME_STATE_DIR = "/var/run/qubes/"
45+
VOLUME_STATE_PREFIX = "volume-running-"
4446
_am_root = os.getuid() == 0
4547

4648
BYTES_TO_ZERO = 1 << 16
@@ -96,7 +98,7 @@ def __init__(
9698
snap_on_start=False,
9799
source=None,
98100
ephemeral=None,
99-
**kwargs
101+
**kwargs,
100102
):
101103
"""Initialize a volume.
102104
@@ -513,6 +515,27 @@ def _not_implemented(self, method_name):
513515
msg = msg.format(str(self.__class__.__name__), method_name)
514516
return NotImplementedError(msg)
515517

518+
@property
519+
def snapshots_disabled(self) -> bool:
520+
return (
521+
self.revisions_to_keep == -1
522+
and not self.snap_on_start
523+
and self.save_on_stop
524+
)
525+
526+
@property
527+
def state_file(self) -> str:
528+
return os.path.join(
529+
VOLUME_STATE_DIR,
530+
VOLUME_STATE_PREFIX
531+
+ f"{self.pool.name}:{self.vid}".replace("-", "--").replace(
532+
"/", "-"
533+
),
534+
)
535+
536+
def is_running(self) -> bool:
537+
return os.path.exists(self.state_file)
538+
516539

517540
class Storage:
518541
"""Class for handling VM virtual disks.
@@ -786,8 +809,37 @@ def block_devices(self):
786809
else:
787810
yield block_dev
788811

812+
def set_revisions_to_keep(self, volume, value):
813+
if value < -1:
814+
raise qubes.exc.QubesValueError(
815+
"Invalid value for revisions_to_keep"
816+
)
817+
818+
currentvalue = self.vm.volumes[volume].revisions_to_keep
819+
enabling_disabling_snapshots = value != currentvalue and (
820+
currentvalue == -1 or value == -1
821+
)
822+
if self.vm.is_running() and enabling_disabling_snapshots:
823+
raise qubes.exc.QubesVMNotHaltedError(self.vm)
824+
825+
if self.vm.klass == "AppVM" and enabling_disabling_snapshots:
826+
for vm in self.vm.dispvms:
827+
if vm.is_running():
828+
raise qubes.exc.QubesVMNotHaltedError(vm)
829+
830+
self.vm.volumes[volume].revisions_to_keep = value
831+
789832
async def start(self):
790833
"""Execute the start method on each volume"""
834+
for vol in self.vm.volumes.values():
835+
if (
836+
vol.source
837+
and vol.source.snapshots_disabled
838+
and vol.source.is_running()
839+
):
840+
raise qubes.exc.QubesVMError(
841+
self.vm, f"Volume {vol.source.vid} is running"
842+
)
791843
await qubes.utils.void_coros_maybe(
792844
# pylint: disable=line-too-long
793845
(
@@ -800,6 +852,10 @@ async def start(self):
800852
for name, vol in self.vm.volumes.items()
801853
)
802854

855+
for vol in self.vm.volumes.values():
856+
with open(vol.state_file, "w", encoding="ascii"):
857+
pass
858+
803859
async def stop(self):
804860
"""Execute the stop method on each volume"""
805861
await qubes.utils.void_coros_maybe(
@@ -813,6 +869,8 @@ async def stop(self):
813869
)
814870
for name, vol in self.vm.volumes.items()
815871
)
872+
for vol in self.vm.volumes.values():
873+
qubes.utils.remove_file(vol.state_file)
816874

817875
def unused_frontend(self):
818876
"""Find an unused device name"""
@@ -863,6 +921,18 @@ async def import_data_end(self, volume, success):
863921
self.get_volume(volume).import_data_end(success=success)
864922
)
865923

924+
async def import_volume(self, dst_volume: Volume, src_volume: Volume):
925+
"""Helper function to import data from another volume"""
926+
if src_volume.is_running() and src_volume.snapshots_disabled:
927+
raise StoragePoolException(
928+
f"Volume {src_volume.vid} must be stopped before importing its "
929+
f"data"
930+
)
931+
932+
return await qubes.utils.coro_maybe(
933+
dst_volume.import_volume(src_volume)
934+
)
935+
866936

867937
class VolumesCollection:
868938
"""Convenient collection wrapper for pool.get_volume and

qubes/storage/file.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ def revisions_to_keep(self):
272272

273273
@revisions_to_keep.setter
274274
def revisions_to_keep(self, value):
275-
if not isinstance(value, int) or value > 1 or value < 0:
275+
if not isinstance(value, int) or value > 1 or value < -1:
276276
raise NotImplementedError(
277277
"FileVolume supports maximum 1 volume revision to keep"
278278
)
@@ -490,9 +490,14 @@ async def start(self):
490490
# shutdown routine wasn't called (power interrupt or so)
491491
_remove_if_exists(self.path_cow)
492492
if not os.path.exists(self.path_cow):
493-
create_sparse_file(self.path_cow, self.size)
493+
if not self.snapshots_disabled:
494+
create_sparse_file(self.path_cow, self.size)
494495
if not self.snap_on_start:
495496
_check_path(self.path)
497+
498+
if self.snapshots_disabled:
499+
return self
500+
496501
if hasattr(self, "path_source_cow"):
497502
if not os.path.exists(self.path_source_cow):
498503
create_sparse_file(self.path_source_cow, self.size)
@@ -524,10 +529,12 @@ async def stop(self):
524529
self._export_lock is not FileVolume._marker_exported
525530
), "trying to stop exported file volume?"
526531
if self.save_on_stop or self.snap_on_start:
527-
await self._destroy_blockdev()
532+
if not self.snapshots_disabled:
533+
await self._destroy_blockdev()
528534
if self.save_on_stop:
529535
if self.rw:
530-
self.commit()
536+
if not self.snapshots_disabled:
537+
self.commit()
531538
else:
532539
_remove_if_exists(self.path_cow)
533540
elif self.snap_on_start:
@@ -569,7 +576,7 @@ def script(self):
569576

570577
def _block_device_path(self):
571578
if not self.snap_on_start:
572-
if not self.save_on_stop:
579+
if not self.save_on_stop or self.snapshots_disabled:
573580
return self.path
574581
return "-".join(
575582
[

qubes/storage/lvm.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,10 @@ async def start(self):
781781
try:
782782
if self.snap_on_start or self.save_on_stop:
783783
if not self.save_on_stop or not self.is_dirty():
784-
await self._snapshot()
784+
if self.snapshots_disabled and self.revisions:
785+
await self._remove_revisions(self.revisions)
786+
if not self.snapshots_disabled:
787+
await self._snapshot()
785788
else:
786789
await self._activate()
787790
else:
@@ -796,7 +799,8 @@ async def stop(self):
796799
changed = True
797800
try:
798801
if self.save_on_stop:
799-
changed = await self._commit()
802+
if not self.snapshots_disabled:
803+
changed = await self._commit()
800804
elif self.snap_on_start:
801805
changed = await self._remove_if_exists(self._vid_snap)
802806
else:
@@ -829,9 +833,10 @@ def block_device(self):
829833
the libvirt XML template as <disk>.
830834
"""
831835
if self.snap_on_start or self.save_on_stop:
832-
snap_path = "/dev/mapper/" + self._vid_snap.replace(
833-
"-", "--"
834-
).replace("/", "-")
836+
vid = self.vid if self.snapshots_disabled else self._vid_snap
837+
snap_path = "/dev/mapper/" + vid.replace("-", "--").replace(
838+
"/", "-"
839+
)
835840
return qubes.storage.BlockDevice(
836841
snap_path, self.name, None, self.rw, self.domain, self.devtype
837842
)

qubes/storage/reflink.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ def __init__(self, *args, **kwargs):
182182
def _update_precache(self):
183183
_remove_file(self._path_precache)
184184
yield
185+
if self.snapshots_disabled:
186+
return
185187
_copy_file(self._path_clean, self._path_precache, copy_mtime=True)
186188

187189
def _remove_stale_precache(self):
@@ -263,6 +265,13 @@ def is_dirty(self):
263265
@_coroutinized
264266
def start(self): # pylint: disable=invalid-overridden-method
265267
self._remove_incomplete_images()
268+
if self.snapshots_disabled:
269+
self._prune_revisions(keep=0)
270+
_remove_file(self._path_precache)
271+
if not self.is_dirty():
272+
_rename_file(self._path_clean, self._path_dirty)
273+
274+
return self
266275
if not self.is_dirty():
267276
if self.snap_on_start:
268277
_remove_file(self._path_clean)
@@ -283,7 +292,10 @@ def start(self): # pylint: disable=invalid-overridden-method
283292
@_coroutinized
284293
def stop(self): # pylint: disable=invalid-overridden-method
285294
if self.is_dirty():
286-
self._commit(self._path_dirty)
295+
if self.snapshots_disabled:
296+
_rename_file(self._path_dirty, self._path_clean)
297+
else:
298+
self._commit(self._path_dirty)
287299
elif not self.save_on_stop:
288300
if not self.snap_on_start:
289301
self._size = self.size # preserve manual resize of image

0 commit comments

Comments
 (0)