restructuring towards python client library #455 (#613)

* python client library clean branch for PR. #455

* removed superfluous file. #455

* removed one more superfluous file. #455

* README.md, .pylintrc and pylint based fixes. #455

* updated wrt. to the review comments. #455

* removed pylint documentation duplication. #455
This commit is contained in:
Benjamin Zeiss 2022-01-22 00:08:29 +01:00 committed by GitHub
parent 7f362c9308
commit 089dc302e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1573 additions and 1467 deletions

9
.gitignore vendored
View File

@ -5,11 +5,12 @@ core
.DS_Store
*.swp
__pycache__
python/web/current
python/web/src/rascsi_interface_pb2.py
python/oled/current
python/oled/src/rascsi_interface_pb2.py
current
rascsi_interface_pb2.py
src/raspberrypi/hfdisk/
*~
messages.pot
messages.mo
# temporary user files
s.sh

View File

@ -52,6 +52,7 @@ VIRTUAL_DRIVER_PATH="$HOME/images"
CFG_PATH="$HOME/.config/rascsi"
WEB_INSTALL_PATH="$BASE/python/web"
OLED_INSTALL_PATH="$BASE/python/oled"
PYTHON_COMMON_PATH="$BASE/python/common"
SYSTEMD_PATH="/etc/systemd/system"
HFS_FORMAT=/usr/bin/hformat
HFDISK_BIN=/usr/bin/hfdisk
@ -94,15 +95,17 @@ function installRaScsi() {
sudo make install CONNECT_TYPE="${CONNECT_TYPE:-FULLSPEC}" </dev/null
}
# install everything required to run an HTTP server (Nginx + Python Flask App)
function installRaScsiWebInterface() {
if [ -f "$WEB_INSTALL_PATH/rascsi_interface_pb2.py" ]; then
sudo rm "$WEB_INSTALL_PATH/rascsi_interface_pb2.py"
function preparePythonCommon() {
if [ -f "$PYTHON_COMMON_PATH/rascsi_interface_pb2.py" ]; then
sudo rm "$PYTHON_COMMON_PATH/rascsi_interface_pb2.py"
echo "Deleting old Python protobuf library rascsi_interface_pb2.py"
fi
echo "Compiling the Python protobuf library rascsi_interface_pb2.py..."
protoc -I="$BASE/src/raspberrypi/" --python_out="$WEB_INSTALL_PATH/src" rascsi_interface.proto
protoc -I="$BASE/src/raspberrypi/" --python_out="$PYTHON_COMMON_PATH/src" rascsi_interface.proto
}
# install everything required to run an HTTP server (Nginx + Python Flask App)
function installRaScsiWebInterface() {
sudo cp -f "$WEB_INSTALL_PATH/service-infra/nginx-default.conf" /etc/nginx/sites-available/default
sudo cp -f "$WEB_INSTALL_PATH/service-infra/502.html" /var/www/html/502.html
@ -779,13 +782,6 @@ function installRaScsiScreen() {
sudo apt-get update && sudo apt-get install libjpeg-dev libpng-dev libopenjp2-7-dev i2c-tools raspi-config -y </dev/null
if [ -f "$OLED_INSTALL_PATH/src/rascsi_interface_pb2.py" ]; then
sudo rm "$OLED_INSTALL_PATH/src/rascsi_interface_pb2.py"
echo "Deleting old Python protobuf library rascsi_interface_pb2.py"
fi
echo "Compiling the Python protobuf library rascsi_interface_pb2.py..."
protoc -I="$BASE/src/raspberrypi/" --python_out="$OLED_INSTALL_PATH/src" rascsi_interface.proto
if [[ $(grep -c "^dtparam=i2c_arm=on" /boot/config.txt) -ge 1 ]]; then
echo "NOTE: I2C support seems to have been configured already."
REBOOT=0
@ -882,6 +878,7 @@ function runChoice() {
backupRaScsiService
installRaScsi
enableRaScsiService
preparePythonCommon
if [[ $(isRaScsiScreenInstalled) -eq 0 ]]; then
echo "Detected rascsi oled service; will run the installation steps for the OLED monitor."
installRaScsiScreen
@ -913,6 +910,7 @@ function runChoice() {
stopRaScsi
compileRaScsi
backupRaScsiService
preparePythonCommon
installRaScsi
enableRaScsiService
if [[ $(isRaScsiScreenInstalled) -eq 0 ]]; then
@ -931,6 +929,7 @@ function runChoice() {
echo "- Add and modify systemd services"
echo "- Modify the Raspberry Pi boot configuration (may require a reboot)"
sudoCheck
preparePythonCommon
installRaScsiScreen
showRaScsiScreenStatus
echo "Installing / Updating RaSCSI OLED Screen - Complete!"
@ -1018,6 +1017,7 @@ function runChoice() {
updateRaScsiGit
createCfgDir
installPackages
preparePythonCommon
installRaScsiWebInterface
echo "Configuring RaSCSI Web Interface stand-alone - Complete!"
echo "Launch the Web Interface with the 'start.sh' script. To use a custom port for the web server: 'start.sh --port=8081"

View File

@ -7,7 +7,7 @@ extension-pkg-whitelist=
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
ignore=CVS,rascsi_interface_pb2.py
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
@ -15,7 +15,7 @@ ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
init-hook=
init-hook=import sys; sys.path.append("common/src"); sys.path.append("web/src"); sys.path.append("oled/src");
# venv hook for pylint
# Requires pylint-venv package:
# $ pip install pylint-venv

32
python/README.md Normal file
View File

@ -0,0 +1,32 @@
# RaSCSI Python Apps
This directory contains Python-based clients for RaSCSI as well as common
packages that are shared among the clients.
The following paragraphs in this README contain instructions that are shared
among all Python apps.
### Static analysis with pylint
It is recommended to run pylint against new code to protect against bugs
and keep the code readable and maintainable.
The local pylint configuration lives in .pylintrc.
In order for pylint to recognize venv libraries, the pylint-venv package is required.
```
sudo apt install pylint3
sudo pip install pylint-venv
source venv/bin/activate
pylint3 python_source_file.py
```
Examples:
```
# check a single file
pylint web/src/web.py
# check the python modules
pylint common/src
pylint web/src
pylint oled/src
```

View File

@ -0,0 +1,2 @@
protobuf==3.19.3
requests==2.26.0

View File

@ -0,0 +1,30 @@
# RaSCSI Common Python Module
The common module contains python modules that are shared among multiple Python
applications such as the OLED or the Web application. It contains shared functionality.
For example, the rascsi python module provides functionality for accessing rascsi through its
protobuf interface and provides convenient classes for that purpose.
### Usage
To make use of the rascsi python module, it needs to be found by the Python scripts using it.
This can be achieved in multiple ways. One way is to simply adapt the PYTHONPATH environment
variable to include the common/src directory:
```
PYTHON_COMMON_PATH=${path_to_common_directory}/common/src
export PYTHONPATH=$PWD:${PYTHON_COMMON_PATH}
python3 myapp.py
```
The most interesting functions are likely found in the classes RaCtlCmds and FileCmds. Classes
can be instantiated, for example, as follows
(assuming that rascsi host, rascsi port and token are somehow retrieved from a command line
argument):
```
sock_cmd = SocketCmds(host=args.rascsi_host, port=args.rascsi_port)
ractl_cmd = RaCtlCmds(sock_cmd=sock_cmd, token=args.token)
```
Usage examples can be found in the existing RaSCSI Python applications.

View File

View File

View File

@ -0,0 +1,21 @@
"""
Module for general settings used in the rascsi module
"""
from os import getcwd
WORK_DIR = getcwd()
REMOVABLE_DEVICE_TYPES = ("SCCD", "SCRM", "SCMO")
# There may be a more elegant way to get the HOME dir of the user that installed RaSCSI
HOME_DIR = "/".join(WORK_DIR.split("/")[0:3])
CFG_DIR = f"{HOME_DIR}/.config/rascsi"
CONFIG_FILE_SUFFIX = "json"
# File ending used for drive properties files
PROPERTIES_SUFFIX = "properties"
# The RESERVATIONS list is used to keep track of the reserved ID memos.
# Initialize with a list of 8 empty strings.
RESERVATIONS = ["" for x in range(0, 8)]

View File

@ -0,0 +1,15 @@
"""
Module for custom exceptions raised by the rascsi module
"""
class FailedSocketConnectionException(Exception):
"""Raise when a rascsi protobuf socket connection cannot be established after multiple tries."""
class EmptySocketChunkException(Exception):
"""Raise when a socket payload contains an empty chunk which implies a possible problem. """
class InvalidProtobufResponse(Exception):
"""Raise when a rascsi socket payload contains unpexpected data. """

View File

@ -0,0 +1,601 @@
"""
Module for methods reading from and writing to the file system
"""
import os
import logging
from pathlib import PurePath
import asyncio
import rascsi_interface_pb2 as proto
from rascsi.common_settings import CFG_DIR, CONFIG_FILE_SUFFIX, PROPERTIES_SUFFIX, RESERVATIONS
from rascsi.ractl_cmds import RaCtlCmds
from rascsi.return_codes import ReturnCodes
from rascsi.socket_cmds import SocketCmds
class FileCmds:
"""
class for methods reading from and writing to the file system
"""
def __init__(self, sock_cmd: SocketCmds, ractl: RaCtlCmds, token=None, locale=None):
self.sock_cmd = sock_cmd
self.ractl = ractl
self.token = token
self.locale = locale
# noinspection PyMethodMayBeStatic
# pylint: disable=no-self-use
def list_files(self, file_types, dir_path):
"""
Takes a (list) or (tuple) of (str) file_types - e.g. ('hda', 'hds')
Returns (list) of (list)s files_list:
index 0 is (str) file name and index 1 is (int) size in bytes
"""
files_list = []
for path, _dirs, files in os.walk(dir_path):
# Only list selected file types
files = [f for f in files if f.lower().endswith(file_types)]
files_list.extend(
[
(
file,
os.path.getsize(os.path.join(path, file))
)
for file in files
]
)
return files_list
# noinspection PyMethodMayBeStatic
def list_config_files(self):
"""
Finds fils with file ending CONFIG_FILE_SUFFIX in CFG_DIR.
Returns a (list) of (str) files_list
"""
files_list = []
for _root, _dirs, files in os.walk(CFG_DIR):
for file in files:
if file.endswith("." + CONFIG_FILE_SUFFIX):
files_list.append(file)
return files_list
def list_images(self):
"""
Sends a IMAGE_FILES_INFO command to the server
Returns a (dict) with (bool) status, (str) msg, and (list) of (dict)s files
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.DEFAULT_IMAGE_FILES_INFO
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
# Get a list of all *.properties files in CFG_DIR
prop_data = self.list_files(PROPERTIES_SUFFIX, CFG_DIR)
prop_files = [PurePath(x[0]).stem for x in prop_data]
from zipfile import ZipFile, is_zipfile
server_info = self.ractl.get_server_info()
files = []
for file in result.image_files_info.image_files:
# Add properties meta data for the image, if applicable
if file.name in prop_files:
process = self.read_drive_properties(f"{CFG_DIR}/{file.name}.{PROPERTIES_SUFFIX}")
prop = process["conf"]
else:
prop = False
if file.name.lower().endswith(".zip"):
zip_path = f"{server_info['image_dir']}/{file.name}"
if is_zipfile(zip_path):
zipfile = ZipFile(zip_path)
# Get a list of (str) containing all zipfile members
zip_members = zipfile.namelist()
# Strip out directories from the list
zip_members = [x for x in zip_members if not x.endswith("/")]
else:
logging.warning("%s is an invalid zip file", zip_path)
zip_members = False
else:
zip_members = False
size_mb = "{:,.1f}".format(file.size / 1024 / 1024)
dtype = proto.PbDeviceType.Name(file.type)
files.append({
"name": file.name,
"size": file.size,
"size_mb": size_mb,
"detected_type": dtype,
"prop": prop,
"zip_members": zip_members,
})
return {"status": result.status, "msg": result.msg, "files": files}
def create_new_image(self, file_name, file_type, size):
"""
Takes (str) file_name, (str) file_type, and (int) size
Sends a CREATE_IMAGE command to the server
Returns (dict) with (bool) status and (str) msg
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.CREATE_IMAGE
command.params["token"] = self.token
command.params["locale"] = self.locale
command.params["file"] = file_name + "." + file_type
command.params["size"] = str(size)
command.params["read_only"] = "false"
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def delete_image(self, file_name):
"""
Takes (str) file_name
Sends a DELETE_IMAGE command to the server
Returns (dict) with (bool) status and (str) msg
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.DELETE_IMAGE
command.params["token"] = self.ractl.token
command.params["locale"] = self.ractl.locale
command.params["file"] = file_name
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def rename_image(self, file_name, new_file_name):
"""
Takes (str) file_name, (str) new_file_name
Sends a RENAME_IMAGE command to the server
Returns (dict) with (bool) status and (str) msg
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.RENAME_IMAGE
command.params["token"] = self.ractl.token
command.params["locale"] = self.ractl.locale
command.params["from"] = file_name
command.params["to"] = new_file_name
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
# noinspection PyMethodMayBeStatic
def delete_file(self, file_path):
"""
Takes (str) file_path with the full path to the file to delete
Returns (dict) with (bool) status and (str) msg
"""
parameters = {
"file_path": file_path
}
if os.path.exists(file_path):
os.remove(file_path)
return {
"status": True,
"return_code": ReturnCodes.DELETEFILE_SUCCESS,
"parameters": parameters,
}
return {
"status": False,
"return_code": ReturnCodes.DELETEFILE_FILE_NOT_FOUND,
"parameters": parameters,
}
# noinspection PyMethodMayBeStatic
def rename_file(self, file_path, target_path):
"""
Takes (str) file_path and (str) target_path
Returns (dict) with (bool) status and (str) msg
"""
parameters = {
"target_path": target_path
}
if os.path.exists(PurePath(target_path).parent):
os.rename(file_path, target_path)
return {
"status": True,
"return_code": ReturnCodes.RENAMEFILE_SUCCESS,
"parameters": parameters,
}
return {
"status": False,
"return_code": ReturnCodes.RENAMEFILE_UNABLE_TO_MOVE,
"parameters": parameters,
}
def unzip_file(self, file_name, member=False, members=False):
"""
Takes (str) file_name, optional (str) member, optional (list) of (str) members
file_name is the name of the zip file to unzip
member is the full path to the particular file in the zip file to unzip
members contains all of the full paths to each of the zip archive members
Returns (dict) with (boolean) status and (list of str) msg
"""
from asyncio import run
server_info = self.ractl.get_server_info()
prop_flag = False
if not member:
unzip_proc = run(self.run_async(
f"unzip -d {server_info['image_dir']} -n -j "
f"{server_info['image_dir']}/{file_name}"
))
if members:
for path in members:
if path.endswith(PROPERTIES_SUFFIX):
name = PurePath(path).name
self.rename_file(f"{server_info['image_dir']}/{name}", f"{CFG_DIR}/{name}")
prop_flag = True
else:
from re import escape
member = escape(member)
unzip_proc = run(self.run_async(
f"unzip -d {server_info['image_dir']} -n -j "
f"{server_info['image_dir']}/{file_name} {member}"
))
# Attempt to unzip a properties file in the same archive dir
unzip_prop = run(self.run_async(
f"unzip -d {CFG_DIR} -n -j "
f"{server_info['image_dir']}/{file_name} {member}.{PROPERTIES_SUFFIX}"
))
if unzip_prop["returncode"] == 0:
prop_flag = True
if unzip_proc["returncode"] != 0:
logging.warning("Unzipping failed: %s", unzip_proc["stderr"])
return {"status": False, "msg": unzip_proc["stderr"]}
from re import findall
unzipped = findall(
"(?:inflating|extracting):(.+)\n",
unzip_proc["stdout"]
)
return {"status": True, "msg": unzipped, "prop_flag": prop_flag}
def download_file_to_iso(self, url, *iso_args):
"""
Takes (str) url and one or more (str) *iso_args
Returns (dict) with (bool) status and (str) msg
"""
from time import time
from subprocess import run, CalledProcessError
server_info = self.ractl.get_server_info()
file_name = PurePath(url).name
tmp_ts = int(time())
tmp_dir = "/tmp/" + str(tmp_ts) + "/"
os.mkdir(tmp_dir)
tmp_full_path = tmp_dir + file_name
iso_filename = f"{server_info['image_dir']}/{file_name}.iso"
req_proc = self.download_to_dir(url, tmp_dir, file_name)
if not req_proc["status"]:
return {"status": False, "msg": req_proc["msg"]}
from zipfile import is_zipfile, ZipFile
if is_zipfile(tmp_full_path):
if "XtraStuf.mac" in str(ZipFile(tmp_full_path).namelist()):
logging.info("MacZip file format detected. Will not unzip to retain resource fork.")
else:
logging.info(
"%s is a zipfile! Will attempt to unzip and store the resulting files.",
tmp_full_path,
)
unzip_proc = asyncio.run(self.run_async(
f"unzip -d {tmp_dir} -n {tmp_full_path}"
))
if not unzip_proc["returncode"]:
logging.info(
"%s was successfully unzipped. Deleting the zipfile.",
tmp_full_path,
)
self.delete_file(tmp_full_path)
try:
run(
[
"genisoimage",
*iso_args,
"-o",
iso_filename,
tmp_dir,
],
capture_output=True,
check=True,
)
except CalledProcessError as error:
logging.warning("Executed shell command: %s", " ".join(error.cmd))
logging.warning("Got error: %s", error.stderr.decode("utf-8"))
return {"status": False, "msg": error.stderr.decode("utf-8")}
parameters = {
"value": " ".join(iso_args)
}
return {
"status": True,
"return_code": ReturnCodes.DOWNLOADFILETOISO_SUCCESS,
"parameters": parameters,
"file_name": iso_filename,
}
# noinspection PyMethodMayBeStatic
def download_to_dir(self, url, save_dir, file_name):
"""
Takes (str) url, (str) save_dir, (str) file_name
Returns (dict) with (bool) status and (str) msg
"""
import requests
logging.info("Making a request to download %s", url)
try:
with requests.get(url, stream=True, headers={"User-Agent": "Mozilla/5.0"}) as req:
req.raise_for_status()
with open(f"{save_dir}/{file_name}", "wb") as download:
for chunk in req.iter_content(chunk_size=8192):
download.write(chunk)
except requests.exceptions.RequestException as error:
logging.warning("Request failed: %s", str(error))
return {"status": False, "msg": str(error)}
logging.info("Response encoding: %s", req.encoding)
logging.info("Response content-type: %s", req.headers["content-type"])
logging.info("Response status code: %s", req.status_code)
parameters = {
"file_name": file_name,
"save_dir": save_dir
}
return {
"status": True,
"return_code": ReturnCodes.DOWNLOADTODIR_SUCCESS,
"parameters": parameters,
}
def write_config(self, file_name):
"""
Takes (str) file_name
Returns (dict) with (bool) status and (str) msg
"""
from json import dump
file_name = f"{CFG_DIR}/{file_name}"
try:
with open(file_name, "w", encoding="ISO-8859-1") as json_file:
version = self.ractl.get_server_info()["version"]
devices = self.ractl.list_devices()["device_list"]
for device in devices:
# Remove keys that we don't want to store in the file
del device["status"]
del device["file"]
# It's cleaner not to store an empty parameter for every device without media
if device["image"] == "":
device["image"] = None
# RaSCSI product names will be generated on the fly by RaSCSI
if device["vendor"] == "RaSCSI":
device["vendor"] = device["product"] = device["revision"] = None
# A block size of 0 is how RaSCSI indicates N/A for block size
if device["block_size"] == 0:
device["block_size"] = None
# Convert to a data type that can be serialized
device["params"] = dict(device["params"])
reserved_ids_and_memos = []
reserved_ids = self.ractl.get_reserved_ids()["ids"]
for scsi_id in reserved_ids:
reserved_ids_and_memos.append({"id": scsi_id,
"memo": RESERVATIONS[int(scsi_id)]})
dump(
{"version": version,
"devices": devices,
"reserved_ids": reserved_ids_and_memos},
json_file,
indent=4
)
parameters = {
"file_name": file_name
}
return {
"status": True,
"return_code": ReturnCodes.WRITECONFIG_SUCCESS,
"parameters": parameters,
}
except (IOError, ValueError, EOFError, TypeError) as error:
logging.error(str(error))
self.delete_file(file_name)
return {"status": False, "msg": str(error)}
except:
logging.error("Could not write to file: %s", file_name)
self.delete_file(file_name)
parameters = {
"file_name": file_name
}
return {
"status": False,
"return_code": ReturnCodes.WRITECONFIG_COULD_NOT_WRITE,
"parameters": parameters,
}
def read_config(self, file_name):
"""
Takes (str) file_name
Returns (dict) with (bool) status and (str) msg
"""
from json import load
file_name = f"{CFG_DIR}/{file_name}"
try:
with open(file_name, encoding="ISO-8859-1") as json_file:
config = load(json_file)
# If the config file format changes again in the future,
# introduce more sophisticated format detection logic here.
if isinstance(config, dict):
self.ractl.detach_all()
ids_to_reserve = []
for item in config["reserved_ids"]:
ids_to_reserve.append(item["id"])
RESERVATIONS[int(item["id"])] = item["memo"]
self.ractl.reserve_scsi_ids(ids_to_reserve)
for row in config["devices"]:
kwargs = {
"device_type": row["device_type"],
"image": row["image"],
"unit": int(row["unit"]),
"vendor": row["vendor"],
"product": row["product"],
"revision": row["revision"],
"block_size": row["block_size"],
}
params = dict(row["params"])
for param in params.keys():
kwargs[param] = params[param]
self.ractl.attach_image(row["id"], **kwargs)
# The config file format in RaSCSI 21.10 is using a list data type at the top level.
# If future config file formats return to the list data type,
# introduce more sophisticated format detection logic here.
elif isinstance(config, list):
self.ractl.detach_all()
for row in config:
kwargs = {
"device_type": row["device_type"],
"image": row["image"],
# "un" for backwards compatibility
"unit": int(row["un"]),
"vendor": row["vendor"],
"product": row["product"],
"revision": row["revision"],
"block_size": row["block_size"],
}
params = dict(row["params"])
for param in params.keys():
kwargs[param] = params[param]
self.ractl.attach_image(row["id"], **kwargs)
else:
return {"status": False,
"return_code": ReturnCodes.READCONFIG_INVALID_CONFIG_FILE_FORMAT}
parameters = {
"file_name": file_name
}
return {
"status": True,
"return_code": ReturnCodes.READCONFIG_SUCCESS,
"parameters": parameters
}
except (IOError, ValueError, EOFError, TypeError) as error:
logging.error(str(error))
return {"status": False, "msg": str(error)}
except:
logging.error("Could not read file: %s", file_name)
parameters = {
"file_name": file_name
}
return {
"status": False,
"return_code": ReturnCodes.READCONFIG_COULD_NOT_READ,
"parameters": parameters
}
def write_drive_properties(self, file_name, conf):
"""
Writes a drive property configuration file to the config dir.
Takes file name base (str) and (list of dicts) conf as arguments
Returns (dict) with (bool) status and (str) msg
"""
from json import dump
file_path = f"{CFG_DIR}/{file_name}"
try:
with open(file_path, "w") as json_file:
dump(conf, json_file, indent=4)
parameters = {
"file_path": file_path
}
return {
"status": True,
"return_code": ReturnCodes.WRITEDRIVEPROPS_SUCCESS,
"parameters": parameters,
}
except (IOError, ValueError, EOFError, TypeError) as error:
logging.error(str(error))
self.delete_file(file_path)
return {"status": False, "msg": str(error)}
except:
logging.error("Could not write to file: %s", file_path)
self.delete_file(file_path)
parameters = {
"file_path": file_path
}
return {
"status": False,
"return_code": ReturnCodes.WRITEDRIVEPROPS_COULD_NOT_WRITE,
"parameters": parameters,
}
# noinspection PyMethodMayBeStatic
def read_drive_properties(self, file_path):
"""
Reads drive properties from json formatted file.
Takes (str) file_path as argument.
Returns (dict) with (bool) status, (str) msg, (dict) conf
"""
from json import load
try:
with open(file_path) as json_file:
conf = load(json_file)
parameters = {
"file_path": file_path
}
return {
"status": True,
"return_codes": ReturnCodes.READDRIVEPROPS_SUCCESS,
"parameters": parameters,
"conf": conf,
}
except (IOError, ValueError, EOFError, TypeError) as error:
logging.error(str(error))
return {"status": False, "msg": str(error)}
except:
logging.error("Could not read file: %s", file_path)
parameters = {
"file_path": file_path
}
return {
"status": False,
"return_codes": ReturnCodes.READDRIVEPROPS_COULD_NOT_READ,
"parameters": parameters,
}
# noinspection PyMethodMayBeStatic
async def run_async(self, cmd):
"""
Takes (str) cmd with the shell command to execute
Executes shell command and captures output
Returns (dict) with (int) returncode, (str) stdout, (str) stderr
"""
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
logging.info("Executed command \"%s\" with status code %d", cmd, proc.returncode)
if stdout:
stdout = stdout.decode()
logging.info("stdout: %s", stdout)
if stderr:
stderr = stderr.decode()
logging.info("stderr: %s", stderr)
return {"returncode": proc.returncode, "stdout": stdout, "stderr": stderr}

View File

@ -0,0 +1,444 @@
"""
Module for commands sent to the RaSCSI backend service.
"""
import rascsi_interface_pb2 as proto
from rascsi.common_settings import REMOVABLE_DEVICE_TYPES
from rascsi.return_codes import ReturnCodes
from rascsi.socket_cmds import SocketCmds
class RaCtlCmds:
"""
Class for commands sent to the RaSCSI backend service.
"""
def __init__(self, sock_cmd: SocketCmds, token=None, locale="en"):
self.sock_cmd = sock_cmd
self.token = token
self.locale = locale
def get_server_info(self):
"""
Sends a SERVER_INFO command to the server.
Returns a dict with:
- (bool) status
- (str) version (RaSCSI version number)
- (list) of (str) log_levels (the log levels RaSCSI supports)
- (str) current_log_level
- (list) of (int) reserved_ids
- (str) image_dir, path to the default images directory
- (int) scan_depth, the current images directory scan depth
- 5 distinct (list)s of (str)s with file endings recognized by RaSCSI
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.SERVER_INFO
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
version = (str(result.server_info.version_info.major_version) + "." +
str(result.server_info.version_info.minor_version) + "." +
str(result.server_info.version_info.patch_version))
log_levels = result.server_info.log_level_info.log_levels
current_log_level = result.server_info.log_level_info.current_log_level
reserved_ids = list(result.server_info.reserved_ids_info.ids)
image_dir = result.server_info.image_files_info.default_image_folder
scan_depth = result.server_info.image_files_info.depth
# Creates lists of file endings recognized by RaSCSI
mappings = result.server_info.mapping_info.mapping
sahd = []
schd = []
scrm = []
scmo = []
sccd = []
for dtype in mappings:
if mappings[dtype] == proto.PbDeviceType.SAHD:
sahd.append(dtype)
elif mappings[dtype] == proto.PbDeviceType.SCHD:
schd.append(dtype)
elif mappings[dtype] == proto.PbDeviceType.SCRM:
scrm.append(dtype)
elif mappings[dtype] == proto.PbDeviceType.SCMO:
scmo.append(dtype)
elif mappings[dtype] == proto.PbDeviceType.SCCD:
sccd.append(dtype)
return {
"status": result.status,
"version": version,
"log_levels": log_levels,
"current_log_level": current_log_level,
"reserved_ids": reserved_ids,
"image_dir": image_dir,
"scan_depth": scan_depth,
"sahd": sahd,
"schd": schd,
"scrm": scrm,
"scmo": scmo,
"sccd": sccd,
}
def get_reserved_ids(self):
"""
Sends a RESERVED_IDS_INFO command to the server.
Returns a dict with:
- (bool) status
- (list) of (int) ids -- currently reserved SCSI IDs
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.RESERVED_IDS_INFO
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
scsi_ids = []
for scsi_id in result.reserved_ids_info.ids:
scsi_ids.append(str(scsi_id))
return {"status": result.status, "ids": scsi_ids}
def get_network_info(self):
"""
Sends a NETWORK_INTERFACES_INFO command to the server.
Returns a dict with:
- (bool) status
- (list) of (str) ifs (network interfaces detected by RaSCSI)
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.NETWORK_INTERFACES_INFO
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
ifs = result.network_interfaces_info.name
return {"status": result.status, "ifs": ifs}
def get_device_types(self):
"""
Sends a DEVICE_TYPES_INFO command to the server.
Returns a dict with:
- (bool) status
- (list) of (str) device_types (device types that RaSCSI supports, ex. SCHD, SCCD, etc)
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.DEVICE_TYPES_INFO
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
device_types = []
for prop in result.device_types_info.properties:
device_types.append(proto.PbDeviceType.Name(prop.type))
return {"status": result.status, "device_types": device_types}
def get_image_files_info(self):
"""
Sends a DEFAULT_IMAGE_FILES_INFO command to the server.
Returns a dict with:
- (bool) status
- (str) images_dir, path to images dir
- (list) of (str) image_files
- (int) scan_depth, the current scan depth
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.DEFAULT_IMAGE_FILES_INFO
command.params["token"] = self.token
command.params["locale"] = self.token
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
images_dir = result.image_files_info.default_image_folder
image_files = result.image_files_info.image_files
scan_depth = result.image_files_info.depth
return {
"status": result.status,
"images_dir": images_dir,
"image_files": image_files,
"scan_depth": scan_depth,
}
def attach_image(self, scsi_id, **kwargs):
"""
Takes (int) scsi_id and kwargs containing 0 or more device properties
If the current attached device is a removable device wihout media inserted,
this sends a INJECT command to the server.
If there is no currently attached device, this sends the ATTACH command to the server.
Returns (bool) status and (str) msg
"""
command = proto.PbCommand()
command.params["token"] = self.token
command.params["locale"] = self.locale
devices = proto.PbDeviceDefinition()
devices.id = int(scsi_id)
if "device_type" in kwargs.keys():
if kwargs["device_type"] not in [None, ""]:
devices.type = proto.PbDeviceType.Value(str(kwargs["device_type"]))
if "unit" in kwargs.keys():
if kwargs["unit"] not in [None, ""]:
devices.unit = kwargs["unit"]
if "image" in kwargs.keys():
if kwargs["image"] not in [None, ""]:
devices.params["file"] = kwargs["image"]
# Handling the inserting of media into an attached removable type device
device_type = kwargs.get("device_type", None)
currently_attached = self.list_devices(scsi_id, kwargs.get("unit"))["device_list"]
if currently_attached:
current_type = currently_attached[0]["device_type"]
else:
current_type = None
if device_type in REMOVABLE_DEVICE_TYPES and current_type in REMOVABLE_DEVICE_TYPES:
if current_type != device_type:
parameters = {
"device_type": device_type,
"current_device_type": current_type
}
return {
"status": False,
"return_code": ReturnCodes.ATTACHIMAGE_COULD_NOT_ATTACH,
"parameters": parameters,
}
command.operation = proto.PbOperation.INSERT
# Handling attaching a new device
else:
command.operation = proto.PbOperation.ATTACH
if "interfaces" in kwargs.keys():
if kwargs["interfaces"] not in [None, ""]:
devices.params["interfaces"] = kwargs["interfaces"]
if "vendor" in kwargs.keys():
if kwargs["vendor"] is not None:
devices.vendor = kwargs["vendor"]
if "product" in kwargs.keys():
if kwargs["product"] is not None:
devices.product = kwargs["product"]
if "revision" in kwargs.keys():
if kwargs["revision"] is not None:
devices.revision = kwargs["revision"]
if "block_size" in kwargs.keys():
if kwargs["block_size"] not in [None, ""]:
devices.block_size = int(kwargs["block_size"])
command.devices.append(devices)
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def detach_by_id(self, scsi_id, unit=None):
"""
Takes (int) scsi_id and optional (int) unit.
Sends a DETACH command to the server.
Returns (bool) status and (str) msg.
"""
devices = proto.PbDeviceDefinition()
devices.id = int(scsi_id)
if unit is not None:
devices.unit = int(unit)
command = proto.PbCommand()
command.operation = proto.PbOperation.DETACH
command.devices.append(devices)
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def detach_all(self):
"""
Sends a DETACH_ALL command to the server.
Returns (bool) status and (str) msg.
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.DETACH_ALL
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def eject_by_id(self, scsi_id, unit=None):
"""
Takes (int) scsi_id and optional (int) unit.
Sends an EJECT command to the server.
Returns (bool) status and (str) msg.
"""
devices = proto.PbDeviceDefinition()
devices.id = int(scsi_id)
if unit is not None:
devices.unit = int(unit)
command = proto.PbCommand()
command.operation = proto.PbOperation.EJECT
command.devices.append(devices)
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def list_devices(self, scsi_id=None, unit=None):
"""
Takes optional (int) scsi_id and optional (int) unit.
Sends a DEVICES_INFO command to the server.
If no scsi_id is provided, returns a (list) of (dict)s of all attached devices.
If scsi_id is is provided, returns a (list) of one (dict) for the given device.
If no attached device is found, returns an empty (list).
Returns (bool) status, (list) of dicts device_list
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.DEVICES_INFO
command.params["token"] = self.token
command.params["locale"] = self.locale
# If method is called with scsi_id parameter, return the info on those devices
# Otherwise, return the info on all attached devices
if scsi_id is not None:
device = proto.PbDeviceDefinition()
device.id = int(scsi_id)
if unit is not None:
device.unit = int(unit)
command.devices.append(device)
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
device_list = []
# Return an empty (list) if no devices are attached
if not result.devices_info.devices:
return {"status": False, "device_list": []}
image_files_info = self.get_image_files_info()
i = 0
while i < len(result.devices_info.devices):
did = result.devices_info.devices[i].id
dunit = result.devices_info.devices[i].unit
dtype = proto.PbDeviceType.Name(result.devices_info.devices[i].type)
dstat = result.devices_info.devices[i].status
dprop = result.devices_info.devices[i].properties
# Building the status string
dstat_msg = []
if dprop.read_only:
dstat_msg.append("Read-Only")
if dstat.protected and dprop.protectable:
dstat_msg.append("Write-Protected")
if dstat.removed and dprop.removable:
dstat_msg.append("No Media")
if dstat.locked and dprop.lockable:
dstat_msg.append("Locked")
dpath = result.devices_info.devices[i].file.name
dfile = dpath.replace(image_files_info["images_dir"] + "/", "")
dparam = result.devices_info.devices[i].params
dven = result.devices_info.devices[i].vendor
dprod = result.devices_info.devices[i].product
drev = result.devices_info.devices[i].revision
dblock = result.devices_info.devices[i].block_size
dsize = int(result.devices_info.devices[i].block_count) * int(dblock)
device_list.append({
"id": did,
"unit": dunit,
"device_type": dtype,
"status": ", ".join(dstat_msg),
"image": dpath,
"file": dfile,
"params": dparam,
"vendor": dven,
"product": dprod,
"revision": drev,
"block_size": dblock,
"size": dsize,
})
i += 1
return {"status": result.status, "msg": result.msg, "device_list": device_list}
def reserve_scsi_ids(self, reserved_scsi_ids):
"""
Sends the RESERVE_IDS command to the server to reserve SCSI IDs.
Takes a (list) of (str) as argument.
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.RESERVE_IDS
command.params["ids"] = ",".join(reserved_scsi_ids)
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def set_log_level(self, log_level):
"""
Sends a LOG_LEVEL command to the server.
Takes (str) log_level as an argument.
Returns (bool) status and (str) msg.
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.LOG_LEVEL
command.params["level"] = str(log_level)
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def shutdown_pi(self, mode):
"""
Sends a SHUT_DOWN command to the server.
Takes (str) mode as an argument.
Returns (bool) status and (str) msg.
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.SHUT_DOWN
command.params["mode"] = str(mode)
command.params["token"] = self.token
command.params["locale"] = self.locale
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}
def is_token_auth(self):
"""
Sends a CHECK_AUTHENTICATION command to the server.
Tells you whether RaSCSI backend is protected by a token password or not.
Returns (bool) status and (str) msg.
"""
command = proto.PbCommand()
command.operation = proto.PbOperation.CHECK_AUTHENTICATION
data = self.sock_cmd.send_pb_command(command.SerializeToString())
result = proto.PbResult()
result.ParseFromString(data)
return {"status": result.status, "msg": result.msg}

View File

@ -0,0 +1,25 @@
"""
Module for return codes that are refrenced in the return payloads of the rascsi module.
"""
from typing import Final
# pylint: disable=too-few-public-methods
class ReturnCodes:
"""Class for the return codes used within the rascsi module."""
DELETEFILE_SUCCESS: Final = 0
DELETEFILE_FILE_NOT_FOUND: Final = 1
RENAMEFILE_SUCCESS: Final = 10
RENAMEFILE_UNABLE_TO_MOVE: Final = 11
DOWNLOADFILETOISO_SUCCESS: Final = 20
DOWNLOADTODIR_SUCCESS: Final = 30
WRITECONFIG_SUCCESS: Final = 40
WRITECONFIG_COULD_NOT_WRITE: Final = 41
READCONFIG_SUCCESS: Final = 50
READCONFIG_COULD_NOT_READ: Final = 51
READCONFIG_INVALID_CONFIG_FILE_FORMAT: Final = 51
WRITEDRIVEPROPS_SUCCESS: Final = 60
WRITEDRIVEPROPS_COULD_NOT_WRITE: Final = 61
READDRIVEPROPS_SUCCESS: Final = 70
READDRIVEPROPS_COULD_NOT_READ: Final = 71
ATTACHIMAGE_COULD_NOT_ATTACH: Final = 80

View File

@ -0,0 +1,91 @@
"""
Module for sending and receiving data over a socket connection with the RaSCSI backend
"""
import logging
from time import sleep
from rascsi.exceptions import (EmptySocketChunkException,
InvalidProtobufResponse,
FailedSocketConnectionException)
class SocketCmds:
"""
Class for sending and receiving data over a socket connection with the RaSCSI backend
"""
def __init__(self, host="localhost", port=6868):
self.host = host
self.port = port
def send_pb_command(self, payload):
"""
Takes a (str) containing a serialized protobuf as argument.
Establishes a socket connection with RaSCSI.
"""
counter = 0
tries = 20
error_msg = ""
import socket
while counter < tries:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((self.host, self.port))
response = self.send_over_socket(sock, payload)
return response
except socket.error as error:
counter += 1
logging.warning("The RaSCSI service is not responding - attempt %s/%s",
str(counter), str(tries))
error_msg = str(error)
sleep(0.2)
except EmptySocketChunkException as ex:
raise ex
except InvalidProtobufResponse as ex:
raise ex
logging.error(error_msg)
raise FailedSocketConnectionException(error_msg)
# pylint: disable=no-self-use
def send_over_socket(self, sock, payload):
"""
Takes a socket object and (str) payload with serialized protobuf.
Sends payload to RaSCSI over socket and captures the response.
Tries to extract and interpret the protobuf header to get response size.
Reads data from socket in 2048 bytes chunks until all data is received.
"""
from struct import pack, unpack