Skip to content

Commit

Permalink
Implement null migration (no config saved to disk).
Browse files Browse the repository at this point in the history
- Automatically use ~/aiidalab as default mount point if it exists.
- Automatically detect used profile from container.
  • Loading branch information
csadorf committed Feb 4, 2022
1 parent 43d7061 commit f9384c6
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 36 deletions.
40 changes: 37 additions & 3 deletions aiidalab_launch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ class ApplicationState:
config: Config = field(default_factory=_load_config)
docker_client: docker.DockerClient = field(default_factory=get_docker_client)

def _apply_migration_null(self):
# Since there is no config file on disk, we can assume that if at all,
# there is only the default profile present.
assert len(self.config.profiles) == 1
assert self.config.profiles[0].name == "default"

default_profile = self.config.profiles[0]
instance = AiidaLabInstance(client=self.docker_client, profile=default_profile)

# Default home bind mount path up until version 2022.1011.
home_bind_mount_path = Path.home() / "aiidalab"

if instance.container:
# There is already a container present, use previously used profile.
self.config.profiles[0] = instance.profile_from_container(
instance.container
)

elif home_bind_mount_path.exists():
# Using ~/aiidalab as home directory mount point, since the
# directory exists. The default mount point was changed to be a
# docker volume after version 2022.1011 to address issue
# https://github.com/aiidalab/aiidalab-launch/issues/72.
self.config.profiles[0].home_mount = str(home_bind_mount_path)

self.config.version = str(parse(__version__))
self.config.save(_application_config_path())

def apply_migrations(self):
if self.config.version is None:
self._apply_migration_null() # no config file saved to disk


pass_app_state = click.make_pass_decorator(ApplicationState, ensure=True)

