Skip to content

Commit

Permalink
Installer HTTP API
Browse files Browse the repository at this point in the history
  • Loading branch information
themylogin committed Apr 16, 2024
1 parent 009e0b1 commit 34b3139
Show file tree
Hide file tree
Showing 16 changed files with 439 additions and 17 deletions.
6 changes: 6 additions & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ Maintainer: Vladimir Vinogradenko <[email protected]>
Build-Depends: debhelper-compat (= 12),
dh-python,
python3-all,
python3-aiohttp-rpc,
python3-ixhardware,
python3-jsonschema,
python3-licenselib,
python3-humanfriendly,
python3-psutil,
python3-pyroute2,
python3-setuptools
Standards-Version: 4.4.0

Expand All @@ -20,10 +23,13 @@ Depends: ${misc:Depends},
gdisk,
openzfs,
parted,
python3-aiohttp-rpc,
python3-ixhardware,
python3-jsonschema,
python3-licenselib,
python3-humanfriendly,
python3-psutil,
python3-pyroute2,
setserial,
squashfs-tools,
util-linux
Expand Down
2 changes: 2 additions & 0 deletions debian/rules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export PYBUILD_NAME=truenas_installer
%:
dh $@ --with python3 --buildsystem=pybuild

override_dh_installsystemd:
dh_installsystemd --no-start -r --no-restart-after-upgrade --name=truenas-installer
8 changes: 8 additions & 0 deletions debian/truenas-installer.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[Unit]
Description=TrueNAS Installer

[Service]
ExecStart=/usr/bin/python3 -m truenas_installer --server

[Install]
WantedBy=multi-user.target
31 changes: 29 additions & 2 deletions truenas_installer/__main__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
import argparse
import asyncio

from aiohttp import web

from ixhardware import parse_dmi

from .installer import Installer
from .installer_menu import InstallerMenu
from .server import InstallerRPCServer


if __name__ == "__main__":
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--server", action="store_true")
args = parser.parse_args()

with open("/etc/version") as f:
version = f.read().strip()

dmi = parse_dmi()

installer = Installer(version, dmi)
installer.run()

if args.server:
rpc_server = InstallerRPCServer(installer)
app = web.Application()
app.router.add_routes([
web.get("/", rpc_server.handle_http_request),
])
app.on_shutdown.append(rpc_server.on_shutdown)
web.run_app(app, port=80)
else:
loop = asyncio.get_event_loop()
loop.create_task(InstallerMenu(installer).run())
loop.run_forever()


if __name__ == "__main__":
main()
7 changes: 4 additions & 3 deletions truenas_installer/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
BOOT_POOL = "boot-pool"


async def install(disks, create_swap, set_pmbr, authentication, sql, callback):
async def install(disks, create_swap, set_pmbr, authentication, post_install, sql, callback):
with installation_lock:
try:
if not os.path.exists("/etc/hostid"):
Expand All @@ -27,7 +27,7 @@ async def install(disks, create_swap, set_pmbr, authentication, sql, callback):
callback(0, "Creating boot pool")
await create_boot_pool([get_partition(disk, 3) for disk in disks])
try:
await run_installer(disks, authentication, sql, callback)
await run_installer(disks, authentication, post_install, sql, callback)
finally:
await run(["zpool", "export", "-f", BOOT_POOL])
except subprocess.CalledProcessError as e:
Expand Down Expand Up @@ -108,7 +108,7 @@ async def create_boot_pool(devices):
await run(["zfs", "create", "-o", "canmount=off", "-o", "mountpoint=legacy", f"{BOOT_POOL}/grub"])


async def run_installer(disks, authentication, sql, callback):
async def run_installer(disks, authentication, post_install, sql, callback):
with tempfile.TemporaryDirectory() as src:
await run(["mount", "/cdrom/TrueNAS-SCALE.update", src, "-t", "squashfs", "-o", "loop"])
try:
Expand All @@ -117,6 +117,7 @@ async def run_installer(disks, authentication, sql, callback):
"disks": disks,
"json": True,
"pool_name": BOOT_POOL,
"post_install": post_install,
"sql": sql,
"src": src,
}
Expand Down
12 changes: 2 additions & 10 deletions truenas_installer/installer.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import asyncio

from .installer_menu import InstallerMenu
import os


class Installer:
def __init__(self, version, dmi):
self.version = version
self.dmi = dmi

def run(self):
loop = asyncio.get_event_loop()

loop.create_task(InstallerMenu(self).run())

loop.run_forever()
self.efi = os.path.exists("/sys/firmware/efi")
4 changes: 2 additions & 2 deletions truenas_installer/installer_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async def _install_upgrade_internal(self):
create_swap = await dialog_yesno("Swap", "Create 16GB swap partition on boot devices?")

