def _minimize_image_size( *, unshare: Optional[Unshare], cur_size: int, image_path: bytes, mount_path: bytes, loop_dev: bytes, ) -> int: 'Returns the new filesystem size.' min_size_out = subprocess.check_output(nsenter_as_root( unshare, 'btrfs', 'inspect-internal', 'min-dev-size', mount_path, )).split(b' ') assert min_size_out[1] == b'bytes' min_size = _fix_up_fs_size(int(min_size_out[0]), MIN_SHRINK_BYTES) if min_size >= cur_size: log.info( f'Nothing to do: the minimum resize limit {min_size} is no less ' f'than the current filesystem size of {cur_size} bytes.' ) return log.info(f'Shrinking {image_path} to the btrfs minimum, {min_size} bytes') run_stdout_to_err(nsenter_as_root( unshare, 'btrfs', 'filesystem', 'resize', str(min_size), mount_path, ), check=True) fs_bytes = int(subprocess.check_output(nsenter_as_user( unshare, 'findmnt', '--bytes', '--noheadings', '--output', 'SIZE', mount_path, ))) # Log an error on size rounding since this is not expected to need it. _create_or_resize_image_file(image_path, fs_bytes, log_level=logging.ERROR) run_stdout_to_err([ 'sudo', 'losetup', '--set-capacity', loop_dev, ], check=True) return min_size
def test_package_image_as_btrfs_loopback(self): with self._package_image( self._sibling_path('create_ops.layer'), 'btrfs', ) as out_path, \ Unshare([Namespace.MOUNT, Namespace.PID]) as unshare, \ tempfile.TemporaryDirectory() as mount_dir, \ tempfile.NamedTemporaryFile() as temp_sendstream: # Future: use a LoopbackMount object here once that's checked in. subprocess.check_call(nsenter_as_root( unshare, 'mount', '-t', 'btrfs', '-o', 'loop,discard,nobarrier', out_path, mount_dir, )) try: # Future: Once I have FD, this should become: # Subvol( # os.path.join(mount_dir.fd_path(), 'create_ops'), # already_exists=True, # ).mark_readonly_and_write_sendstream_to_file(temp_sendstream) subprocess.check_call(nsenter_as_root( unshare, 'btrfs', 'send', '-f', temp_sendstream.name, os.path.join(mount_dir, 'create_ops'), )) self._assert_sendstream_files_equal( self._sibling_path('create_ops-original.sendstream'), temp_sendstream.name, ) finally: nsenter_as_root(unshare, 'umount', mount_dir)
def test_package_image_as_btrfs_loopback_writable(self): with self._package_image( self._sibling_path('create_ops.layer'), 'btrfs', writable_subvolume=True, ) as out_path, \ Unshare([Namespace.MOUNT, Namespace.PID]) as unshare, \ tempfile.TemporaryDirectory() as mount_dir: os.chmod( out_path, stat.S_IMODE(os.stat(out_path).st_mode) | (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH), ) subprocess.check_call(nsenter_as_root( unshare, 'mount', '-t', 'btrfs', '-o', 'loop,discard,nobarrier', out_path, mount_dir, )) try: subprocess.check_call(nsenter_as_root( unshare, 'touch', os.path.join(mount_dir, 'create_ops', 'foo'), )) finally: nsenter_as_root(unshare, 'umount', mount_dir)
def _mount_image_file( unshare: Optional[Unshare], file_path: bytes, mount_path: bytes, ) -> bytes: log.info(f'Mounting btrfs {file_path} at {mount_path}') # Explicitly set filesystem type to detect shenanigans. run_stdout_to_err(nsenter_as_root( unshare, 'mount', '-t', 'btrfs', '-o', 'loop,discard,nobarrier', file_path, mount_path, ), check=True) loop_dev = subprocess.check_output(nsenter_as_user( unshare, 'findmnt', '--noheadings', '--output', 'SOURCE', mount_path, )).rstrip(b'\n') # This increases the chances that --direct-io=on will succeed, since one # of the common failure modes is that the loopback's sector size is NOT # a multiple of the sector size of the underlying device (the devices # we've seen in production have sector sizes of 512, 1024, or 4096). if run_stdout_to_err([ 'sudo', 'losetup', '--sector-size=4096', loop_dev, ]).returncode != 0: log.error( f'Failed to set --sector-size=4096 for {loop_dev}, setting ' 'direct IO is more likely to fail.' ) # This helps perf and avoids doubling our usage of buffer cache. # Also, when the image is on tmpfs, setting direct IO fails. if run_stdout_to_err([ 'sudo', 'losetup', '--direct-io=on', loop_dev, ]).returncode != 0: log.error( f'Could not enable --direct-io for {loop_dev}, expect worse ' 'performance.' ) return loop_dev
def test_package_image_as_squashfs(self): with self._package_image( self._sibling_path('create_ops.layer'), 'squashfs', ) as out_path, TempSubvolumes(sys.argv[0]) as temp_subvolumes, \ tempfile.NamedTemporaryFile() as temp_sendstream: subvol = temp_subvolumes.create('subvol') with Unshare([Namespace.MOUNT, Namespace.PID]) as unshare, \ tempfile.TemporaryDirectory() as mount_dir: subprocess.check_call(nsenter_as_root( unshare, 'mount', '-t', 'squashfs', '-o', 'loop', out_path, mount_dir, )) # `unsquashfs` would have been cleaner than `mount` + # `rsync`, and faster too, but unfortunately it corrupts # device nodes as of v4.3. subprocess.check_call(nsenter_as_root( unshare, 'rsync', '--archive', '--hard-links', '--sparse', '--xattrs', mount_dir + '/', subvol.path(), )) with subvol.mark_readonly_and_write_sendstream_to_file( temp_sendstream ): pass original_render = _render_sendstream_path( self._sibling_path('create_ops-original.sendstream'), ) # SquashFS does not preserve the original's cloned extents of # zeros, nor the zero-hole-zero patter. In all cases, it # (efficiently) transmutes the whole file into 1 sparse hole. self.assertEqual(original_render[1].pop('56KB_nuls'), [ '(File d57344(create_ops@56KB_nuls_clone:0+49152@0/' + 'create_ops@56KB_nuls_clone:49152+8192@49152))' ]) original_render[1]['56KB_nuls'] = ['(File h57344)'] self.assertEqual(original_render[1].pop('56KB_nuls_clone'), [ '(File d57344(create_ops@56KB_nuls:0+49152@0/' + 'create_ops@56KB_nuls:49152+8192@49152))' ]) original_render[1]['56KB_nuls_clone'] = ['(File h57344)'] self.assertEqual(original_render[1].pop('zeros_hole_zeros'), [ '(File d16384h16384d16384)' ]) original_render[1]['zeros_hole_zeros'] = ['(File h49152)'] self.assertEqual( original_render, _render_sendstream_path(temp_sendstream.name), )
def _check_ns_diff(self, unshare: Unshare, ns_diff: Iterable[str]): list_ns_cmd = [ 'readlink', *(f'/proc/self/ns/{name}' for name in _NS_FILES), ] in_ns, out_ns = [ dict( ns_ino.split(':') for ns_ino in subprocess.check_output( cmd).decode().strip().split('\n')) for cmd in [ list_ns_cmd, nsenter_as_root(unshare, *list_ns_cmd), ] ] for ns in ns_diff: self.assertNotEqual(in_ns.pop(ns), out_ns.pop(ns), ns) self.assertEqual(in_ns, out_ns)
def _send_to_loopback_if_fits(self, output_path, fs_size_bytes) -> bool: ''' Creates a loopback of the specified size, and sends the current subvolume to it. Returns True if the subvolume fits in that space. ''' open(output_path, 'wb').close() with pipe() as (r_send, w_send), \ Unshare([Namespace.MOUNT, Namespace.PID]) as ns, \ LoopbackVolume(ns, output_path, fs_size_bytes) as loop_vol, \ self.mark_readonly_and_write_sendstream_to_file(w_send): w_send.close() # This end is now fully owned by `btrfs send`. with r_send: recv_ret = run_stdout_to_err(nsenter_as_root( ns, 'btrfs', 'receive', loop_vol.dir(), ), stdin=r_send, stderr=subprocess.PIPE) if recv_ret.returncode != 0: if recv_ret.stderr.endswith(self._OUT_OF_SPACE_SUFFIX): return False # It's pretty lame to rely on `btrfs receive` continuing # to be unlocalized, and emitting that particular error # message, so we fall back to checking available bytes. size_ret = subprocess.run(nsenter_as_user( ns, 'findmnt', '--noheadings', '--bytes', '--output', 'AVAIL', loop_vol.dir(), ), stdout=subprocess.PIPE) # If the `findmnt` fails, don't mask the original error. if size_ret.returncode == 0 and int(size_ret.stdout) == 0: return False # Covering this is hard, so the test plan is "inspection". log.error( # pragma: no cover 'Unhandled receive stderr:\n\n' + recv_ret.stderr.decode(errors='surrogateescape'), ) recv_ret.check_returncode() loop_vol.minimize_size() return True
def unmount_if_mounted(self): if self._mount_dir: # Nothing might have been mounted, ignore exit code run_stdout_to_err( nsenter_as_root(self._unshare, 'umount', self._mount_dir), )
def test_nsenter_wrappers(self): self.assertEqual(('a', 'b'), nsenter_as_user(None, 'a', 'b')) self.assertEqual(('sudo', 'c', 'd'), nsenter_as_root(None, 'c', 'd'))
def test_mount_namespace(self): try: sleep_pid = None with tempfile.TemporaryDirectory() as mnt_src, \ tempfile.TemporaryDirectory() as mnt_dest1, \ tempfile.TemporaryDirectory() as mnt_dest2: with open(os.path.join(mnt_src, 'cypa'), 'w') as outfile: outfile.write('kvoh') def check_mnt_dest(mnt_dest: str): cypa = os.path.join(mnt_dest, 'cypa') # The outer NS cannot see the mount self.assertFalse(os.path.exists(cypa)) # But we can read it from inside the namespace self.assertEqual( b'kvoh', subprocess.check_output( nsenter_as_user(unshare, 'cat', cypa), )) with Unshare([Namespace.MOUNT]) as unshare: # Without a PID namespace, this will outlive the # __exit__ -- in fact, this process would leak but for # our `finally`. proc, sleep_pid = self._popen_sleep_forever(unshare) subprocess.check_call( nsenter_as_root( unshare, 'mount', mnt_src, mnt_dest1, '-o', 'bind', )) check_mnt_dest(mnt_dest1) # Mount namespaces remain usable after the keepalive dies self._kill_keepalive(unshare) # We can make a second mount inside the namespace subprocess.check_call( nsenter_as_root( unshare, 'mount', mnt_src, mnt_dest2, '-o', 'bind', )) check_mnt_dest(mnt_dest2) check_mnt_dest(mnt_dest1) # The old mount is still good # Outside the context, nsenter cannot work. There's no way # to test the mounts are gone since we don't have any handle # by which to access them. That's the point. with self.assertRaisesRegex( RuntimeError, 'Must nsenter from inside an Unshare', ): check_mnt_dest(mnt_dest1) time.sleep(2) # Give some time for `sleep` to exit erroneously self.assertIs(None, proc.poll()) # Processes leak finally: # Ensure we don't leak the `sleep infinity` -- since it was # started via `sudo`, `subprocess` cannot kill it automatically. if sleep_pid: if proc.poll() is None: os.kill(sleep_pid, signal.SIGTERM) proc.wait()