Expand All @@ -114,7 +146,8 @@ def callback(ctx, param, value): # noqa: U100
count=True,
help="Provide this option to increase the output verbosity of the launcher.",
)
def cli(verbose):
@pass_app_state
def cli(app_state, verbose):
# Use the verbosity count to determine the logging level...
logging.basicConfig(
level=LOGGING_LEVELS[verbose] if verbose in LOGGING_LEVELS else logging.DEBUG
Expand All @@ -138,6 +171,9 @@ def cli(verbose):
if "pipx" in __file__:
click.secho("Run `pipx upgrade aiidalab-launch` to update.", fg="yellow")

# Apply migrations
app_state.apply_migrations()


@cli.command()
def version():
Expand Down Expand Up @@ -210,8 +246,6 @@ def add_profile(ctx, app_state, port, home_mount, profile):
# Determine next available port or use the one provided by the user.
configured_ports = [prof.port for prof in app_state.config.profiles if prof.port]
port = port or (max(configured_ports, default=-1) + 1) or DEFAULT_PORT
# Determine home mount path unless provided.
home_mount = home_mount or str(Path.home() / f"aiidalab-{profile}")

try:
new_profile = Profile(
Expand Down
97 changes: 74 additions & 23 deletions aiidalab_launch/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from enum import Enum, auto
from pathlib import Path
from pathlib import Path, PosixPath, PurePosixPath, WindowsPath
from secrets import token_hex
from typing import Any, AsyncGenerator, Generator
from urllib.parse import quote_plus
Expand All @@ -18,7 +18,7 @@
import toml
from packaging.version import parse as parse_version

from .util import _async_wrap_iter
from .util import _async_wrap_iter, get_docker_env
from .version import __version__

MAIN_PROFILE_NAME = "default"
Expand All @@ -40,10 +40,6 @@
_REGEX_VALID_PROFILE_NAMES = r"[a-zA-Z0-9][a-zA-Z0-9.-]+"


def _default_home_mount() -> str:
return str(Path.home().joinpath("aiidalab"))


def _default_port() -> int: # explicit function required to enable test patching
return DEFAULT_PORT

Expand All @@ -55,7 +51,7 @@ class Profile:
default_apps: list[str] = field(default_factory=lambda: ["aiidalab-widgets-base"])
system_user: str = "aiida"
image: str = "aiidalab/aiidalab-docker-stack:latest"
home_mount: str | None = field(default_factory=lambda: _default_home_mount())
home_mount: str | None = None

def __post_init__(self):
if (
Expand All @@ -68,7 +64,7 @@ def __post_init__(self):
"start with an alphanumeric character."
)
if self.home_mount is None:
self.home_mount = f"aiidalab_{self.name}_home"
self.home_mount = f"{CONTAINER_PREFIX}{self.name}_home"

def container_name(self) -> str:
return f"{CONTAINER_PREFIX}{self.name}"
Expand All @@ -95,7 +91,11 @@ def loads(cls, name: str, s: str) -> Profile:
class Config:
profiles: list[Profile] = field(default_factory=lambda: [Profile()])
default_profile: str = MAIN_PROFILE_NAME
version: str = CONFIG_VERSION

# The configuration is always stored to disk beginning with version
# 2022.1012, which means we assume that if no configuration is stored
# we cannot make any assumptions about the latest applicable version.
version: str | None = None

@classmethod
def loads(cls, blob: str) -> Config:
Expand Down Expand Up @@ -217,6 +217,48 @@ def _mounts(self) -> Generator[docker.types.Mount, None, None]:
if self.profile.home_mount:
yield self._home_mount()

def _find_docker_home_mount(
self, container: docker.models.containers.Container
) -> Path | None:
# Find the specified home bind mount path for the existing container.
try:
home_mount = [
mount
for mount in container.attrs["Mounts"]
if mount["Destination"] == f"/home/{self.profile.system_user}"
][0]
except IndexError:
return None
if home_mount["Type"] == "bind":
docker_root = PurePosixPath("/host_mnt")
docker_path = PurePosixPath(home_mount["Source"])
try:
# Try Windows
drive = docker_path.relative_to(docker_root).parts[0]
return WindowsPath(
f"{drive}:",
docker_path.root,
docker_path.relative_to(docker_root, drive),
)
except NotImplementedError:
return PosixPath(docker_root.root, docker_path.relative_to(docker_root))
elif home_mount["Type"] == "volume":
return home_mount["Name"]
else:
raise RuntimeError("Unexpected mount type.")

def profile_from_container(
self, container: docker.models.containers.Container
) -> Profile:
return Profile(
name=self.profile.name,
port=self.host_port(container),
default_apps=self._aiidalab_default_apps(container),
home_mount=str(self._find_docker_home_mount(container)),
image=container.image.tags[0],
system_user=self._system_user(container),
)

def configuration_changes(self) -> Generator[str, None, None]:
assert self.container is not None
assert self.image is not None
Expand Down Expand Up @@ -399,29 +441,38 @@ async def status(self, timeout: float | None = 5.0) -> AiidaLabInstanceStatus:
return self.AiidaLabInstanceStatus.EXITED
return self.AiidaLabInstanceStatus.DOWN

@staticmethod
def _aiidalab_default_apps(
container: docker.models.containers.Container,
) -> list:
try:
return get_docker_env(container, "AIIDALAB_DEFAULT_APPS").split()
except KeyError:
return []

@staticmethod
def _system_user(container: docker.models.containers.Container) -> str:
return get_docker_env(container, "SYSTEM_USER")

def jupyter_token(self) -> str | None:
if self.container:
try:
re_token = r"JUPYTER_TOKEN=(?P<token>[a-z0-9]{64})"
for item in self.container.attrs["Config"]["Env"]:
match = re.match(re_token, item)
if match:
return match.groupdict()["token"]
except (KeyError, IndexError):
return get_docker_env(self.container, "JUPYTER_TOKEN")
except KeyError:
pass
return None

def host_port(self) -> int | None:
if self.container:
try:
host_config = self.container.attrs["HostConfig"]
return host_config["PortBindings"]["8888/tcp"][0]["HostPort"]
except (KeyError, IndexError):
pass
@staticmethod
def host_port(container: docker.models.containers.Container) -> int | None:
try:
host_config = container.attrs["HostConfig"]
return int(host_config["PortBindings"]["8888/tcp"][0]["HostPort"])
except (KeyError, IndexError):
pass
return None

def url(self) -> str:
assert self.container is not None
host_port = self.host_port()
host_port = self.host_port(self.container)
jupyter_token = self.jupyter_token()
return f"http://localhost:{host_port}/?token={jupyter_token}"
21 changes: 21 additions & 0 deletions aiidalab_launch/util.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import asyncio
import logging
import re
import webbrowser
from contextlib import contextmanager
from pathlib import Path, PurePosixPath
from textwrap import wrap
from threading import Event, Thread, Timer
from typing import Any, AsyncGenerator, Generator, Iterable, Optional
Expand Down Expand Up @@ -155,3 +157,22 @@ def confirm_with_value(value: str, text: str, abort: bool = False) -> bool:
raise click.Abort
else:
return False


def docker_bind_mount_path(path: Path) -> PurePosixPath:
"Construct the expected docker bind mount path (platform independent)."
return PurePosixPath(
"/host_mnt/", path.drive.strip(":"), path.relative_to(path.drive, path.root)
)


def get_docker_env(container: docker.models.containers.Container, env_name: str) -> str:
re_pattern = f"{re.escape(env_name)}=(?P<value>.*)"
try:
for item in container.attrs["Config"]["Env"]:
match = re.search(re_pattern, item)
if match:
return match.groupdict()["value"]
except KeyError:
pass
raise KeyError(env_name)
13 changes: 6 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import random
import string
import sys
from pathlib import Path

import click
import docker
Expand Down Expand Up @@ -43,20 +44,18 @@ def app_config(tmp_path, monkeypatch):
yield app_config_dir


@pytest.fixture(autouse=True)
def default_home_mount(tmp_path, monkeypatch):
home_mount = str(tmp_path.joinpath("home"))
monkeypatch.setattr(aiidalab_launch.core, "_default_home_mount", lambda: home_mount)
yield home_mount


@pytest.fixture(autouse=True)
def container_prefix(random_token, monkeypatch):
container_prefix = f"aiidalab-launch_tests_{random_token}_"
monkeypatch.setattr(aiidalab_launch.core, "CONTAINER_PREFIX", container_prefix)
yield container_prefix


@pytest.fixture(autouse=True)
def home_path(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path.joinpath("home"))


@pytest.fixture
def profile():
return Profile()
Expand Down
4 changes: 1 addition & 3 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@
from time import sleep

import pytest
from packaging.version import parse as parse_version

from aiidalab_launch.core import Config, Profile
from aiidalab_launch.version import __version__

VALID_PROFILE_NAMES = ["abc", "Abc", "aBC", "a0", "a-a", "a-0"]

Expand Down Expand Up @@ -57,7 +55,7 @@ def test_config_dumps_loads(config):


def test_config_version(config):
assert config.version == parse_version(__version__).base_version
assert config.version is None


async def test_instance_init(instance):
Expand Down

0 comments on commit f9384c6

Please sign in to comment.