fix(installer): identify live loop backing after dracut switch-root
This commit is contained in:
+31
-11
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import getpass
|
import getpass
|
||||||
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
@@ -17,6 +18,7 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -241,17 +243,12 @@ class SystemProbe:
|
|||||||
# Mounted loops may hide the physical filesystem that stores their file.
|
# Mounted loops may hide the physical filesystem that stores their file.
|
||||||
for path, node in topo.nodes.items():
|
for path, node in topo.nodes.items():
|
||||||
if node['type'] == 'loop' and path in topo.protected:
|
if node['type'] == 'loop' and path in topo.protected:
|
||||||
backing_file = Path('/sys/dev/block') / node['maj:min'] / 'loop/backing_file'
|
identity = self._loop_backing_identity(path, node['maj:min'])
|
||||||
try:
|
sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
|
||||||
backing = backing_file.read_text().strip()
|
if len(sources) != 1:
|
||||||
except OSError as exc:
|
raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
|
||||||
raise InstallError(f'Cannot inspect mounted loop backing file: {path}') from exc
|
source = sources[0]
|
||||||
backing = re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), backing)
|
topo.protect(source)
|
||||||
if not backing.startswith('/'):
|
|
||||||
backing = '/' + backing
|
|
||||||
source = self._source_for_path(backing, topo)
|
|
||||||
if not source:
|
|
||||||
raise InstallError(f'Unknown physical backing for mounted loop: {path}')
|
|
||||||
topo.link(path, source)
|
topo.link(path, source)
|
||||||
if not Path(payload).is_file():
|
if not Path(payload).is_file():
|
||||||
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
|
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
|
||||||
@@ -262,6 +259,29 @@ class SystemProbe:
|
|||||||
topo.protect(source)
|
topo.protect(source)
|
||||||
return topo
|
return topo
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _loop_backing_identity(path, expected_identity):
|
||||||
|
# LOOP_GET_STATUS64 reports the backing inode's filesystem device,
|
||||||
|
# independent of names which dracut switch_root may have relocated.
|
||||||
|
# lo_rdevice is nonzero when the backing inode is itself a block device.
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
|
||||||
|
try:
|
||||||
|
info = os.fstat(descriptor)
|
||||||
|
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
||||||
|
if not stat.S_ISBLK(info.st_mode) or identity != expected_identity:
|
||||||
|
raise InstallError(f'Mounted loop device identity changed: {path}')
|
||||||
|
status = fcntl.ioctl(descriptor, 0x4C05, bytes(232))
|
||||||
|
if not isinstance(status, bytes) or len(status) != 232:
|
||||||
|
raise InstallError(f'Malformed loop status: {path}')
|
||||||
|
filesystem_device, _, block_device = struct.unpack_from('=QQQ', status)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
except (OSError, struct.error) as exc:
|
||||||
|
raise InstallError(f'Cannot inspect mounted loop backing identity: {path}') from exc
|
||||||
|
device = block_device or filesystem_device
|
||||||
|
return f'{os.major(device)}:{os.minor(device)}'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mounts(raw):
|
def _mounts(raw):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
{
|
||||||
|
"filesystems": [
|
||||||
|
{
|
||||||
|
"source": "LiveOS_rootfs",
|
||||||
|
"target": "/",
|
||||||
|
"fstype": "overlay",
|
||||||
|
"maj:min": "0:31",
|
||||||
|
"options": "rw,relatime,lowerdir=/run/rootfsbase,upperdir=/run/overlayfs,workdir=/run/ovlwork,uuid=on",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"source": "tmpfs",
|
||||||
|
"target": "/tmp",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:40",
|
||||||
|
"options": "rw,nosuid,nodev,nr_inodes=1048576,inode64,usrquota"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "devtmpfs",
|
||||||
|
"target": "/dev",
|
||||||
|
"fstype": "devtmpfs",
|
||||||
|
"maj:min": "0:6",
|
||||||
|
"options": "rw,nosuid,size=1930828k,nr_inodes=482707,mode=755,inode64",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"source": "hugetlbfs",
|
||||||
|
"target": "/dev/hugepages",
|
||||||
|
"fstype": "hugetlbfs",
|
||||||
|
"maj:min": "0:36",
|
||||||
|
"options": "rw,nosuid,nodev,relatime,pagesize=2M"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "mqueue",
|
||||||
|
"target": "/dev/mqueue",
|
||||||
|
"fstype": "mqueue",
|
||||||
|
"maj:min": "0:21",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmpfs",
|
||||||
|
"target": "/dev/shm",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:24",
|
||||||
|
"options": "rw,nosuid,nodev,inode64,usrquota"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "devpts",
|
||||||
|
"target": "/dev/pts",
|
||||||
|
"fstype": "devpts",
|
||||||
|
"maj:min": "0:25",
|
||||||
|
"options": "rw,nosuid,noexec,relatime,gid=5,mode=600,ptmxmode=000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "sysfs",
|
||||||
|
"target": "/sys",
|
||||||
|
"fstype": "sysfs",
|
||||||
|
"maj:min": "0:23",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"source": "fusectl",
|
||||||
|
"target": "/sys/fs/fuse/connections",
|
||||||
|
"fstype": "fusectl",
|
||||||
|
"maj:min": "0:37",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tracefs",
|
||||||
|
"target": "/sys/kernel/tracing",
|
||||||
|
"fstype": "tracefs",
|
||||||
|
"maj:min": "0:13",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "debugfs",
|
||||||
|
"target": "/sys/kernel/debug",
|
||||||
|
"fstype": "debugfs",
|
||||||
|
"maj:min": "0:8",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "securityfs",
|
||||||
|
"target": "/sys/kernel/security",
|
||||||
|
"fstype": "securityfs",
|
||||||
|
"maj:min": "0:7",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "cgroup2",
|
||||||
|
"target": "/sys/fs/cgroup",
|
||||||
|
"fstype": "cgroup2",
|
||||||
|
"maj:min": "0:27",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot,memory_hugetlb_accounting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "none",
|
||||||
|
"target": "/sys/fs/pstore",
|
||||||
|
"fstype": "pstore",
|
||||||
|
"maj:min": "0:28",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "efivarfs",
|
||||||
|
"target": "/sys/firmware/efi/efivars",
|
||||||
|
"fstype": "efivarfs",
|
||||||
|
"maj:min": "0:29",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "bpf",
|
||||||
|
"target": "/sys/fs/bpf",
|
||||||
|
"fstype": "bpf",
|
||||||
|
"maj:min": "0:30",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime,mode=700"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "configfs",
|
||||||
|
"target": "/sys/kernel/config",
|
||||||
|
"fstype": "configfs",
|
||||||
|
"maj:min": "0:19",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "proc",
|
||||||
|
"target": "/proc",
|
||||||
|
"fstype": "proc",
|
||||||
|
"maj:min": "0:22",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"source": "systemd-1",
|
||||||
|
"target": "/proc/sys/fs/binfmt_misc",
|
||||||
|
"fstype": "autofs",
|
||||||
|
"maj:min": "0:33",
|
||||||
|
"options": "rw,relatime,fd=38,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=5489",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"source": "binfmt_misc",
|
||||||
|
"target": "/proc/sys/fs/binfmt_misc",
|
||||||
|
"fstype": "binfmt_misc",
|
||||||
|
"maj:min": "0:42",
|
||||||
|
"options": "rw,nosuid,nodev,noexec,relatime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmpfs",
|
||||||
|
"target": "/run",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:26",
|
||||||
|
"options": "rw,nosuid,nodev,size=797732k,nr_inodes=819200,mode=755,inode64",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"source": "none",
|
||||||
|
"target": "/run/credentials/systemd-journald.service",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:35",
|
||||||
|
"options": "ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "tmpfs",
|
||||||
|
"target": "/run/user/1000",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:46",
|
||||||
|
"options": "rw,nosuid,nodev,relatime,size=398864k,nr_inodes=99716,mode=700,uid=1000,gid=1000,inode64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/dev/sr0",
|
||||||
|
"target": "/run/initramfs/live",
|
||||||
|
"fstype": "iso9660",
|
||||||
|
"maj:min": "11:0",
|
||||||
|
"options": "ro,relatime,nojoliet,check=s,map=n,blocksize=2048,iocharset=utf8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/dev/loop0",
|
||||||
|
"target": "/run/rootfsbase",
|
||||||
|
"fstype": "squashfs",
|
||||||
|
"maj:min": "7:0",
|
||||||
|
"options": "ro,relatime,errors=continue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "none",
|
||||||
|
"target": "/run/credentials/getty@tty2.service",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:47",
|
||||||
|
"options": "ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "none",
|
||||||
|
"target": "/run/credentials/serial-getty@ttyS0.service",
|
||||||
|
"fstype": "tmpfs",
|
||||||
|
"maj:min": "0:48",
|
||||||
|
"options": "ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"blockdevices": [
|
||||||
|
{
|
||||||
|
"path": "/dev/loop0",
|
||||||
|
"type": "loop",
|
||||||
|
"size": 2220142592,
|
||||||
|
"maj:min": "7:0",
|
||||||
|
"pkname": null,
|
||||||
|
"mountpoints": [
|
||||||
|
"/run/rootfsbase"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/dev/sr0",
|
||||||
|
"type": "rom",
|
||||||
|
"size": 2345107456,
|
||||||
|
"maj:min": "11:0",
|
||||||
|
"pkname": null,
|
||||||
|
"mountpoints": [
|
||||||
|
"/run/initramfs/live"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/dev/sr1",
|
||||||
|
"type": "rom",
|
||||||
|
"size": 1073741312,
|
||||||
|
"maj:min": "11:1",
|
||||||
|
"pkname": null,
|
||||||
|
"mountpoints": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/dev/vda",
|
||||||
|
"type": "disk",
|
||||||
|
"size": 34359738368,
|
||||||
|
"maj:min": "253:0",
|
||||||
|
"pkname": null,
|
||||||
|
"mountpoints": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+85
-6
@@ -4,6 +4,9 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
|
from types import SimpleNamespace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -403,6 +406,86 @@ class AdditionalFailureTests(unittest.TestCase):
|
|||||||
i.prompt_password()
|
i.prompt_password()
|
||||||
|
|
||||||
|
|
||||||
|
class RelocatedLiveLoopTests(unittest.TestCase):
|
||||||
|
def probe(self, *, backing_device=None, backing_rdevice=0, ioctl_error=None,
|
||||||
|
opened_device=None, opened_mode=stat.S_IFBLK):
|
||||||
|
fixtures = Path(__file__).parent / 'fixtures'
|
||||||
|
data = json.loads((fixtures / 'installer-dracut-lsblk.json').read_text())
|
||||||
|
mounts = json.loads((fixtures / 'installer-dracut-findmnt.json').read_text())
|
||||||
|
media = next(m for m in i.SystemProbe._mounts(json.dumps(mounts)) if m['source'] == '/dev/sr0')
|
||||||
|
calls = []
|
||||||
|
class ReadOnlyRunner:
|
||||||
|
def run(self, args):
|
||||||
|
calls.append(args)
|
||||||
|
if args[0] == 'lsblk':
|
||||||
|
return json.dumps(data)
|
||||||
|
if '--target' not in args:
|
||||||
|
return json.dumps(mounts)
|
||||||
|
if args[args.index('--target') + 1] != i.DEFAULT_PAYLOAD:
|
||||||
|
raise i.InstallError('Real dracut stale backing path has no current mount')
|
||||||
|
return json.dumps({'filesystems': [media]})
|
||||||
|
probe = i.SystemProbe(ReadOnlyRunner())
|
||||||
|
probe.physical_disk = lambda path: path == '/dev/vda'
|
||||||
|
packet = struct.pack('=QQQ', os.makedev(11, 0) if backing_device is None else backing_device,
|
||||||
|
3462, backing_rdevice) + bytes(208)
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
stack.enter_context(patch.object(Path, 'is_file', return_value=True))
|
||||||
|
stack.enter_context(patch.object(Path, 'read_text', return_value='/LiveOS/squashfs.img'))
|
||||||
|
opened = stack.enter_context(patch('os.open', return_value=91))
|
||||||
|
closed = stack.enter_context(patch('os.close'))
|
||||||
|
stack.enter_context(patch('os.fstat', return_value=SimpleNamespace(st_mode=opened_mode,
|
||||||
|
st_rdev=os.makedev(7, 0) if opened_device is None else opened_device)))
|
||||||
|
# Patch the syscall even before the installer imports fcntl.
|
||||||
|
syscall = stack.enter_context(patch('fcntl.ioctl', side_effect=ioctl_error,
|
||||||
|
return_value=packet))
|
||||||
|
topo = probe.snapshot(i.DEFAULT_PAYLOAD)
|
||||||
|
opened.assert_called_once_with('/dev/loop0', os.O_RDONLY | os.O_CLOEXEC)
|
||||||
|
closed.assert_called_once_with(91)
|
||||||
|
return probe, topo, calls, syscall
|
||||||
|
|
||||||
|
def test_actual_switched_root_topology_resolves_loop_by_kernel_identity(self):
|
||||||
|
probe, topo, calls, syscall = self.probe()
|
||||||
|
self.assertFalse(topo.component('/dev/vda') & topo.protected)
|
||||||
|
self.assertIn('/dev/loop0', topo.component('/dev/sr0'))
|
||||||
|
self.assertTrue(topo.component('/dev/sr0') & topo.protected)
|
||||||
|
self.assertEqual(syscall.call_args.args, (91, 0x4C05, bytes(232)))
|
||||||
|
self.assertFalse(any('/LiveOS/squashfs.img' in args for args in calls))
|
||||||
|
probe.snapshot = lambda payload: topo
|
||||||
|
self.assertEqual(i.validate_request(i.Request('/dev/vda', 'hydratest'), probe), '/dev/vda')
|
||||||
|
probe.physical_disk = lambda path: path == '/dev/sr0'
|
||||||
|
with self.assertRaises(i.InstallError):
|
||||||
|
i.validate_request(i.Request('/dev/sr0', 'hydratest'), probe)
|
||||||
|
# A mounted loop backed by a normal disk still forbids that whole disk.
|
||||||
|
topo.nodes['/dev/sr0']['type'] = 'disk'
|
||||||
|
topo.nodes['/dev/sr0']['size'] = 32 * 1024 ** 3
|
||||||
|
with self.assertRaisesRegex(i.InstallError, 'backs mounted/root/live storage'):
|
||||||
|
i.validate_request(i.Request('/dev/sr0', 'hydratest'), probe)
|
||||||
|
|
||||||
|
def test_unknown_loop_filesystem_identity_fails_closed(self):
|
||||||
|
for identity in (os.makedev(8, 1), os.makedev(0, 99)):
|
||||||
|
with self.subTest(identity=identity), self.assertRaisesRegex(i.InstallError, 'Unknown physical backing'):
|
||||||
|
self.probe(backing_device=identity)
|
||||||
|
|
||||||
|
def test_unreadable_or_changed_loop_device_fails_closed(self):
|
||||||
|
cases = [{'ioctl_error': OSError('cannot read loop status')},
|
||||||
|
{'opened_device': os.makedev(7, 1)}, {'opened_mode': stat.S_IFREG}]
|
||||||
|
for case in cases:
|
||||||
|
with self.subTest(case=case), self.assertRaises(i.InstallError):
|
||||||
|
self.probe(**case)
|
||||||
|
|
||||||
|
def test_truncated_loop_status_fails_closed_and_closes_descriptor(self):
|
||||||
|
with patch('os.open', return_value=91), patch('os.close') as closed, \
|
||||||
|
patch('os.fstat', return_value=SimpleNamespace(st_mode=stat.S_IFBLK, st_rdev=os.makedev(7, 0))), \
|
||||||
|
patch('fcntl.ioctl', return_value=bytes(16)):
|
||||||
|
with self.assertRaisesRegex(i.InstallError, 'Malformed loop status'):
|
||||||
|
i.SystemProbe._loop_backing_identity('/dev/loop0', '7:0')
|
||||||
|
closed.assert_called_once_with(91)
|
||||||
|
|
||||||
|
def test_direct_block_backing_uses_kernel_rdevice(self):
|
||||||
|
_, topo, _, _ = self.probe(backing_device=os.makedev(0, 6), backing_rdevice=os.makedev(11, 0))
|
||||||
|
self.assertIn('/dev/loop0', topo.component('/dev/sr0'))
|
||||||
|
|
||||||
|
|
||||||
class SnapshotTests(unittest.TestCase):
|
class SnapshotTests(unittest.TestCase):
|
||||||
def test_snapshot_connects_live_loop_to_its_physical_backing(self):
|
def test_snapshot_connects_live_loop_to_its_physical_backing(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -416,12 +499,8 @@ class SnapshotTests(unittest.TestCase):
|
|||||||
if args[0] == 'lsblk':
|
if args[0] == 'lsblk':
|
||||||
return json.dumps(data)
|
return json.dumps(data)
|
||||||
return json.dumps({'filesystems': [media] if '--target' in args else [media, loop]})
|
return json.dumps({'filesystems': [media] if '--target' in args else [media, loop]})
|
||||||
real_read = Path.read_text
|
with patch.object(i.SystemProbe, '_loop_backing_identity',
|
||||||
def read(path, *args, **kw):
|
return_value=media['maj:min']):
|
||||||
if str(path).startswith('/sys/dev/block/'):
|
|
||||||
return str(payload)
|
|
||||||
return real_read(path, *args, **kw)
|
|
||||||
with patch.object(Path, 'read_text', read):
|
|
||||||
topo = i.SystemProbe(ReadOnlyRunner()).snapshot(str(payload))
|
topo = i.SystemProbe(ReadOnlyRunner()).snapshot(str(payload))
|
||||||
self.assertFalse(topo.component('/dev/vda') & topo.protected)
|
self.assertFalse(topo.component('/dev/vda') & topo.protected)
|
||||||
self.assertIn('/dev/loop0', topo.component('/dev/vdb'))
|
self.assertIn('/dev/loop0', topo.component('/dev/vdb'))
|
||||||
|
|||||||
Reference in New Issue
Block a user