set_pmbr = False
if not os.path.exists("/sys/firmware/efi"):
if not self.installer.efi:
set_pmbr = await dialog_yesno(
"Legacy Boot",
(
Expand All @@ -117,7 +117,7 @@ async def _install_upgrade_internal(self):
sql = await serial_sql()

try:
await install(destination_disks, create_swap, set_pmbr, authentication_method, sql, self._callback)
await install(destination_disks, create_swap, set_pmbr, authentication_method, None, sql, self._callback)
except InstallError as e:
await dialog_msgbox("Installation Error", e.message)
return False
Expand Down
36 changes: 36 additions & 0 deletions truenas_installer/network_interfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from dataclasses import dataclass

from pyroute2 import IPRoute, NetlinkDumpInterrupted

__all__ = ["list_network_interfaces"]


@dataclass
class NetworkInterface:
name: str


async def list_network_interfaces():
max_retries = 3
for attempt in range(1, max_retries + 1):
try:
with IPRoute() as ipr:
interfaces = [NetworkInterface(dev.get_attr("IFLA_IFNAME")) for dev in ipr.get_links()]
except NetlinkDumpInterrupted:
if attempt < max_retries:
# When the kernel is producing a dump of a kernel structure
# over multiple netlink messages, and the structure changes
# mid-way, NLM_F_DUMP_INTR is added to the header flags.
# This an indication that the requested dump contains
# inconsistent data and must be re-requested. See function
# nl_dump_check_consistent() in include/net/netlink.h. The
# pyroute2 library raises this specific exception for this
# scenario, so we'll try again (up to a max of 3 times).
continue
else:
raise

return [
interface for interface in interfaces
if interface.name not in ["lo"]
]
24 changes: 24 additions & 0 deletions truenas_installer/server/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import aiohttp_rpc

import truenas_installer.server.api
from truenas_installer.server.api.adoption import adoption_middleware
from .error import exception_middleware
from .method import methods

__all__ = ["InstallerRPCServer"]


class InstallerRPCServer(aiohttp_rpc.WsJsonRpcServer):
def __init__(self, installer):
self.installer = installer
super().__init__(
middlewares=(
adoption_middleware,
exception_middleware,
aiohttp_rpc.middlewares.extra_args_middleware,
),
)

for method in methods.values():
method.server = self
self.add_method(aiohttp_rpc.protocol.JsonRpcMethod(method.call, name=method.name))
4 changes: 4 additions & 0 deletions truenas_installer/server/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import truenas_installer.server.api.adoption
import truenas_installer.server.api.info
import truenas_installer.server.api.install
import truenas_installer.server.api.power
59 changes: 59 additions & 0 deletions truenas_installer/server/api/adoption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import errno
import secrets
import typing

from aiohttp_rpc import errors, protocol

from truenas_installer.server.error import Error
from truenas_installer.server.method import method

__all__ = ["is_adopted", "adopt", "authenticate"]

access_key = None


@method(None, {"type": "boolean"})
async def is_adopted(context):
return access_key is not None


@method(None, {"type": "string"})
async def adopt(context):
global access_key

if access_key is not None:
raise Error("System is already adopted")

access_key = secrets.token_urlsafe(32)

setattr(context.rpc_request.context["http_request"], "_authenticated", True)

return access_key


@method({"type": "string"}, None)
async def authenticate(context, key):
global access_key

if access_key is None:
raise Error("The system is not adopted")

if key != access_key:
raise Error("Invalid access key", errno.EINVAL)

setattr(context.rpc_request.context["http_request"], "_authenticated", True)


async def adoption_middleware(request: protocol.JsonRpcRequest, handler: typing.Callable) -> protocol.JsonRpcResponse:
if access_key is not None:
if not (
request.method_name in ["is_adopted", "authenticate"] or
getattr(request.context["http_request"], "_authenticated", False)
):
return protocol.JsonRpcResponse(
id=request.id,
jsonrpc=request.jsonrpc,
error=errors.InvalidParams("You must authenticate before making this call", data={"errno": errno.EACCES}),
)

return await handler(request)
54 changes: 54 additions & 0 deletions truenas_installer/server/api/info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from dataclasses import asdict

from truenas_installer.disks import list_disks as _list_disks
from truenas_installer.network_interfaces import list_network_interfaces as _list_network_interfaces
from truenas_installer.lock import installation_lock
from truenas_installer.server.method import method

__all__ = ["system_info", "list_disks", "list_network_interfaces"]


@method(None, {
"type": "object",
"properties": {
"running": {"type": "boolean"},
"version": {"type": "string"},
"efi": {"type": "boolean"},
},
})
async def system_info(context):
return {
"running": installation_lock.locked(),
"version": context.installer.version,
"efi": context.installer.efi,
}


@method(None, {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"size": {"type": "number"},
"model": {"type": "string"},
"label": {"type": "string"},
"removable": {"type": "boolean"},
},
},
})
async def list_disks(context):
return [asdict(disk) for disk in await _list_disks()]


@method(None, {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
},
},
})
async def list_network_interfaces(context):
return [asdict(interface) for interface in await _list_network_interfaces()]
Loading

0 comments on commit 34b3139

Please sign in to comment.