diff --git a/nsfarm/board/_board.py b/nsfarm/board/_board.py index a8c974d6240ec1a585fa8e44c8e3b6594009bc1e..313789fb0730c5277e15e06304c61ac309976287 100644 --- a/nsfarm/board/_board.py +++ b/nsfarm/board/_board.py @@ -8,7 +8,7 @@ import serial import serial.tools.miniterm from pexpect import fdpexpect from .. import cli -from ..lxd import Container +from ..lxd import LXDConnection, Container, NetInterface MINITERM_DEFAULT_EXIT = '\x1d' # Ctrl+] MINITERM_DEFAULT_MENU = '\x14' # Ctrl+T @@ -63,9 +63,10 @@ class Board(abc.ABC): self._pexpect.sendline("") return cli.Uboot(self._pexpect) - def bootup(self, device_wan, os_branch): + def bootup(self, lxd_connection: LXDConnection, device_wan: NetInterface, os_branch: str) -> cli.Shell: """Boot board using TFTP boot. This ensures that board is booted up and ready to accept commands. + lxd_connection: instance of nsfarm.lxd.LXDConnection device_wan: Wan device to board. This is instance of nsfarm.lxd.NetInterface. os_branch: Turris OS branch to download medkit from. @@ -74,7 +75,7 @@ class Board(abc.ABC): # First get U-Boot prompt uboot = self.uboot() # Now load image from TFTP - with Container("boot", devices=[device_wan, ]) as cont: + with Container(lxd_connection, "boot", devices=[device_wan, ]) as cont: ccli = cli.Shell(cont.pexpect()) ccli.run(f"prepare_turris_image {os_branch}") uboot.run('setenv ipaddr 192.168.1.142') diff --git a/nsfarm/lxd/__init__.py b/nsfarm/lxd/__init__.py index 3427d4b0da9a4e9ac6c5ea93eb9a9cb83fd34564..d6cef98ba9e950800390ba98db7713d0576786e5 100644 --- a/nsfarm/lxd/__init__.py +++ b/nsfarm/lxd/__init__.py @@ -1,2 +1,3 @@ +from .connection import LXDConnection from .container import Container from .device import NetInterface diff --git a/nsfarm/lxd/_lxd.py b/nsfarm/lxd/_lxd.py deleted file mode 100644 index 2b477deb92380731dbf8adca418bfde2dec31285..0000000000000000000000000000000000000000 --- a/nsfarm/lxd/_lxd.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Internal global LXD handle. -""" -import logging -import pylxd - -IMAGES_SOURCE = "https://images.linuxcontainers.org" - -ROOT_PROFILE = "nsfarm-root" -INTERNET_PROFILE = "nsfarm-internet" -REQUIRED_PROFILES = (ROOT_PROFILE, INTERNET_PROFILE) - -# Global LXD handles -images = None -local = None - - -def _profile_device(profile, check_func): - return any(check_func(dev) for dev in profile.devices.values()) - - -def connect(): - """Make sure that we are connected to LXD. - """ - # Suppress logging of pylxd components - logging.getLogger('ws4py').setLevel(logging.ERROR) - logging.getLogger('urllib3').setLevel(logging.ERROR) - # Initialize LXD connection to linuximages.org - global images - if images is None: - images = pylxd.Client(IMAGES_SOURCE) - # Initialize LXD connection to local server - global local - if local is None: - local = pylxd.Client() - # Verify profiles - for name in REQUIRED_PROFILES: - if not local.profiles.exists(name): - # TODO better exception - raise Exception(f"Missing required LXD profile: {name}") - root = local.profiles.get(ROOT_PROFILE) - internet = local.profiles.get(INTERNET_PROFILE) - if not _profile_device(root, lambda dev: dev["type"] == "disk"): - # TODO better exception - raise Exception("nsfarm-root does not provide disk device") - if not _profile_device(internet, lambda dev: dev["type"] == "nic" and dev["name"] == "internet"): - # TODO better exception - raise Exception("nsfarm-internet does not provide appropriate nic") diff --git a/nsfarm/lxd/connection.py b/nsfarm/lxd/connection.py new file mode 100644 index 0000000000000000000000000000000000000000..8f1a19bd89e7aaf6976a066a5671d08cd3307854 --- /dev/null +++ b/nsfarm/lxd/connection.py @@ -0,0 +1,21 @@ +"""LXD connection for NSFarm. +""" +import logging +import pylxd + + +class LXDConnection: + """This is generic connection handler for LXD handling both connections to local and images server. + """ + IMAGES_SOURCE = "https://images.linuxcontainers.org" + + ROOT_PROFILE = "nsfarm-root" + INTERNET_PROFILE = "nsfarm-internet" + + def __init__(self): + # Suppress logging of pylxd components + logging.getLogger('ws4py').setLevel(logging.ERROR) + logging.getLogger('urllib3').setLevel(logging.ERROR) + + self.local = pylxd.Client() + self.images = pylxd.Client(self.IMAGES_SOURCE) diff --git a/nsfarm/lxd/container.py b/nsfarm/lxd/container.py index 14e67e948302a0dee13db31f74740c632a398f52..2ebf4dc8075f0309c30cff226d53b506ea1a3318 100644 --- a/nsfarm/lxd/container.py +++ b/nsfarm/lxd/container.py @@ -2,6 +2,7 @@ """ import os import time +import typing import itertools import pathlib import hashlib @@ -9,7 +10,8 @@ import logging import pexpect import pylxd from .. import cli -from . import _lxd +from .connection import LXDConnection +from .device import NetInterface IMAGE_INIT_PATH = "/nsfarm-init.sh" # Where we deploy initialization script for image @@ -23,7 +25,9 @@ class Container: """ # TODO log syslog somehow - def __init__(self, img_name, devices=(), internet=True): + def __init__(self, lxd_connection: LXDConnection, img_name: str, devices: typing.List[NetInterface] = (), + internet: bool = True): + self._lxd = lxd_connection self._name = img_name self._internet = internet self._devices = tuple(devices) @@ -35,17 +39,15 @@ class Container: raise Exception(f"There seems to be no file describing image: {self._file_path}") if not self._dir_path.is_dir(): self._dir_path = None - # Make sure that we are connected to LXD - _lxd.connect() # Get parent with open(self._file_path) as file: # This reads second line of file while initial hash removed parent = next(itertools.islice(file, 1, 2))[1:].strip() self._parent = None if parent.startswith("nsfarm:"): - self._parent = Container(parent[7:]) + self._parent = Container(lxd_connection, parent[7:]) elif parent.startswith("images:"): - self._parent = _lxd.images.images.get_by_alias(parent[7:]) + self._parent = self._lxd.images.images.get_by_alias(parent[7:]) else: raise Exception(f"The file has parent from unknown source: {parent}: {self.fpath}") # Calculate identity hash and generate image name @@ -91,8 +93,8 @@ class Container: """ if self._lxd_image: return - if _lxd.local.images.exists(self._image_alias, alias=True): - self._lxd_image = _lxd.local.images.get_by_alias(self._image_alias) + if self._lxd.local.images.exists(self._image_alias, alias=True): + self._lxd_image = self._lxd.local.images.get_by_alias(self._image_alias) return # We do not have appropriate image so prepare it logger.warning("Bootstrapping image: %s", self._image_alias) @@ -106,11 +108,11 @@ class Container: else: # We have to pull it from images image_source["mode"] = "pull" - image_source["server"] = _lxd.IMAGES_SOURCE + image_source["server"] = self._lxd.IMAGES_SOURCE image_source["alias"] = self._parent.fingerprint container_name = f"nsfarm-bootstrap-{self._name}-{self._hash}" try: - container = _lxd.local.containers.create({ + container = self._lxd.local.containers.create({ 'name': container_name, 'profiles': ['nsfarm-root', 'nsfarm-internet'], 'source': image_source @@ -121,7 +123,7 @@ class Container: raise logger.warning("Other instance is already bootsrapping image probably. " "Waiting for following container to go away: %s", container_name) - while _lxd.local.containers.exists(container_name): + while self._lxd.local.containers.exists(container_name): time.sleep(1) self.prepare_image() # possibly get created image or try again return @@ -163,7 +165,7 @@ class Container: for device in self._devices: devices.update(device.acquire(self)) # Create and start container - self._lxd_container = _lxd.local.containers.create({ + self._lxd_container = self._lxd.local.containers.create({ 'name': self._container_name(), 'ephemeral': True, 'profiles': profiles, @@ -180,9 +182,9 @@ class Container: def _container_name(self, prefix="nsfarm"): name = f"{prefix}-{self._name}-{os.getpid()}" - if _lxd.local.containers.exists(name): + if self._lxd.local.containers.exists(name): i = 1 - while _lxd.local.containers.exists(f"{name}-{i}"): + while self._lxd.local.containers.exists(f"{name}-{i}"): i += 1 name = f"{name}-{i}" return name diff --git a/nsfarm/lxd/utils.py b/nsfarm/lxd/utils.py index 15aeb9f4edad8c2b42ade3ee00cd6fb39c10e029..8f770e7c321f2d482475b21a413b5d469a1b8d9e 100644 --- a/nsfarm/lxd/utils.py +++ b/nsfarm/lxd/utils.py @@ -4,8 +4,8 @@ import os import logging from datetime import datetime import dateutil.parser -from . import container -from . import _lxd +from .connection import LXDConnection +from .container import Container, IMGS_DIR logger = logging.getLogger(__package__) @@ -18,11 +18,11 @@ def clean(delta, dry_run=False): Returns list of (to be) removed images. """ - _lxd.connect() + connection = LXDConnection() since = datetime.today() - delta removed = list() - for img in _lxd.local.images.all(): + for img in connection.local.images.all(): if not any(alias.startswith("nsfarm/") for alias in img.aliases): continue last_used = dateutil.parser.parse( @@ -42,7 +42,7 @@ def all_images(): This collects all *.sh files in imgs directory in root of nsfarm project. """ - return (imgf[:-3] for imgf in os.listdir(container.IMGS_DIR) if imgf.endswith(".sh")) + return (imgf[:-3] for imgf in os.listdir(IMGS_DIR) if imgf.endswith(".sh")) def bootstrap(imgs=None): @@ -53,11 +53,8 @@ def bootstrap(imgs=None): Returns True if all images were bootstrapped correctly. """ success = True + connection = LXDConnection() for img in all_images() if imgs is None else imgs: logger.info("Trying to bootstrap: %s", img) - try: - container.Container(img).prepare_image() - except Exception: - success = False - logger.exception("Bootstrap failed for: %s", img) + Container(connection, img).prepare_image() return success diff --git a/selftests/test_lxd.py b/selftests/test_lxd.py new file mode 100644 index 0000000000000000000000000000000000000000..48a473e7dedea681bf2733fc0f4d065495e4d143 --- /dev/null +++ b/selftests/test_lxd.py @@ -0,0 +1,31 @@ +import pytest +from nsfarm import lxd + + +@pytest.fixture(name="connection", scope="module") +def fixture_connection(): + return lxd.LXDConnection() + + +@pytest.mark.parametrize("profile", [ + lxd.LXDConnection.ROOT_PROFILE, + lxd.LXDConnection.INTERNET_PROFILE, +]) +def test_profiles_exists(connection, profile): + """Check that all profiles we need are configured in LXD. + """ + assert connection.local.profiles.exists(profile) + + +def test_profile_root(connection): + """Minimal sanity check of root profile. + """ + profile = connection.local.profiles.get(lxd.LXDConnection.ROOT_PROFILE) + assert any(dev for dev in profile.devices.values() if dev["type"] == "disk") + + +def test_profile_internet(connection): + """Minimal sanity check of internet profile. + """ + profile = connection.local.profiles.get(lxd.LXDConnection.INTERNET_PROFILE) + assert any(dev for dev in profile.devices.values() if dev["type"] == "nic" and dev["name"] == "internet") diff --git a/tests/conftest.py b/tests/conftest.py index 07c73eabafe134c5d9ebd79e1875c593251fb37e..550cbe41afcdaff92dc4e6526f7e966d6e130c2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,14 @@ def fixture_board(request): return brd +# TODO probably mark this with some LXD available/configured mark +@pytest.fixture(scope="session", name="lxd") +def fixture_lxd(request): + """Provides access to nsfarm.lxd.LXDConnection instance. + """ + return nsfarm.lxd.LXDConnection() + + @pytest.fixture(scope="session", name="wan", params=[pytest.param(None, marks=pytest.mark.wan)]) def fixture_wan(request): """Top level fixture used to share WAN interface handler. @@ -88,12 +96,12 @@ def fixture_lan1(request): # Boot and setup fixtures ############################################################################################## @pytest.fixture(name="board_serial", scope="session") -def fixture_board_serial(request, board, wan): +def fixture_board_serial(request, lxd, board, wan): """Boot board to Shell. Provides instance of nsfarm.cli.Shell() """ request.addfinalizer(lambda: board.reset(True)) - return board.bootup(wan, request.config.target_branch) + return board.bootup(lxd, wan, request.config.target_branch) @pytest.fixture(name="board_root_password", scope="session") @@ -133,10 +141,10 @@ def fixture_client_board(board_serial, board_root_password, lan1_client): # Common containers #################################################################################################### @pytest.fixture(name="lan1_client", scope="module") -def fixture_lan1_client(lan1): +def fixture_lan1_client(lxd, lan1): """Starts client container on LAN1 and provides it. """ - with nsfarm.lxd.Container('client', devices=[lan1, ], internet=False) as container: + with nsfarm.lxd.Container(lxd, 'client', devices=[lan1, ], internet=False) as container: yield container @@ -144,13 +152,13 @@ def fixture_lan1_client(lan1): # Standard configuration ############################################################################################### @pytest.fixture(name="basic_isp", scope="module") -def fixture_basic_isp(board, client_board, wan): +def fixture_basic_isp(lxd, board, client_board, wan): """Basic config we consider general. It provides you with configured WAN. Returns handle for ISP container on WAN interface. """ # TODO what about other settings that are part of guide - with nsfarm.lxd.Container('isp-common', devices=[wan, ]) as container: + with nsfarm.lxd.Container(lxd, 'isp-common', devices=[wan, ]) as container: client_board.run("uci set network.wan.proto='static'") client_board.run("uci set network.wan.ipaddr='172.16.1.42'") client_board.run("uci set network.wan.netmask='255.240.0.0'") diff --git a/tests/network/test_wan.py b/tests/network/test_wan.py index ffe58fd80cc6a4c818a841c0c38634540ce3e5c3..a2ecb048549c19d0c36cf07b79aaf417cb6f3fe0 100644 --- a/tests/network/test_wan.py +++ b/tests/network/test_wan.py @@ -16,11 +16,11 @@ class TestStatic(common.InternetTests): """ @pytest.fixture(scope="class", autouse=True) - def client(self, board, client_board, wan): + def client(self, lxd, board, client_board, wan): """Configure WAN to use static IP """ print("We are in client fixture once") - with nsfarm.lxd.Container('isp-common', devices=[wan, ]): + with nsfarm.lxd.Container(lxd, 'isp-common', devices=[wan, ]): # TODO implement some utility class to set and revert uci configs on router client_board.run("uci set network.wan.proto='static'") client_board.run("uci set network.wan.ipaddr='172.16.1.42'") @@ -45,10 +45,10 @@ class TestDHCP(common.InternetTests): """ @pytest.fixture(scope="class", autouse=True) - def client(self, board, client_board, wan): + def client(self, lxd, board, client_board, wan): """Configure WAN to use DHCP """ - with nsfarm.lxd.Container('isp-dhcp', devices=[wan, ]): + with nsfarm.lxd.Container(lxd, 'isp-dhcp', devices=[wan, ]): client_board.run("uci set network.wan.proto='dhcp'") client_board.run("uci commit network") client_board.run("/etc/init.d/network restart")