otg msd and big refactoring

This commit is contained in:
Devaev Maxim
2019-10-29 02:16:12 +03:00
parent f6214191af
commit 10f8c2b335
15 changed files with 1300 additions and 356 deletions

View File

@@ -20,11 +20,12 @@
# ========================================================================== #
import types
import contextlib
from typing import Dict
from typing import Type
from typing import AsyncGenerator
from typing import Optional
from .. import BasePlugin
from .. import get_plugin_class
@@ -44,19 +45,29 @@ class MsdOfflineError(MsdOperationError):
super().__init__("MSD is not found")
class MsdAlreadyConnectedError(MsdOperationError):
def __init__(self) -> None:
super().__init__("MSD is already connected to Server")
class MsdAlreadyDisconnectedError(MsdOperationError):
def __init__(self) -> None:
super().__init__("MSD is already disconnected from Server")
class MsdConnectedError(MsdOperationError):
def __init__(self) -> None:
super().__init__("MSD connected to Server, but should not")
super().__init__("MSD is connected to Server, but shouldn't for this operation")
class MsdDisconnectedError(MsdOperationError):
def __init__(self) -> None:
super().__init__("MSD is disconnected from Server, but should be for this operation")
class MsdImageNotSelected(MsdOperationError):
def __init__(self) -> None:
super().__init__("The image is not selected")
class MsdUnknownImageError(MsdOperationError):
def __init__(self) -> None:
super().__init__("The image is not found in the storage")
class MsdImageExistsError(MsdOperationError):
def __init__(self) -> None:
super().__init__("This image is already exists")
class MsdIsBusyError(MsdOperationError):
@@ -69,52 +80,51 @@ class MsdMultiNotSupported(MsdOperationError):
super().__init__("This MSD does not support storing multiple images")
class MsdCdromNotSupported(MsdOperationError):
def __init__(self) -> None:
super().__init__("This MSD does not support CD-ROM emulation")
# =====
class BaseMsd(BasePlugin):
def get_state(self) -> Dict:
raise NotImplementedError
async def get_state(self) -> Dict:
raise NotImplementedError()
async def poll_state(self) -> AsyncGenerator[Dict, None]:
yield {}
raise NotImplementedError
if True: # pylint: disable=using-constant-test
# XXX: Vulture hack
raise NotImplementedError()
yield
async def reset(self) -> None:
raise NotImplementedError
raise NotImplementedError()
async def cleanup(self) -> None:
pass
# =====
async def connect(self) -> Dict:
raise NotImplementedError
async def set_params(self, name: Optional[str]=None, cdrom: Optional[bool]=None) -> None:
raise NotImplementedError()
async def disconnect(self) -> Dict:
raise NotImplementedError
async def connect(self) -> None:
raise NotImplementedError()
async def select(self, name: str, cdrom: bool) -> Dict:
raise NotImplementedError
async def disconnect(self) -> None:
raise NotImplementedError()
async def remove(self, name: str) -> Dict:
raise NotImplementedError
async def __aenter__(self) -> "BaseMsd":
raise NotImplementedError
async def write_image_info(self, name: str, complete: bool) -> None:
raise NotImplementedError
@contextlib.asynccontextmanager
async def write_image(self, name: str) -> AsyncGenerator[None, None]:
if True: # pylint: disable=using-constant-test
# XXX: Vulture hack
raise NotImplementedError()
yield
async def write_image_chunk(self, chunk: bytes) -> int:
raise NotImplementedError
raise NotImplementedError()
async def __aexit__(
self,
_exc_type: Type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
raise NotImplementedError
async def remove(self, name: str) -> None:
raise NotImplementedError()
# =====

View File

@@ -21,11 +21,11 @@
import asyncio
import types
import contextlib
from typing import Dict
from typing import Type
from typing import AsyncGenerator
from typing import Optional
from . import MsdOperationError
from . import BaseMsd
@@ -39,23 +39,22 @@ class MsdDisabledError(MsdOperationError):
# =====
class Plugin(BaseMsd):
def get_state(self) -> Dict:
async def get_state(self) -> Dict:
return {
"enabled": False,
"multi": False,
"online": False,
"busy": False,
"uploading": False,
"written": 0,
"current": None,
"storage": None,
"cdrom": None,
"connected": False,
"drive": None,
"features": {
"multi": False,
"cdrom": False,
},
}
async def poll_state(self) -> AsyncGenerator[Dict, None]:
while True:
yield self.get_state()
yield (await self.get_state())
await asyncio.sleep(60)
async def reset(self) -> None:
@@ -63,32 +62,24 @@ class Plugin(BaseMsd):
# =====
async def connect(self) -> Dict:
async def set_params(self, name: Optional[str]=None, cdrom: Optional[bool]=None) -> None:
raise MsdDisabledError()
async def disconnect(self) -> Dict:
async def connect(self) -> None:
raise MsdDisabledError()
async def select(self, name: str, cdrom: bool) -> Dict:
async def disconnect(self) -> None:
raise MsdDisabledError()
async def remove(self, name: str) -> Dict:
raise MsdDisabledError()
async def __aenter__(self) -> BaseMsd:
raise MsdDisabledError()
async def write_image_info(self, name: str, complete: bool) -> None:
raise MsdDisabledError()
@contextlib.asynccontextmanager
async def write_image(self, name: str) -> AsyncGenerator[None, None]:
if True: # pylint: disable=using-constant-test
# XXX: Vulture hack
raise MsdDisabledError()
yield
async def write_image_chunk(self, chunk: bytes) -> int:
raise MsdDisabledError()
async def __aexit__(
self,
_exc_type: Type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
async def remove(self, name: str) -> None:
raise MsdDisabledError()

View File

@@ -1,108 +0,0 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import asyncio
import types
from typing import Dict
from typing import Type
from typing import AsyncGenerator
from ...yamlconf import Option
from ...validators.os import valid_abs_dir
from ...validators.os import valid_command
from . import MsdOperationError
from . import BaseMsd
# =====
class MsdCliOnlyError(MsdOperationError):
def __init__(self) -> None:
super().__init__("Only CLI")
# =====
class Plugin(BaseMsd):
@classmethod
def get_plugin_options(cls) -> Dict:
sudo = ["/usr/bin/sudo", "--non-interactive"]
return {
"storage": Option("/var/lib/kvmd/msd", type=valid_abs_dir, unpack_as="storage_path"),
"remount_cmd": Option([*sudo, "/usr/bin/kvmd-helper-otgmsd-remount", "{mode}"], type=valid_command),
"unlock_cmd": Option([*sudo, "/usr/bin/kvmd-helper-otgmsd-unlock", "unlock"], type=valid_command),
}
def get_state(self) -> Dict:
return {
"enabled": False,
"multi": False,
"online": False,
"busy": False,
"uploading": False,
"written": 0,
"current": None,
"storage": None,
"cdrom": None,
"connected": False,
}
async def poll_state(self) -> AsyncGenerator[Dict, None]:
while True:
yield self.get_state()
await asyncio.sleep(60)
async def reset(self) -> None:
raise MsdCliOnlyError()
# =====
async def connect(self) -> Dict:
raise MsdCliOnlyError()
async def disconnect(self) -> Dict:
raise MsdCliOnlyError()
async def select(self, name: str, cdrom: bool) -> Dict:
raise MsdCliOnlyError()
async def remove(self, name: str) -> Dict:
raise MsdCliOnlyError()
async def __aenter__(self) -> BaseMsd:
raise MsdCliOnlyError()
async def write_image_info(self, name: str, complete: bool) -> None:
raise MsdCliOnlyError()
async def write_image_chunk(self, chunk: bytes) -> int:
raise MsdCliOnlyError()
async def __aexit__(
self,
_exc_type: Type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
raise MsdCliOnlyError()

View File

@@ -0,0 +1,531 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import asyncio
import contextlib
import dataclasses
from typing import List
from typing import Dict
from typing import AsyncGenerator
from typing import Optional
import aiofiles
import aiofiles.base
from ....logging import get_logger
from ....inotify import InotifyMask
from ....inotify import Inotify
from ....yamlconf import Option
from ....validators.os import valid_abs_dir
from ....validators.os import valid_command
from .... import aiotools
from .... import aioregion
from .. import MsdError
from .. import MsdOfflineError
from .. import MsdConnectedError
from .. import MsdDisconnectedError
from .. import MsdImageNotSelected
from .. import MsdUnknownImageError
from .. import MsdImageExistsError
from .. import MsdIsBusyError
from .. import BaseMsd
from .drive import Drive
from .helpers import remount_storage
from .helpers import unlock_drive
# =====
@dataclasses.dataclass(frozen=True)
class _DriveImage:
name: str
path: str
size: int
complete: bool
in_storage: bool
@dataclasses.dataclass(frozen=True)
class _DriveState:
image: Optional[_DriveImage]
cdrom: bool
rw: bool
@dataclasses.dataclass(frozen=True)
class _StorageState:
size: int
free: int
images: Dict[str, _DriveImage]
# =====
@dataclasses.dataclass
class _VirtualDriveState:
image: Optional[_DriveImage]
connected: bool
cdrom: bool
@classmethod
def from_drive_state(cls, state: _DriveState) -> "_VirtualDriveState":
return _VirtualDriveState(
image=state.image,
connected=bool(state.image),
cdrom=state.cdrom,
)
class _State:
def __init__(self, changes_queue: asyncio.queues.Queue) -> None:
self.__changes_queue = changes_queue
self.storage: Optional[_StorageState] = None
self.vd: Optional[_VirtualDriveState] = None
self._lock = asyncio.Lock()
self._region = aioregion.AioExclusiveRegion(MsdIsBusyError)
@contextlib.asynccontextmanager
async def busy(self, check_online: bool=True) -> AsyncGenerator[None, None]:
with self._region:
async with self._lock:
await self.__changes_queue.put(None)
if check_online:
if self.vd is None:
raise MsdOfflineError()
assert self.storage
yield
await self.__changes_queue.put(None)
def is_busy(self) -> bool:
return self._region.is_busy()
# =====
class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=super-init-not-called
self,
storage_path: str,
remount_cmd: List[str],
unlock_cmd: List[str],
gadget: str, # XXX: Not from options, see /kvmd/apps/kvmd/__init__.py for details
) -> None:
self.__storage_path = os.path.normpath(storage_path)
self.__images_path = os.path.join(self.__storage_path, "images")
self.__meta_path = os.path.join(self.__storage_path, "meta")
self.__remount_cmd = remount_cmd
self.__unlock_cmd = unlock_cmd
self.__drive = Drive(gadget, instance=0, lun=0)
self.__new_file: Optional[aiofiles.base.AiofilesContextManager] = None
self.__new_file_written = 0
self.__changes_queue: asyncio.queues.Queue = asyncio.Queue()
self.__state = _State(self.__changes_queue)
logger = get_logger(0)
logger.info("Using OTG gadget %r as MSD", gadget)
aiotools.run_sync(self.__reload_state())
@classmethod
def get_plugin_options(cls) -> Dict:
sudo = ["/usr/bin/sudo", "--non-interactive"]
return {
"storage": Option("/var/lib/kvmd/msd", type=valid_abs_dir, unpack_as="storage_path"),
"remount_cmd": Option([*sudo, "/usr/bin/kvmd-helper-otgmsd-remount", "{mode}"], type=valid_command),
"unlock_cmd": Option([*sudo, "/usr/bin/kvmd-helper-otgmsd-unlock", "unlock"], type=valid_command),
}
async def get_state(self) -> Dict:
async with self.__state._lock: # pylint: disable=protected-access
storage: Optional[Dict] = None
if self.__state.storage:
storage = dataclasses.asdict(self.__state.storage)
for name in list(storage["images"]):
del storage["images"][name]["path"]
del storage["images"][name]["in_storage"]
storage["uploading"] = bool(self.__new_file)
vd: Optional[Dict] = None
if self.__state.vd:
vd = dataclasses.asdict(self.__state.vd)
if vd["image"]:
del vd["image"]["path"]
return {
"enabled": False, # FIXME
"online": bool(self.__state.vd),
"busy": self.__state.is_busy(),
"storage": storage,
"drive": vd,
"features": {
"multi": True,
"cdrom": True,
},
}
async def poll_state(self) -> AsyncGenerator[Dict, None]:
inotify_task = asyncio.create_task(self.__watch_inotify())
prev_state: Dict = {}
try:
while True:
if inotify_task.cancelled():
break
if inotify_task.done():
RuntimeError("Inotify task is dead")
try:
await asyncio.wait_for(self.__changes_queue.get(), timeout=0.1)
except asyncio.TimeoutError:
continue
state = await self.get_state()
if state != prev_state:
yield state
prev_state = state
finally:
if not inotify_task.done():
inotify_task.cancel()
await inotify_task
@aiotools.atomic
async def reset(self) -> None:
async with self.__state.busy(check_online=False):
try:
await self.__unlock_drive()
self.__drive.set_image_path("")
self.__drive.set_rw_flag(False)
self.__drive.set_cdrom_flag(False)
except asyncio.CancelledError:
raise
except Exception:
get_logger(0).exception("Can't reset MSD")
@aiotools.atomic
async def cleanup(self) -> None:
await self.__close_new_file()
# =====
@aiotools.atomic
async def set_params(self, name: Optional[str]=None, cdrom: Optional[bool]=None) -> None:
async with self.__state.busy():
assert self.__state.storage
assert self.__state.vd
if self.__state.vd.connected or self.__drive.get_image_path():
raise MsdConnectedError()
if name is not None:
if name:
image = self.__state.storage.images.get(name)
if image is None or not os.path.exists(image.path):
raise MsdUnknownImageError()
assert image.in_storage
self.__state.vd.image = image
else:
self.__state.vd.image = None
if cdrom is not None:
self.__state.vd.cdrom = cdrom
@aiotools.atomic
async def connect(self) -> None:
async with self.__state.busy():
assert self.__state.vd
if self.__state.vd.connected or self.__drive.get_image_path():
raise MsdConnectedError()
if self.__state.vd.image is None:
raise MsdImageNotSelected()
assert self.__state.vd.image.in_storage
if not os.path.exists(self.__state.vd.image.path):
raise MsdUnknownImageError()
await self.__unlock_drive()
self.__drive.set_cdrom_flag(self.__state.vd.cdrom)
self.__drive.set_image_path(self.__state.vd.image.path)
self.__state.vd.connected = True
@aiotools.atomic
async def disconnect(self) -> None:
async with self.__state.busy():
assert self.__state.vd
if not (self.__state.vd.connected or self.__drive.get_image_path()):
raise MsdDisconnectedError()
await self.__unlock_drive()
self.__drive.set_image_path("")
self.__state.vd.connected = False
@contextlib.asynccontextmanager
async def write_image(self, name: str) -> AsyncGenerator[None, None]:
try:
with self.__state._region: # pylint: disable=protected-access
try:
async with self.__state._lock: # pylint: disable=protected-access
await self.__changes_queue.put(None)
assert self.__state.storage
assert self.__state.vd
if self.__state.vd.connected or self.__drive.get_image_path():
raise MsdConnectedError()
path = os.path.join(self.__images_path, name)
if name in self.__state.storage.images or os.path.exists(path):
raise MsdImageExistsError()
await self.__remount_storage(rw=True)
self.__set_image_complete(name, False)
self.__new_file_written = 0
self.__new_file = await aiofiles.open(path, mode="w+b", buffering=0)
await self.__changes_queue.put(None)
yield
self.__set_image_complete(name, True)
finally:
await self.__close_new_file()
try:
await self.__remount_storage(rw=False)
except asyncio.CancelledError: # pylint: disable=try-except-raise
raise
except Exception:
pass
finally:
await self.__changes_queue.put(None)
@aiotools.atomic
async def write_image_chunk(self, chunk: bytes) -> int:
assert self.__new_file
await aiotools.afile_write_now(self.__new_file, chunk)
self.__new_file_written += len(chunk)
return self.__new_file_written
async def remove(self, name: str) -> None:
async with self.__state.busy():
assert self.__state.storage
assert self.__state.vd
if self.__state.vd.connected or self.__drive.get_image_path():
raise MsdConnectedError()
image = self.__state.storage.images.get(name)
if image is None or not os.path.exists(image.path):
raise MsdUnknownImageError()
assert image.in_storage
if self.__state.vd.image == image:
self.__state.vd.image = None
del self.__state.storage.images[name]
await self.__remount_storage(rw=True)
os.remove(image.path)
self.__set_image_complete(name, False)
await self.__remount_storage(rw=False)
# =====
async def __close_new_file(self) -> None:
try:
if self.__new_file:
get_logger().info("Closing new image file ...")
await self.__new_file.close()
except asyncio.CancelledError: # pylint: disable=try-except-raise
raise
except Exception:
get_logger().exception("Can't close device file")
finally:
self.__new_file = None
self.__new_file_written = 0
# =====
async def __watch_inotify(self) -> None:
logger = get_logger(0)
while True:
try:
while True:
# Активно ждем, пока не будут на месте все каталоги.
await self.__reload_state()
await self.__changes_queue.put(None)
if self.__state.vd:
break
await asyncio.sleep(5)
with Inotify() as inotify:
inotify.watch(self.__images_path, InotifyMask.ALL_MODIFY_EVENTS)
inotify.watch(self.__meta_path, InotifyMask.ALL_MODIFY_EVENTS)
inotify.watch(self.__drive.get_sysfs_path(), InotifyMask.ALL_MODIFY_EVENTS)
# После установки вотчеров еще раз проверяем стейт, чтобы ничего не потерять
await self.__reload_state()
await self.__changes_queue.put(None)
while self.__state.vd: # Если живы после предыдущей проверки
need_restart = False
need_reload_state = False
for event in (await inotify.get_series(timeout=1)):
need_reload_state = True
if event.mask & (InotifyMask.DELETE_SELF | InotifyMask.MOVE_SELF | InotifyMask.UNMOUNT):
# Если выгрузили OTG, что-то отмонтировали или делают еще какую-то странную фигню
logger.warning("Got fatal inotify event: %s; reinitializing MSD ...", event)
need_restart = True
break
if need_restart:
break
if need_reload_state:
await self.__reload_state()
await self.__changes_queue.put(None)
except asyncio.CancelledError: # pylint: disable=try-except-raise
raise
except Exception:
logger.exception("Unexpected MSD watcher error")
async def __reload_state(self) -> None:
logger = get_logger(0)
async with self.__state._lock: # pylint: disable=protected-access
try:
drive_state = self.__get_drive_state()
if drive_state.rw:
# Внештатное использование MSD, ломаемся
raise MsdError("MSD has been switched to RW-mode manually")
if self.__state.vd is None and drive_state.image is None:
# Если только что включились и образ не подключен - попробовать
# перемонтировать хранилище (и создать images и meta).
logger.info("Probing to remount storage ...")
await self.__remount_storage(rw=True)
await self.__remount_storage(rw=False)
storage_state = self.__get_storage_state()
except asyncio.CancelledError: # pylint: disable=try-except-raise
raise
except Exception:
logger.exception("Error while reloading MSD state; switching to offline")
self.__state.storage = None
self.__state.vd = None
else:
self.__state.storage = storage_state
if drive_state.image:
# При подключенном образе виртуальный стейт заменяется реальным
self.__state.vd = _VirtualDriveState.from_drive_state(drive_state)
else:
if self.__state.vd is None:
# Если раньше MSD был отключен
self.__state.vd = _VirtualDriveState.from_drive_state(drive_state)
if (
self.__state.vd.image
and (not self.__state.vd.image.in_storage or not os.path.exists(self.__state.vd.image.path))
):
# Если только что отключили ручной образ вне хранилища или ранее выбранный образ был удален
self.__state.vd.image = None
self.__state.vd.connected = False
# =====
def __get_storage_state(self) -> _StorageState:
images: Dict[str, _DriveImage] = {}
for name in os.listdir(self.__images_path):
path = os.path.join(self.__images_path, name)
if os.path.exists(path):
size = self.__get_file_size(path)
if size >= 0:
images[name] = _DriveImage(
name=name,
path=path,
size=size,
complete=self.__is_image_complete(name),
in_storage=True,
)
st = os.statvfs(self.__storage_path)
return _StorageState(
size=(st.f_blocks * st.f_frsize),
free=(st.f_bavail * st.f_frsize),
images=images,
)
def __get_drive_state(self) -> _DriveState:
image: Optional[_DriveImage] = None
path = self.__drive.get_image_path()
if path:
name = os.path.basename(path)
in_storage = (os.path.dirname(path) == self.__images_path)
image = _DriveImage(
name=name,
path=path,
size=max(self.__get_file_size(path), 0),
complete=(self.__is_image_complete(name) if in_storage else True),
in_storage=in_storage,
)
return _DriveState(
image=image,
cdrom=self.__drive.get_cdrom_flag(),
rw=self.__drive.get_rw_flag(),
)
# =====
def __get_file_size(self, path: str) -> int:
try:
return os.path.getsize(path)
except Exception as err:
get_logger().warning("Can't get size of file %s: %s", path, err)
return -1
def __is_image_complete(self, name: str) -> bool:
return os.path.exists(os.path.join(self.__meta_path, name + ".complete"))
def __set_image_complete(self, name: str, flag: bool) -> None:
path = os.path.join(self.__meta_path, name + ".complete")
if flag:
open(path, "w").close()
else:
if os.path.exists(path):
os.remove(path)
# =====
async def __remount_storage(self, rw: bool) -> None:
await remount_storage(self.__remount_cmd, rw)
async def __unlock_drive(self) -> None:
await unlock_drive(self.__unlock_cmd)

View File

@@ -0,0 +1,80 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import os
import errno
from .. import MsdOperationError
# =====
class MsdDriveLockedError(MsdOperationError):
def __init__(self) -> None:
super().__init__("MSD drive is locked on IO operation")
# =====
class Drive:
def __init__(self, gadget: str, instance: int, lun: int) -> None:
self.__path = os.path.join(
"/sys/kernel/config/usb_gadget",
gadget,
f"functions/mass_storage.usb{instance}/lun.{lun}",
)
def get_sysfs_path(self) -> str:
return self.__path
# =====
def set_image_path(self, path: str) -> None:
self.__set_param("file", path)
def get_image_path(self) -> str:
return self.__get_param("file")
def set_cdrom_flag(self, flag: bool) -> None:
self.__set_param("cdrom", str(int(flag)))
def get_cdrom_flag(self) -> bool:
return bool(int(self.__get_param("cdrom")))
def set_rw_flag(self, flag: bool) -> None:
self.__set_param("ro", str(int(not flag)))
def get_rw_flag(self) -> bool:
return (not int(self.__get_param("ro")))
# =====
def __get_param(self, param: str) -> str:
with open(os.path.join(self.__path, param)) as param_file:
return param_file.read().strip()
def __set_param(self, param: str, value: str) -> None:
try:
with open(os.path.join(self.__path, param), "w") as param_file:
param_file.write(value + "\n")
except OSError as err:
if err.errno == errno.EBUSY:
raise MsdDriveLockedError()
raise

View File

@@ -0,0 +1,79 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
# #
# ========================================================================== #
import signal
import asyncio
import asyncio.subprocess
from typing import List
from ....logging import get_logger
from .. import MsdError
# =====
async def remount_storage(base_cmd: List[str], rw: bool) -> None:
logger = get_logger(0)
mode = ("rw" if rw else "ro")
cmd = [
part.format(mode=mode)
for part in base_cmd
]
logger.info("Remounting internal storage to %s ...", mode.upper())
try:
await _run_helper(cmd)
except Exception:
logger.error("Can't remount internal storage")
raise
async def unlock_drive(base_cmd: List[str]) -> None:
logger = get_logger(0)
logger.info("Unlocking the drive ...")
try:
await _run_helper(base_cmd)
except Exception:
logger.error("Can't unlock the drive")
raise
# =====
async def _run_helper(cmd: List[str]) -> None:
logger = get_logger(0)
logger.info("Executing helper %s ...", cmd)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
preexec_fn=(lambda: signal.signal(signal.SIGINT, signal.SIG_IGN)),
)
stdout = (await proc.communicate())[0].decode(errors="ignore").strip()
if stdout:
log = (logger.info if proc.returncode == 0 else logger.error)
for line in stdout.split("\n"):
log("Console: %s", line)
if proc.returncode != 0:
raise MsdError(f"Error while helper execution: pid={proc.pid}; retcode={proc.returncode}")

View File

@@ -26,16 +26,13 @@ import fcntl
import struct
import asyncio
import asyncio.queues
import contextlib
import dataclasses
import types
from typing import Dict
from typing import IO
from typing import Callable
from typing import Type
from typing import AsyncGenerator
from typing import Optional
from typing import Any
import aiofiles
import aiofiles.base
@@ -57,11 +54,11 @@ from ...validators.hw import valid_gpio_pin
from . import MsdError
from . import MsdOfflineError
from . import MsdAlreadyConnectedError
from . import MsdAlreadyDisconnectedError
from . import MsdConnectedError
from . import MsdDisconnectedError
from . import MsdIsBusyError
from . import MsdMultiNotSupported
from . import MsdCdromNotSupported
from . import BaseMsd
@@ -83,11 +80,11 @@ class _DeviceInfo:
_IMAGE_INFO_SIZE = 4096
_IMAGE_INFO_MAGIC_SIZE = 16
_IMAGE_INFO_IMAGE_NAME_SIZE = 256
_IMAGE_INFO_PADS_SIZE = _IMAGE_INFO_SIZE - _IMAGE_INFO_IMAGE_NAME_SIZE - 1 - 8 - _IMAGE_INFO_MAGIC_SIZE * 8
_IMAGE_INFO_NAME_SIZE = 256
_IMAGE_INFO_PADS_SIZE = _IMAGE_INFO_SIZE - _IMAGE_INFO_NAME_SIZE - 1 - 8 - _IMAGE_INFO_MAGIC_SIZE * 8
_IMAGE_INFO_FORMAT = ">%dL%dc?Q%dx%dL" % (
_IMAGE_INFO_MAGIC_SIZE,
_IMAGE_INFO_IMAGE_NAME_SIZE,
_IMAGE_INFO_NAME_SIZE,
_IMAGE_INFO_PADS_SIZE,
_IMAGE_INFO_MAGIC_SIZE,
)
@@ -100,8 +97,8 @@ def _make_image_info_bytes(name: str, size: int, complete: bool) -> bytes:
*_IMAGE_INFO_MAGIC,
*memoryview(( # type: ignore
name.encode("utf-8")
+ b"\x00" * _IMAGE_INFO_IMAGE_NAME_SIZE
)[:_IMAGE_INFO_IMAGE_NAME_SIZE]).cast("c"),
+ b"\x00" * _IMAGE_INFO_NAME_SIZE
)[:_IMAGE_INFO_NAME_SIZE]).cast("c"),
complete,
size,
*_IMAGE_INFO_MAGIC,
@@ -117,11 +114,15 @@ def _parse_image_info_bytes(data: bytes) -> Optional[_ImageInfo]:
magic_begin = parsed[:_IMAGE_INFO_MAGIC_SIZE]
magic_end = parsed[-_IMAGE_INFO_MAGIC_SIZE:]
if magic_begin == magic_end == _IMAGE_INFO_MAGIC:
image_name_bytes = b"".join(parsed[_IMAGE_INFO_MAGIC_SIZE:_IMAGE_INFO_MAGIC_SIZE + _IMAGE_INFO_IMAGE_NAME_SIZE])
image_name_bytes = b"".join(parsed[
_IMAGE_INFO_MAGIC_SIZE # noqa: E203
:
_IMAGE_INFO_MAGIC_SIZE + _IMAGE_INFO_NAME_SIZE
])
return _ImageInfo(
name=image_name_bytes.decode("utf-8", errors="ignore").strip("\x00").strip(),
size=parsed[_IMAGE_INFO_MAGIC_SIZE + _IMAGE_INFO_IMAGE_NAME_SIZE + 1],
complete=parsed[_IMAGE_INFO_MAGIC_SIZE + _IMAGE_INFO_IMAGE_NAME_SIZE],
size=parsed[_IMAGE_INFO_MAGIC_SIZE + _IMAGE_INFO_NAME_SIZE + 1],
complete=parsed[_IMAGE_INFO_MAGIC_SIZE + _IMAGE_INFO_NAME_SIZE],
)
return None
@@ -152,14 +153,7 @@ def _explore_device(device_path: str) -> _DeviceInfo:
)
def _msd_working(method: Callable) -> Callable:
async def wrapper(self: "Plugin", *args: Any, **kwargs: Any) -> Any:
if not self._device_info: # pylint: disable=protected-access
raise MsdOfflineError()
return (await method(self, *args, **kwargs))
return wrapper
# =====
class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
def __init__( # pylint: disable=super-init-not-called
self,
@@ -182,10 +176,11 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
self.__region = aioregion.AioExclusiveRegion(MsdIsBusyError)
self._device_info: Optional[_DeviceInfo] = None
self.__device_info: Optional[_DeviceInfo] = None
self.__connected = False
self.__device_file: Optional[aiofiles.base.AiofilesContextManager] = None
self.__written = 0
self.__on_kvm = True
self.__state_queue: asyncio.queues.Queue = asyncio.Queue()
@@ -209,27 +204,29 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
"reset_delay": Option(1.0, type=valid_float_f01),
}
def get_state(self) -> Dict:
current: Optional[Dict] = None
async def get_state(self) -> Dict:
storage: Optional[Dict] = None
if self._device_info:
drive: Optional[Dict] = None
if self.__device_info:
storage = {
"size": self._device_info.size,
"free": self._device_info.free,
"size": self.__device_info.size,
"free": self.__device_info.free,
"uploading": bool(self.__device_file)
}
drive = {
"image": (self.__device_info.image and dataclasses.asdict(self.__device_info.image)),
"connected": self.__connected,
}
if self._device_info.image:
current = dataclasses.asdict(self._device_info.image)
return {
"enabled": True,
"multi": False,
"online": bool(self._device_info),
"online": bool(self.__device_info),
"busy": self.__region.is_busy(),
"uploading": bool(self.__device_file),
"written": self.__written,
"current": current,
"storage": storage,
"cdrom": None,
"connected": (not self.__on_kvm),
"drive": drive,
"features": {
"multi": False,
"cdrom": False,
},
}
async def poll_state(self) -> AsyncGenerator[Dict, None]:
@@ -250,7 +247,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
gpio.write(self.__reset_pin, False)
gpio.write(self.__target_pin, False)
self.__on_kvm = True
self.__connected = False
await self.__load_device_info()
get_logger(0).info("MSD reset has been successful")
@@ -259,7 +256,7 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
gpio.write(self.__reset_pin, False)
finally:
self.__region.exit()
await self.__state_queue.put(self.get_state())
await self.__state_queue.put(await self.get_state())
@aiotools.atomic
async def cleanup(self) -> None:
@@ -269,116 +266,107 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
# =====
@_msd_working
async def set_params(self, name: Optional[str]=None, cdrom: Optional[bool]=None) -> None:
async with self.__working():
if name is not None:
raise MsdMultiNotSupported()
if cdrom is not None:
raise MsdCdromNotSupported()
@aiotools.atomic
async def connect(self) -> Dict:
notify = False
state: Dict = {}
try:
with self.__region:
if not self.__on_kvm:
raise MsdAlreadyConnectedError()
notify = True
async def connect(self) -> None:
async with self.__working():
notify = False
try:
with self.__region:
if self.__connected:
raise MsdConnectedError()
notify = True
gpio.write(self.__target_pin, True)
self.__on_kvm = False
get_logger(0).info("MSD switched to Server")
gpio.write(self.__target_pin, True)
self.__connected = True
get_logger(0).info("MSD switched to Server")
finally:
if notify:
await self.__state_queue.put(await self.get_state())
state = self.get_state()
return state
finally:
if notify:
await self.__state_queue.put(state or self.get_state())
@_msd_working
@aiotools.atomic
async def disconnect(self) -> Dict:
notify = False
state: Dict = {}
try:
with self.__region:
if self.__on_kvm:
raise MsdAlreadyDisconnectedError()
notify = True
async def disconnect(self) -> None:
async with self.__working():
notify = False
try:
with self.__region:
if not self.__connected:
raise MsdDisconnectedError()
notify = True
gpio.write(self.__target_pin, False)
gpio.write(self.__target_pin, False)
try:
await self.__load_device_info()
except Exception:
if self.__connected:
gpio.write(self.__target_pin, True)
raise
self.__connected = False
get_logger(0).info("MSD switched to KVM: %s", self.__device_info)
finally:
if notify:
await self.__state_queue.put(await self.get_state())
@contextlib.asynccontextmanager
async def write_image(self, name: str) -> AsyncGenerator[None, None]:
async with self.__working():
self.__region.enter()
try:
assert self.__device_info
if self.__connected:
raise MsdConnectedError()
self.__device_file = await aiofiles.open(self.__device_info.path, mode="w+b", buffering=0)
self.__written = 0
await self.__write_image_info(name, complete=False)
await self.__state_queue.put(await self.get_state())
yield
await self.__write_image_info(name, complete=True)
finally:
try:
await self.__close_device_file()
await self.__load_device_info()
except Exception:
if not self.__on_kvm:
gpio.write(self.__target_pin, True)
raise
self.__on_kvm = True
get_logger(0).info("MSD switched to KVM: %s", self._device_info)
state = self.get_state()
return state
finally:
if notify:
await self.__state_queue.put(state or self.get_state())
@_msd_working
async def select(self, name: str, cdrom: bool) -> Dict:
raise MsdMultiNotSupported()
@_msd_working
async def remove(self, name: str) -> Dict:
raise MsdMultiNotSupported()
@_msd_working
@aiotools.atomic
async def __aenter__(self) -> "Plugin":
assert self._device_info
self.__region.enter()
try:
if not self.__on_kvm:
raise MsdConnectedError()
self.__device_file = await aiofiles.open(self._device_info.path, mode="w+b", buffering=0)
self.__written = 0
return self
except Exception:
self.__region.exit()
raise
finally:
await self.__state_queue.put(self.get_state())
@aiotools.atomic
async def write_image_info(self, name: str, complete: bool) -> None:
assert self.__device_file
assert self._device_info
if self._device_info.size - self.__written > _IMAGE_INFO_SIZE:
await self.__device_file.seek(self._device_info.size - _IMAGE_INFO_SIZE)
await self.__write_to_device_file(_make_image_info_bytes(name, self.__written, complete))
await self.__device_file.seek(0)
else:
get_logger().error("Can't write image info because device is full")
finally:
self.__region.exit()
await self.__state_queue.put(await self.get_state())
@aiotools.atomic
async def write_image_chunk(self, chunk: bytes) -> int:
await self.__write_to_device_file(chunk)
assert self.__device_file
await aiotools.afile_write_now(self.__device_file, chunk)
self.__written += len(chunk)
return self.__written
@aiotools.atomic
async def __aexit__(
self,
_exc_type: Type[BaseException],
_exc: BaseException,
_tb: types.TracebackType,
) -> None:
async def remove(self, name: str) -> None:
async with self.__working():
raise MsdMultiNotSupported()
try:
await self.__close_device_file()
await self.__load_device_info()
finally:
self.__region.exit()
await self.__state_queue.put(self.get_state())
# =====
async def __write_to_device_file(self, data: bytes) -> None:
@contextlib.asynccontextmanager
async def __working(self) -> AsyncGenerator[None, None]:
if not self.__device_info:
raise MsdOfflineError()
yield
# =====
async def __write_image_info(self, name: str, complete: bool) -> None:
assert self.__device_file
await self.__device_file.write(data)
await self.__device_file.flush()
await aiotools.run_async(os.fsync, self.__device_file.fileno())
assert self.__device_info
if self.__device_info.size - self.__written > _IMAGE_INFO_SIZE:
await self.__device_file.seek(self.__device_info.size - _IMAGE_INFO_SIZE)
await aiotools.afile_write_now(self.__device_file, _make_image_info_bytes(name, self.__written, complete))
await self.__device_file.seek(0)
else:
get_logger().error("Can't write image info because device is full")
async def __close_device_file(self) -> None:
try:
@@ -398,13 +386,13 @@ class Plugin(BaseMsd): # pylint: disable=too-many-instance-attributes
while True:
await asyncio.sleep(self.__init_delay)
try:
self._device_info = await aiotools.run_async(_explore_device, self.__device_path)
self.__device_info = await aiotools.run_async(_explore_device, self.__device_path)
break
except asyncio.CancelledError: # pylint: disable=try-except-raise
raise
except Exception:
if retries == 0:
self._device_info = None
self.__device_info = None
raise MsdError("Can't load device info")
get_logger().exception("Can't load device info; retries=%d", retries)
retries -= 1