diff --git a/src/ai/backend/install/app.tcss b/src/ai/backend/install/app.tcss index 5d39e34f5b..59d025439c 100644 --- a/src/ai/backend/install/app.tcss +++ b/src/ai/backend/install/app.tcss @@ -53,6 +53,6 @@ ModeMenu Label { .log { height: 30; width: 1fr; - padding: 1 1; + padding: 1 2; background: $panel-darken-3; } diff --git a/src/ai/backend/install/cli.py b/src/ai/backend/install/cli.py index a8ac613f61..0383eed546 100644 --- a/src/ai/backend/install/cli.py +++ b/src/ai/backend/install/cli.py @@ -21,9 +21,13 @@ Static, ) -from ai.backend.install import __version__ from ai.backend.plugin.entrypoint import find_build_root +from . import __version__ +from .common import detect_os +from .context import Context, current_app, current_log +from .dev import bootstrap_pants, install_editable_webui, install_git_hooks, install_git_lfs +from .docker import check_docker, get_preferred_pants_local_exec_root from .types import InstallModes top_tasks: WeakSet[asyncio.Task] = WeakSet() @@ -42,15 +46,31 @@ async def begin_install(self) -> None: top_tasks.add(asyncio.create_task(self.install())) async def install(self) -> None: - log: RichLog = cast(RichLog, self.query_one(".log")) + _log: RichLog = cast(RichLog, self.query_one(".log")) + _log_token = current_log.set(_log) try: - for tick in range(3): - await asyncio.sleep(1) - log.write(Text.from_markup(f"[gold1](dev)[/] something is going: {tick}")) + # prerequisites + await detect_os() + await install_git_lfs() + await install_git_hooks() + await check_docker() + local_execution_root_dir = await get_preferred_pants_local_exec_root() + await bootstrap_pants(local_execution_root_dir) + # install + await install_editable_webui() + # TODO: install agent-watcher + # TODO: install storage-agent + # TODO: install storage-watcher + ctx = Context() + await ctx.install_halfstack(ha_setup=False) + # configure + # TODO: ... except asyncio.CancelledError: - log.write(Text.from_markup("[red]Interrupted!")) + _log.write(Text.from_markup("[red]Interrupted!")) await asyncio.sleep(1) raise + finally: + current_log.reset(_log_token) class PackageSetup(Static): @@ -68,9 +88,15 @@ async def begin_install(self) -> None: async def install(self) -> None: log: RichLog = cast(RichLog, self.query_one(".log")) try: - for tick in range(3): - await asyncio.sleep(1) - log.write(Text.from_markup(f"[gold1](pkg)[/] something is going: {tick}")) + # prerequisites + await detect_os() + await check_docker() + # install + # TODO: download packages + ctx = Context() + await ctx.install_halfstack(ha_setup=False) + # configure + # TODO: ... except asyncio.CancelledError: log.write(Text.from_markup("[red]Interrupted!")) await asyncio.sleep(1) @@ -148,8 +174,8 @@ def start_package_mode(self) -> None: class InstallerApp(App): BINDINGS = [ - Binding("q", "quit", "Quit the installer"), - Binding("ctrl+c", "quit", "Quit the installer", show=False, priority=True), + Binding("q", "shutdown", "Quit the installer"), + Binding("ctrl+c", "shutdown", "Quit the installer", show=False, priority=True), ] CSS_PATH = "app.tcss" @@ -170,7 +196,7 @@ def on_mount(self) -> None: header.tall = True self.title = "Backend.AI Installer" - async def action_quit(self): + async def action_shutdown(self, message: str | None = None, exit_code: int = 0) -> None: for t in {*top_tasks}: if t.done(): continue @@ -179,7 +205,7 @@ async def action_quit(self): await t except asyncio.CancelledError: pass - self.exit() + self.exit(return_code=exit_code, message=message) @click.command( @@ -202,4 +228,5 @@ def main( ) -> None: """The installer""" app = InstallerApp(mode) + current_app.set(app) app.run() diff --git a/src/ai/backend/install/common.py b/src/ai/backend/install/common.py index 000436efc4..d43834a8b3 100644 --- a/src/ai/backend/install/common.py +++ b/src/ai/backend/install/common.py @@ -1,21 +1,61 @@ -from textual.widgets import RichLog - - -async def install_halfstack(log: RichLog, ha_setup: bool) -> None: - pass - - -async def load_fixtures(log: RichLog) -> None: - pass - - -async def detect_cuda(log: RichLog) -> None: - pass - - -async def populate_bundled_images(log: RichLog) -> None: +from .context import current_os +from .types import OSInfo + + +async def detect_os(): + """ + # Detect distribution + KNOWN_DISTRO="(Debian|Ubuntu|RedHat|CentOS|openSUSE|Amazon|Arista|SUSE)" + DISTRO=$(lsb_release -d 2>/dev/null | grep -Eo $KNOWN_DISTRO || grep -Eo $KNOWN_DISTRO /etc/issue 2>/dev/null || uname -s) + + if [ $DISTRO = "Darwin" ]; then + DISTRO="Darwin" + STANDALONE_PYTHON_PLATFORM="apple-darwin" + elif [ -f /etc/debian_version -o "$DISTRO" == "Debian" -o "$DISTRO" == "Ubuntu" ]; then + DISTRO="Debian" + STANDALONE_PYTHON_PLATFORM="unknown-linux-gnu" + elif [ -f /etc/redhat-release -o "$DISTRO" == "RedHat" -o "$DISTRO" == "CentOS" -o "$DISTRO" == "Amazon" ]; then + DISTRO="RedHat" + STANDALONE_PYTHON_PLATFORM="unknown-linux-gnu" + elif [ -f /etc/system-release -o "$DISTRO" == "Amazon" ]; then + DISTRO="RedHat" + STANDALONE_PYTHON_PLATFORM="unknown-linux-gnu" + elif [ -f /usr/lib/os-release -o "$DISTRO" == "SUSE" ]; then + DISTRO="SUSE" + STANDALONE_PYTHON_PLATFORM="unknown-linux-gnu" + else + show_error "Sorry, your host OS distribution is not supported by this script." + show_info "Please send us a pull request or file an issue to support your environment!" + exit 1 + fi + """ + current_os.set( + OSInfo( + platform="", + distro="", + ) + ) + + +async def detect_cuda() -> None: pass -async def pull_image(log: RichLog) -> None: - pass +async def check_docker_desktop_mount() -> None: + """ + echo "validating Docker Desktop mount permissions..." + docker pull alpine:3.8 > /dev/null + docker run --rm -v "$HOME/.pyenv:/root/vol" alpine:3.8 ls /root/vol > /dev/null 2>&1 + if [ $? -ne 0 ]; then + # backend.ai-krunner-DISTRO pkgs are installed in pyenv's virtualenv, + # so ~/.pyenv must be mountable. + show_error "You must allow mount of '$HOME/.pyenv' in the File Sharing preference of the Docker Desktop app." + exit 1 + fi + docker run --rm -v "$ROOT_PATH:/root/vol" alpine:3.8 ls /root/vol > /dev/null 2>&1 + if [ $? -ne 0 ]; then + show_error "You must allow mount of '$ROOT_PATH' in the File Sharing preference of the Docker Desktop app." + exit 1 + fi + echo "${REWRITELN}validating Docker Desktop mount permissions: ok" + """ diff --git a/src/ai/backend/install/context.py b/src/ai/backend/install/context.py index c2b23c6fbd..8c25afc0c9 100644 --- a/src/ai/backend/install/context.py +++ b/src/ai/backend/install/context.py @@ -1,4 +1,19 @@ +from __future__ import annotations + import enum +from contextvars import ContextVar +from typing import TYPE_CHECKING + +from textual.widgets import RichLog + +from .types import OSInfo + +if TYPE_CHECKING: + from .cli import InstallerApp + +current_log: ContextVar[RichLog] = ContextVar("current_log") +current_app: ContextVar[InstallerApp] = ContextVar("current_app") +current_os: ContextVar[OSInfo] = ContextVar("current_os") class PostGuide(enum.Enum): @@ -17,6 +32,12 @@ def add_post_guide(self, guide: PostGuide) -> None: def show_post_guide(self) -> None: pass + async def install_halfstack(self, ha_setup: bool) -> None: + pass + + async def load_fixtures(self) -> None: + pass + async def configure_services(self) -> None: pass @@ -37,3 +58,9 @@ async def configure_webui(self) -> None: async def dump_etcd_config(self) -> None: pass + + async def populate_bundled_images(self) -> None: + pass + + async def pull_image(self) -> None: + pass diff --git a/src/ai/backend/install/dev.py b/src/ai/backend/install/dev.py index 0358907a0a..946be04b3b 100644 --- a/src/ai/backend/install/dev.py +++ b/src/ai/backend/install/dev.py @@ -1,25 +1,145 @@ -from textual.widgets import RichLog +from rich.text import Text +from .context import current_log -async def install_git_lfs(log: RichLog) -> None: - pass +async def install_git_lfs() -> None: + log = current_log.get() + log.write(Text.from_markup("[dim green]Installing Git LFS")) + """ + case $DISTRO in + Debian) + $sudo apt-get install -y git-lfs + ;; + RedHat) + curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.rpm.sh | $sudo bash + $sudo yum install -y git-lfs + ;; + SUSE) + $sudo zypper install -y git-lfs + ;; + Darwin) + brew install git-lfs + ;; + esac + git lfs install + """ -async def install_git_hooks(log: RichLog) -> None: - pass +async def install_git_hooks() -> None: + log = current_log.get() + log.write(Text.from_markup("[dim green]Installing Git hooks")) + """ + local magic_str="monorepo standard pre-commit hook" + if [ -f .git/hooks/pre-commit ]; then + grep -Fq "$magic_str" .git/hooks/pre-commit + if [ $? -eq 0 ]; then + : + else + echo "" >> .git/hooks/pre-commit + cat scripts/pre-commit >> .git/hooks/pre-commit + fi + else + cp scripts/pre-commit .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + fi + local magic_str="monorepo standard pre-push hook" + if [ -f .git/hooks/pre-push ]; then + grep -Fq "$magic_str" .git/hooks/pre-push + if [ $? -eq 0 ]; then + : + else + echo "" >> .git/hooks/pre-push + cat scripts/pre-push >> .git/hooks/pre-push + fi + else + cp scripts/pre-push .git/hooks/pre-push + chmod +x .git/hooks/pre-push + fi + """ -async def bootstrap_pants(log: RichLog) -> None: - pass +async def bootstrap_pants(local_execution_root_dir: str) -> None: + log = current_log.get() + log.write(Text.from_markup("[dim green]Bootstrapping Pantsbuild")) + log.write(f"local_execution_root_dir = {local_execution_root_dir}") + """ + pants_local_exec_root=$($docker_sudo $bpython scripts/check-docker.py --get-preferred-pants-local-exec-root) + mkdir -p "$pants_local_exec_root" + $bpython scripts/tomltool.py -f .pants.rc set 'GLOBAL.local_execution_root_dir' "$pants_local_exec_root" + set +e + if command -v pants &> /dev/null ; then + echo "Pants system command is already installed." + else + case $DISTRO in + Darwin) + brew install pantsbuild/tap/pants + ;; + *) + curl --proto '=https' --tlsv1.2 -fsSL https://static.pantsbuild.org/setup/get-pants.sh > /tmp/get-pants.sh + bash /tmp/get-pants.sh + if ! command -v pants &> /dev/null ; then + $sudo ln -s $HOME/bin/pants /usr/local/bin/pants + show_note "Symlinked $HOME/bin/pants from /usr/local/bin/pants as we could not find it from PATH..." + fi + ;; + esac + fi + pants version + if [ $? -eq 1 ]; then + # If we can't find the prebuilt Pants package, then try the source installation. + show_error "Cannot proceed the installation because Pants is not available for your platform!" + exit 1 + fi + set -e + """ -async def install_editable_webui(log: RichLog) -> None: - pass + """ + pants export \ + --resolve=python-default \ + --resolve=python-kernel \ + --resolve=pants-plugins \ + --resolve=towncrier \ + --resolve=ruff \ + --resolve=mypy \ + --resolve=black + """ -async def check_docker(log: RichLog) -> None: - pass - - -async def check_docker_compose(log: RichLog) -> None: - pass +async def install_editable_webui() -> None: + log = current_log.get() + log.write(Text.from_markup("[dim green]Installing the editable version of webui")) + """ + if ! command -v node &> /dev/null; then + install_node + fi + show_info "Installing editable version of Web UI..." + if [ -d "./src/ai/backend/webui" ]; then + echo "src/ai/backend/webui already exists, so running 'make clean' on it..." + cd src/ai/backend/webui + make clean + else + git clone https://github.com/lablup/backend.ai-webui ./src/ai/backend/webui + cd src/ai/backend/webui + cp configs/default.toml config.toml + local site_name=$(basename $(pwd)) + # The debug mode here is only for 'hard-core' debugging scenarios -- it changes lots of behaviors. + # (e.g., separate debugging of Electron's renderer and main threads) + sed_inplace "s@debug = true@debug = false@" config.toml + # The webserver endpoint to use in the session mode. + sed_inplace "s@#[[:space:]]*apiEndpoint =.*@apiEndpoint = "'"'"http://127.0.0.1:${WEBSERVER_PORT}"'"@' config.toml + sed_inplace "s@#[[:space:]]*apiEndpointText =.*@apiEndpointText = "'"'"${site_name}"'"@' config.toml + # webServerURL lets the electron app use the web UI contents from the server. + # The server may be either a `npm run server:d` instance or a `./py -m ai.backend.web.server` instance. + # In the former case, you may live-edit the webui sources while running them in the electron app. + sed_inplace "s@webServerURL =.*@webServerURL = "'"'"http://127.0.0.1:${WEBSERVER_PORT}"'"@' config.toml + sed_inplace "s@proxyURL =.*@proxyURL = "'"'"http://127.0.0.1:${WSPROXY_PORT}"'"@' config.toml + echo "PROXYLISTENIP=0.0.0.0" >> .env + echo "PROXYBASEHOST=localhost" >> .env + echo "PROXYBASEPORT=${WSPROXY_PORT}" >> .env + fi + npm i + make compile + make compile_wsproxy + cd ../../../.. + """ diff --git a/src/ai/backend/install/docker.py b/src/ai/backend/install/docker.py new file mode 100644 index 0000000000..8fbdc709f1 --- /dev/null +++ b/src/ai/backend/install/docker.py @@ -0,0 +1,140 @@ +import asyncio +import base64 +import hashlib +import os +import re +import sys +from pathlib import Path + +from .context import current_log +from .utils import request_unix + +__all__ = ("check_docker",) + + +def parse_version(expr): + result = [] + for part in expr.split("."): + try: + result.append(int(part)) + except ValueError: + result.append(part) + return tuple(result) + + +def get_build_root() -> Path: + p = Path.cwd() + while p != p.parent: + if (p / "BUILD_ROOT").is_file(): + return p + p = p.parent + raise RuntimeError("Cannot determine the build root path") + + +def simple_hash(data: bytes) -> str: + h = hashlib.sha1() + h.update(data) + # generate a filesystem-safe base64 string + return base64.b64encode(h.digest()[:12], altchars=b"._").decode() + + +async def detect_snap_docker(): + if not Path("/run/snapd.socket").is_socket(): + return None + async with request_unix( + "GET", "/run/snapd.socket", "http://localhost/v2/snaps?names=docker" + ) as r: + if r.status != 200: + raise RuntimeError("Failed to query Snapd package information") + response_data = await r.json() + for pkg_data in response_data["result"]: + if pkg_data["name"] == "docker": + return pkg_data["version"] + + +async def detect_system_docker(): + sock_paths = [ + Path("/run/docker.sock"), # Linux default + Path("/var/run/docker.sock"), # macOS default + ] + if env_sock_path := os.environ.get("DOCKER_HOST", None): + # Some special setups like OrbStack may have a custom DOCKER_HOST. + env_sock_path = env_sock_path.removeprefix("unix://") + sock_paths.insert(0, Path(env_sock_path)) + for sock_path in sock_paths: + if sock_path.is_socket(): + break + else: + return None + async with request_unix("GET", str(sock_path), "http://localhost/version") as r: + if r.status != 200: + raise RuntimeError("Failed to query the Docker daemon API") + response_data = await r.json() + return response_data["Version"] + + +def fail_with_snap_docker_refresh_request() -> None: + log = current_log.get() + log.write("Please install Docker 20.10.15 or later from the Snap package index.") + log.write("Instructions: `sudo snap refresh docker --edge`") + sys.exit(1) + + +def fail_with_system_docker_install_request() -> None: + log = current_log.get() + log.write("Please install Docker for your system.") + log.write("Instructions: https://docs.docker.com/install/") + sys.exit(1) + + +def fail_with_compose_install_request() -> None: + log = current_log.get() + log.write("Please install docker-compose v2 or later.") + log.write("Instructions: https://docs.docker.com/compose/install/") + sys.exit(1) + + +async def get_preferred_pants_local_exec_root() -> str: + docker_version = await detect_snap_docker() + build_root_path = get_build_root() + build_root_name = build_root_path.name + build_root_hash = simple_hash(os.fsencode(build_root_path)) + if docker_version is not None: + # For Snap-based Docker, use a home directory path + return str(Path.home() / f".cache/{build_root_name}-{build_root_hash}-pants") + else: + # Otherwise, use the standard tmp directory + return f"/tmp/{build_root_name}-{build_root_hash}-pants" + + +async def check_docker() -> None: + log = current_log.get() + docker_version = await detect_snap_docker() + if docker_version is not None: + log.write(f"Detected Docker installation: Snap package ({docker_version})") + if parse_version(docker_version) < (20, 10, 15): + fail_with_snap_docker_refresh_request() + else: + docker_version = await detect_system_docker() + if docker_version is not None: + log.write(f"Detected Docker installation: System package ({docker_version})") + else: + fail_with_system_docker_install_request() + + proc = await asyncio.create_subprocess_exec( + "docker", "compose", "version", stdout=asyncio.subprocess.PIPE + ) + assert proc.stdout is not None + stdout = await proc.stdout.read() + exit_code = await proc.wait() + if exit_code != 0: + fail_with_compose_install_request() + m = re.search(r"\d+\.\d+\.\d+", stdout.decode()) + if m is None: + log.write("Failed to retrieve the docker-compose version!") + sys.exit(1) + else: + compose_version = m.group(0) + log.write(f"Detected docker-compose installation ({compose_version})") + if parse_version(compose_version) < (2, 0, 0): + fail_with_compose_install_request() diff --git a/src/ai/backend/install/python.py b/src/ai/backend/install/python.py new file mode 100644 index 0000000000..e969a98ce6 --- /dev/null +++ b/src/ai/backend/install/python.py @@ -0,0 +1,13 @@ +from textual.widgets import RichLog + + +async def check_python(log: RichLog) -> None: + pass + + +async def install_pyenv(log: RichLog) -> None: + pass + + +async def install_python(log: RichLog) -> None: + pass diff --git a/src/ai/backend/install/types.py b/src/ai/backend/install/types.py index 68364150a3..be83ec9bb4 100644 --- a/src/ai/backend/install/types.py +++ b/src/ai/backend/install/types.py @@ -9,6 +9,12 @@ class InstallModes(enum.StrEnum): PACKAGE = "PACKAGE" +@dataclasses.dataclass() +class OSInfo: + platform: str + distro: str + + @dataclasses.dataclass() class HalfstackConfig: postgres_addr: HostPortPair diff --git a/src/ai/backend/install/utils.py b/src/ai/backend/install/utils.py new file mode 100644 index 0000000000..18f3bfb406 --- /dev/null +++ b/src/ai/backend/install/utils.py @@ -0,0 +1,29 @@ +from contextlib import asynccontextmanager as actxmgr +from typing import AsyncIterator + +import aiohttp + + +@actxmgr +async def request( + method: str, + url: str, + **kwargs, +) -> AsyncIterator[aiohttp.ClientResponse]: + connector = aiohttp.TCPConnector() + async with aiohttp.ClientSession(connector=connector) as s: + async with s.request(method, url, **kwargs) as r: + yield r + + +@actxmgr +async def request_unix( + method: str, + socket_path: str, + url: str, + **kwargs, +) -> AsyncIterator[aiohttp.ClientResponse]: + connector = aiohttp.UnixConnector(socket_path) + async with aiohttp.ClientSession(connector=connector) as s: + async with s.request(method, url, **kwargs) as r: + yield r