loop: add helpers to use IPC to create loop devices
loop.py is a simple wrapper around the kernel loop API. remoteloop.py uses this to create a server/clinet pair that communicates over an AF_UNIX/SOCK_DGRAM socket to allow the server to create loop devices for the client. The client passes a fd that should be bound to the resulting loop device, and a dir-fd where the loop device node should be created. The server returns the name of the device node to the client. The idea is that the client is run from whithin a container without access to devtmpfs (and hence /dev/loop-control), and the server runs on the host. The client would typically pass its (fake) /dev as the output directory. For the client this will be similar to `losetup -f foo.img --show`. [@larskarlitski: pylint: ignore the new LoopInfo class, because it only has dynamic attributes. Also disable attribute-defined-outside-init, which (among other problems) is not ignored for that class.] Signed-off-by: Tom Gundersen <teg@jklm.no>
This commit is contained in:
parent
5d5766a98a
commit
c124ab264b
3 changed files with 476 additions and 1 deletions
|
|
@ -1,3 +1,6 @@
|
||||||
[MASTER]
|
[MASTER]
|
||||||
disable=missing-docstring,too-few-public-methods,invalid-name,duplicate-code,superfluous-parens,too-many-locals
|
disable=missing-docstring,too-few-public-methods,invalid-name,duplicate-code,superfluous-parens,too-many-locals,attribute-defined-outside-init
|
||||||
max-line-length=120
|
max-line-length=120
|
||||||
|
|
||||||
|
[TYPECHECK]
|
||||||
|
ignored-classes=osbuild.loop.LoopInfo
|
||||||
|
|
|
||||||
334
osbuild/loop.py
Normal file
334
osbuild/loop.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
import ctypes
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Loop",
|
||||||
|
"LoopControl",
|
||||||
|
"UnexpectedDevice"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class UnexpectedDevice(Exception):
|
||||||
|
def __init__(self, expected_minor, rdev, mode):
|
||||||
|
super(UnexpectedDevice, self).__init__()
|
||||||
|
self.expected_minor = expected_minor
|
||||||
|
self.rdev = rdev
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
|
||||||
|
class LoopInfo(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('lo_device', ctypes.c_uint64),
|
||||||
|
('lo_inode', ctypes.c_uint64),
|
||||||
|
('lo_rdevice', ctypes.c_uint64),
|
||||||
|
('lo_offset', ctypes.c_uint64),
|
||||||
|
('lo_sizelimit', ctypes.c_uint64),
|
||||||
|
('lo_number', ctypes.c_uint32),
|
||||||
|
('lo_encrypt_type', ctypes.c_uint32),
|
||||||
|
('lo_encrypt_key_size', ctypes.c_uint32),
|
||||||
|
('lo_flags', ctypes.c_uint32),
|
||||||
|
('lo_file_name', ctypes.c_uint8 * 64),
|
||||||
|
('lo_crypt_name', ctypes.c_uint8 * 64),
|
||||||
|
('lo_encrypet_key', ctypes.c_uint8 * 32),
|
||||||
|
('lo_init', ctypes.c_uint64 * 2)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Loop:
|
||||||
|
"""Loopback device
|
||||||
|
|
||||||
|
A class representing a Linux loopback device, typically found at
|
||||||
|
/dev/loop{minor}.
|
||||||
|
|
||||||
|
Methods
|
||||||
|
-------
|
||||||
|
set_fd(fd)
|
||||||
|
Bind a file descriptor to the loopback device
|
||||||
|
clear_fd()
|
||||||
|
Unbind the file descriptor from the loopback device
|
||||||
|
change_fd(fd)
|
||||||
|
Replace the bound file descriptor
|
||||||
|
set_capacity()
|
||||||
|
Re-read the capacity of the backing file
|
||||||
|
set_status(offset=None, sizelimit=None, autoclear=None, partscan=None)
|
||||||
|
Set properties of the loopback device
|
||||||
|
mknod(dir_fd, mode=0o600)
|
||||||
|
Create a secondary device node
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOOP_MAJOR = 7
|
||||||
|
|
||||||
|
LO_FLAGS_READ_ONLY = 1
|
||||||
|
LO_FLAGS_AUTOCLEAR = 4
|
||||||
|
LO_FLAGS_PARTSCAN = 8
|
||||||
|
LO_FLAGS_DIRECT_IO = 16
|
||||||
|
|
||||||
|
LOOP_SET_FD = 0x4C00
|
||||||
|
LOOP_CLR_FD = 0x4C01
|
||||||
|
LOOP_SET_STATUS64 = 0x4C04
|
||||||
|
LOOP_GET_STATUS64 = 0x4C05
|
||||||
|
LOOP_CHANGE_FD = 0x4C06
|
||||||
|
LOOP_SET_CAPACITY = 0x4C07
|
||||||
|
LOOP_SET_DIRECT_IO = 0x4C08
|
||||||
|
LOOP_SET_BLOCK_SIZE = 0x4C09
|
||||||
|
|
||||||
|
def __init__(self, minor, dir_fd=None):
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
minor
|
||||||
|
the minor number of the underlying device
|
||||||
|
dir_fd : int, optional
|
||||||
|
A directory file descriptor to a filesytem containing the
|
||||||
|
underlying device node, or None to use /dev (default is None)
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
UnexpectedDevice
|
||||||
|
If the file in the expected device node location is not the
|
||||||
|
expected device node
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not dir_fd:
|
||||||
|
dir_fd = os.open("/dev", os.O_DIRECTORY)
|
||||||
|
self.devname = f"loop{minor}"
|
||||||
|
self.minor = minor
|
||||||
|
self.fd = os.open(self.devname, os.O_RDWR, dir_fd=dir_fd)
|
||||||
|
info = os.stat(self.fd)
|
||||||
|
if ((not stat.S_ISBLK(info.st_mode)) or
|
||||||
|
(not os.major(info.st_rdev) == self.LOOP_MAJOR) or
|
||||||
|
(not os.minor(info.st_rdev) == minor)):
|
||||||
|
raise UnexpectedDevice(minor, info.st_rdev, info.st_mode)
|
||||||
|
|
||||||
|
def set_fd(self, fd):
|
||||||
|
"""Bind a file descriptor to the loopback device
|
||||||
|
|
||||||
|
The loopback device must be unbound. The backing file must be
|
||||||
|
either a regular file or a block device. If the backing file is
|
||||||
|
itself a loopback device, then a cycle must not be created. If
|
||||||
|
the backing file is opened read-only, then the resulting
|
||||||
|
loopback device will be read-only too.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
fd : int
|
||||||
|
the file descriptor to bind
|
||||||
|
"""
|
||||||
|
|
||||||
|
fcntl.ioctl(self.fd, self.LOOP_SET_FD, fd)
|
||||||
|
|
||||||
|
def clear_fd(self):
|
||||||
|
"""Unbind the file descriptor from the loopback device
|
||||||
|
|
||||||
|
The loopback device must be bound. The device is then marked
|
||||||
|
to be cleared, so once nobodoy holds it open any longer the
|
||||||
|
backing file is unbound and the device returns to the unbound
|
||||||
|
state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fcntl.ioctl(self.fd, self.LOOP_CLR_FD)
|
||||||
|
|
||||||
|
def change_fd(self, fd):
|
||||||
|
"""Replace the bound filedescriptor
|
||||||
|
|
||||||
|
Atomically replace the backing filedescriptor of the loopback
|
||||||
|
device, even if the device is held open.
|
||||||
|
|
||||||
|
The effective size (taking sizelimit into account) of the new
|
||||||
|
and existing backing file descriptors must be the same, and
|
||||||
|
the loopback device must be read-only. The loopback device will
|
||||||
|
remain read-only, even if the new file descriptor was opened
|
||||||
|
read-write.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
fd : int
|
||||||
|
the file descriptor to change to
|
||||||
|
"""
|
||||||
|
|
||||||
|
fcntl.ioctl(self.fd, self.LOOP_CHANGE_FD, fd)
|
||||||
|
|
||||||
|
def set_status(self, offset=None, sizelimit=None, autoclear=None, partscan=None):
|
||||||
|
"""Set properties of the loopback device
|
||||||
|
|
||||||
|
The loopback device must be bound, and the properties will be
|
||||||
|
cleared once the device is unbound, but preserved by changing
|
||||||
|
the backnig file descriptor.
|
||||||
|
|
||||||
|
Note that this operation is not atomic: All the current properties
|
||||||
|
are read out, the ones specified in this function call are modified,
|
||||||
|
and then they are written back. For this reason, concurrent
|
||||||
|
modification of the properties must be avoided.
|
||||||
|
|
||||||
|
Setting sizelimit means the size of the loopback device is taken
|
||||||
|
to be the max of the size of the backing file and the limit. A
|
||||||
|
limit of 0 is taken to mean unlimited.
|
||||||
|
|
||||||
|
Enabling autoclear has the same effect as calling clear_fd().
|
||||||
|
|
||||||
|
When partscan is first enabed, the partition table of the
|
||||||
|
device is scanned, and new blockdevices potentially added for
|
||||||
|
the partitions.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
offset : int, optional
|
||||||
|
The offset in bytes from the start of the backing file, or
|
||||||
|
None to leave unchanged (default is None)
|
||||||
|
sizelimit : int, optional
|
||||||
|
The max size in bytes to make the loopback device, or None
|
||||||
|
to leave unchanged (default is None)
|
||||||
|
autoclear : bool, optional
|
||||||
|
Whether or not to enable autoclear, or None to leave unchanged
|
||||||
|
(default is None)
|
||||||
|
partscan : bool, optional
|
||||||
|
Whether or not to enable partition scanning, or None to leave
|
||||||
|
unchanged (default is None)
|
||||||
|
"""
|
||||||
|
|
||||||
|
info = LoopInfo()
|
||||||
|
fcntl.ioctl(self.fd, self.LOOP_GET_STATUS64, info)
|
||||||
|
if offset:
|
||||||
|
info.lo_offset = offset
|
||||||
|
if sizelimit:
|
||||||
|
info.lo_sizelimit = sizelimit
|
||||||
|
if autoclear is not None:
|
||||||
|
if autoclear:
|
||||||
|
info.lo_flags |= self.LO_FLAGS_AUTOCLEAR
|
||||||
|
else:
|
||||||
|
info.lo_flags &= ~self.LO_FLAGS_AUTOCLEAR
|
||||||
|
if partscan is not None:
|
||||||
|
if partscan:
|
||||||
|
info.lo_flags |= self.LO_FLAGS_PARTSCAN
|
||||||
|
else:
|
||||||
|
info.lo_flags &= ~self.LO_FLAGS_PARTSCAN
|
||||||
|
fcntl.ioctl(self.fd, self.LOOP_SET_STATUS64, info)
|
||||||
|
|
||||||
|
def mknod(self, dir_fd=None, mode=0o600):
|
||||||
|
"""Create a secondary device node
|
||||||
|
|
||||||
|
Create a device node with the correct name, mode, minor and major
|
||||||
|
number in the provided directory.
|
||||||
|
|
||||||
|
Note that the device node will survive even if a device is
|
||||||
|
unbound and rebound, so anyone with access to the device node
|
||||||
|
will have access to any future devices with the same minor
|
||||||
|
number. The intended use of this is to first bind a file
|
||||||
|
descriptor to a loopback device, then mknod it where it should
|
||||||
|
be accessed from, and only after the destination directory is
|
||||||
|
ensured to have been destroyed/made inaccessible should the the
|
||||||
|
loopback device be unbound.
|
||||||
|
|
||||||
|
Note that the provided directory should not be devtmpfs, as the
|
||||||
|
device node is guaranteed to already exist there, and the call
|
||||||
|
would hence fail.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
dir_fd : int, optional
|
||||||
|
Target directory file descriptor, or None to use /dev (None is default)
|
||||||
|
mode : int, optional
|
||||||
|
Access mode on the created device node (0o600 is default)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not dir_fd:
|
||||||
|
dir_fd = os.open("/dev", os.O_DIRECTORY)
|
||||||
|
os.mknod(self.devname,
|
||||||
|
mode=(stat.S_IMODE(mode) | stat.S_IFBLK),
|
||||||
|
device=os.makedev(self.LOOP_MAJOR, self.minor),
|
||||||
|
dir_fd=dir_fd)
|
||||||
|
|
||||||
|
|
||||||
|
class LoopControl:
|
||||||
|
"""Loopback control device
|
||||||
|
|
||||||
|
A class representing the Linux loopback control device, typically
|
||||||
|
found at /dev/loop-control. It allows the creation and destruction
|
||||||
|
of loopback devices.
|
||||||
|
|
||||||
|
A loopback device may be bound, which means that a file descriptor
|
||||||
|
has been attached to it as its backing file. Otherwise, it is
|
||||||
|
considered unbound.
|
||||||
|
|
||||||
|
Methods
|
||||||
|
-------
|
||||||
|
add(minor)
|
||||||
|
Add a new loopback device
|
||||||
|
remove(minor)
|
||||||
|
Remove an existing loopback device
|
||||||
|
get_unbound(minor)
|
||||||
|
Get or create the first unbound loopback device
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOOP_CTL_ADD = 0x4C80
|
||||||
|
LOOP_CTL_REMOVE = 0x4C81
|
||||||
|
LOOP_CTL_GET_FREE = 0x4C82
|
||||||
|
|
||||||
|
def __init__(self, dir_fd=None):
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
dir_fd : int, optional
|
||||||
|
A directory filedescriptor to a devtmpfs filesystem,
|
||||||
|
or None to use /dev (default is None)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not dir_fd:
|
||||||
|
dir_fd = os.open("/dev", os.O_DIRECTORY)
|
||||||
|
self.fd = os.open("loop-control", os.O_RDWR, dir_fd=dir_fd)
|
||||||
|
|
||||||
|
def add(self, minor=-1):
|
||||||
|
"""Add a new loopback device
|
||||||
|
|
||||||
|
Add a new, unbound loopback device. If a minor number is given
|
||||||
|
and it is positive, a loopback device with that minor number
|
||||||
|
is added. Otherwise, if there are no unbound devices, a device
|
||||||
|
using the first unused minor number is created.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
minor : int, optional
|
||||||
|
The requested minor number, or a negative value for
|
||||||
|
unspecified (default is -1)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
The minor number of the created device
|
||||||
|
"""
|
||||||
|
|
||||||
|
return fcntl.ioctl(self.fd, self.LOOP_CTL_ADD, minor)
|
||||||
|
|
||||||
|
def remove(self, minor=-1):
|
||||||
|
"""Remove an existing loopback device
|
||||||
|
|
||||||
|
Removes an unbound and unopen loopback device. If a minor
|
||||||
|
number is given and it is positive, the loopback device
|
||||||
|
with that minor number is removed. Otherwise, the first
|
||||||
|
unbound device is attempted removed.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
minor : int, optional
|
||||||
|
The requested minor number, or a negative value for
|
||||||
|
unspecified (deafult is -1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
fcntl.ioctl(self.fd, self.LOOP_CTL_REMOVE, minor)
|
||||||
|
|
||||||
|
def get_unbound(self):
|
||||||
|
"""Get or create an unbound loopback device
|
||||||
|
|
||||||
|
If an unbound loopback device exists, returns it.
|
||||||
|
Otherwise, create a new one.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
int
|
||||||
|
The minor number of the returned device
|
||||||
|
"""
|
||||||
|
|
||||||
|
return fcntl.ioctl(self.fd, self.LOOP_CTL_GET_FREE)
|
||||||
138
osbuild/remoteloop.py
Normal file
138
osbuild/remoteloop.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import array
|
||||||
|
import asyncio
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import osbuild.loop as loop
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LoopClient",
|
||||||
|
"LoopServer"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_fds(sock, msglen):
|
||||||
|
fds = array.array("i") # Array of ints
|
||||||
|
msg, ancdata, _, addr = sock.recvmsg(msglen, socket.CMSG_LEN(253 * fds.itemsize))
|
||||||
|
for cmsg_level, cmsg_type, cmsg_data in ancdata:
|
||||||
|
if (cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS):
|
||||||
|
# Append data, ignoring any truncated integers at the end.
|
||||||
|
fds.fromstring(cmsg_data[:len(cmsg_data) - (len(cmsg_data) % fds.itemsize)])
|
||||||
|
return json.loads(msg), list(fds), addr
|
||||||
|
|
||||||
|
|
||||||
|
def dump_fds(sock, obj, fds):
|
||||||
|
sock.sendmsg([json.dumps(obj).encode('utf-8')], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", fds))])
|
||||||
|
|
||||||
|
|
||||||
|
class LoopServer:
|
||||||
|
"""Server for creating loopback devices
|
||||||
|
|
||||||
|
The server listens for requests on a AF_UNIX/SOCK_DRGAM sockets.
|
||||||
|
|
||||||
|
A request should contain SCM_RIGHTS of two filedescriptors, one
|
||||||
|
that sholud be the backing file for the new loopdevice, and a
|
||||||
|
second that should be a directory file descriptor where the new
|
||||||
|
device node will be created.
|
||||||
|
|
||||||
|
The payload should be a JSON object with the mandatory arguments
|
||||||
|
@fd which is the offset in the SCM_RIGHTS array for the backing
|
||||||
|
file descriptor and @dir_fd which is the offset for the output
|
||||||
|
directory. Optionally, @offset and @sizelimit in bytes may also
|
||||||
|
be specified.
|
||||||
|
|
||||||
|
The server respods with a JSON object containing the device name
|
||||||
|
of the new device node created in the output directory.
|
||||||
|
|
||||||
|
The created loopback device is guaranteed to be bound to the
|
||||||
|
given backing file descriptor for the lifetime of the LoopServer
|
||||||
|
object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sock):
|
||||||
|
self.devs = []
|
||||||
|
self.sock = sock
|
||||||
|
self.ctl = loop.LoopControl()
|
||||||
|
self.event_loop = asyncio.new_event_loop()
|
||||||
|
self.event_loop.add_reader(self.sock, self._dispatch)
|
||||||
|
self.thread = threading.Thread(target=self._run_event_loop)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
def _create_device(self, fd, dir_fd, offset=None, sizelimit=None):
|
||||||
|
while True:
|
||||||
|
# Getting an unbound loopback device and attaching a backing
|
||||||
|
# file descriptor to it is racy, so we must use a retry loop
|
||||||
|
lo = loop.Loop(self.ctl.get_unbound())
|
||||||
|
try:
|
||||||
|
lo.set_fd(fd)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == errno.EBUSY:
|
||||||
|
continue
|
||||||
|
raise e
|
||||||
|
break
|
||||||
|
lo.set_status(offset=offset, sizelimit=sizelimit, autoclear=True)
|
||||||
|
lo.mknod(dir_fd)
|
||||||
|
# Pin the Loop objects so they are only released when the LoopServer
|
||||||
|
# is destroyed.
|
||||||
|
self.devs.append(lo)
|
||||||
|
return lo.devname
|
||||||
|
|
||||||
|
def _dispatch(self):
|
||||||
|
args, fds, addr = load_fds(self.sock, 1024)
|
||||||
|
|
||||||
|
fd = fds[args["fd"]]
|
||||||
|
dir_fd = fds[args["dir_fd"]]
|
||||||
|
offset = args.get("offset")
|
||||||
|
sizelimit = args.get("sizelimit")
|
||||||
|
|
||||||
|
devname = self._create_device(fd, dir_fd, offset, sizelimit)
|
||||||
|
ret = json.dumps({"devname": devname})
|
||||||
|
self.sock.sendto(ret.encode('utf-8'), addr)
|
||||||
|
|
||||||
|
def _run_event_loop(self):
|
||||||
|
# Set the thread-local event loop
|
||||||
|
asyncio.set_event_loop(self.event_loop)
|
||||||
|
# Run event loop until stopped
|
||||||
|
self.event_loop.run_forever()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
self.event_loop.call_soon_threadsafe(self.event_loop.stop)
|
||||||
|
self.thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
class LoopClient:
|
||||||
|
def __init__(self, sock):
|
||||||
|
self.sock = sock
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
def create_device(self, fd, dir_fd=None, offset=None, sizelimit=None):
|
||||||
|
req = {}
|
||||||
|
fds = array.array("i")
|
||||||
|
|
||||||
|
if not dir_fd:
|
||||||
|
dir_fd = os.open("/dev", os.O_DIRECTORY)
|
||||||
|
|
||||||
|
fds.append(fd)
|
||||||
|
req["fd"] = 0
|
||||||
|
fds.append(dir_fd)
|
||||||
|
req["dir_fd"] = 1
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
req["offset"] = offset
|
||||||
|
if sizelimit:
|
||||||
|
req["sizelimit"] = sizelimit
|
||||||
|
|
||||||
|
dump_fds(self.sock, req, fds)
|
||||||
|
ret = json.loads(self.sock.recv(1024))
|
||||||
|
|
||||||
|
return ret["devname"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue